SourceChord

C#とXAML好きなプログラマの備忘録。最近はWPF系の話題が中心です。

WPF4.5の新機能~「イベントのマークアップ拡張」で、イベント発生時のコマンド呼び出しをスッキリ記述する~

この記事はXAMLアドベントカレンダー2014 8日目の記事です。

前置き

先日のConnect();では、久しぶりにWPFの今後のロードマップの話がありましたねw
WPFの次バージョンが気になる今日この頃ですが、今日は現時点での最新版WPF4.5で追加されていた新機能についてのお話をしたいと思います。

WPF4.5の新機能

WPF4.5で追加された新機能、ちゃんと使ってみると、地味に便利な機能が多々あるのです。
新機能一覧は以下のページにまとまっています。
WPF Version 4.5 の新機能
サンプルコードもないし、説明も微妙にわかりにくいので、ちょっと残念な感じですが。。。

この記事で作る物

この記事では、WPF4.5の新機能の一番最後に書かれている、「イベントのマークアップ拡張」についてご紹介します。
イベントのマークアップ拡張を用いると、「イベント発生時にVMのコマンド呼び出しを行う」という処理をXAMLでシンプルに書けるようになります。
同様の事はBlendSDKのInvokeCommandActionを用いても書くことができます。しかし、比較してみるとXAMLのスッキリ具合は一目瞭然。

f:id:minami_SC:20141208004718p:plain


また、この記事ではデータバインディングやMVVMなどについての予備知識が必要になるかと思います。
「MVVMってな~に?」って方は、XAML Advent Calendar3日目のkaorunさんの記事を是非!!
サンプルコードを見ながら理解するMVVMの基礎的な実装 - Neutral Scent
「イベントのマークアップ拡張」の作成は、上記記事の最後で出てきているCommandの呼び出し方の延長のような内容になるかと思います。


少々長くなりますがお付き合いくださいませ。

それでは、さっそく本題に入っていきたいと思います。

イベントのマークアップ拡張とは

前述の新機能についてのリンクでは、以下のような一文で説明されています。

WPF4.5 はイベントのマークアップ拡張をサポートします。 WPF で定義されていないイベントのマークアップ拡張は、誰でも作成できます。

なんのこっちゃ??って感じですね。
要はXAMLのイベントのプロパティに対しても、マークアップ拡張を適用することができるようになったということのようです。

マークアップ拡張

まずはマークアップ拡張とはなんぞや、という説明です。
マークアップ拡張というのは、XAMLを書いてて時々出てくる、中かっこ{}で括られた部分。BindingとかStaticResourceとか、そういったものを指します。
f:id:minami_SC:20141208004812p:plain
また、MarkupExtensionを継承するクラスを作ることで、独自のマークアップ拡張を作ることもできます。

イベントのマークアップ拡張でできること

今まで、XAMLで以下のように普通にイベントの設定をすると、コードビハインドのイベントハンドラ呼び出しを指定することしかできませんでした。
f:id:minami_SC:20141208011936p:plain

VMでICommandを作っておくと、View側のButtonやMenuItemからバインドして実行できるようになりますが、
VMで作成したコマンドをバインドすることができるのは、ButtonBase派生の各種ボタン系クラスやMenuItemなど、ICommandSourceを実装している一部のコントロールのみです。
それ以外のコントロールや任意のイベントでからVMのコマンドを実行したい、という場合には、イベントハンドラを使うか、BlendSDKの中にあるInvokeCommandActionなどを用いる、というのが一般的な方法だったかと思います。

しかし、WPF4.5では、このマークアップ拡張をイベントのプロパティにも適用することができるようになりました。独自のマークアップ拡張を作って、VMのコマンド呼び出しを設定することもできるようになります。
要はBlendSDKのInvokeCommandActionやCallMethodActionのようなものが、マークアップ拡張を使ってスッキリ書けるようになります。
InvokeCommandActionなどと比較してみると、以下のようなメリット/デメリットがあります。
f:id:minami_SC:20141208004820p:plain
ビヘイビアなどとは異なり、BlendのUI上からの設定はできませんが、
XAMLの記述は非常にシンプル&インテリセンスも効くので、XAML手書き派の人にはとても便利なのではと思います。


