ObservableCollectionの要素の変更通知を受け取る

ObservableCollection でコレクションに格納されている要素の変更通知を受け取る方法です。

単純に Add されたときに要素に PropertyChanged を設定するだけでは全く考慮が足りないため現実的に子要素から通知を受け取る実装を考えたいと思います。

確認環境

  • .NET Core 3.1
  • VisualStudio2022
  • Windows11

コンソールアプリで確認

確認用コード

要素クラス

まず ObservableCollection の要素に設定する Item クラスを以下のように定義します。

変更通知を受け取るために INotifyPropertyChanged を継承しています。Value を変更すると通知を送るというような実装になります。

// Item.cs
public class Item : INotifyPropertyChanged
{
    // INotifyPropertyChanged impl --->
    public event PropertyChangedEventHandler PropertyChanged;
    private void RaisePropertyChanged([CallerMemberName] string propertyName = "")
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    // <---

    private string _value;
    public string Value
    {
        get => _value;
        set
        {
            _value = value;
            RaisePropertyChanged();
        }
    }
    public Item(string value) => Value = value;
    public static implicit operator Item(string value) => new Item(value);
    public override string ToString() => Value;
}

バインド用クラス

次に ObservableCollection と要素にイベントを設定するクラスを次のように定義します。

実装がかなり長いです。これは、Reset が発生したときに変更前と変更後の要素を NewItems と OldItems から取得できないという問題があるため、内部で List にキャッシュを持っていてその内容を ObservableCollection が操作されたときに同期する実装となっています。順序も併せて同期するのでさらに長くなっています。

また、新しくコレクションを設定したときに既に子要素が存在する可能性があるので一括でイベントを設定する、要素が追加されたときにイベントを追加、削除されたときにイベントを設定、解除する、コレクションが削除され時に要素のイベントを一括で削除するようにするなどの実装をしています。

特に、単純にコレクションに Add した時にイベントを追加するみたいにしてると要素から取り除かれたオブジェクトにイベントが飛んだり。イベントを設定しっぱなしにするとリソースリークにつながるので除去されたときに確実にイベントを外す実装をしています。

// ObservableCollectionBinding.cs
public class ObservableCollectionBinding<T>
{
    private ObservableCollection<T> _dataSource;
    public ObservableCollection<T> DataSource
    {
        get => _dataSource;
        set => SetDataSource(value);
    }

    // Reset 時に変更された要素を識別できないので内部キャッシュが必要
    private List<T> _cache = new List<T>();

    private void SetDataSource(ObservableCollection<T> collection)
    {
        if (ReferenceEquals(_dataSource, collection))
        {
            return; // 同じ値
        }

        UnBind();
        Bind(collection);
        
        _dataSource = collection;
    }

    // コレクションと子要素にイベントを設定する
    private void Bind(ObservableCollection<T> collection)
    {
        // コレクションの変更通知をバインド
        collection.CollectionChanged += OnCollectionChanged;

        // 各要素の変更通知をバインド
        foreach (var item in collection)
        {
            if (item is INotifyPropertyChanged inpc)
            {
                inpc.PropertyChanged -= OnPropertyChanged;
                inpc.PropertyChanged += OnPropertyChanged;
            }
        }
    }

    // コレクションと子要素からイベントを削除する
    private void UnBind()
    {
        // コレクションの変更通知を解除
        if (_dataSource is INotifyCollectionChanged incc)
        {
            incc.CollectionChanged -= OnCollectionChanged;
        }

        // 各要素の変更通知を全て解除
        if (_dataSource is IEnumerable collection)
        {
            foreach (var item in collection)
            {
                if (item is INotifyPropertyChanged inpc)
                {
                    inpc.PropertyChanged -= OnPropertyChanged;
                }
            }
        }
    }

