ラベル Reactive Extensions の投稿を表示しています。 すべての投稿を表示
ラベル Reactive Extensions の投稿を表示しています。 すべての投稿を表示

2026年8月22日土曜日

DynamicData入門

C#にはLINQという素晴らしい機能があります。平たく言えば(LINQ to Objectsを念頭に説明するならば)、配列の内容を自由に変形させるための機能で、これにより、配列から特定のデータを抽出したり、並び替えたり、変換したりすることが非常に容易にできるようになりました。

そして、Reactive Extensions (Rx)というライブラリが生まれました。LINQ to Objectsが配列を変形させるものだったのに対して、Rxはオブジェクトの時系列での変化をLINQで変形させてしまおうという発想のものでした。これまた革命的で、いわゆるC#の「イベント」という機能をレガシーに追いやったものでした。

しかし、Rxでの配列の取り扱いはイマイチでした。配列の中身だって時系列で変化するので、それをReactive(反応的)に変形して観測したいというニーズはあるはずです。RxにはReactiveCollectionクラスがあるものの、実際はINotifyCollectionChangedインターフェースで発生するイベントをRxでラッピングした程度のもので、LINQ to Objectsの感覚でコレクションを変形し、それをそのままObservableCollectionの感覚で観測するということまではできませんでした。

例えば、あるObservableCollectionの要素のうち偶数の要素のみを取り出したObservableCollectionを欲しくなったとします。元のObservableCollectionに3が追加されても変形後のObservableCollectionでは何もイベントが発生されませんが、4が追加されたらAddイベントが発生するようなものです。そうすると以下のようなコードを書きたくなりますが、これではうまく動きません。

var list = new ObservableCollection<int>();
var filtered = list
    .Where(p => p % 2 == 0)
    .ToReadOnlyObservableCollection();

そもそもこのコードはToReadOnlyObservableCollectionのところでコンパイルエラーになります。それもそのはず、Whereメソッドは単にLINQ to Objectsの機能に過ぎないのです。

そこで登場したのがDynamicDataです。まさに、LINQ to Objectsの感覚でコレクションを変形し、その結果をそのままReadOnlyObservableCollectionに投影してくれるのです。Rxの痒い所に手が届く、もしくはLINQ to ObjectsとRxを統合させた最終形態とも言うべきライブラリです。今回はこれを掘り進めていきます。

DynamicDataの基本

コレクション型

DynamicDataには2つのコレクション型があります。

var list = new SourceList<string>();
var chache = new SourceCache<string, int>(p => p.Length);

SourceListはただのリストです。一方でSourceCacheはいわゆるHashSetのようなもので、キーの値が同じ要素は最大で1つまでしかコレクション内には存在しません。キーはコンストラクタでキーセレクタを与えることにより定義します。

要素の編集

要素の追加、削除などは直感的な名前の拡張メソッドを介して行うことができます。

list.Add("abc");
list.Remove("abc");
cache.AddOrUpdate("abc");
cache.Remove("abc");

Add系/Remove系はこれで問題なくできますが、あらゆるコレクション操作がここでできるわけではありません。実はこのような使い方はDynamicDataの真髄ではありません。 DynamicDataにはEditメソッドがあります。

list.Edit(l => {
    l.AddRange(["abc", "def", "ghi"]);
    l[0] = "ABC";
});

こうすることで、Editメソッド内のデリゲートの処理が終わった後にまとめて変更通知を行ってくれるようになるのです。これにより無駄な変更通知呼び出しが発生せず、負荷の削減につながります。AddやRemoveなどの拡張メソッドは、実はこのEditを呼び出しているだけに過ぎないのです。

要素の閲覧

単純に配列のように要素を読み取りたかったとしても、実はSourceListやSourceCacheはIReadOnlyCollection<T>やIEnumerable<T>を実装していません。Itemsプロパティを経由して閲覧する必要があります。

foreach(var item in list.Items) {
    Console.WriteLine(item);
}

コレクションの変更監視

ついに来ました。やりたかったことです。早速ですがこんな感じになります。

ReadOnlyObservableCollection<string> output;
var list = new SourceList<string>();

list.Connect()
    .Transform(p => p.ToUpper())
    .Bind(out output)
    .Subscribe();

まず、DynamicDataでは、IObservable<IChangeSet<T>>型に対して様々な演算を与えていきます。IChangeSet<T>型はコレクションの変化(追加とか削除とか)を含んだ内容で、これをRxで流すことで時系列を追従できるようにしているのです。SourceList / SourceCacheからこのIObservable<IChangeSet<T>>を作るメソッドがConnect()になります。

