LoginSignup
5
4

More than 3 years have passed since last update.

2つのObservableCollectionの双方向同期

Posted at

概要

2つのObservableCollectionを双方向に同期させたいことがあります。
以下のGIFは、左にObservableCollection<int>、右にObservableCollection<string>がBindingされています。2つのObservableCollectionを双方向に同期させて、どちらを変更しても、もう片方に反映されるデモです。

demo.gif

デモはWPFで作成しましたが、ObservableCollection自体はWPFには依存しないので、UWPでもコンソールでも使えます。

コード

2つのObservableCollectionを同期するために、両方のCollectionChangedイベントを購読して、相手側を変更しています。
無限ループを避けるため、ローカル変数のisChangingで既に変更中かの状態を保持しています。

public static class ObservableCollectionExtension
{
    /// <summary>
    /// 指定したコレクションからコピーされた要素を格納するObservableCollectionを生成
    /// </summary>
    public static ObservableCollection<T> ToObservableCollection<T>(this IEnumerable<T> source) => new ObservableCollection<T>(source);

    /// <summary>
    /// 指定したObservableCollectionと双方向に同期したObservableCollectionを生成する
    /// </summary>
    public static ObservableCollection<TargetT> ToObservableCollctionSynced<SourceT, TargetT>(this ObservableCollection<SourceT> sources,
    Func<SourceT, TargetT> sourceToTarget, Func<TargetT, SourceT> targetToSource)
    {
        //sourcesの要素を変換したコレクションを生成
        var targets = sources.Select(sourceToTarget).ToObservableCollection();

        //2つのコレクションを同期させる
        SyncCollectionTwoWay(sources, targets, sourceToTarget, targetToSource);

        //同期済みのコレクションを返す
        return targets;
    }

    /// <summary>
    /// 2つのObservableCollectionを双方向に同期させる
    /// </summary>
    public static void SyncCollectionTwoWay<SourceT, TargetT>(ObservableCollection<SourceT> sources, ObservableCollection<TargetT> targets,
        Func<SourceT, TargetT> sourceToTarget, Func<TargetT, SourceT> targetToSource)
    {
        bool isChanging = false;

        //Source -> Target
        sources.CollectionChanged += (o, e) =>
            ExcuteIfNotChanging(() => SyncByChangedEventArgs(sources, targets, sourceToTarget, e));
        //Target -> Source
        targets.CollectionChanged += (o, e) =>
            ExcuteIfNotChanging(() => SyncByChangedEventArgs(targets, sources, targetToSource, e));

        //変更イベントループしてしまわないように、ローカル変数(isChanging)でチェック
        //ローカル変数(isChanging)にアクセスするため、ローカル関数で記述
        void ExcuteIfNotChanging(Action action)
        {
            if (isChanging)
                return;
            isChanging = true;
            action.Invoke();
            isChanging = false;
        }
    }

    private static void SyncByChangedEventArgs<OriginT, DestT>(ObservableCollection<OriginT> origin, ObservableCollection<DestT> dest,
        Func<OriginT, DestT> originToDest, NotifyCollectionChangedEventArgs originE)
    {
        switch (originE.Action)
        {
            case NotifyCollectionChangedAction.Add:
                if (originE.NewItems?[0] is OriginT addItem)
                    dest.Insert(originE.NewStartingIndex, originToDest(addItem));
                return;

            case NotifyCollectionChangedAction.Remove:
                if (originE.OldStartingIndex >= 0)
                    dest.RemoveAt(originE.OldStartingIndex);
                return;

            case NotifyCollectionChangedAction.Replace:
                if (originE.NewItems?[0] is OriginT replaceItem)
                    dest[originE.NewStartingIndex] = originToDest(replaceItem);
                return;

            case NotifyCollectionChangedAction.Move:
                dest.Move(originE.OldStartingIndex, originE.NewStartingIndex);
                return;

            case NotifyCollectionChangedAction.Reset:
                dest.Clear();
                foreach (DestT item in origin.Select(originToDest))
                    dest.Add(item);
                return;
        }
    }
}

使用方法

使用方法は単純で元となるなるObservableCollectionから拡張メソッドで呼ぶだけです。その際に双方向の要素変換のデリゲートを引数に指定します。
ここではSource->Targetは数字を文字列にして固定文字列を足したもの、Target->Sourceは文字列の3文字目以降を数字に変換したもの、になっています。

class MainWindowViewModel
{
    public ObservableCollection<int> Sources { get; } = new ObservableCollection<int>(new[] { 10, 20, 30 });
    public ObservableCollection<string> Targets { get; }

