Deep Insider の Tutor コーナー
>>  Deep Insider は本サイトからスピンオフした姉妹サイトです。よろしく! 
Xamarin逆引きTips

Xamarin逆引きTips

MvvmCrossでカスタムコントロールをTwo-Wayバインディングに対応させるには?

2015年7月17日

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)。

図1 カスタムコントロールにおけるTwo-Wayバインディングの概念図(ValueChangedイベントを活用する場合)
図1 カスタムコントロールにおけるTwo-Wayバインディングの概念図(ValueChangedイベントを活用する場合)

 なお、標準コンポーネントで値の変更をサポートしているものについては、バインディング作成時にターゲット・バインディング・ファクトリに登録されているTargetBindingクラスで対応値の変更イベントを捕捉するように作られている。

Two-Wayバインディングをサポートするカスタムコントロールの実装

 それでは、変更イベントを使用したViewModelへの値の反映を確認してみよう。そのためにはまずカスタムコントロールを実装する必要がある。今回はタップされると内部のカウンターが増加するプロパティを持つボタンを実装して確認する。

 「Tips:MvvmCrossのプロジェクトをセットアップするには?」の手順に従い、MvvmCrossプロジェクトを作成する。ソリューション名は「TwoWaySample」と設定する。

iOS版のカスタムボタンの実装

 まずは、Touchプロジェクトにカスタムボタンを作成する。

 TwoWaySample.TouchプロジェクトのViewsフォルダーの下にControlsフォルダーを作成する。次に、そのControlsフォルダーを右クリックし、コンテキストメニューの[追加]をポイントして[新しいファイル]を選択する。開いた[新しいファイル]ダイアログで[General]の[空のクラス]を選択する。名前に「TapCountButton」と入力して[新規]ボタンをクリックする。TapCountButton.csファイルが作成されるので、以下のように編集する。

C#
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
    }
  }
}
リスト1 iOS版のカスタムボタン

タップされるとプロパティの数値がインクリメントされ、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」とする。内容は以下の通り編集する。

C#
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++;
    }
  }
}
リスト2 Android版のカスタムボタン

コンストラクターとボタンタップ時のイベント名が変わった以外はiOS版と同じ内容となっている。

ViewModelの実装

 次に、CoreプロジェクトのViewModelを実装する。

 TwoWaySample.CoreプロジェクトのViewModelsフォルダー内にあるFirstViewModel.csファイルを次のように修正する。

C#
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));
      }
    }
  }
}
リスト3 ViewModelの実装

タップされた回数のプロパティとリセットするコマンドを実装する。

 ボタンがタップされた回数を保持するCountプロパティ(1)と、このカウントをリセットするCountResetCommandを用意した(2)。

Touchプロジェクトの実装

 Touchプロジェクト側でバインディングを定義する。

 TwoWaySample.TouchプロジェクトのViewsフォルダー内にあるFirstView.csファイルを以下のように修正する。

C#
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();
    }
  }
}
リスト4 Touchプロジェクト側でのバインディング定義(FirstView.cs)

 ここでは、カウントの数を表示するラベル(1)、カウントアップするカスタムボタン(2)、カウントをリセットするボタン(3)を作成して画面に追加している。

 ラベルとカスタムボタンのTapCountプロパティにはViewModelのCountプロパティを(45)、リセットボタンにはViewModelのCountResetCommandをバインディングしている(6)。TapCountプロパティは標準プロパティではないため、Forメソッドでバインディング先のプロパティを明示指定している。

Droidプロジェクトの実装

 続いて、Droidプロジェクト側でバインディングを定義する。Resourceslayoutフォルダー内にあるFirstView.axmlファイルを以下のように修正する。

XML
 
 
 
 
 
 
 
 
 
 
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>
リスト5 Droidプロジェクト側でのバインディング定義(FirstView.axml)

 ここでは、カウントの数を表示するラベルの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プロパティに反映されている。

図2 実装したアプリの動き

[Count Up]ボタンをタップすると数字がカウントアップし、[Reset]ボタンをタップすると数字が0にリセットされる。

TargetBindingを使用したカスタムバインディングの定義

 ここまで解説した方法はカスタムクラスを実装する場合は都合がよいが、NuGetパッケージやXamarin Componentsから取得した既存のコンポーネントに関して適用しようとする場合はそれらをサブクラス化してChangedイベントを追加するよりもMvxTargetBindingのサブクラスを実装し、ターゲット・バインディング・ファクトリに追加する方が都合がよい。

 この動きを確認するために、Touchプロジェクト・Droidプロジェクト共にTapCountButtonTapCountChangedイベントの名前を変更して命名規則からはずし、ターゲット・バインディング・ファクトリへ定義を追加してTwo-Wayバインディングに対応させる。

TapCountButtonのイベント名変更

 まず、Touchプロジェクト、Droidプロジェクト共にTapCountChangedプロパティの名前を「TapCountUp」と変更する。この変更はVisual StudioやXamarin Studioのリファクタリング機能を使うことで簡単にできる。MacのXamarin Studioでは、コード中のTapCountChangedプロパティの識別子部分にカーソルを当て、右クリックして[リファクタリング]-[名前の変更]と開く。表示されたダイアログボックスで目的の名前である「TapCountUp」と入力して[OK]ボタンをクリックすれば使用している箇所を全て変更してくれる。

