【C#】ListとObservableCollectionを同期する

初めに

XAML 環境で、Model は List<T>, ViewModel は ObservableCollection<T> で双方向でバインドしたい、、、のようなケースが発生したときに汎用的にバインドする実装例を紹介したいと思います。

Model 側が既に List<T> で定義されてしまって変更できない場合や、モデル層は通知をするのは責務範囲外などの設計論上の都合で Model の List<T> を変更できない場合が対象です。

概念的な実装方法ですが、以下のように中間層に通知ありのラッパー(通知機能付きのデコレーター)を作成することで実現したいと思います。↓ こんな感じ。

ModelのList ⇔ Model を通知可能にするデコレーター ⇔ ViewModelのObservableCollection

既に存在する、List はどうやっても通知を出せないため、デコレーター経由で List を操作することで疑似的に、List と ObservableCollection の内容を同期することができます。

確認環境

この記事は以下環境で実装 & 動作確認しています。

  • .NET8
  • Windows11 + VisualStudio2022

.NET8(C#12相当)の構文を使用しているため、これ以前の環境でエラーが出る可能性があります。

実装例

以下、超長いですが、ご了承ください。

全部読むのがだるい人は .NET8 環境であれば、コードブロック右上のコピペボタンからコピーして自分のコードに貼り付ければそのまま動作します。public メソッドだけ見て、貼り付ければ使用できます。

ListDecoratorクラス

IList と ObservableCollection を同期するための実装です。

IList<T> を継承して ListDecorator クラスとして宣言しています。また ObservableCollection にある Move メソッドもサポートしています。

using System.Collections;
using System.Collections.ObjectModel;
using System.Collections.Specialized;

/// <summary>
/// ModelのIListとViewModelのObservableCollectionを同期させるためのラッパー
/// </summary>
/// <typeparam name="T"></typeparam>
public class ListDecorator<T> : IList<T>, IDisposable
{
    //
    // Fields
    // - - - - - - - - - - - - - - - - - - - -

    readonly IList<T> _list;
    readonly ObservableCollection<T> _observableCollection;

    // 無限ループ防止の一時停止用フラグ
    // true: 一時停止 / false: 通常
    bool _suspend;

    // オブジェクトが破棄されたかどうかのフラグ
    // true: 破棄済み / false: まだ
    bool _disposed;

    //
    // Props
    // - - - - - - - - - - - - - - - - - - - -

    public int Count => _observableCollection.Count;

    public bool IsReadOnly => false; // コンストラクタでチェック済み

    //
    // Indexer
    // - - - - - - - - - - - - - - - - - - - -

    public T this[int index]
    {
        get => _list[index];
        set
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            _observableCollection[index] = value;
        }
    }

    //
    // Constructors
    // - - - - - - - - - - - - - - - - - - - -

    public ListDecorator(IList<T> list, ObservableCollection<T> collection)
    {
        ArgumentNullException.ThrowIfNull(list);
        ArgumentNullException.ThrowIfNull(collection);
        if (list.IsReadOnly || ((IList)collection).IsReadOnly)
        {
            throw new NotSupportedException("Does not support read-only lists");
        }
        if (collection.Count != 0) // 念のため値が設定済みのオブジェクトは指定させない
        {
            throw new ArgumentException("Specify an empty ObservableCollection");
        }
        if (collection.GetType() != typeof(ObservableCollection<T>))
        {
            throw new NotSupportedException("Only ObservableCollection is supported");
        }

        _list = list;
        _observableCollection = collection;

        foreach (T item in _list) // モデル側のデータをコピーする
        {
            _observableCollection.Add(item);
        }
        _observableCollection.CollectionChanged += OnCollectionChanged;
    }

    //
    // IDisposable
    // - - - - - - - - - - - - - - - - - - - -    

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }

        if (disposing)
        {
            _observableCollection.CollectionChanged -= OnCollectionChanged;
        }

        _disposed = true;
    }

    //
    // IList<T> implementation
    // - - - - - - - - - - - - - - - - - - - -

    // 追加
    public void Add(T item)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _observableCollection.Add(item);
    }
    // 指定位置に挿入
    public void Insert(int index, T item)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _observableCollection.Insert(index, item);
    }
    // 要素を削除
    public bool Remove(T item)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        return _observableCollection.Remove(item);
    }
    // 指定位置を削除
    public void RemoveAt(int index)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _observableCollection.RemoveAt(index);
    }
    // 内容を全て削除
    public void Clear()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _observableCollection.Clear();
    }
    // 引数の配列に要素をコピーする
    public void CopyTo(T[] array, int arrayIndex)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _observableCollection.CopyTo(array, arrayIndex);
    }
    // 存在するか確認する
    public bool Contains(T item) => _observableCollection.Contains(item);
    // 指定要素の位置を取得する
    public int IndexOf(T item) => _observableCollection.IndexOf(item);
    // foreach向け実装
    public IEnumerator<T> GetEnumerator() => _observableCollection.GetEnumerator();
    // foreach向け実装
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    //
    // Public Methods
    // - - - - - - - - - - - - - - - - - - - -

    // 指定位置へ要素を移動
    public void Move(int oldIndex, int newIndex)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        _observableCollection.Move(oldIndex, newIndex);
    }

    //
    // Private Methods
    // - - - - - - - - - - - - - - - - - - - -    

    // ObservableCollectionの変更通知を処理するイベントハンドラー
    void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (_suspend)
        {
            return;
        }
        try
        {
            _suspend = true;

            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add: OnAdd(e); break;
                case NotifyCollectionChangedAction.Remove: OnRemove(e); break;
                case NotifyCollectionChangedAction.Replace: OnReplace(e); break;
                case NotifyCollectionChangedAction.Move: OnMove(e); break;
                case NotifyCollectionChangedAction.Reset: OnReset(e); break;
                default:
                    throw new
                    NotSupportedException($"This type is not supported. type={e.Action}");
            }
        }
        finally
        {
            _suspend = false;
        }
    }

    // ObservableCollectionの各処理
    void OnAdd(NotifyCollectionChangedEventArgs e)
    {
        if (!TryGetItem(e.NewItems, out T item))
        {
            return;
        }
        if (e.NewStartingIndex == _list.Count)
        {
            _list.Add(item);
        }
        else
        {
            _list.Insert(e.NewStartingIndex, item);
        }
    }
    void OnRemove(NotifyCollectionChangedEventArgs e)
    {
        _list.RemoveAt(e.OldStartingIndex);
    }
    void OnReplace(NotifyCollectionChangedEventArgs e)
    {
        if (!TryGetItem(e.NewItems, out T item))
        {
            return;
        }
        _list[e.OldStartingIndex] = item;
    }
    void OnMove(NotifyCollectionChangedEventArgs e)
    {
        if (!TryGetItem(e.NewItems, out T newItem))
        {
            return;
        }
        _list.RemoveAt(e.OldStartingIndex);
        _list.Insert(e.NewStartingIndex, newItem);
    }
    void OnReset(NotifyCollectionChangedEventArgs e)
    {
        _list.Clear();
        foreach (T item in _observableCollection)
        {
            _list.Add(item);
        }
    }

    // 通知を受けた時に値を取り出す
    // ** 複数件操作が発生する別実装は考慮除外
    static bool TryGetItem(IList? items, out T item)
    {
        item = default!; // nullもありえる

        if (items is not IList list || list.Count == 0)
        {
            return false;
        }
        item = (T)list[0]!;
        return true;
    }
}