    public MainWindowViewModel()
    {

        Targets = Sources
            .ToObservableCollctionSynced(
            x => $"C:{x}",
            x => int.Parse(x.Substring(2)));
    }
}

デモではViewを直接変更するため、あえてコードビハインドで変更しています。

<Window
   x:Class="ObservableColletionSyncedTest.MainWindow"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   xmlns:local="clr-namespace:ObservableColletionSyncedTest">
   <Window.DataContext>
      <local:MainWindowViewModel />
   </Window.DataContext>
   <UniformGrid Columns="2">
      <StackPanel>
         <Label HorizontalContentAlignment="Center" Content="Source" />
         <Button Click="AddSourceButton_Click" Content="Add" />
         <Button Click="RemoveSourceButton_Click" Content="Remove" />
         <Button Click="ReplaceSourceButton_Click" Content="Replace" />
         <Button Click="MoveSourceButton_Click" Content="Move" />
         <Button Click="ClearSourceButton_Click" Content="Clear" />
         <ListBox x:Name="sources" ItemsSource="{Binding Sources}" />
      </StackPanel>
      <StackPanel>
         <Label HorizontalContentAlignment="Center" Content="Target" />
         <Button Click="AddTargetButton_Click" Content="Add" />
         <Button Click="RemoveTargetButton_Click" Content="Remove" />
         <Button Click="ReplaceTargetButton_Click" Content="Replace" />
         <Button Click="MoveTargetButton_Click" Content="Move" />
         <Button Click="ClearTargetButton_Click" Content="Clear" />
         <ListBox x:Name="targets" ItemsSource="{Binding Targets}" />
      </StackPanel>
   </UniformGrid>
</Window>
public partial class MainWindow : Window
{
    public MainWindow() { InitializeComponent(); }

    Random random = new Random();
    ObservableCollection<string> targetItems => (targets.ItemsSource as ObservableCollection<string>);
    ObservableCollection<int> sourcesItems => (sources.ItemsSource as ObservableCollection<int>);
    private int CreateSourceValue() => random.Next(0, 99);
    private int GetRandomIndex<T>(Collection<T> collection) => random.Next(0, collection.Count);

    private void AddSourceButton_Click(object sender, RoutedEventArgs e) =>
        sourcesItems.Add(CreateSourceValue());
    private void AddTargetButton_Click(object sender, RoutedEventArgs e) =>
        targetItems.Add($"A:{CreateSourceValue()}");

    private void RemoveSourceButton_Click(object sender, RoutedEventArgs e) =>
        sourcesItems.RemoveAt(GetRandomIndex(sourcesItems));
    private void RemoveTargetButton_Click(object sender, RoutedEventArgs e) =>
        targetItems.RemoveAt(GetRandomIndex(targetItems));
    private void ReplaceSourceButton_Click(object sender, RoutedEventArgs e) =>
        sourcesItems[GetRandomIndex(sourcesItems)] = CreateSourceValue();
    private void ReplaceTargetButton_Click(object sender, RoutedEventArgs e) =>
        targetItems[GetRandomIndex(targetItems)] = $"R:{CreateSourceValue()}";

    private void Move<T>(ObservableCollection<T> collection)
    {
        int indexOld = GetRandomIndex(collection);
        int indexNew = GetRandomIndex(collection);
        collection.Move(indexOld, indexNew);
    }
    private void MoveSourceButton_Click(object sender, RoutedEventArgs e) => Move(sourcesItems);
    private void MoveTargetButton_Click(object sender, RoutedEventArgs e) => Move(targetItems);

    private void ClearSourceButton_Click(object sender, RoutedEventArgs e) => sourcesItems.Clear();
    private void ClearTargetButton_Click(object sender, RoutedEventArgs e) => targetItems.Clear();
}

注意点

デモではわかりやすくするため、ObservableCollectionを両方ともViewにBindingしていましたが、この用途なら、どちらかのListBoxに双方向の変換のConverterを挟んだほうがよいです。
実際はModel層とViewModel層のObservableCollectionを同期したい、といった用途が多いと思います。

ColletionChangedイベントの購読を解除する方法は無いため、2つのObservableCollectionの寿命が違う場合はメモリリークします。

参考

全体コード

以下の場所においておきます。
https://github.com/soi013/ObservableColletionSyncedTest/

環境

VisualStudio 2019 Version 16.8.3
.NET Core 3.1
C#8

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4