実装してみる

ということで、さっそくイベントのマークアップ拡張を書いてみます。
マークアップ拡張の書き方自体についての詳細は、Web上の数多のページにお任せするとして今回は割愛します。。


まずは、MarkupExtensionから派生するクラスを作ります。ここでは、InvokeCommandExtensionというクラスを作成します。

独自のMarkupExtensionを作るには、以下二つの手順を行えばOKです。

  • MarkupExtensionを継承するクラスを作成
    • クラス名には、サフィックスとしてExtensionを付けます。
    • マークアップ拡張のクラス名は、XAMLで記述する際に末尾のExtensionという部分は省略して書くことができるため。
  • 作成したクラスで、ProvideValueメソッドをオーバーライドする。

ProvideValueメソッド

ProvideValueメソッドでは、イベントハンドラとして実行されるデリゲートを返す必要があります。
リフレクションなどを駆使して、イベント発生時に実行すべき内容のデリゲートを作っていきます。

対象のイベントの解析

まずは、ProvideValueメソッドの引数として渡されるserviceProviderから、マークアップ拡張が書かれた要素についての情報を取得します。
つづいて、GetServiceメソッドを呼び出し、IProvideValueTargetを取得します。
ここで注意が必要なのですが、この取得した結果のTargetPropertyでは、MethodInfoが渡される場合と、EventInfoが渡される場合とがあります。

↓のページによると、対象のイベントが添付イベントか通常のイベントかによって挙動が違うようです。
https://connect.microsoft.com/VisualStudio/feedback/details/695888
MouseEnterなどのような添付プロパティとして作られているイベントではMethodinfoが渡され、Clickなどのようなコントロール自身が持つイベントの場合はEventInfoが渡されるようです。

どちらの場合にも対応できるように、EventInfoとMethodInfo両方でキャストしてみて、変換できた方を使うようにコードを書いています。以下の部分がそのコードです。
(これって、C#6のnull伝搬演算子が使えるようになれば、もうちょいスッキリ記述できそうですね。)

var ei = pvt.TargetProperty as EventInfo;
var mi = pvt.TargetProperty as MethodInfo;
var type = (ei != null) ? ei.EventHandlerType :
                            (mi != null) ? mi.GetParameters()[1].ParameterType :
                                            null;
コマンドを呼び出すイベントハンドラの作成

この部分はリフレクションを多用していて少々複雑ですが、さらっとだけ解説します。

イベントハンドラは、一般的に第一引数にobject型のsenderが渡され、第二引数でEventArgs派生の任意の型のパラメータが渡されます。
この第二引数の実際の型はイベントごとに異なるため、ジェネリックメソッドを用意しておきます。
そして、前述のEventInfo/MethodInfoから取得したパラメータの型を用いて、MakeGenericMethodメソッドジェネリックの型引数を固定したイベントハンドラを作成して返してます。
うーん複雑。。。。

var target = pvt.TargetObject as FrameworkElement;

// ここで、イベントハンドラを作成し、マークアップ拡張の結果として返す
_targetCommand = (ICommand)ParsePropertyPath(target.DataContext, this.BindingCommandPath);

var nonGenericMethod = GetType().GetMethod("PrivateHandlerGeneric", BindingFlags.NonPublic | BindingFlags.Instance);
var argType = type.GetMethod("Invoke").GetParameters()[1].ParameterType;
var genericMethod = nonGenericMethod.MakeGenericMethod(argType);

return Delegate.CreateDelegate(type, this, genericMethod);

コード

今回作成したマークアップ拡張は以下の通りです。

InvokeCommandExtension.cs
    [MarkupExtensionReturnType(typeof(EventHandler))]
    public sealed class InvokeCommandExtension : MarkupExtension
    {
        /// <summary>
        /// イベント発生時の呼び出すコマンドのパスを取得または設定します。
        /// </summary>
        public string BindingCommandPath { get; set; }

        // イベント発生時に呼び出すコマンド
        private ICommand _targetCommand;


        public InvokeCommandExtension(string bindingCommandPath)
        {
            this.BindingCommandPath = bindingCommandPath;
        }

        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            var pvt = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;

            if (pvt != null)
            {
                var ei = pvt.TargetProperty as EventInfo;
                var mi = pvt.TargetProperty as MethodInfo;
                var type = (ei != null) ? ei.EventHandlerType :
                                          (mi != null) ? mi.GetParameters()[1].ParameterType :
                                                         null;

                if (type != null)
                {
                    var target = pvt.TargetObject as FrameworkElement;

                    // ここで、イベントハンドラを作成し、マークアップ拡張の結果として返す
                    _targetCommand = (ICommand)ParsePropertyPath(target.DataContext, this.BindingCommandPath);

                    var nonGenericMethod = GetType().GetMethod("PrivateHandlerGeneric", BindingFlags.NonPublic | BindingFlags.Instance);
                    var argType = type.GetMethod("Invoke").GetParameters()[1].ParameterType;
                    var genericMethod = nonGenericMethod.MakeGenericMethod(argType);

                    return Delegate.CreateDelegate(type, this, genericMethod); ;
                }

            }

            return null;
        }

        private void PrivateHandlerGeneric<T>(object sender, T e)
        {
            // コマンドを呼び出す
            if(_targetCommand != null && _targetCommand.CanExecute(e))
            {
                _targetCommand.Execute(e);
            }
        }

        /// <summary>
        /// target引数で渡されたオブジェクトに対し、pathで示されたプロパティをリフレクションを用いて取得します。
        /// </summary>
        /// <param name="target"></param>
        /// <param name="path"></param>
        /// <returns></returns>
        static object ParsePropertyPath(object target, string path)
        {
            // DataContextがnullの場合の対処
            if (target == null)
                return null;

            var props = path.Split('.');
            foreach(var prop in props)
            {
                target = target.GetType().GetProperty(prop).GetValue(target);
            }
            return target;
        }
    }