続いて、ある要素を別の形に変換するメソッドがTransformになります。LINQ to ObjectsでのSelectメソッドに相当します。Connectが吐き出してくる方がIObservableなので、Rxと被らないようなメソッド名にせざるを得なかったのでしょうね。 

Bindメソッドで、ReadOnlyObservableCollectionにその変形後のコレクションをバインディングしています。これにより、ReadOnlyObservableCollectionとして変形後のコレクションの変更通知を受けられるようになります。

DynamicDataのメソッド一覧

DynamicDataでは、LINQ to Objectsとは異なる名前のメソッドでコレクションを変形させていくことになります。わかりやすいように対応表を作りました。

変換系演算子

LINQ DynamicData 説明
Select Transform 要素の変換
SelectMany TransformMany 複数のコレクションを単一のコレクションに展開する
Where Filter 要素のフィルタリング
OrderBy / OrderByDescending Sort 並び替え。SortメソッドにはComparerを引き渡す必要があるが、便利なSortExpressionComparer<T>が用意されている。
ThenBy / ThenByDescending Sort ComparerでThenByの機能を実現する必要がある(SortExpressionComparerで対応できる)。

特にポイントになるのは、例えばFilterやSortの引数にはIObservableな条件を渡せるというところです。例えば、以下のようなコードを書くことで、ソート条件をReactiveに変更することができ、ソート条件が変わって並び変わったタイミングでReadOnlyObservableCollectionの変更通知も発火します。

public record Person(string Name, string Pronunciation, int Age, double TallCentimeter);

var personComparer = new Subject<IComparer<Person>>();
ReadOnlyObservableCollection<Person> output;
var list = new SourceList<Person>();

list.Connect()
    .Sort(personComparer)
    .Bind(out output)
    .Subscribe();

personComparer.OnNext(SortExpressionComparer<Person>.Ascending(p => p.Age));

list.AddRange([
    new Person("宮内 れんげ", "みやうち れんげ", 6, 116.0),
    new Person("一条 ほたる", "いちじょう ほたる", 12, 164.0),
    new Person("越谷 夏海", "こしがや なつみ", 13, 155.0),
    new Person("越谷 小鞠", "こしがや こまり", 14, 140.0),
]);

foreach(var person in output) {
    Console.WriteLine(person);
}

// ソート条件を変更する(ここでコレクションの変更通知が発火する)
personComparer.OnNext(SortExpressionComparer<Person>.Ascending(p => p.TallCentimeter));

foreach(var person in output) {
    Console.WriteLine(person);
}

ちなみに、DynamicData.Alias名前空間には、Select / SelectMany / Whereという名前でTransform / TransformMany / Filterをラッピングした拡張メソッドが用意されています。LINQ to Objectsとなどと混同しそうなので見やすくなるのかは少し懐疑的ですが、必要に応じて活用しても良いでしょう。

グルーピング / 連結演算子

LINQ DynamicData 説明
GroupBy GroupOn キーを指定してグループ化する
Join InnerJoin SourceCache専用。いわゆる内部結合。
- InnerJoinMany SourceCache専用。いわゆる内部結合だが、同じキーを持つ要素をグルーピングする。
- LeftJoin SourceCache専用。いわゆる左外部結合。
GroupJoin LeftJoinMany SourceCache専用。いわゆる左外部結合だが、同じキーを持つ要素をグルーピングする。
- RightJoin / RightJoinMany SourceCache専用。いわゆる右外部結合。
- FullJoin / FullJoinMany SourceCache専用。いわゆる完全外部結合。

そもそものLINQ to ObjectsのGroupJoinは結合とグルーピングを同時に行うようなメソッドで、SQLには同等なものは存在しません。DynamicDataでは、SQLに倣って内部結合と3つの外部結合を実装していますが、LINQ to Objectsとも対応が取れるようにグルーピング機能を備えた~Manyメソッドも用意してくれているようです。例えば、GroupJoinっぽい使い方をするとしたら以下のようなコードになるでしょう。

public record Family(int Id, string Name);
public record Person(int Id, string Name, string Pronunciation, int Age, double TallCentimeter, int familyId);

var families = new SourceCache<Family, int>(f => f.Id);
var people = new SourceCache<Person, int>(p => p.Id);
ReadOnlyObservableCollection<(Family, Person[])> output;