    // 子要素のプロパティの変更通知を受けるイベントハンドラー
    private void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        Console.WriteLine($"OnPropertyChanged, PropertyName={e.PropertyName}");
    }

    // コレクションの変更通知を受け取るイベントハンドラー
    private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Add: OnCollectionChanged_Add(e); break;
            case NotifyCollectionChangedAction.Remove: OnCollectionChanged_Remove(e); break;
            case NotifyCollectionChangedAction.Replace: OnCollectionChanged_Replace(e); break;
            case NotifyCollectionChangedAction.Move: OnCollectionChanged_Move(e); break;
            case NotifyCollectionChangedAction.Reset: OnCollectionChagned_Reset(e); break;
        }
    }

    // コレクションに要素が追加されたとき
    private void OnCollectionChanged_Add(NotifyCollectionChangedEventArgs e)
    {
        ProcNewItems(e.NewItems);

        if (_cache.Count == e.NewStartingIndex)
        {
            // 1件しかこないと決め打ちする
            _cache.Add((T)e.NewItems[0]);

            // 複数くることなんてある?
            //foreach (var item in e.NewItems)
            //{
            //    _cache.Add((T)item);
            //}
        }
        else
        {
            _cache.Insert(e.NewStartingIndex, (T)e.NewItems[0]);
        }
    }

    // コレクションから要素が削除されたとき
    private void OnCollectionChanged_Remove(NotifyCollectionChangedEventArgs e)
    {
        ProcOldItems(e.OldItems);
        _cache.RemoveAt(e.OldStartingIndex);
    }

    // コレクションの要素が置き換わった時
    private void OnCollectionChanged_Replace(NotifyCollectionChangedEventArgs e)
    {
        ProcOldItems(e.OldItems);
        ProcNewItems(e.NewItems);
        _cache[e.OldStartingIndex] = (T)e.NewItems[0];
    }

    // コレクションの要素が移動した時
    private void OnCollectionChanged_Move(NotifyCollectionChangedEventArgs e)
    {
        var tmp = _cache[e.NewStartingIndex];
        _cache[e.NewStartingIndex] = (T)e.NewItems[0];
        _cache[e.OldStartingIndex] = tmp;
    }

    // コレクションの要素が大幅に変わった時
    private void OnCollectionChagned_Reset(NotifyCollectionChangedEventArgs e)
    {
        // OldItems に通知が来ないからキャッシュ経由でイベントを削除
        ProcOldItems(_cache);
        _cache.Clear();
        ProcNewItems(_dataSource);
        _cache.AddRange(_dataSource);
    }

    // 要素にイベントを設定する
    private void ProcNewItems(IList newItems)
    {
        foreach (var item in newItems)
        {
            if (item is INotifyPropertyChanged inpc)
            {
                inpc.PropertyChanged -= OnPropertyChanged;
                inpc.PropertyChanged += OnPropertyChanged;
            }
        }
    }
    // 要素からイベントを削除する
    private void ProcOldItems(IList oldItems)
    {
        foreach (var item in oldItems)
        {
            if (item is INotifyPropertyChanged inpc)
            {
                inpc.PropertyChanged -= OnPropertyChanged;
            }
        }
    }
}

使い方

次に動作確認用の実装になります。

まず、以下のようにバインド用のオブジェクトをフィールドに配置しておいてあとから ObservableCollection をDataSource プロパティに追加するようにしています。

// Program.cs
internal class Program
{
    private static readonly ObservableCollectionBinding<Item> _b 
        = new ObservableCollectionBinding<Item>();

    private static void Main(string[] args)
    {
        var list = new ObservableCollection<Item>();
        _b.DataSource = list;

        Item item_000 = "000";
        Item item_001 = "001";
        Item item_002 = "002";
        Item item_003 = "003";
        Item item_004 = "004";
        Item item_005 = "005";

        // NotifyCollectionChangedAction.Addが発生する
        list.Add(item_003);
        list.Add(item_002);
        list.Add(item_001);
        list.Add(item_000);
        item_000.Value = "999";

        // NotifyCollectionChangedAction.Addが発生する
        list.Insert(2, item_005);

        // NotifyCollectionChangedAction.Removeが発生する
        var item = list[0];
        list.Remove(item);

        // NotifyCollectionChangedAction.Replaceが発生する
        list[2] = "004";

        // NotifyCollectionChangedAction.Moveが発生する
        list.Move(2, 0);

        // NotifyCollectionChangedAction.Resetが発生する
        list.Clear();
    }
}

以上で子要素の通知を取得することができるようになりました。

ただこれだけだとイベントを受け取るだけで何も具体的な処理が入ってないのでアプリケーションごとに必要な処理を追加していくことになると思います。

関連記事

takap-tech.com