使い方

作成したマークアップ拡張を使うために、名前空間の定義をしておき、
以下のようにイベントのプロパティで、VMの呼び出したいコマンドを指定するだけです。

            <Rectangle Width="120"
                       Height="88"
                       Fill="#FFF4F4F5"
                       MouseEnter="{local:InvokeCommand ShowMessageCommand}"
                       Stroke="Black" />

こうすると、MouseEnterイベント発生時に、VMのShowMessageCommandが呼び出されます。


イベントの引数を使う

CommandParameterを受け付けるようにICommandを実装した場合には、イベントのパラメータを受け取ることもできます。
対象イベントのイベントハンドラのEventArgsと同様の型がコマンド実行時に引数としてわたってきます。

例えば、ファイルをドロップした時に発生するイベント、Dropイベント発生時にコマンドを実行するように以下のようにXAMLを書いたとします。

            <Image Grid.Row="1"
                   AllowDrop="True"
                   Drop="{local:InvokeCommand DropFile}" />       

このとき、以下のように作ったコマンドのインスタンスVMのDropFileプロパティに設定しておくと、Dropイベントの引数をコマンドで使用することができます。

    public class DropFileCommand : ICommand
    {
        public bool CanExecute(object parameter)
        {
            return true;
        }

        public event EventHandler CanExecuteChanged;

        public void Execute(object parameter)
        {
            var args = parameter as DragEventArgs;
            if (args != null)
            {
                var fileInfos = args.Data.GetData(DataFormats.FileDrop) as string[];
                string str = string.Join("\n", fileInfos);
                MessageBox.Show(str, "ドロップされたファイル一覧");
            }
        }
    }

※ここでは説明のためにICommandを直接実装したコマンドのクラスを作りましたが、
RelayCommandやDelegateCommandなどと呼ばれる、ICommandの汎用的な実装と一緒に使った方がよいかと思います。

サンプル

