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>

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

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

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

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

参考文献

2026年8月2日日曜日

WPFのGrid内に動的に要素を配置する

WPFのGrid内に動的に要素を配置したいと思ったことはありませんか?

WPFのGridは、基本的な使い方では静的になります。RowDefinitionsプロパティColumnDefinitionsプロパティはバインディングできず、XAMLからは静的にしか設定することができません。そのため、実行時まで行や列の数が定まらないようなものはそのままでは作ることができません。

これを動的に作れるように作っていきましょう。

没案:ItemsContorl in ItemsControl

Gridは2次元に要素を並べるものなので、配列の配列を作って2次元のデータを用意し、例えば、行ごとのItemsControlをさらにItemsControlで縦に並べればそもそもGridなんていらないのでは?と思うかもしれません。

実際はそれでも何とかなりますが、行や列数が増えるとめちゃめちゃ重たくなります。そもそもItemsControlがそこそこ重たいコントロールなので、それを何百個も作ったら重くなるのは想像に難くないでしょう。

今回の記事では、そのような構造は取らず、あくまでもGridコントロールを動的に制御するというアプローチで2次元の要素を並べるようにしていきたいと考えています。

ViewModelの準備

まずは、セル一つ分を表現するViewModelを準備していきます。

public class CellViewModel : ViewModel
{
    #region Text

    public string Text
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    } = "";

    #endregion

    #region Row

    public int Row
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion

    #region Column

    public int Column
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion
}

シンプルなViewModelです。Row / Columnは、このセルを配置するRow / Columnのインデックスを示したものです。Textは今回の本質とは関係ありませんが、何も表示されないと味気ないので表示内容のテキストを保持するものになります。

余談ですが、このコードはC#14のfieldキーワードを使用することで、バッキングフィールドに直接アクセスするようにしてコードを最小化しています。まさに変更通知プロパティのための機能ですね。

続いて、MainWindowViewModelです。少しややこしく見えるかもしれませんが、行数 / 列数を示すRows / ColumnsプロパティをCellsの内容変更に自動で追従するようにしたためのコードが多いだけでそんなに変なことはやっていませんので、よく読んでいただければ内容がすぐにわかるでしょう。

public class MainWindowViewModel : ViewModel
{
    public MainWindowViewModel()
    {
        _Cells = new ObservableCollection<CellViewModel>();
        Cells = new ReadOnlyObservableCollection<CellViewModel>(_Cells);

        ((INotifyCollectionChanged)Cells).CollectionChanged += Cells_CollectionChanged;
    }

    public void Initialize()
    {
        _Cells.Add(new CellViewModel() { Row = 0, Column = 0, Text = "A1" });
        _Cells.Add(new CellViewModel() { Row = 1, Column = 1, Text = "B2" });
        _Cells.Add(new CellViewModel() { Row = 2, Column = 0, Text = "A3" });
    }

    readonly ObservableCollection<CellViewModel> _Cells;
    public ReadOnlyObservableCollection<CellViewModel> Cells { get; }

    #region Cells_CollectionChanged

    private void Cells_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        switch(e.Action) {
            case NotifyCollectionChangedAction.Add:
                HandleAdd(e.NewItems!);
                break;

            case NotifyCollectionChangedAction.Remove:
                HandleRemove(e.OldItems!);
                break;

            case NotifyCollectionChangedAction.Replace:
                HandleRemove(e.OldItems!);
                HandleAdd(e.NewItems!);
                break;

            case NotifyCollectionChangedAction.Reset:
                RecalculateRows();
                RecalculateColumns();
                break;
        }
    }

    private void HandleAdd(IList newItems)
    {
        foreach(CellViewModel cell in newItems) {
            if(cell.Row + 1 > Rows)
                Rows = cell.Row + 1;

            if(cell.Column + 1 > Columns)
                Columns = cell.Column + 1;
        }
    }

    private void HandleRemove(IList oldItems)
    {
        bool needRecalcRows = false;
        bool needRecalcColumns = false;

        foreach(CellViewModel cell in oldItems) {
            if(cell.Row + 1 == Rows)
                needRecalcRows = true;

            if(cell.Column + 1 == Columns)
                needRecalcColumns = true;

            if(needRecalcRows && needRecalcColumns)
                break;
        }

        if(needRecalcRows)
            RecalculateRows();

        if(needRecalcColumns)
            RecalculateColumns();
    }

    private void RecalculateRows()
    {
        int maxRow = 0;

        foreach(var cell in Cells) {
            if(cell.Row + 1 > maxRow)
                maxRow = cell.Row + 1;
        }

        Rows = maxRow;
    }

    private void RecalculateColumns()
    {
        int maxColumn = 0;

        foreach(var cell in Cells) {
            if(cell.Column + 1 > maxColumn)
                maxColumn = cell.Column + 1;
        }

        Columns = maxColumn;
    }

    #endregion

    #region Rows

    public int Rows
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion

    #region Columns

    public int Columns
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion
}