図3 リファクタリング機能の[名前の変更]を使いこなせば変更を素早く行える

 この変更で、TapCountChangedイベントとTapCountプロパティのコードは以下のように変更される。

C#
public event EventHandler TapCountUp;

int tapCount = 0;

public int TapCount
{
  get { return tapCount; }
  set
  {
    tapCount = value;
    if (TapCountUp != null)
    {
      TapCountUp(this, EventArgs.Empty);
    }
  }
}
リスト6 「TapCountChanged」の名前を「TapCountUp」に変更した後のTapCountButtonクラスのコード

 この状態で実行すると、[Count Up]ボタンをタップしてもラベルの数字は0のまま動かない。TapCountChangedイベントが命名規則から外れ、MvvmCrossのバインディングシステムが値の変更を検出できなくなったためだ。

TargetBindingクラスの実装

 次に、TargetBindingクラスを実装する。TwoWaySample.Touchプロジェクトのプロジェクト直下にTargetBindingsフォルダーを作成する。次に、新しいファイルを追加するためのダイアログで[General]の[空のクラス]を選択して、名前に「TapCountButtonTapCountTargetBinding」と入力して[新規]ボタンをクリックする。作成されたTapCountButtonTapCountTargetBinding.csファイルを以下のように編集する。

C#
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);
    }
  }
}
リスト7 TargetBindingの実装(TapCountButtonTapCountTargetBinding.cs)

 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メソッドを呼び出すことにより追加を行う。

C#
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));
    }

    // -- ここまで追加 --

  }
}
リスト8 TouchプロジェクトのSetupクラスに対する追加(Setup.cs)

Droidプロジェクトの場合も、(「TwoWaySample.Touch」→「TwoWaySample.Droid」になるぐらいで)このコードとほぼ同じになるので、説明を割愛する。

 RegisterCustomBindingFactoryメソッドは、型引数としてバインディング先の型を、また第1引数に対象となるプロパティ、第2引数にTargetBindingを作成するラムダ式を指定するようになっている。

 この状態で実行すると、Changedイベントを使用した場合と同じようにアプリが動作する。

Hint: Androidでカスタムビューが別アセンブリにある場合の追加処理

 Androidでは、NuGetパッケージやXamarin Componentsからカスタムビューを取得したり、プロジェクト分割してあるなどの理由でバインディング先のビューウィジェットが別のアセンブリに含まれていたりする場合、MvvmCrossのバインディングシステムに対して目的のビューウィジェットが含まれるアセンブリを登録する必要がある。これはDroidプロジェクトのSetup.csファイルを開き、SetupクラスのAndroidViewAssembliesプロパティをオーバーライドして、使用するクラスのアセンブリを追加して返すことで可能だ。次のコードはその例である。

C#
protected override System.Collections.Generic.IList<System.Reflection.Assembly> AndroidViewAssemblies
{
  get
  {
    var assemblies = base.AndroidViewAssemblies;
    assemblies.Add(typeof(Iseteki.CustomControls.TapCountButton).Assembly);
    return assemblies;
  }
}
リスト9 カスタムビューウィジェットのアセンブリ追加

登録に必要な値は、ビューウィジェットのクラスをtypeofSystem.Typeオブジェクトを取得すれば、そのAssemblyプロパティから取得できる。

 なお、MvxAndroidSetupクラスのデフォルト実装で以下のアセンブリが指定されているため、これらのアセンブリに含まれているビューウィジェットに対する処理は行わなくてもよい。

  • Mono.Android
  • Cirrious.MvvmCross.Binding.Droid
  • Droidプロジェクトから作成されるアセンブリ

※以下では、本稿の前後を合わせて5回分(第53回~第57回)のみ表示しています。
 連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。

Xamarin逆引きTips
53. MvvmCrossでWebBrowserプラグインを使用するには?

WebBrowserプラグインを追加・利用する例を通じて、MvvmCrossでのiOS/Androidアプリ開発におけるMvvmCrossプラグインの基本的な使い方を説明する。

Xamarin逆引きTips
54. コードを書く前に正規表現をテストするには?(.NET/Xamarin対応)

.NET/Monoの基本クラスライブラリを使って正規表現を書く場合、そのテストはどうする? Xamarin Studioの正規表現ツールキットを使って手軽に行う方法を紹介。

Xamarin逆引きTips
55. 【現在、表示中】≫ MvvmCrossでカスタムコントロールをTwo-Wayバインディングに対応させるには?

MvvmCrossでのiOS/Androidアプリ開発において、カスタムビュークラスをTwo-Wayバインディングに対応させる方法を解説する。

Xamarin逆引きTips
56. Xamarin.FormsでAzureモバイルサービスによるToDoアプリを作成するには?

ひな型プロジェクトが用意されているXamarin.iOSやXamarin.Androidではなく、Xamarin.FormsからAzureモバイルサービスを活用する基本的な方法を、簡単なToDoアプリを題材に解説する。

Xamarin逆引きTips
57. MvvmCrossで画像をバインディングするには?

MvvmCrossでのiOS/Androidアプリ開発において、画像のURLをViewへバインディングできるMvxImageViewの使い方を説明する。

サイトからのお知らせ

Twitterでつぶやこう!