せっかくなので、このInvokeCommandマークアップ拡張を使い、XAMLから各種イベント発生時にてVMのコマンドを呼び出すサンプルを作ってみました。

VMの実装にはBindableBase/RelayCommandというヘルパークラスを併用しています。
BindableBaseクラスは、INotifyPropertyChangedを実装するためのベースクラス、RelayCommandはICommandの汎用的な実装です。
prismやMvvmLightToolkitなどでも同じようなコードがあるかと思いますが、そういうよくある実装と同じものを使っていただければ問題ありません。
ここでは以下二つの記事で書いたBindableBase/RelayCommandというクラスを使っています。
Win8/Win8.1のストアアプリのテンプレートから拝借してきたコードをちょこっと改変したものです。
WPFでもBindableBaseを使ってINotifyPropertyChangedを実装する - SourceChord
XAMLからViewModelのメソッドにバインドする~RelayCommand~ - SourceChord

プロジェクト一式を見たい場合は、この記事の最後の方に書いたCodeplexのページを見てください。

サンプルの内容

実行すると以下のような画面が表示されます。
f:id:minami_SC:20141208020212p:plain:w300

以下二つのイベント発生時に、VMのShowMessageCommandを実行します。
このコマンドが実行されると、メッセージボックスの表示が行われます。
・TextBoxのGotFocusイベント
・RectangleのMouseEnterイベント

「コマンドが実行された」ということを分かりやすく示すために、VMからメッセージボックスの表示を行ってます。MVVM的にはお行儀悪いですね。。。(汗


画面右側では、ファイルドロップ時のイベントを受け付けるサンプルとなっています。
Imageコントロールにエクスプローラなどから画像ファイルをドロップすると、表示する画像が切り替わります。

Imageコントロールには以下3か所の設定をしています。。

AllowDrop True・・・こいつをTrueにしておくことで、コントロールがファイルドロップを受け付けるようになります。
Source VMのImagePathプロパティにバインド。※ImagePathがnullの場合には、デフォルトの画像を表示するようにTargetNullValueの設定を行っています。(Imageコントロールが何も表示してないと、Width/Heightが0になってしまって、ファイルドロップを受け付けるUIの領域がなくなってしまうので。。。)
Dropイベント 今回作成したInvokeCommandマークアップ拡張でVMのDropFileCommandを呼び出します。DropFileCommandコマンドは、DropEventArgs型の引数を受け付けるコマンドとして実装しています。コマンドが実行されると、ドロップされたファイルのパスをImagePathに設定します。

サンプルコード

MainWindow.xaml
<Window x:Class="MarkupExtensionsForEvents.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:MarkupExtensionsForEvents"
        Title="MainWindow"
        Width="525"
        Height="350">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <Window.Resources>
        <BitmapImage x:Key="imgDefault" UriSource="Image/sample.jpg" />
    </Window.Resources>
    <Grid ShowGridLines="True">
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>

        <StackPanel VerticalAlignment="Center">
            <TextBlock Text="GotFocusイベントが発生すると、ShowMessageCommandを呼び出します。" TextWrapping="WrapWithOverflow" />
            <TextBox Width="120"
                     Height="23"
                     Margin="0,0,0,30"
                     GotFocus="{local:InvokeCommand ShowMessageCommand}"
                     Text="TextBox"
                     TextWrapping="Wrap" />

            <TextBlock Text="MouseEnterイベントが発生すると、ShowMessageCommandを呼び出します。" TextWrapping="WrapWithOverflow" />
            <Rectangle Width="120"
                       Height="88"
                       Fill="#FFF4F4F5"
                       MouseEnter="{local:InvokeCommand ShowMessageCommand}"
                       Stroke="Black" />
        </StackPanel>

        <StackPanel Grid.Column="1">
            <TextBlock Text="Drop image file here." />
            <Image Grid.Row="1"
                   AllowDrop="True"
                   Drop="{local:InvokeCommand DropFileCommand}"
                   Source="{Binding ImagePath,
                                    TargetNullValue={StaticResource imgDefault}}" />
        </StackPanel>

    </Grid>
