記事のカテゴリー: C#、.NET9、WPF
仮想化したListView
は画面に表示されている範囲内の項目コンテナ(ListViewItem
)だけを作成します。その動作によりパフォーマンスを向上しているわけですが、MVVMでは問題が起こります。
項目の選択状態をViewModelとバインドする
ListView
の項目の選択状態をViewModelで必要とする場合、一般的でかつ直感的な手法としてはListViewItem
が持つIsSelected
プロパティと対応するViewModelクラスのプロパティをバインドします。
しかし、このバインディングは仮想化によって項目コンテナが未作成の場合では、ListView
とViewModelの間で選択状態をうまく同期できない問題を含んでいます。
C#:
- // 項目に対応する ViewModel クラス
- public class ItemViewModel : ViewModel
- {
- private bool isSelected = false;
- public bool IsSelected
- {
- get { return isSelected; }
- set
- {
- if (value == isSelected)
- {
- return;
- }
- isSelected = value;
- NotifyPropertyChanged();
- }
- }
- }
XAML:
- <ListView ItemsSource="{Binding ItemViewModels}">
- <ListView.View>
- <GridView />
- </ListView.View>
- <ListView.ItemContainerStyle>
- <!-- IsSelected プロパティに双方向でバインドする -->
- <Style TargetType="ListViewItem">
- <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
- </Style>
- </ListView.ItemContainerStyle>
- </ListView>
問題が起こるシナリオ
例えば次のシナリオを考えます。
ListView
に表示領域を超える大量の項目を追加する。- ViewModelですべての項目を選択する。
- ユーザーが1つの項目をクリックして選択する(他の項目は選択解除される)
- ユーザーが
ListView
を下にスクロールする - 表示領域外にあった項目がなぜか選択されている
この問題は、3でユーザーは他の項目を選択解除したにもかかわらず、表示領域外の項目については項目コンテナが存在しないためViewModelに選択状態が反映されないことから生じます。その結果、4のタイミングで初めて項目コンテナが作成され、ViewModel側の値を受け取り選択状態になってしまいます。
回避策(1) SelectionChanged
イベントでViewModelに反映する
検証結果: ×
項目コンテナが存在しない場合にViewModelに反映されないことが問題なら、ListView
のSelectionChanged
イベントを使って自前でViewModelに反映するというアプローチです。しかし、この方法ではViewModelで複数の項目を選択することができなくなる新しい問題を生んでしまいます。ListView
とViewModelは項目単位で対応しているために、ViewModel側の更新は項目単位でSelectionChanged
イベントを発生させます。
ViewModelで複数の項目を選択したときの流れ:
- ViewModelで複数の項目(A、B、C)を選択する。
- 項目Aを選択した
SelectionChanged
イベントが発生し、項目B、Cを選択解除する。
SelectionChanged
イベントで明確にイベントの要因がViewかViewModelかを判断する方法があればいいのですが。
回避策(2) (1)+更新中フラグ
検証結果: ×
回避策(1)の改良版です。ViewModelが選択状態を設定している間、更新中フラグをtrue
に設定します。更新中フラグがtrue
のときはSelectionChanged
イベントでは何もしません(ViewModelへの反映をしません)。
C#:
- public static class IsSelectedSynchronizer
- {
- // 添付ビヘイビアの機能が有効かどうかを表す添付プロパティ
- public static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached(
- "IsAttached", typeof(bool), typeof(IsSelectedSynchronizer), new FrameworkPropertyMetadata(false, OnIsAttachedChanged));
- public static bool GetIsAttached(DependencyObject obj) { return (bool)obj.GetValue(IsAttachedProperty); }
- public static void SetIsAttached(DependencyObject obj, bool value) { obj.SetValue(IsAttachedProperty, value); }
- /// ソース側 (ViewModel) で選択状態を更新しているかどうかを表す添付プロパティ
- public static readonly DependencyProperty SynchronizingProperty = DependencyProperty.RegisterAttached(
- "Synchronizing", typeof(bool), typeof(IsSelectedSynchronizer), new FrameworkPropertyMetadata(false));
- public static bool GetSynchronizing(DependencyObject obj) { return (bool)obj.GetValue(SynchronizingProperty); }
- public static void SetSynchronizing(DependencyObject obj, bool value) { obj.SetValue(SynchronizingProperty, value); }
- private static void OnIsAttachedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
- {
- if ((obj is not ListBox listBox)
- || (listBox.SelectionMode == SelectionMode.Single)
- || (listBox.GetValue(VirtualizingPanel.IsVirtualizingProperty) is not true))
- {
- return;
- }
- if (e.NewValue is true)
- {
- WeakEventManager<ListBox, SelectionChangedEventArgs>.AddHandler(listBox, nameof(listBox.SelectionChanged), ListBox_SelectionChanged);
- }
- else
- {
- WeakEventManager<ListBox, SelectionChangedEventArgs>.RemoveHandler(listBox, nameof(listBox.SelectionChanged), ListBox_SelectionChanged);
- }
- }
- private static void ListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- // ViewModel 側へ反映するローカルメソッド
- static void UpdateViewModel(ListBox listBox)
- {
- foreach (ItemViewModel itemViewModel in listBox.Items.Cast<ItemViewModel>())
- {
- itemViewModel.IsSelected = listBox.SelectedItems.Contains(item);
- }
- }
- if ((sender is not ListBox listBox) || (GetSynchronizing(listBox) is true))
- {
- return;
- }
- // ViewModel 側へ反映する
- UpdateViewModel(listBox);
- }
XAML:
- <ListView
- ItemsSource="{Binding ItemViewModels}"
- local:IsSelectedSynchronizer.IsAttached="True"
- local:IsSelectedSynchronizer.Synchronizing="{Binding Synchronizing}">
- <ListView.View>
- <GridView />
- </ListView.View>
- <ListView.ItemContainerStyle>
- <!-- IsSelected プロパティに単方向でバインドする -->
- <Style TargetType="ListViewItem">
- <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=OneWay}" />
- </Style>
- </ListView.ItemContainerStyle>
- </ListView>
しかし、実際に試してみるとViewModelが選択状態を設定している一連の処理の間にプロパティ変更コールバックも(当然)SelectionChanged
イベントも発生しませんでした。
ViewModelで複数の項目を選択したときの流れ:
- ViewModelで更新中フラグを
true
に設定する。 - ViewModelで複数の項目(A、B、C)を選択する。
- ViewModelで更新中フラグを
false
に設定する。 - 項目Aを選択した
SelectionChanged
イベントが発生し、項目B、Cを選択解除する。
結果は(1)と変わりません。プロパティ変更コールバックは即時割り込むわけではなく、UIスレッドがアイドルになると実行されるようです。
回避策(3) (2)+DoEvents
検証結果: 〇
回避策(2)の改良版です。Windows FormsにはDoEvents
というメソッドがありました。そのDoEvents
相当の処理をしてプロパティ変更コールバックを即時実行させます。
C#:
- Synchronizing = true; // 更新中フラグを true に
- try
- {
- // すべての項目を選択する
- foreach (ItemViewModel itemViewModel in itemViewModels)
- {
- itemViewModel.IsSelected = true;
- await Dispatcher.Yield(); // プロパティ変更コールバックを即時実行させる
- }
- }
- finally
- {
- Synchronizing = false; // 更新中フラグを false に
- }
回避策(4) SelectionItems
をバインドする
検証結果: △
回避策(3)で目的は達成しましたが、もう一つのアプローチを検証しました。個々の項目でバインドしないでListView
のSelectionItems
にバインドする方法です。SelectionItems
ならそもそも仮想化の影響(未生成の項目コンテナの影響)を受けません。SelectionItems
は読み取り専用の依存関係プロパティなので添付ビヘイビアで疑似的なバインドをします。
C#:
- public static class SelectedItemsSynchronizer
- {
- // 添付ビヘイビアの機能が有効かどうかを表す添付プロパティ
- public static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached(
- "IsAttached", typeof(bool), typeof(SelectedItemsSynchronizer), new FrameworkPropertyMetadata(false, OnIsAttachedChanged));
- public static bool GetIsAttached(DependencyObject obj) { return (bool)obj.GetValue(IsAttachedProperty); }
- public static void SetIsAttached(DependencyObject obj, bool value) { obj.SetValue(IsAttachedProperty, value); }
- // SelectedItems をバインドする添付プロパティ
- public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.RegisterAttached(
- "SelectedItems",
- typeof(IEnumerable<object>),
- typeof(SelectedItemsSynchronizer),
- new FrameworkPropertyMetadata(Enumerable.Empty<object>(), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemsChanged));
- public static IEnumerable<object> GetSelectedItems(DependencyObject obj) { return (IEnumerable<object>)obj.GetValue(SelectedItemsProperty); }
- public static void SetSelectedItems(DependencyObject obj, IEnumerable<object> value) { obj.SetValue(SelectedItemsProperty, value); }
- private static void OnIsAttachedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
- {
- if ((obj is not ListBox listBox)
- || (listBox.SelectionMode == SelectionMode.Single)
- || (listBox.GetValue(VirtualizingPanel.IsVirtualizingProperty) is not true))
- {
- return;
- }
- if (e.NewValue is true)
- {
- WeakEventManager<ListBox, SelectionChangedEventArgs>.AddHandler(listBox, nameof(listBox.SelectionChanged), ListBox_SelectionChanged);
- }
- else
- {
- WeakEventManager<ListBox, SelectionChangedEventArgs>.RemoveHandler(listBox, nameof(listBox.SelectionChanged), ListBox_SelectionChanged);
- }
- }
- private static void OnSelectedItemsChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
- {
- // View 側へ反映するローカルメソッド
- void UpdateView(ListBox listBox, IEnumerable<object> oldValue, IEnumerable<object> newValue)
- {
- foreach (object item in oldValue.Except(newValue))
- {
- listBox.SelectedItems.Remove(item);
- }
- foreach (object item in newValue.Except(oldValue))
- {
- listBox.SelectedItems.Add(item);
- }
- }
- if ((obj is not ListBox listBox)
- || (listBox.SelectionMode == SelectionMode.Single)
- || (listBox.GetValue(VirtualizingPanel.IsVirtualizingProperty) is not true)
- || e.NewValue is not IEnumerable<object> newValue)
- {
- return;
- }
- // 値が違うなら View 側へ反映する
- IEnumerable<object> oldValue = listBox.SelectedItems.Cast<object>().ToArray(); // Cast は遅延評価なので ToArray する
- if (!SetEquals(oldValue, newValue))
- {
- UpdateView(listBox, oldValue, newValue);
- }
- }
- private static void ListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (sender is not ListBox listBox)
- {
- return;
- }
- // 値が違うなら View 側からプロパティを更新する
- IEnumerable<object> oldValue = GetSelectedItems(listBox);
- IEnumerable<object> newValue = listBox.SelectedItems.Cast<object>().ToArray(); // Cast は遅延評価なので ToArray する
- if (!SetEquals(oldValue, newValue))
- {
- SetSelectedItems(listBox, newValue);
- }
- }
- // 2つのシーケンスが同じ要素を持っているかどうかを判断する
- private static bool SetEquals(IEnumerable<object> first, IEnumerable<object> second)
- {
- return !first.Except(second).Any() && !second.Except(first).Any();
- }
- }
UpdateView
ローカルメソッドを見るとわかる通り、ListView
のSelectedItems
はIList
であり、AddRange
(複数追加)は存在せずAdd
(単数追加)しかありません。結局のところ、この方法でもListView
への反映は項目単位になります。
ViewModelで複数の項目を選択したときの流れ:
処理 | プロパティ | |
---|---|---|
1 | ViewModelで複数の項目(A、B、C)を選択する。 | A、B、C |
2 | プロパティ変更コールバックでSelectedItems に項目Aを追加する。 |
A、B、C |
3 | (割り込み)即時に項目Aを選択したSelectionChanged イベントが発生し、項目B、Cを選択解除する。 |
A(間違った値) |
4 | プロパティ変更コールバックでSelectedItems に項目Bを追加する。 |
A(間違った値) |
5 | (割り込み)即時に項目A、Bを選択したSelectionChanged イベントが発生し、項目Bを選択する。 |
A、B(間違った値) |
6 | プロパティ変更コールバックでSelectedItems に項目Cを追加する。 |
A、B(間違った値) |
6 | (割り込み)即時に項目A、B、Cを選択したSelectionChanged イベントが発生し、項目Cを選択する。 |
A、B、C |
このようにプロパティは間違った値を経由しますが、結果的にはなぜか同期します。
回避策(5) (4)+更新中フラグ
検証結果: 〇
回避策(4)の改良版です。もう一度更新中フラグを採用します。今回はプロパティ変更コールバックにSelectionChanged
イベントが割り込むことはすでに分かっています。
C#:
- public static class SelectedItemsSynchronizer
- {
- ...
- // 追加
- // View へ反映している処理中かどうかを表す内部用の添付プロパティ
- private static readonly DependencyProperty SynchronizingProperty = DependencyProperty.RegisterAttached(
- "Synchronizing", typeof(bool), typeof(SelectedItemsSynchronizer), new FrameworkPropertyMetadata(false));
- private static bool GetSynchronizing(DependencyObject obj) { return (bool)obj.GetValue(SynchronizingProperty); }
- private static void SetSynchronizing(DependencyObject obj, bool value) { obj.SetValue(SynchronizingProperty, value); }
- ...
- // 変更
- private static void OnSelectedItemsChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
- {
- void UpdateView(ListBox listBox, IEnumerable<object> oldValue, IEnumerable<object> newValue)
- {
- SetSynchronizing(listBox, true);
- try
- {
- foreach (object item in oldValue.Except(newValue))
- {
- listBox.SelectedItems.Remove(item);
- }
- foreach (object item in newValue.Except(oldValue))
- {
- listBox.SelectedItems.Add(item);
- }
- }
- finally
- {
- SetSynchronizing(listBox, false);
- }
- }
- ...
- }
- //変更
- private static void ListBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if ((sender is not ListBox listBox) || (GetSynchronizing(listBox) is true))
- {
- return;
- }
- ...
- }
- ...
- }
更新履歴
- : 初稿
- : 回避策の項を大幅加筆しました。
0 件のコメント:
コメントを投稿