動作確認用のコード

ちゃんと双方向でデータが同期しているかの動作チェック用のコードです。

記事の段階で以下のチェック用のコードをパスすることは確認済みです。

using System.Collections.ObjectModel;
using System.Diagnostics;

// チェック用のサンプルクラス
public class Item(int no, string name)
{
    public int No { get; set; } = no;
    public string Name { get; set; } = name ?? throw new ArgumentNullException(nameof(name));
    public override string ToString() => $"{No}, {Name}";
}

internal class AppMain
{
    // 動作チェックを実行する
    static void Main(string[] args)
    {
        ObservableCollection<Item> items = [];

        List<Item> list = [
            new Item(0, "000"),
            new Item(1, "111"),
            new Item(2, "222"),
            new Item(3, "333"),
        ];

        ListDecorator<Item> sync = new(list, items);

        // これ以降はList<T>は直接触らずsync経由で操作する

        // 1) 初期状態で同期しているかチェック
        CheckIfNotThrow(sync, items);

        // 2) クリアしてチェック
        sync.Clear();
        CheckIfNotThrow(sync, items);

        // 3) 追加してチェック
        sync.Add(new Item(4, "444"));
        items.Add(new Item(5, "555"));
        sync.Add(new Item(6, "666"));
        items.Add(new Item(7, "777"));
        CheckIfNotThrow(sync, items);

        // 4) 挿入してチェック
        var p1 = new Item(8, "888");
        var p2 = new Item(9, "999");
        var p3 = new Item(10, "1010");
        var p4 = new Item(11, "1111");
        sync.Insert(2, p1);
        CheckIfNotThrow(sync, items);
        items.Insert(2, p2);
        CheckIfNotThrow(sync, items);
        sync.Insert(4, p3);
        CheckIfNotThrow(sync, items);
        items.Insert(4, p4);
        CheckIfNotThrow(sync, items);

        // 5) 削除してチェック
        sync.Remove(p1);
        items.Remove(p2);
        CheckIfNotThrow(sync, items);
        sync.RemoveAt(0);
        CheckIfNotThrow(sync, items);
        sync.RemoveAt(3);
        CheckIfNotThrow(sync, items);

        // 6) Containsの動作チェック
        bool p3Result1 = sync.Contains(p3);
        bool p3Result2 = items.Contains(p3);
        Debug.Assert(p3Result1 == p3Result2);
        bool p4Result1 = sync.Contains(p4);
        bool p4Result2 = items.Contains(p4);
        Debug.Assert(p4Result1 == p4Result2);

        // 7) IndexOfの動作チェック
        int p3Index1 = sync.IndexOf(p3);
        int p3Index2 = items.IndexOf(p3);
        Debug.Assert(p3Index1 == p3Index2);
        int p4Index1 = sync.IndexOf(p4);
        int p4Index2 = items.IndexOf(p4);
        Debug.Assert(p4Index1 == p4Index2);

        // 8) CopyToの動作チェック
        Item[] copyItems1 = new Item[4];
        sync.CopyTo(copyItems1, 0);
        Item[] copyItems2 = new Item[4];
        items.CopyTo(copyItems2, 0);
        CheckIfNotThrow(copyItems1, copyItems2);

        // 9) Moveの動作チェック
        sync.Move(3, 0);
        CheckIfNotThrow(sync, items);
        items.Move(2, 0);
        CheckIfNotThrow(sync, items);

        // 最終結果出力
        CheckIfNotThrow(sync, items);
        DumpData(sync, items);
    }

