しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

Xamarin.Forms で特定のイベント発生時に ViewModel の Command を実行する

Xamarin.Forms のコントロールはまだまだプロパティ実装が不十分なこともあり、イベントは用意されているけどそのイベントが起きたときに実行されるような Command を Binding できないことが多々あります

イベントしか用意されていない場合、Binding で ViewModel の処理を呼び出すには、大きくは下記2つの方向性があるかなと思います

  • 指定したイベント発生時に Binding された Command を実行する Behavior を作る

  • EventTrigger から Binding された Command を実行する TriggerAction を作る

1つ目の方法は海外の方のブログですが、EventToCommand という Behavior として Xamarin.Forms.Behaviors v.1.1.0 « Corrado's Blog 2.0 で紹介されています

ただ、こちらの方法は2番目の方法と違って、イベント発生の監視から自作するので少し大がかりです;

そこで、2番目の方法で実現ができないか試してみました

用意したのは InvokeCommandAction という TriggerAction クラスです

/// <summary>
/// Command を実行するトリガーアクション
/// </summary>
public class InvokeCommandAction : TriggerAction<Element>
{
    #region Command

    /// <summary>
    /// Command
    /// </summary>
    private ICommand command = null;

    /// <summary>
    /// Command の Binding 情報
    /// </summary>
    public Binding Command { get; set; }

    #endregion //Command

    #region CommandParameter

    /// <summary>
    /// Command のパラメーター
    /// </summary>
    private object commandParameter = null;

    /// <summary>
    /// Command のパラメーターの Binding 情報
    /// </summary>
    public Binding CommandParameter { get; set; }

    #endregion //CommandParameter

    /// <summary>
    /// トリガーアクションを実行する
    /// </summary>
    /// <param name="sender">アクション実行者</param>
    protected override void Invoke(Element sender)
    {
        if (this.Command == null || this.Command.Path == null || sender.BindingContext == null)
        {
            return;
        }

        // Binding 情報を解析して、Element.BidingContext から Command と CommandParameter を取得する 
        var bindingContext = sender.BindingContext;
        if (string.IsNullOrWhiteSpace(this.Command.Path))
        {
            this.command = bindingContext as ICommand;
        }
        else
        {
            var value = (from p in bindingContext.GetType().GetPropertiesHierarchical()
                            where p.CanRead && this.Command.Path.Equals(p.Name)
                            select p.GetValue(bindingContext)).FirstOrDefault();
            if (this.Command.Converter != null)
            {
                value = this.Command.Converter.Convert(
                    value,
                    typeof(ICommand),
                    this.Command.ConverterParameter,
                    CultureInfo.CurrentCulture);
            }
            this.command = value as ICommand;
        }
        if (string.IsNullOrWhiteSpace(this.CommandParameter.Path))
        {
            this.commandParameter = bindingContext;
        }
        else
        {
            var value = (from p in bindingContext.GetType().GetPropertiesHierarchical()
                            where p.CanRead && this.CommandParameter.Path.Equals(p.Name)
                            select p.GetValue(bindingContext)).FirstOrDefault();
            if (this.CommandParameter.Converter != null)
            {
                value = this.CommandParameter.Converter.Convert(
                    value, 
                    typeof(object), 
                    this.CommandParameter.ConverterParameter,
                    CultureInfo.CurrentCulture);
            }
            this.commandParameter = value;
        }

        // 実行可能であれば Command を呼び出す
        if (this.command == null || !this.command.CanExecute(this.commandParameter))
        {
            return;
        }

        this.command.Execute(this.commandParameter);
    }
}

本来なら Command や CommandParameter のプロパティは BindableProperty にして Binding 可能にするのですが、なんと TriggerAction は BindableObject を継承してないので、BindableProperty を持つことができません!

このあたり、Xamarin の中の人に「なんでやねん!」と文句を言いたいですが、ないものは仕方がないのでやってみたのが上記のコードです