Behaviorの準備

さて、ここで用意したViewModelからの情報をGridに伝搬してあげたいのですが、最初に説明した通りRowDefinitionsプロパティColumnDefinitionsプロパティは直接バインディングすることができずXAML経由で扱えません。そのような場合はそう、Behaviorですね。

public class DynamicGridBehavior : Behavior<Grid>
{
    protected override void OnAttached()
    {
        OnRowChanged(Rows);
        OnColumnChanged(Columns);

        base.OnAttached();
    }

    #region Columns

    public int Columns
    {
        get { return (int)GetValue(ColumnsProperty); }
        set { SetValue(ColumnsProperty, value); }
    }

    public static readonly DependencyProperty ColumnsProperty =
        DependencyProperty.Register(nameof(Columns), typeof(int), typeof(DynamicGridBehavior), new PropertyMetadata(default(int), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is int newValue)
                me.OnColumnChanged(newValue);
        }));

    void OnColumnChanged(int newValue)
    {
        if(AssociatedObject == null)
            return;

        if(AssociatedObject.ColumnDefinitions.Count > newValue) {
            AssociatedObject.ColumnDefinitions.RemoveRange(newValue, AssociatedObject.ColumnDefinitions.Count - newValue);
        } else if(AssociatedObject.ColumnDefinitions.Count < newValue) {
            for(int i = AssociatedObject.ColumnDefinitions.Count; i < newValue; i++)
                AssociatedObject.ColumnDefinitions.Add(new ColumnDefinition() { Width = ColumnWidth });
        }
    }

    #endregion

    #region ColumnWidth

    public GridLength ColumnWidth
    {
        get { return (GridLength)GetValue(ColumnWidthProperty); }
        set { SetValue(ColumnWidthProperty, value); }
    }

    public static readonly DependencyProperty ColumnWidthProperty =
        DependencyProperty.Register(nameof(ColumnWidth), typeof(GridLength), typeof(DynamicGridBehavior), new PropertyMetadata(default(GridLength), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is GridLength newValue)
                me.OnColumnWidthChanged(newValue);
        }));

    void OnColumnWidthChanged(GridLength newValue)
    {
        if(AssociatedObject == null)
            return;

        foreach(var column in AssociatedObject.ColumnDefinitions)
            column.Width = newValue;
    }

    #endregion

    #region Rows

    public int Rows
    {
        get { return (int)GetValue(RowsProperty); }
        set { SetValue(RowsProperty, value); }
    }

    public static readonly DependencyProperty RowsProperty =
        DependencyProperty.Register(nameof(Rows), typeof(int), typeof(DynamicGridBehavior), new PropertyMetadata(default(int), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is int newValue)
                me.OnRowChanged(newValue);
        }));

    void OnRowChanged(int newValue)
    {
        if(AssociatedObject == null)
            return;

        if(AssociatedObject.RowDefinitions.Count > newValue) {
            AssociatedObject.RowDefinitions.RemoveRange(newValue, AssociatedObject.RowDefinitions.Count - newValue);
        } else if(AssociatedObject.RowDefinitions.Count < newValue) {
            for(int i = AssociatedObject.RowDefinitions.Count; i < newValue; i++)
                AssociatedObject.RowDefinitions.Add(new RowDefinition() { Height = RowHeight });
        }
    }

    #endregion

    #region RowHeight

    public GridLength RowHeight
    {
        get { return (GridLength)GetValue(RowHeightProperty); }
        set { SetValue(RowHeightProperty, value); }
    }

    public static readonly DependencyProperty RowHeightProperty =
        DependencyProperty.Register(nameof(RowHeight), typeof(GridLength), typeof(DynamicGridBehavior), new PropertyMetadata(default(GridLength), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is GridLength newValue)
                me.OnRowHeightChanged(newValue);
        }));

    void OnRowHeightChanged(GridLength newValue)
    {
        if(AssociatedObject == null)
            return;

        foreach(var column in AssociatedObject.RowDefinitions)
            column.Height = newValue;
    }

    #endregion
}

