5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Visual Studio 2017+Eto.Formsでクロスプラットフォームアプリを作る際のコツ

Last updated at Posted at 2018-03-02

概要

 タイトルの通りの記事です。次の記事で紹介されていて興味を持ったのと、今でも更新され続けているので信頼できると思ったからですね。
  クロスプラットフォームなGUIフレームワーク「Eto.Forms」の紹介 - ごった日記

導入

 Visual Studioを起動した後、初手で「ツール(T)→拡張機能と更新プログラム(U)...」を選択して「拡張機能と更新プログラム」ウィンドウを開き、「オンライン」タブからEto.Formsを導入します。
 具体的には、次のウィンドウで「ダウンロード」ボタンを押してダウンロードした後、 VisualStudioを再起動 すればEto.Formsが導入されます。

image.png

 導入後は、新しいプロジェクトを作成する際に、Eto.Formsを利用したプロジェクトをテンプレートとして選択することができます。

image.png

 プロジェクトの設定画面ですが、私はWPF使いなので、「XAML」でFormを編集することにしました。

image.png

ファイル構造をざっと説明

 まずフォルダパスがこんな感じです。

  • ルートディレクトリにある「HDC2」はアプリ名ですので、プロジェクト名を他の名前にすれば当然別の名前になります
  • ルートディレクトリの1個下に、共通コードである「HDC2」フォルダと、UI用の「HDC2.Desktop」フォルダが生成されます。「HDC2」の単語の意味は前述したので説明は省略
  • HDC2フォルダは共通(.NET Standard製)ですが、HDC2.Desktopフォルダは見るからに プラットフォーム毎に別バイナリ になりそうです

image.png

 次に実行ファイルですが、上記の図で言えば「HDC2\HDC2.Desktop\bin\Debug\net461」フォルダ内に「HDC2.Desktop.exe」といった名前で収められています(リリースビルドだとDebugReleaseになる)。
 フォルダ比較したところ、「HDC2\HDC2.Desktop\bin\Debug\net461」フォルダの中身は「HDC2\HDC2.Desktop\bin\Debug\net461\HDC2.Desktop.app\Contents\MonoBundle」フォルダとほぼ同じだと分かりました……重複して存在する意味は??

「XAML」って選んだけどどれぐらいWPFに近いの?

 ……確かにXAMLですが、サンプルがサンプルなせいで コードビハインド前提 のように見えます。

image.png

image.png

 XAMLでMVVMを使うのは当然 なのでどう対策するかですが、実は「INotifyPropertyChangedを継承したViewModelDataContextに宛がう」といったベタな戦法で対処することができます。
  Data Binding · picoe/Eto Wiki 

 テンプレートに載っているサンプルコードを、そのままMVVMモデルとして使えるように書き直すとこんな感じ。
 ※WPFと違い、メニュー部分はコードビハインドする必要があります

MainForm.xeto.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- 本当は「MainForm.xeto」ってファイル名です -->
<Form
	xmlns="http://schema.picoe.ca/eto.forms" 
	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
	Title="My Eto Form"
	ClientSize="400, 350"
	Padding="10"
	>
	<StackLayout>
		<Label Text="{Binding LabelText}"/>
		<Button Text="ClickMe" Command="{Binding ClickMeCommand}"/>
	</StackLayout>

	<Form.Menu>
		<MenuBar>
			<ButtonMenuItem Text="F&amp;ile">
				<ButtonMenuItem Text="Click Me!" Click="HandleClickMe" />
			</ButtonMenuItem>
			<MenuBar.ApplicationItems>
				<ButtonMenuItem Text="Preferences.." Shortcut="{On Control+O, Mac=Application+Comma}" />
			</MenuBar.ApplicationItems>
			<MenuBar.QuitItem>
				<ButtonMenuItem Text="Quit" Shortcut="CommonModifier+Q" Click="HandleQuit" />
			</MenuBar.QuitItem>
			<MenuBar.AboutItem>
				<ButtonMenuItem Text="About..." Click="HandleAbout" />
			</MenuBar.AboutItem>
		</MenuBar>
	</Form.Menu>
	<Form.ToolBar>
		<ToolBar>
			<ButtonToolItem Text="Click Me!" Click="HandleClickMe" />
		</ToolBar>
	</Form.ToolBar>
