2つのObservableCollectionの双方向同期

# 概要

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

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/b5e612a5-1d79-c286-fba1-53120ced8927.gif)

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

# コード

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

```csharp
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>
    /// ２つの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文字目以降を数字に変換したもの、になっています。

```csharp
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を直接変更するため、あえてコードビハインドで変更しています。

```xml
<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>
```

```csharp
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の寿命が違う場合はメモリリークします。

# 参考
http://nomoredeathmarch.hatenablog.com/entry/2019/03/02/180147

# 全体コード
以下の場所においておきます。
https://github.com/soi013/ObservableColletionSyncedTest/

# 環境
VisualStudio 2019 Version 16.8.3
.NET Core 3.1
C#8