families.Connect()
    .LeftJoinMany(people.Connect(), p => p.familyId, (f, g) => (family: f, people: g.Items.ToArray()))
    .Bind(out output)
    .Subscribe();

families.AddOrUpdate(new Family(1, "宮内家"));
families.AddOrUpdate(new Family(2, "一条家"));
families.AddOrUpdate(new Family(3, "越谷家"));
families.AddOrUpdate(new Family(4, "富士宮家"));

people.AddOrUpdate(new Person(1, "宮内 れんげ", "みやうち れんげ", 6, 116.0, 1));
people.AddOrUpdate(new Person(2, "一条 ほたる", "いちじょう ほたる", 12, 164.0, 2));
people.AddOrUpdate(new Person(3, "越谷 夏海", "こしがや なつみ", 13, 155.0, 3));
people.AddOrUpdate(new Person(4, "越谷 小鞠", "こしがや こまり", 14, 140.0, 3));

foreach((var family, var persons) in output) {
    Console.WriteLine($"{family}, People: [{string.Join(", ", persons.Select(p => p.Name))}]");
}

ちなみに、説明に書いてあるようにJoin系メソッドはSourceCacheでしか使えません。

Merge演算子

LINQ DynamicData 説明
- MergeChangeSets 複数のコレクションを一つのコレクションにマージします。
- MergeManyChangeSets コレクションの内部のコレクションをマージし、単一のコレクションとして扱います。(SelectManyのような挙動。)

LINQ to Objectsには存在しないMerge系メソッドです(Rxには存在します)。複数のコレクションを連結して一つのコレクションに仕立て上げます。ですが、単純に連結したものというより、Merge元のコレクションで行ったAdd / Remove / Moveなどの操作がMerge後のコレクションでも行われるみたいなイメージになります。SourceListの場合はその理解でそんなに困らないのですが、SourceCacheの場合は同じキーの要素が1つしか存在できないので、少し変わった挙動をします。

var miyauchi = new SourceCache<Person, int>(p => p.Id);
var ichijo = new SourceCache<Person, int>(p => p.Id);
var koshigaya = new SourceCache<Person, int>(p => p.Id);
ReadOnlyObservableCollection<Person> output;

miyauchi.Connect()
    .MergeChangeSets(ichijo.Connect())
    .MergeChangeSets(koshigaya.Connect())
    .Bind(out output)
    .Subscribe();

((INotifyCollectionChanged)output).CollectionChanged += (s, e) => ShowCollectionChanged(e);

koshigaya.AddOrUpdate(new Person(1, "越谷 夏海", "こしがや なつみ", 13, 155.0));
koshigaya.AddOrUpdate(new Person(2, "越谷 小鞠", "こしがや こまり", 14, 140.0));
miyauchi.AddOrUpdate(new Person(3, "宮内 れんげ", "みやうち れんげ", 6, 116.0));
ichijo.AddOrUpdate(new Person(4, "一条 ほたる", "いちじょう ほたる", 12, 164.0));

// 追加した順に4人が表示される
foreach(var person in output) {
    Console.WriteLine(person);
}

koshigaya.AddOrUpdate(ichijo.Items[0]); // 一条ほたるを越谷小鞠の後に追加

// 4人しか表示されない(重複しているインスタンスは除外される)
foreach(var person in output) {
    Console.WriteLine(person);
}

ichijo.RemoveKey(4); // 一条ほたるを削除

// 4人が表示される(越谷家に一条ほたるが残っているため)
foreach(var person in output) {
    Console.WriteLine(person);
}

koshigaya.RemoveKey(4); // 一条ほたるを削除

// 3人が表示される(一条ほたるがどこにも残っていないため)
foreach(var person in output) {
    Console.WriteLine(person);
}

このように、同じキーの要素が別々のコレクションに複数あると、Merge後のデータでは先に入っていたデータが勝ちます(後から追加したデータは追加されません)。しかし、先に入っていたデータが削除された場合は、後か入ったデータに置き換えられたという形で残ります。すべてから削除されたタイミングで、Merge後のデータからも削除されることになります。

MergeManyChangeSetsはSelectに対するSelectManyのような挙動です。

集計系演算子

