仮想化と項目の選択状態

2025年2月22日土曜日

C#

記事のカテゴリー: C#、.NET9、WPF

仮想化したListViewは画面に表示する範囲の項目コンテナ(ListViewItem)しか作りません。その動作によりパフォーマンスを向上しているわけですが、MVVMでは問題が起こります。

項目の選択状態をViewModelで扱う

ListViewの項目の選択状態をViewModelで扱う場合、ListViewItemのIsSelectedプロパティと、1対1で対応するViewModelクラスのプロパティをバインドします。このときViewModelはコンテナが未作成の項目の選択状態を扱うこともあり、ViewとViewModelで選択状態をうまく同期できません。

C#:

// ViewModel の基本クラス
public abstract class ViewModel : INotifyPropertyChanged
{
    protected void NotifyPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
    public event PropertyChangedEventHandler? PropertyChanged;
}

// 項目に対応する 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 Items}">
    <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. UIでクリックして1つの項目を選択する(他の項目は選択解除される
  4. ListViewを下にスクロールする
  5. 画面外にあった項目がなぜか選択されている

この問題は3で他の項目を選択解除したにもかかわらず、画面外の項目についてはコンテナが存在しないためViewからViewModelに選択状態を反映しないことから生じます。その結果、4で初めてコンテナを作って、ViewModelの値を受け取り選択状態になります。

回避策

例えコンテナが存在しない状態でもViewとViewModelで項目の選択状態を同期するには、ViewからViewModelへの反映を自前で実装する必要があります。ListViewの項目の選択状態が変わったときに発生するSelectionChangedイベントを使い、発生原因がユーザーの入力の場合のみにViewModelへ反映します。ただし、SelectionChangedイベントの引数では発生原因がバインディングかユーザーの入力かは分からないので、マウスやキーボードの状態を確認します。

XAML:

<ListView ItemsSource="{Binding Items}">
    <ListView.View>
        <GridView />
    </ListView.View>
    <ListView.ItemContainerStyle>
        <!--  (View から ViewModel は自前で実装するので) 一方向でバインドするように修正  -->
        <Style TargetType="ListViewItem">
            <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=OneWay}" />
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

C#:

// 適当な添付ビヘイビアの中で
private static void ListView_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
    // 発生原因がユーザーの入力かどうかを判断する
    static bool FromView(ListView listView)
    {
        if (listView.IsKeyboardFocusWithin
            && (Keyboard.IsKeyDown(Key.PageUp) || Keyboard.IsKeyDown(Key.PageDown)
            || Keyboard.IsKeyDown(Key.End) || Keyboard.IsKeyDown(Key.Home)
            || Keyboard.IsKeyDown(Key.Up) || Keyboard.IsKeyDown(Key.Down)
            || (Keyboard.IsKeyDown(Key.A) && Keyboard.Modifiers == ModifierKeys.Control)))
        {
            return true;
        }
        else if (listView.IsMouseCaptureWithin && listView.IsMouseOver
            && (Mouse.LeftButton == MouseButtonState.Pressed
            || Mouse.RightButton == MouseButtonState.Pressed))
        {
            return true;
        }
        return false;
    }

    // 選択状態を ViewModel に反映する
    static void UpdateViewModel(ListView listView)
    {
        foreach (ItemViewModel item in listView.Items.Cast<ItemViewModel>())
        {
            item.IsSelected = listView.SelectedItems.Contains(item);
        }
    }

    if (sender is not ListView listView)
    {
        return;
    }

    if (FromView(listView))
    {
        UpdateViewModel(listView);
    }
}