</Form>
MainForm.xeto.cs
using System;
using Eto.Forms;
using Eto.Serialization.Xaml;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;

namespace HDC2
{	
	public class MainForm : Form
	{
		public MainForm() {
			XamlReader.Load(this);
			// ViewModelを登録する
			var model = new MainModel();
			DataContext = model;
		}

		protected void HandleClickMe(object sender, EventArgs e)
		{
			// メニュー(ButtonMenuItem)のCommandプロパティに「"{Binding ClickMeCommand}"」と
			// 設定しても動かなかったので、コードビハインドに見せかけてDataContextを直接叩く荒業
			(DataContext as MainModel).ClickMeCommand.Execute(sender);
		}

		protected void HandleAbout(object sender, EventArgs e)
		{
			new AboutDialog().ShowDialog(this);
		}

		protected void HandleQuit(object sender, EventArgs e)
		{
			Application.Instance.Quit();
		}
	}

	public class MainModel : INotifyPropertyChanged
	{
		// プロパティ
		public ICommand ClickMeCommand { get; private set; }
		public string LabelText { get; private set; }

		// 実行するメソッド
		private void ClickMe(object sender, EventArgs e) {
			MessageBox.Show("I was clicked!");
		}

		private void OnPropertyChanged([CallerMemberName] string memberName = null) {
			PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(memberName));
		}
		public event PropertyChangedEventHandler PropertyChanged;

		// コンストラクタ
		public MainModel() {
			ClickMeCommand = new Command(ClickMe);
			LabelText = "Some Content";
		}
	}
}

ReactivePropertyが使えるか?

 MVVM支援用ライブラリとして個人的に重宝しているものとして ReactiveProperty があります。
  【C#】ReactiveProperty全然分からねぇ!って人向けのFAQ集【修正済】

 Eto.Formsはフレームワークとして.NET Standardを使用していますが、ReactivePropertyも同様に.NET Standardに対応しています。
 ゆえに使えるはず……と思って試してみたところ、 あっさり成功しました。 変更箇所はこんな感じです。ちなみにReactiveCollectionもちゃんと使えます

MainForm.xeto.xml
<!-- 本当は「MainForm.xeto」ってファイル名です -->
<Label Text="{Binding LabelText}"/>
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
<Label Text="{Binding LabelText.Value}"/>
MainForm.xeto.cs
public ICommand ClickMeCommand { get; private set; }
public string LabelText { get; private set; }
private void ClickMe(object sender, EventArgs e) {
	MessageBox.Show("I was clicked!");
}
public MainModel() {
	ClickMeCommand = new Command(ClickMe);
	LabelText = "Some Content";
}
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
public ReactiveCommand ClickMeCommand { get; }
public ReactiveProperty<string> LabelText { get; } = new ReactiveProperty<string>("Some Content");
private void ClickMe() {
	MessageBox.Show("I was clicked!");
}
public MainModel() {
	ClickMeCommand = new ReactiveCommand();
	lickMeCommand.Subscribe(ClickMe);
}

Windowsで開発する際の注意点

 Eto.Formsでは、「共通コード」を実現するために裏で「WPF」や「Gtk」などのフレームワークが動いています。
 その辛みで、プロパティの値を変更する際に例外が出てしまうといったとんでもない事案を見つけました。
  ※現象が再現する最小コードを作って投げたIssue→Raise an exception when overwrite the value of ReactiveProperty.Value not in constructor method · Issue #1041 · picoe/Eto
 ところが、この問題について「Eto.Forms は Gtk しか SynchronizationContext に対応していないみたいなので自前でセットしたら動きました」との助言を頂きました。つまり、Eto.DesktopにおけるProgram.csについて、

