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