    // 全要素を比較して一致しているかチェックする
    static void CheckIfNotThrow<T>(IList<T> list1, IList<T> list2)
    {
        if (list1.Count != list2.Count)
        {
            throw new InvalidDataException("Diff Count");
        }

        for (int i = 0; i < list1.Count; i++)
        {
            if (!ReferenceEquals(list1[i], list2[i])) // 参照レベルで同じはず
            {
                throw new InvalidDataException($"Diff Element. {i}");
            }
        }
    }

    // 現在のオブジェクトの内容をコンソールに出力する
    static void DumpData<T>(IList<T> list1, IList<T> list2)
    {
        for (int i = 0; i < list1.Count; i++)
        {
            Console.WriteLine(
                $"[{i:D3}] {(ReferenceEquals(list1[i], list2[i]) 
                    ? "OK" : "NG")}  |  Value= {list1[i]} | {list2[i]}");
        }
    }
}

使い方

ViewModelを作成するときに以下のように使用します。

画面にバインドする ObservableCollection と、Model と同期操作を保証する、IDisposable _syncObj をフィールドに配置して初期化しておきます。

こうしておくことで、Items と _syncObj どちらからメソッドを実行しても Model の List が表示と同期するようになります。

コメントにもありますが、注意事項として、モデルの List を直接操作すると同期状態が崩れてしまいます。

そうなるとモデルだけがデータを保持することになるため、これ以降は ListDecorator 経由で List を操作する必要があります。

// 何らかのViewModel
public class SampleViewModel  : IDisposable
{
    // XAMLにバインドするオブジェクト
    public ObservableCollection<Item> Items { get; private set; } = [];

    // 同期を維持するためにフィールドに保持しておく
    IDisposable _syncObj;

    public SampleViewModel(IList<Item> list)
    {
        // モデルを受け取って同期オブジェクトを初期化
        //  → Listは持ってると操作を間違える可能性があるので読み捨て
        //      & 操作はItems経由で行う
        _syncObj = new ListDecorator<Item>(list, Items);
    }

    // 何らかの操作
    public void Add(Item item) => Items.Add(item);

    // 解放処理(適当
    public void Dispose() => _syncObj.Dispose();

    // etc...
}

参考記事

takap-tech.com

takap-tech.com