Command と CommandParameter を Biding 型にして、TriggerAction が呼び出された際に Binding 情報をもとに BindingContext から Command と CommandParameter をリフレクションで引っ張ってくるという荒業をやっています

Command が呼び出されるたびにリフレクションが動くので、パフォーマンス的に問題があるのですが、それほど頻繁に Command が呼び出されないのなら大丈夫とは思います・・・きっとそのうち TriggerAction が BindableObject を継承してくれるだろうし!!

お試しのために XAML に組み込んでみました

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:cv="clr-namespace:XamarinBandSample.Converters;assembly=XamarinBandSample"
             xmlns:t="clr-namespace:XamarinBandSample.Triggers;assembly=XamarinBandSample"
             xmlns:prismmvvm="clr-namespace:Prism.Mvvm;assembly=XamarinBandSample"
             prismmvvm:ViewModelLocator.AutoWireViewModel="true"
             x:Class="XamarinBandSample.Views.TopPage">
  ~ 中略 ~
        <Label Text="Detecting"
               Grid.Column="0"
               Grid.Row="0"
               VerticalOptions="Center"
               FontSize="Medium"/>
        <Switch IsToggled="{Binding IsSensorDetecting}"
                Grid.Column="1"
                Grid.Row="0">
          <Switch.Triggers>
            <EventTrigger Event="Toggled">
              <t:InvokeCommandAction Command="{Binding ChangeDetectSensorsCommand}" 
                                     CommandParameter="{Binding IsSensorDetecting}"/>
            </EventTrigger>
          </Switch.Triggers>
        </Switch>

  ~ 中略 ~

</ContentPage>

Switch コントロールに Toggled イベント発生時に ChangeDetectSensorsCommand を実行するように EventTigger、InvokeCommandAction を記述しました

ViewModel 側はこんな感じ

/// <summary>
/// トップ画面の ViewModel
/// </summary>
public class TopPageViewModel : BindableBase
{
  ~ 中略 ~

    #region IsSensorDetecting

    /// <summary>
    /// センサー監視フラグ
    /// </summary>
    private bool isSensorDetecting = false;

    /// <summary>
    /// センサー監視フラグ
    /// </summary>
    public bool IsSensorDetecting
    {
        get { return this.isSensorDetecting; }
        set { this.SetProperty(ref this.isSensorDetecting, value); }
    }

    #endregion //IsSensorDetecting

  ~ 中略 ~

    /// <summary>
    /// センサー監視切替コマンド
    /// </summary>
    public ICommand ChangeDetectSensorsCommand { get; private set; }

    #endregion //ChangeDetectSensorsCommand

  ~ 中略 ~

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="manager">Microsoft Band デバイス管理クラス</param>
    [InjectionConstructor]
    public TopPageViewModel(IBandManager manager)
    {
        this.manager = manager;
        this.SelectBasicsCommand = new DelegateCommand(this.SelectBasics, () => { return !this.ShowBasics; });
        this.SelectSensorsCommand = new DelegateCommand(this.SelectSensors, () => { return !this.ShowSensors; });
        this.ConnectCommand = DelegateCommand.FromAsyncHandler(this.Connect);
        this.ChangeDetectSensorsCommand = DelegateCommand<bool>.FromAsyncHandler(this.ChangeDetectSensors);
    }

  ~ 中略 ~

    /// <summary>
    /// センサー監視切替
    /// </summary>
    /// <param name="detecting">センサー監視フラグ</param>
    /// <returns>Task</returns>
    private async Task ChangeDetectSensors(bool detecting)
    {
        if (detecting)
        {
            //TODO: センサー監視開始処理
        }
        else
        {
            //TODO: センサー監視終了処理
        }
    }
}

通常通り Command を作っているだけです

このコードで ChangeDetectSensors() のメソッドブレークポイントを張って実行してみました

Switch を OFF → ON に切り替えると処理が中断

f:id:matatabi_ux:20150419164338g:plain

ちゃんと CommandParameter も伝搬して Command が呼び出されました

これでコントロールにイベントさえあれば、任意の Binding された Command を呼び出せそうです