Columns / ColumnWidth / Rows / RowHeightプロパティを実装しました。こちらもまた依存関係プロパティのせいでコード量が多くなっていますが、やっていることとしては、Columns / Rowsプロパティで列数 / 行数を設定して、それに対応するようにGrid.ColumnDefinitionsとGrid.RowDefinitionsの中身を調整しているにすぎません。新たに行や列を作る場合は、その行幅 / 列幅を共通で設定できるようColumnWidth / RowHeightプロパティも用意しています。

XAMLの実装

さて、最後にXAMLを実装していきます。 

<Window x:Class="GridTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:GridTest.Views"
        xmlns:vb="clr-namespace:GridTest.Views.Behaviors"
        xmlns:vm="clr-namespace:GridTest.ViewModels"
        Title="MainWindow" Width="640" Height="480">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <i:Interaction.Triggers>
        <!--  Dispose method is called, when Window closing.  -->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>

        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize" />
        </i:EventTrigger>

        <!--  If you make user choose 'OK or Cancel' closing Window, then please use Window Close cancel Behavior.  -->

    </i:Interaction.Triggers>

    <ItemsControl ItemsSource="{Binding Cells}" >
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Grid>
                    <i:Interaction.Behaviors>
                        <vb:DynamicGridBehavior Rows="{Binding Rows}" Columns="{Binding Columns}" RowHeight="50" ColumnWidth="200" />
                    </i:Interaction.Behaviors>
                </Grid>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate DataType="{x:Type vm:CellViewModel}">
                <Border BorderBrush="Gray" BorderThickness="1" >
                    <TextBlock Text="{Binding Text}" HorizontalAlignment="Center" VerticalAlignment="Center" />
                </Border>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
        <ItemsControl.ItemContainerStyle>
            <Style TargetType="ContentPresenter">
                <Setter Property="Grid.Row" Value="{Binding Row}" />
                <Setter Property="Grid.Column" Value="{Binding Column}" />
            </Style>
        </ItemsControl.ItemContainerStyle>
    </ItemsControl>
</Window>

一つひとつ説明していきます。

まず、動的な要素をパネルにマッピングする場合はItemsControlを使います。具体的なパネルはItemsControl.ItemsPanelで指定しますので、ここでGridを指定してあげています。Gridの内容を動的に設定する必要があるので、先ほど作ったDynamicGridBehaviorを設定しています。 

DataTemplateは特段説明する必要はないと思います。なお、静的に配置する場合は各要素にGrid.Row / Grid.Column添付プロパティを追加することになりますが、ここでは指定しません。なぜならば、BorderはさらにContentPresenterでラッピングされるため、Borderで指定したところで正しく反映されないのです。

ContentPresenterにどうやって添付プロパティを設定するかと言えば、ItemContainerStyleになります。これによってGrid.Row / Grid.Column添付プロパティを設定することができるわけですね。

これによって、このように動的に正しく配置されたGridができました。

まとめ

ポイントをまとめると以下の通りとなります。

  • Grid.RowDefinitions / Grid.ColumnDefinitionsはバインディングできないため、Behaviorを作ってC#のコードから操作をする
  • パネルに動的に要素を配置するためにItemsControlを使用する
  • ItemsControlを使った場合、Grid直下の要素はContentPresenterになるため、ItemContainerStyleを使用してGrid.Row / Grid.Column添付プロパティを設定する 
以上となります。