Program.cs
[STAThread]
static void Main(string[] args)
{
	new Application(Eto.Platform.Detect).Run(new MainForm());
}

とあるところを、

Program.cs
[STAThread]
static void Main(string[] args)
{
	var app = new Application(Eto.Platform.Detect);
	if (app.Platform.IsWpf) {
		SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext());
	}
	app.Run(new MainForm());
}

と書き換えてしまえば正常に動くというのです。実際試したら例外も出ず普通に動くのですがそれはorz

右クリックメニュー(コンテキストメニュー)の仕込み方

 試行錯誤して見つけたのですが、WPFのノリに似た感じでした。以下はListBoxでの例です。

sample.xeto.xml
<!-- 本当は「sample.xeto」ってファイル名です -->
<ListBox/>
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
<ListBox>
	<ListBox.ContextMenu>
		<ContextMenu>
			<ButtonMenuItem Text="右クリックメニュー" Click="SampleFunc" />
		</ContextMenu>
	</ListBox.ContextMenu>
</ListBox>

 なお、後述するメニューにCommandをBindingできない不具合はこのコンテキストメニューでも同様ですので注意!

ドラッグ&ドロップの仕込み方

 なんでググってもこれぐらいのことのサンプルソース出てこないんだよと思いながら試行錯誤して見つけました。
 以下、ListBoxでの例です(他のコントロールでも可能)。

sample.xeto.xml
<!-- 本当は「sample.xeto」ってファイル名です -->
<ListBox/>
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
<ListBox AllowDrop="True" DragDrop="DrapDropEvent"/>
sample.xeto.cs
protected void DrapDropEvent(object sender, DragEventArgs e) {
	// ファイル名一覧をforeachで回しながら、絶対パス名を表示していく
	foreach(var fileUri in e.Data.Uris) {
		Console.WriteLine(fileUri.LocalPath);
	}
}

 ただし、Eto.Formsで言えばVer.2.4.0からという比較的新しい機能なせいか、Linuxではまだ動かないのですけれどねorz

その他小ネタ集

メニューアイテムに関する恐ろしい罠について

 Eto.Formsプルダウンメニューの翻訳対応 - ごった日記
 この記事によるととんでもないことが書かれています。つまり、

  • MenuBar.ApplicationItemsMenuBar.QuitItemを使用すると、Fileメニューが勝手に追加される
  • MenuBar.AboutItemを使用すると、Helpメニューが勝手に追加される
  • Fileメニューが無いとLinuxではアプリが強制終了する

 ……明らかに ローカライズのことを考えてないクソ仕様 だと思いますが、幸いにも「コードビハインドで上書きする」技が使えます。つまり、何食わぬ顔でXAMLを読み込んだ後、当該文字列をダイレクトに書き換えてやればいいのです。

MainForm.xeto.cs
public MainForm() {
	XamlReader.Load(this);
	// メニューバーを書き変える
	this.Menu.ApplicationMenu.Text = "ファイル(&F)";
	this.Menu.HelpMenu.Text = "ヘルプ(&H)";
	// ViewModelを登録する
	var model = new MainModel();
	DataContext = model;
}

メニュー項目に対するBindingが一部効かない件について

 例えばButtonMenuItemにおけるCommandプロパティにCommand="Binding SampleCommand"セットしても無駄でした。つまりコードビハインドでないとメニュー関係は処理できないわけで、看板に偽りありとしか言いようがありませんね。
 なおButtonとかのCommandプロパティにはBindingできる謎……。

About(バージョン情報欄)について

 Eto.Formsのサンプルを読めば気づくと思いますが、AboutDialog型のインスタンスに対してShowDialog(Window型)メソッドを発動することにより、バージョン情報のダイアログを表示することができます。
 ソフト名や概要などの設定項目は、プロジェクトのプロパティから「パッケージ」を選ぶことによってある程度は編集できますが、たぶん 直接AboutDialog型のプロパティを弄った方が楽です

image

