Xamarin逆引きTips
MvvmCrossでカスタムコントロールをTwo-Wayバインディングに対応させるには?
MvvmCrossでのiOS/Androidアプリ開発において、カスタムビュークラスをTwo-Wayバインディングに対応させる方法を解説する。
MvvmCrossのiOS/Androidアプリ開発では、プロパティバインディングを使用して画面へViewModelの値を反映し、画面での変更をViewModelに反映する。しかしながら、独自に継承で作るなどしたデフォルトで用意されていないビューウィジェットの場合、Two-Wayモードのバインディングが動作しない。今回はiOS/AndroidのカスタムビューウィジェットをTwo-Wayモードに対応させる方法を解説する。
Two-Wayバインディングの仕組み
ViewModelのプロパティは、その値が変化したときにフレームワーク側に通知するためにRaisePropertyChanged
メソッドを呼び出している。同様に、ビューコンポーネント側の値の変更があった際にViewModelへ反映する際も、値の変更があったことをフレームワークに通知する必要がある。
カスタムコントロールを実装し、値の変更をViewModelに反映したい場合、そのプロパティ名に「Changed」を付けたイベントを用意し、値が変化したときにそのイベントを発生させることでフレームワーク側に変更を通知できる。例えばValue
というプロパティがあった場合、ValueChanged
というイベントを用意することになる(図1)。
なお、標準コンポーネントで値の変更をサポートしているものについては、バインディング作成時にターゲット・バインディング・ファクトリに登録されているTargetBinding
クラスで対応値の変更イベントを捕捉するように作られている。
Two-Wayバインディングをサポートするカスタムコントロールの実装
それでは、変更イベントを使用したViewModelへの値の反映を確認してみよう。そのためにはまずカスタムコントロールを実装する必要がある。今回はタップされると内部のカウンターが増加するプロパティを持つボタンを実装して確認する。
「Tips:MvvmCrossのプロジェクトをセットアップするには?」の手順に従い、MvvmCrossプロジェクトを作成する。ソリューション名は「TwoWaySample」と設定する。
iOS版のカスタムボタンの実装
まずは、Touchプロジェクトにカスタムボタンを作成する。
TwoWaySample.Touch
プロジェクトのViews
フォルダーの下にControls
フォルダーを作成する。次に、そのControlsフォルダーを右クリックし、コンテキストメニューの[追加]をポイントして[新しいファイル]を選択する。開いた[新しいファイル]ダイアログで[General]の[空のクラス]を選択する。名前に「TapCountButton」と入力して[新規]ボタンをクリックする。TapCountButton.cs
ファイルが作成されるので、以下のように編集する。
using System;
using CoreGraphics;
using Foundation;
using UIKit;
namespace TwoWaySample.Touch.Views.Controls
{
public class TapCountButton : UIButton
{
public TapCountButton(NSCoder coder) : base(coder)
{
Setup();
}
public TapCountButton() : base()
{
Setup();
}
public TapCountButton(CGRect frame) : base(frame)
{
Setup();
}
public TapCountButton(UIButtonType type) : base(type)
{
Setup();
}
private void Setup()
{
this.TouchUpInside += OnTouchUpInside; // 1
}
public event EventHandler TapCountChanged;
int tapCount = 0;
public int TapCount // 2
{
get { return tapCount; }
set
{
tapCount = value;
if (TapCountChanged != null)
{
TapCountChanged(this, EventArgs.Empty); // 3
}
}
}
void OnTouchUpInside (object sender, EventArgs e)
{
TapCount++; // 4
}
}
}
|
タップされるとプロパティの数値がインクリメントされ、TapCountChangedイベントが発生する。
iOSのカスタムビュー実装の際に留意したいのは、各種ビューウィジェットのベースであるUIView
クラスのコンストラクターの中にある、NSCoder
オブジェクトを引数として取るものだ。このコンストラクターはStoryboardや.xibファイルに定義されている情報からインスタンスが作成されるときに呼ばれる。コンストラクター上で初期化処理が必要なものがある場合は、通常のコンストラクターだけでなく、NSCoder
を持つものもオーバーライドしておくように注意が必要だ。いくつか引数のパターンがある場合は、初期化処理を1つのメソッドにまとめ、各コンストラクターからそれを呼ぶとよい。今回はSetup
メソッドを作成して、ボタンタップ時のイベントを捕捉するようにした(1)。
今回はタップされた回数のプロパティとして、TapCount
プロパティを用意する(2)。TapCount
プロパティは値が変化した場合に、TapCountChanged
イベントを呼び出すように実装する(3)。
あとは、ボタンタップ時にこのプロパティがインクリメントされるようにする(4)。
Andorid版のカスタムボタンの実装
DroidプロジェクトにもiOSと同様にカスタムボタンを実装する。TwoWaySample.Droid
プロジェクトのViews
フォルダーの下にControls
フォルダーを作成する。このフォルダーにiOS版と同様の手順で空のクラスを追加する。名前も同じ「TapCountButton」とする。内容は以下の通り編集する。
using System;
using Android.Content;
using Android.Util;
using Android.Widget;
namespace TwoWaySample.Droid.Views.Controls
{
public class TapCountButton : Button
{
public TapCountButton(Context context) : base(context)
{
Setup();
}
public TapCountButton(Context context, IAttributeSet attrs) : base(context, attrs)
{
Setup();
}
public TapCountButton(Context context, IAttributeSet attrs, int defStyle) : base(context, attrs, defStyle)
{
Setup();
}
private void Setup()
{
this.Click += OnClick;
}
public event EventHandler TapCountChanged;
int tapCount = 0;
public int TapCount // 2
{
get { return tapCount; }
set
{
tapCount = value;
if (TapCountChanged != null)
{
TapCountChanged(this, EventArgs.Empty);
}
}
}
void OnClick (object sender, EventArgs e)
{
TapCount++;
}
}
}
|
コンストラクターとボタンタップ時のイベント名が変わった以外はiOS版と同じ内容となっている。
ViewModelの実装
次に、CoreプロジェクトのViewModelを実装する。
TwoWaySample.CoreプロジェクトのViewModels
フォルダー内にあるFirstViewModel.cs
ファイルを次のように修正する。
using Cirrious.MvvmCross.ViewModels;
namespace TwoWaySample.Core.ViewModels
{
public class FirstViewModel : MvxViewModel
{
int count;
public int Count // 1
{
get { return count; }
set
{
count = value;
RaisePropertyChanged(() => Count);
}
}
MvxCommand countResetCommand;
public MvxCommand CountResetCommand // 2
{
get
{
return countResetCommand ??
(countResetCommand = new MvxCommand(() => Count = 0));
}
}
}
}
|
タップされた回数のプロパティとリセットするコマンドを実装する。
ボタンがタップされた回数を保持するCount
プロパティ(1)と、このカウントをリセットするCountResetCommand
を用意した(2)。
Touchプロジェクトの実装
Touchプロジェクト側でバインディングを定義する。
TwoWaySample.Touch
プロジェクトのViews
フォルダー内にあるFirstView.cs
ファイルを以下のように修正する。
using Cirrious.MvvmCross.Binding.BindingContext;
using Cirrious.MvvmCross.Touch.Views;
using CoreGraphics;
using Foundation;
using ObjCRuntime;
using UIKit;
using TwoWaySample.Touch.Views.Controls;
namespace TwoWaySample.Touch.Views
{
[Register("FirstView")]
public class FirstView : MvxViewController
{
public override void ViewDidLoad()
{
View = new UIView { BackgroundColor = UIColor.White };
base.ViewDidLoad();
// ios7 layout
if (RespondsToSelector(new Selector("edgesForExtendedLayout")))
{
EdgesForExtendedLayout = UIRectEdge.None;
}
var label = new UILabel(new CGRect(10, 10, 300, 40)); // 1
Add(label);
var countButton = new TapCountButton(UIButtonType.System) // 2
{
Frame = new CGRect(10, 50, 300, 40)
};
countButton.SetTitle("Count Up", UIControlState.Normal);
Add(countButton);
var resetButton = new UIButton(UIButtonType.System) // 3
{
Frame = new CGRect(10, 90, 300, 40)
};
resetButton.SetTitle("Reset", UIControlState.Normal);
Add(resetButton);
var set = this.CreateBindingSet<FirstView, Core.ViewModels.FirstViewModel>();
set.Bind(label).To(vm => vm.Count); // 4
set.Bind(countButton).For(v => v.TapCount).To(vm => vm.Count); // 5
set.Bind(resetButton).To(vm => vm.CountResetCommand); // 6
set.Apply();
}
}
}
|
ここでは、カウントの数を表示するラベル(1)、カウントアップするカスタムボタン(2)、カウントをリセットするボタン(3)を作成して画面に追加している。
ラベルとカスタムボタンのTapCount
プロパティにはViewModelのCount
プロパティを(45)、リセットボタンにはViewModelのCountResetCommand
をバインディングしている(6)。TapCount
プロパティは標準プロパティではないため、For
メソッドでバインディング先のプロパティを明示指定している。
Droidプロジェクトの実装
続いて、Droidプロジェクト側でバインディングを定義する。Resources
-layout
フォルダー内にあるFirstView.axml
ファイルを以下のように修正する。
1
2
3
|
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:local="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="40dp"
local:MvxBind="Text Count" />
<TapCountButton
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="20dp"
android:text="Count Up"
local:MvxBind="TapCount Count" />
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="20dp"
android:text="Reset"
local:MvxBind="Click CountResetCommand" />
</LinearLayout>
|
ここでは、カウントの数を表示するラベルのText
プロパティにViewModelのCount
プロパティを(1)、カウントアップするカスタムボタンのTapCount
プロパティにViewModelのCount
プロパティを(2)、カウントをリセットするボタンのClick
イベントにViewModelのCountResetCommand
コマンド(3)をそれぞれバインディングしている。
アプリの実行
この状態でアプリを実行すると、数字をカウントするラベルと[Count Up]ボタン、[Reset]ボタンが表示される。[Count Up]ボタンをタップするとラベルの数字が増えていく。この動作は、[Count Up]ボタンに定義してあるTapCount
プロパティがカウントアップし、イベントが発生してViewModelのCount
プロパティが更新され、それがラベルに波及することで行われている。また、[Reset]ボタンをタップすると数字が0にリセットされる。こちらはViewModelのCountResetCommand
が実行され、Count
プロパティが0になり、その変更が[Count Up]ボタンのTapCount
プロパティに反映されている。
TargetBindingを使用したカスタムバインディングの定義
ここまで解説した方法はカスタムクラスを実装する場合は都合がよいが、NuGetパッケージやXamarin Componentsから取得した既存のコンポーネントに関して適用しようとする場合はそれらをサブクラス化してChangedイベントを追加するよりもMvxTargetBinding
のサブクラスを実装し、ターゲット・バインディング・ファクトリに追加する方が都合がよい。
この動きを確認するために、Touchプロジェクト・Droidプロジェクト共にTapCountButton
のTapCountChanged
イベントの名前を変更して命名規則からはずし、ターゲット・バインディング・ファクトリへ定義を追加してTwo-Wayバインディングに対応させる。
TapCountButtonのイベント名変更
まず、Touchプロジェクト、Droidプロジェクト共にTapCountChanged
プロパティの名前を「TapCountUp」と変更する。この変更はVisual StudioやXamarin Studioのリファクタリング機能を使うことで簡単にできる。MacのXamarin Studioでは、コード中のTapCountChanged
プロパティの識別子部分にカーソルを当て、右クリックして[リファクタリング]-[名前の変更]と開く。表示されたダイアログボックスで目的の名前である「TapCountUp」と入力して[OK]ボタンをクリックすれば使用している箇所を全て変更してくれる。
この変更で、TapCountChanged
イベントとTapCount
プロパティのコードは以下のように変更される。
public event EventHandler TapCountUp;
int tapCount = 0;
public int TapCount
{
get { return tapCount; }
set
{
tapCount = value;
if (TapCountUp != null)
{
TapCountUp(this, EventArgs.Empty);
}
}
}
|
この状態で実行すると、[Count Up]ボタンをタップしてもラベルの数字は0のまま動かない。TapCountChanged
イベントが命名規則から外れ、MvvmCrossのバインディングシステムが値の変更を検出できなくなったためだ。
TargetBindingクラスの実装
次に、TargetBinding
クラスを実装する。TwoWaySample.Touch
プロジェクトのプロジェクト直下にTargetBindings
フォルダーを作成する。次に、新しいファイルを追加するためのダイアログで[General]の[空のクラス]を選択して、名前に「TapCountButtonTapCountTargetBinding」と入力して[新規]ボタンをクリックする。作成されたTapCountButtonTapCountTargetBinding.cs
ファイルを以下のように編集する。
using System;
using Cirrious.MvvmCross.Binding;
using Cirrious.MvvmCross.Binding.Bindings.Target;
using TwoWaySample.Touch.Views.Controls;
namespace TwoWaySample.Touch.TargetBindings
{
public class TapCountButtonTapCountTargetBinding : MvxConvertingTargetBinding
{
public TapCountButtonTapCountTargetBinding(TapCountButton target) : base(target)
{
}
protected TapCountButton TapCountButton
{
get { return (TapCountButton)Target; } // 1
}
public override MvxBindingMode DefaultMode
{
get { return MvxBindingMode.TwoWay; } // 2
}
public override Type TargetType
{
get { return typeof(int); } // 3
}
protected override void SetValueImpl(object target, object value)
{
var button = (TapCountButton)target;
button.TapCount = (int)value; // 4
}
public override void SubscribeToEvents()
{
TapCountButton.TapCountUp += OnTapCountUp; // 5
}
void OnTapCountUp (object sender, EventArgs e)
{
FireValueChanged(TapCountButton.TapCount); // 6
}
protected override void Dispose(bool isDisposing)
{
if (isDisposing)
{
var target = Target as TapCountButton;
if (target != null)
{
target.TapCountUp -= OnTapCountUp; // 7
}
}
base.Dispose(isDisposing);
}
}
}
|
TargetBindingを実現するためのMvxTargetBinding
クラスはかなりプリミティブな実装となっているため、実際はこれをそのまま使うのではなく、いくつかあるサブクラスのいずれかを継承して使用する。今回はプロパティへの適用に適したMvxConvertingTargetBinding
クラスを選択した。上記のクラスではバインディング先のオブジェクトはTarget
プロパティとして格納されているが、処理の中で使用しやすいように、型をTapCountButton
に変換して値を返すプロパティを用意した(1)。
次に、このバインディングのデフォルトのバインディングの方向と、バインディング先のプロパティの型を宣言する。具体的には、DefaultMode
プロパティをオーバーライドしてバインディングの方向がTwo-Way
モードであることを宣言し(2)、同じようにTargetType
プロパティをオーバーライドしてバインディング先のプロパティがint
型であることを宣言している(3)。
MvxTargetBinding
ではSetValue
という、ViewModelからの値変更が通知されるメソッドが存在するが、(今回、基本クラスとして使っている)MvxConvertingTargetBinding
ではいくつか前処理を行っているため、実際の反映処理はSetValueImpl
メソッドで行う。引数のtarget
に入っているオブジェクトをビューウィジェットの型にキャストして、そのプロパティに値をセットする(4)。
値の変更を通知するイベントを定義する処理はSubscribeToEvents
メソッドに記述する(5)。イベントの中ではFireValueChanged
メソッドを呼び出すことでViewModelの値の変更が行われる(6)。また、イベントを使用した場合はDispose
メソッドをオーバーライドし、ここでイベントへの接続を解除する(7)。
Droidプロジェクトも同様の手順でTapCountButtonTapCountTargetBinding
を実装する。今回はiOSもAndroidもインターフェースを共通にしてあるので、実装はusing
節にあるTwoWaySample.Touch.Views.Controls
の定義をTwoWaySample.Droid.Views.Controls
に変更するのみで、クラス内のコードはTouchプロジェクトと同じ内容となる。
ターゲット・バインディング・ファクトリに追加する
作成したTargetBinding
クラスはターゲット・バインディング・ファクトリに追加することで使用可能な状態となる。この処理はSetup
クラス内に記述する。Touchプロジェクト・Droidプロジェクト共に、それぞれのSetup.cs
ファイルを開き、FillTargetFactories
メソッドをオーバーライドして、RegisterCustomBindingFactory
メソッドを呼び出すことにより追加を行う。
using Cirrious.CrossCore.Platform;
using Cirrious.MvvmCross.Binding.Bindings.Target.Construction;
using Cirrious.MvvmCross.Touch.Platform;
using Cirrious.MvvmCross.ViewModels;
using TwoWaySample.Touch.TargetBindings;
using TwoWaySample.Touch.Views.Controls;
using UIKit;
namespace TwoWaySample.Touch
{
public class Setup : MvxTouchSetup
{
// 中略
// -- ここから追加 --
protected override void FillTargetFactories(IMvxTargetBindingFactoryRegistry registry)
{
base.FillTargetFactories(registry);
registry.RegisterCustomBindingFactory<TapCountButton>("TapCount",
view => new TapCountButtonTapCountTargetBinding(view));
}
// -- ここまで追加 --
}
}
|
※Droidプロジェクトの場合も、(「TwoWaySample.Touch」→「TwoWaySample.Droid」になるぐらいで)このコードとほぼ同じになるので、説明を割愛する。
RegisterCustomBindingFactory
メソッドは、型引数としてバインディング先の型を、また第1引数に対象となるプロパティ、第2引数にTargetBindingを作成するラムダ式を指定するようになっている。
この状態で実行すると、Changedイベントを使用した場合と同じようにアプリが動作する。
Hint: Androidでカスタムビューが別アセンブリにある場合の追加処理
Androidでは、NuGetパッケージやXamarin Componentsからカスタムビューを取得したり、プロジェクト分割してあるなどの理由でバインディング先のビューウィジェットが別のアセンブリに含まれていたりする場合、MvvmCrossのバインディングシステムに対して目的のビューウィジェットが含まれるアセンブリを登録する必要がある。これはDroidプロジェクトのSetup.cs
ファイルを開き、Setup
クラスのAndroidViewAssemblies
プロパティをオーバーライドして、使用するクラスのアセンブリを追加して返すことで可能だ。次のコードはその例である。
protected override System.Collections.Generic.IList<System.Reflection.Assembly> AndroidViewAssemblies
{
get
{
var assemblies = base.AndroidViewAssemblies;
assemblies.Add(typeof(Iseteki.CustomControls.TapCountButton).Assembly);
return assemblies;
}
}
|
登録に必要な値は、ビューウィジェットのクラスをtypeof
でSystem.Type
オブジェクトを取得すれば、そのAssembly
プロパティから取得できる。
なお、MvxAndroidSetup
クラスのデフォルト実装で以下のアセンブリが指定されているため、これらのアセンブリに含まれているビューウィジェットに対する処理は行わなくてもよい。
- Mono.Android
- Cirrious.MvvmCross.Binding.Droid
- Droidプロジェクトから作成されるアセンブリ
※以下では、本稿の前後を合わせて5回分(第53回~第57回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
53. MvvmCrossでWebBrowserプラグインを使用するには?
WebBrowserプラグインを追加・利用する例を通じて、MvvmCrossでのiOS/Androidアプリ開発におけるMvvmCrossプラグインの基本的な使い方を説明する。
54. コードを書く前に正規表現をテストするには?(.NET/Xamarin対応)
.NET/Monoの基本クラスライブラリを使って正規表現を書く場合、そのテストはどうする? Xamarin Studioの正規表現ツールキットを使って手軽に行う方法を紹介。
55. 【現在、表示中】≫ MvvmCrossでカスタムコントロールをTwo-Wayバインディングに対応させるには?
MvvmCrossでのiOS/Androidアプリ開発において、カスタムビュークラスをTwo-Wayバインディングに対応させる方法を解説する。
56. Xamarin.FormsでAzureモバイルサービスによるToDoアプリを作成するには?
ひな型プロジェクトが用意されているXamarin.iOSやXamarin.Androidではなく、Xamarin.FormsからAzureモバイルサービスを活用する基本的な方法を、簡単なToDoアプリを題材に解説する。
57. MvvmCrossで画像をバインディングするには?
MvvmCrossでのiOS/Androidアプリ開発において、画像のURLをViewへバインディングできるMvxImageViewの使い方を説明する。