LINQ DynamicData 説明
Count Count コレクション内の要素数をIObservable<int>で取得します。条件を指定するデリゲートは渡せないので、必要に応じてFilterメソッドを事前に呼びましょう。
Any IsNotEmpty 要素数が空ではないかをIObservable<bool>で取得します。これもCount同様に条件は指定できません。
- IsEmpty IsNotEmptyの逆です。
All - DynamicDataにはAll()メソッドに相当するものはないです(条件を渡すものが無いので)。Filterで補集合の条件を渡してIsEmptyを呼ぶのが良いでしょう。
Max Maximum コレクション内の最大値を取得します。
Min Minimum Maximumと同様です。
Sum Sum コレクションの合計値を取得します。
Average Avg コレクションの平均値を取得します。
- StdDev コレクションの標準偏差を取得します。

Count / SumメソッドはLINQのメソッドと名前がかぶっているので、呼び出し時に間違ったほうを呼ばないように注意してください。

仮想化演算子

DynamicDataには仮想化という概念があります。大きなデータの一部を画面に表示したいなどの状況で使うことを想定して、コレクションから一部分を抜き出してきて操作することを目的としたものですが、LINQで言うところのSkip / Take / Firstなどに近い機能を提供しますが、目的が少し違うため、完全に同じものを提供するわけではありません。

LINQ DynamicData 説明
Skip / Take Virtualise コレクションの一部区間を切り出してくるメソッド。
- Page コレクションを一定サイズの区間に切り分けて、指定したページ番号のものを引き出してくるメソッド。
Take Top 先頭から指定した個数を切り出す。

見ての通りVirtualiseとスペルがイギリス訛りなので注意して下さい。

var list = new SourceList<int>();

list.Connect()
    .Virtualise(Observable.Return(new VirtualRequest(20, 10)))    // 20個目から10個を切り出す
    .Bind(out var output)
    .Subscribe();

// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
    list.Add(num);

// 20~29のみが表示される
foreach(var num in output)
    Console.WriteLine(num);

このようにVirtuariseメソッドでは配列の区間を切り出すことになります。LINQで言うところのSkip / Takeの組み合わせとなります。

var list = new SourceList<int>();

list.Connect()
    .Page(Observable.Return(new PageRequest(3, 10)))    // 1ページ当たり10件、3ページ目(20~29)
    .Bind(out var output)
    .Subscribe();

// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
    list.Add(num);

// 20~29のみが表示される
foreach(var num in output)
    Console.WriteLine(num);

Pageメソッドは一定区間ごとにコレクションを区切ります。ページ番号は0スタートではなく1スタートであることには注意が必要です。

var list = new SourceList<int>();

list.Connect()
    .Top(10)    // 先頭10件
    .Bind(out var output)
    .Subscribe();

// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
    list.Add(num);

// 0~9のみが表示される
foreach(var num in output)
    Console.WriteLine(num);

Topはシンプルで先頭N件を引っ張ってくるだけですね。

最終手段

対応するメソッドが無い!困った!となった場合の最終手段があります。QueryWhenChangedです。

var list = new SourceList<int>();

list.Connect()
    .QueryWhenChanged(p => p.LastOrDefault())   // IObservable<int>としてLastOrDefaultの値を返す
    .DistinctUntilChanged()
    .Subscribe(Console.WriteLine);

//((INotifyCollectionChanged)output).CollectionChanged += (s, e) => ShowCollectionChanged(e);

// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
    list.Add(num);

コレクションが変化するたびにコレクション全体が流れてきますので、良く知るLINQメソッドをそのまま適用することができます。ただし、都度全体が流れてきてそれを計算することになるので、気を付けないと膨大な計算が必要になってしまうことがあります。

INotifyCollectionChangedからの変換

冒頭で説明した通り、DynamicDataでは標準でSourceList<T>とSourceCache<T, TKey>という2種類のコレクションが標準で用意されています。しかし、世の中にはそれ以外の変更通知コレクション型はたくさんありますので、それらが使えないと誰もこのライブラリを使いたいとは思えません。

安心してください。その時のために、ToObservableChangeSet()があります。

var list = new ObservableCollection<int>();

list.ToObservableChangeSet()
    .Top(10)    // 先頭10件
    .Bind(out var output)
    .Subscribe();

ObservableCollectionは見ての通りそのまま使えます(それ用のオーバーロードが用意されています)。ObservableCollectionを実装していないものを使用する場合は以下のようになります。

var list = new Livet.StatefulModel.ObservableSynchronizedCollection<int>();

