仮想化と項目の選択状態

2025年2月22日土曜日

C# WPF

記事のカテゴリー: 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>

問題が起こるシナリオ

例えば次のシナリオを考えます。

  1. ListViewに表示領域を超える大量の項目を追加する。
  2. ViewModelですべての項目を選択する。
  3. ユーザーが1つの項目をクリックして選択する(他の項目は選択解除される
  4. ユーザーがListViewを下にスクロールする
  5. 表示領域外にあった項目がなぜか選択されている

この問題は、3でユーザーは他の項目を選択解除したにもかかわらず、表示領域外の項目については項目コンテナが存在しないためViewModelに選択状態が反映されないことから生じます。その結果、4のタイミングで初めて項目コンテナが作成され、ViewModel側の値を受け取り選択状態になってしまいます。

回避策(1) SelectionChangedイベントでViewModelに反映する

検証結果: ×

項目コンテナが存在しない場合にViewModelに反映されないことが問題なら、ListViewSelectionChangedイベントを使って自前でViewModelに反映するというアプローチです。しかし、この方法ではViewModelで複数の項目を選択することができなくなる新しい問題を生んでしまいます。ListViewとViewModelは項目単位で対応しているために、ViewModel側の更新は項目単位でSelectionChangedイベントを発生させます。

ViewModelで複数の項目を選択したときの流れ:

  1. ViewModelで複数の項目(A、B、C)を選択する。
  2. 項目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で複数の項目を選択したときの流れ:

  1. ViewModelで更新中フラグをtrueに設定する。
  2. ViewModelで複数の項目(A、B、C)を選択する。
  3. ViewModelで更新中フラグをfalseに設定する。
  4. 項目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)で目的は達成しましたが、もう一つのアプローチを検証しました。個々の項目でバインドしないでListViewSelectionItemsにバインドする方法です。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ローカルメソッドを見るとわかる通り、ListViewSelectedItemsIListであり、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;
        }

        ...
    }
    
    ...
}

更新履歴

  • : 初稿
  • : 回避策の項を大幅加筆しました。