</Window>

MainWindowViewModel.cs

    /// <summary>
    /// MainWindow.xamlに使用するViewModel
    /// </summary>
    class MainWindowViewModel : BindableBase
    {
        private string imagePath;
        /// <summary>
        /// 表示する画像のパスを取得または設定します。
        /// </summary>
        public string ImagePath
        {
            get { return imagePath; }
            set { this.SetProperty(ref this.imagePath, value); }
        }


        #region コマンドの実装
        private RelayCommand showMessageCommand;
        public RelayCommand ShowMessageCommand
        {
            get { return showMessageCommand = showMessageCommand ?? new RelayCommand(ShowMessage); }
        }
        private void ShowMessage()
        {
            MessageBox.Show("ShowMessage Command Invoked!!");
        }

        private RelayCommand<DragEventArgs> dropFileCommand;
        public RelayCommand<DragEventArgs> DropFileCommand
        {
            get { return dropFileCommand = dropFileCommand ?? new RelayCommand<DragEventArgs>(DropFile); }
        }

        private void DropFile(DragEventArgs parameter)
        {
            var fileInfos = parameter.Data.GetData(DataFormats.FileDrop) as string[];
            if (fileInfos != null)
            {
                // ドロップされたファイルの1番目の要素のパスを、画面表示用のプロパティに設定
                this.ImagePath = fileInfos[0];
            }
        }
        #endregion
    }

制限

今回実装したInvokeCommandExtensionクラスにはいくつかの制限があります。

DataContextの変更に追従しない

ProvideValueが呼ばれた時点でのDataContextから呼び出すコマンドの設定などをしています。そのため、実行時にDataContextを入れ替えたりした場合に、その変更に追従せず、元のDataContextのコマンドを呼び出してしまいます。
対策としては、ProvideValueメソッド内で、DataContextChangedイベントのイベントハンドラを設定しておき、DataContextChangedが起きたら、呼び出すコマンドなどをの再設定を行うようにすればよいかな、と思います。
この辺はまた近い内に対処してアップデートしていきたいと思います。

コレクション走査パスでのバインディングには未対応

ListBoxのItemsSourceにコレクションをバインドした場合などによく使う、選択した項目の要素を取得する以下の構文には対応していません。
(/を付けて、リストで選択中の要素を指定する構文)
f:id:minami_SC:20141208004831p:plain

EventArgsの使用には注意

これは制限というより、注意すべき点ですが、、、
ここで作成したInvokeCommandExtensionでは、イベントのEventArgsをCommand呼び出し時のパラメータとして渡しています。
そのため、VM側のコマンドでEventArgsを使うことができます。

、、、が、RoutedEventArgsなどではメンバにSourceプロパティを持つので、View側の要素にいとも容易く触れることができてしまいます。
気を付けないと、Vにベッタリと依存したVMになってしまうので、使う際には意識しておいた方がよいかと思います。

サンプルコード

ここで作ったマークアップ拡張と、それを利用したサンプルコードを、Codeplexに上げておきました。
興味のある方は是非ご覧になってください。

MarkupExtensionsForEvents


2016/01/20追記

GitHubに移行しました

不具合修正

以前、「制限」に書いてた不具合も、以下の通り直しています。

  1. DataContextの変更に追従するようにしました。
  2. コレクション走査パスでのバインディングに対応しました。

参考にしたページ

この記事を書くにあたって、以下のページを参考にさせていただきました。

WPF 4.5: Markup Extension for Events | Pavel's Blog
上記のページを見て、WPFでこんなことできるのか!?と随分驚きました。
今回の記事は基本的には、この辺の海外のサイトの記事を見て、自分なりに解釈した結果をまとめたものです。

Silverlight 5 betaのMVVMサポートのキーはマークアップ拡張だった? - かずきのBlog@hatena
Silverlight5では、実は同じようなことができてたみたいですね。
ProvideValueの実装方法などを参考にさせてもらってます。





明日のXAML Advent CalendarはP3PPPさんです。
よろしくお願いします♪