list.ToObservableChangeSet<ObservableSynchronizedCollection<int>, int>()
    .Top(10)    // 先頭10件
    .Bind(out var output)
    .Subscribe();

オーバーロードでは、渡すコレクション型と要素の型を渡す必要があります。渡すコレクションの制約は

where TCollection : INotifyCollectionChanged, IEnumerable<T>

となっていますので、たいていの変更通知コレクションは使えるかと思います。

-------------------------------

これくらいが使えればある程度は使いこなせるのではないでしょうか。

公式ドキュメントにはこの記事では言及できていない機能もありますし、私自身もまだ充分に使いこなせているとは言えないレベルだとは思いますが、ひとまずこれを備忘録に使い込んでみようと思っています。

参考文献

2024年10月13日日曜日

WPFでReactivePropertyを使う際のお作法

今回はWPFでReactivePropertyを使う際のお作法を備忘録的に紹介していこうと思います。

ReactivePropertyとはReactive Extensions (Rx)ベースで作られたプロパティを提供するライブラリです。INotifyPropertyChangedを実装しているためViewModelにそのまま使用することができ、また、LINQを使って値の伝搬を表現することができるため、Statefulなアプリを簡単に実装することができる、非常に強力なライブラリです。

1. 導入

Nugetからダウンロードできます。

ReactiveProperty.WPFは入れなくても一応使えないことは無いのですが、あとでハマることになりますので、WPFアプリなら思考停止でとりあえず入れてしまいましょう。

2. UIスレッドへの転送設定

WPFには単一のスレッドからしかUI要素にアクセスできないという仕様があります。ですので、ViewModelにてイベントをそのスレッドに転送してやらねばならないのですが、ReactivePropertyには便利なことに特定のスレッドでイベントを発生させる機能があります。

さて、転送するにあたって、当然ながらどのスレッドがUIスレッドかということをライブラリに教え込まなければなりません。それは、App.xaml/App.xaml.csに以下のコードを追加することで行います。

<Application x:Class="WpfTest.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfTest"
             StartupUri="MainWindow.xaml"
             Startup="Application_Startup">
    
    <Application.Resources>
    </Application.Resources>
</Application>
public partial class App : Application
{        
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        ReactivePropertyScheduler.SetDefault(new ReactivePropertyWpfScheduler(Dispatcher));
    }
}

アプリのスタートアップ時に、Application.DispatcherをReactivePropertySchedulerとして登録するという作業になります。ReactivePropertyWpfSchedulerはReactiveProperty.WPFに入っているクラスで、WPF向けのスケジューラーです。

3. ReactivePropertyとReactivePropertySlim

さて、これで準備が整いましたので、実際に使用していきます。

ReactiveProperty(ライブラリ)には、ReactiveProperty(クラス)とReactivePropertySlim(クラス)があります。ReactivePropertyは色々な機能を持っているが故にパフォーマンスが悪めなのですが、ReactivePropertySlimは機能を絞ってパフォーマンスをかなり改善しています。そのReactivePropertySlimで削られた機能として、主に以下の2つがあります。

  • UIスレッドへの自動転送機能
  • 入力値のバリデーション機能

いずれも、ViewModelとしては必要な機能です。ですので、基本的にViewModelではReactivePropertyModelではReactivePropertySlimを使用すると良いでしょう。

public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;
    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        Text = model.Text.ToReactivePropertyAsSynchronized(p => p.Value);
    }

    public ReactiveProperty<string> Text { get; }
}
public interface IMainWindowModel
{
    IReactiveProperty<string> Text { get; }
}
public class MainWindowModel : IMainWindowModel
{
    public MainWindowModel()
    {
        Text = new ReactivePropertySlim<string>();
        Text.Subscribe(p => System.Diagnostics.Debug.WriteLine(p));
    }

    public ReactivePropertySlim<string> Text { get; }
    IReactiveProperty<string> IMainWindowModel.Text => Text;
}

このコードでは、Prism前提でModelとViewModelを疎結合にするため、間にIMainWindowModelを挟んでいます。そこまでの抽象化が必要ない場合は適宜コードを読み替えてください。

ReactivePropertyとReactivePropertySlimを双方向に同期させるためにはToReactivePropertyAsSynchronized()を使います。片方向(Model→ViewModel)の場合はToReadOnlyReactiveProperty()で良いでしょう。

--------------

以上です。その他の細かい使い方は公式ドキュメントを参照ください。

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のメソッドを呼ぶのと比べてどっちが気持ち悪いんだろうな…。