UIの癖の違いについて

 全体的に見て、表現能力はWPFより明らかに見劣りします
 例えば<TableLayout>はWPFで言うところの<Grid>っぽいコンテナですが、<Grid>ではGrid.ColumnおよびGrid.Rowプロパティで位置決めしていたものを、<TableLayout>では``の中に1行づつコンテナを詰め込む方向の実装になります。また、**ColumnSpanやRowSpanが無い**ので、それっぽいものを作りたい場合は`をネストさせて組むしかありません。
 ところが、様々なオブジェクト・コンテナに指定する`Spacing`および`Padding`プロパティは、WPFの`Marin`および`Padding`プロパティと違い、周囲を同じピクセル数でしか空けることができません。一応`"10,5"`のようにカンマ区切りで2個の引数を取れるものもありますがそれでも2個まで。その結果、かなりレイアウト構築に制限を受けることになります。
 しかも、これはおま環かもしれませんが、レイアウトを表すXMLファイル(`*.xeto`)を標準のテキストエディター(Eto.Forms preview)で開いて作業していると、しばらくしたらVisualStudioごとフリーズし、強制終了を余儀なくされます。上記のようにプレビュー欄が何の役にも立たないくせにこの体たらくですので、編集する際は「XML(テキスト)エディター」を使用することを推奨します。
 とはいえ、Eto.Forms previewじゃないと使えないプロパティがIntelliSenseで表示されることがあるといったふざけた仕様により、Eto.Forms previewの使用を余儀なくされることはあるでしょうorz

DXYBmoZU0AAWgGL-orig.jpg

Linuxで動かす際の注意事項

 Linuxで動かす際は、**Mono**を利用してビルド後のexeファイルを動かします。
 ただ、その辺のUbuntu 16.04 LTSなどで何気なく「sudo apt-get install mono-complete」としただけだと、最新のMonoじゃないのでEto.Forms製アプリが動かないといったドツボにはまります。
 したがって、公式に記載された手順に従い、事前にリポジトリを追加してからインストールする必要があります(以下はUbuntu 16.04の場合)。

sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF
echo "deb http://download.mono-project.com/repo/ubuntu stable-xenial main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list
sudo apt-get update

Listboxにどう対応するか?

 LabelコントロールやButtonコントロールにおけるTextプロパティや、ImageViewコントロールにおけるImageプロパティのように、1要素でOKなプロパティについてはReactiveProperty<Hoge>型を割り当てることができます。
 また、ListBoxコントロールにおけるDataStoreプロパティのように、複数要素を割り当てるプロパティにはReactiveCollection<Hoge>型を割り当てることができます。ただ、WPFとオブジェクト名やプロパティ名が一部異なっているので覚えるのが大変ですわ……。

画像データの加工について

 System.Drawing.Bitmapのような名前のEto.Drawing.Bitmap型がありますので、そこからEto.Drawing.Graphics型のインスタンスを引き出して叩くのが基本です。画像に画像を合成したい時はこんな感じ。

BitmapSample.cs
// 画像データを用意する
var image1 = new Eto.Drawing.Bitmap(w1, h2, PixelFormat.Format24bppRgb);
var image2 = new Eto.Drawing.Bitmap(w2, h2, PixelFormat.Format24bppRgb);
// Eto.Drawing.Graphics型はIDisposeを継承しているのでusingが使える
using(var g = new Eto.Drawing.Graphics(image1)){
  // image2から切り出す範囲を指定(下記例だと左上座標は(x,y)、幅w3・高さh3の範囲)
  var cropRect = new Eto.Drawing.Rectangle(x, y, w3, h3);
  // tempImageはimage2から切り出された範囲
  var tempImage = image2.Clone(cropRect);
  // image1の指定した位置(x2,y2)にtempImageを貼り付ける
  // ちなみにGraphics.DrawImage(Image, float x, float y)メソッドは
  // 見ての通り第2・第3引数がなぜか実数だが、整数を入れてもキャストされるので
  // 問題なく使用できる
  g.DrawImage(tempImage, x2, y2);
}

参考資料

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?