2015年11月18日水曜日

ListViewItemのイベントをMVVMスタイルのプログラムで受信する

WPFでListViewを使うときというのは、たいてい、複数のプロパティを持つようなデータが複数あるときですよね。それゆえに、たいていItemsSourceに各々のデータに対応するViewModelをバインディングして、ListView.ViewにDisplayMemberBindingなどを設定したGridViewColumnを設定したGridViewを設定して使うことになると思います。
内部的には、そのデータに対応するViewはListViewItemになっていて、ItemsSourceで指定したVMがそのDataContextに指定されています。そして、ListViewItemのプロパティならば、ListView.ItemContainerStyleを使ってスタイルを指定することでいじることができます。例えばこんな感じです。

<ListView ItemsSource="{Binding Data}" >
    <!-- 中略 -->
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem" >
            <Setter Property="ContextMenu" >
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Open" Command="{Binding OpenCommand}" />
                        <MenuItem Header="Close" Command="{Binding CloseCommand}" />
                    </ContextMenu>
                </Setter.Value>                            
            </Setter>
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

このXAMLは、リストビューで表示している各項目に対してコンテキストメニューを表示させるようにしています。このスタイルはListViewItemに対して適用されているので、メニューにバインディングしたコマンドなど(上記の例ではOpenCommandやCloseCommand)は各アイテムのViewModelにバインドされます(上記の例ではData)。非常に素直な設計だと思います。データに対する操作を各データのVMが受け取るわけですからね。
このほかにもListView.ContextMenuのほうにコンテキストメニューを登録し、SelectedItemなどをうまいこと使ってデータ処理をすることもできますが、この場合はヘッダーを右クリックしてもメニューが出てきてしまう点に問題があります(ListViewコントロール全域に対してコンテストメニューが登録されているわけですからね)。その観点からも、やはり上記のような実装が素直だと言えます。

さて、ここまでは非常に王道なのですが、ListViewItemが発行するイベントを受信しようとすると、とたんに問題が難しくなります。
前述の通り、ListViewItemは直接いじれないので、スタイルを介してプロパティ等をいじることになります。そして、プロパティと同様にEventSetterというセッターがあり、これを通すことでイベントの受信も可能になります。
しかし、EventSetterは、そのイベントをコードビハインドに用意したイベントハンドラに飛ばします。コードビハインドで受信をしてしまうと、それをViewModelにきれいに飛ばすのがなかなか難しくなります。コードビハインドでDataContextを適当なViewModel型にキャストしメソッドを呼び出せば確かにViewからViewModelにイベントの発生を伝えることができますが、XAMLで表現したバインディング以外にデータの流れる経路ができてしまうことには非常に抵抗感があります。
もちろんですが、スタイルにはi:Interaction.TriggersにEventTriggerを入れて使う、なんてこともできません。それができれば何も苦労はしないんですがねえ。

というわけで、今回はいろいろ試行錯誤した結果、いかにListViewItemで発生したイベントを受信し、それをViewModelに伝えるかということを綴っていきたいと思います。

結論から言いますが、ReactivePropertyを使いました。別にReactivePropertyがこれ用の機能を持っているというわけでもないんですが、一番使いやすかったので使いました。まずは、XAMLのほうから紹介します。

<ListView ItemsSource="{Binding Data}" >
    <ListView.View>
        <GridView>
            <!-- 中略 -->
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem" >
            <!-- 中略 -->
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="MouseDoubleClick">
            <rp:EventToReactiveProperty ReactiveProperty="{Binding ListViewDoubleClicked}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</ListView>

ListViewのほうにEventTriggerを指定し、ダブルクリックのイベントを受信しています。EventTriggerは多くのMVVMスタイルのXAMLで見かけるものなので特段説明はいらないかと思います。この時点では、たとえばヘッダー領域などを含んだ、ListView全体のダブルクリックを拾ってしまう点に注意する必要があります。
さて、EventTriggerで指定したEventToReactivePropertyですが、これがReactivePropertyでサポートしている機能の1つで、イベントを直接ViewModelのReactiveProperty(IObservable<T>とINotifyPropertyChangedを実装したプロパティ)に飛ばすことができます。飛んでくるデータソースはRoutedEventArgs(もちろんイベントによってはそれを継承したEventArgsだったりする)で、ViewModelにEventArgsの処理を書くのは行儀が悪いので、作者の方のブログではReactiveConverter<T, U>を継承したクラスを作り、イベントをより実用的な型に変換しておりました。
しかし、どうもReactiveConverter<T, U>を挟むと、イベントの送り主がAssosiateObject (この場合はListView)になってしまうようです。挟まなかった場合、ListViewItemのさらにいくつか子要素(TextBlockだったり、Borderだったりします)が送り主になるようです。ListViewItemの子要素ならば、DataContextはListViewItemのものになるはずですから、これをいいことに、DataContextに関連付けられているViewModelにダブルクリックのイベントを伝えることにしました。

なお、上記のXAMLではちゃんと{Binding ListViewDoubleClicked}という形でイベントの受け取り先のReactivePropertyがバインディングされており、コードビハインドとViewModelの間につながりを持たせるということは行わずに済んでいます。

public MainWindowViewModel()
{
    ListViewDoubleClicked
        .Select(p => new { EventArgs = p, ViewModel = (p?.Source as System.Windows.FrameworkElement)?.DataContext as DataElementViewModel })
        .Where(p => p.ViewModel != null)
        .Do(p => p.EventArgs.Handled = true)    //HandledをtrueにするためにDoを挟む
        .Select(p => p.ViewModel)
        .Subscribe(p => p.DoubleClicked());
}

public ReactiveProperty<System.Windows.Input.MouseButtonEventArgs> ListViewDoubleClicked { get; } = new ReactiveProperty<System.Windows.Input.MouseButtonEventArgs>();

つづいてViewModelの一部です。上記の通り、素のままではListViewが発行したすべてのダブルクリックイベントを受信してしまうので、Reactive Extensionsを使ってそれをフィルタリングしています。Reactive ExtensionsはLINQを使って時間的に流れてくるデータをフィルタリングや変換できる、とても強力なライブラリです。
まずはSelectで受信したRoutedEventArgsと、そのViewModelを合成した匿名クラスを作っています。null条件演算子をふんだんに使い、指定のViewModelの型にできなかったときはnullになるように作っています。そして、次にWhereでそのnullになったものを弾き、生き残ったものに対してDoを挟んでEventArgsのHandledをtrueにする作業をしています。最後にViewModelをSelectし、SubscribeでViewModelにダブルクリックが行われたときに呼ぶべきメソッドを呼ぶようにしています。

ViewModelでWPFのRoutedEventの処理をやっている時点で気持ち悪いって言えば気持ち悪いですが、うーん、コードビハインドからViewModelのメソッドを呼ぶのと比べてどっちが気持ち悪いんだろうな…。

0 件のコメント:

コメントを投稿