WPF ListBox 選択 取得

WPFのListBoxコントロールにはSelectionModeプロパティがあり、Multipleを指定すると複数項目の選択が可能です。
しかし、「最大で5件まで」のような制御をXAMLから設定することはできません。本稿では、ViewModel側で最大選択可能数の制御を行う方法について紹介したいと思います。

事前準備

  • ListBoxにバインドするデータに、bool型のIsSelectedプロパティを生やします。このプロパティは変更時にPropertyChangedが起きるようにしてください。
  • 生やしたIsSelectedプロパティをListBoxにバインドし、SelectedItemsChangedイベントが起きるたびに選んだ項目のIsSelectedが変更されるようにします。具体的には、以下のようにXAMLを設定します。

View.xaml

<ListBox ItemsSource="{Binding Items.Value}" SelectionMode="Multiple" <ItemsControl.ItemContainerStyle> <Style TargetType="{x:Type ListBoxItem}"> <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" /> </Style> </ItemsControl.ItemContainerStyle> </ListBox>

また、ViewModelも先にバインドするデータソースを定義しておきます。モデル部分は本質ではないので省略してますが、IModelを実装するクラスがItemsプロパティで画面表示するデータの元を持っているとします。

ViewModel.cs

// バインドするデータ -> Modelから取得する public class ItemData : BindableBase { public bool IsSelected { get => _isSelected; set => SetProperty(ref _isSelected; value); } private bool _isSelected; } public class ViewModel { public ReadOnlyReactiveCollection<ItemData> Items { get; } private readonly IModel _model; public ViewModel(IModel model) { _model = model; // もしModelがObservableCollection<T>で持つなら Items = _model.Items.ToReadOnlyReactiveCollection(/* 変換処理 */); // もしModelがIEnumerable<T>で持つなら Items = _model.ObserveProperty(m => m.Items). SelectMany(item => /* 変換処理 */). ToReadOnlyReactiveCollection(); } }

上記ではModelがIEnumerableでデータを持っている場合ObservableCollectionでデータを持っている場合の2通りでReadOnlyReactiveCollection<ItemData>の変換処理を書いています。ReactiveCollectionを使う理由はコレクションの中身のIsSelectedの変更状態を監視する必要があるためです。

上限の実装方法

「選択可能な数に上限を設定する」を実現するには、以下の方法が思いつきます。

  1. 現在の選択数が上限数に達したら、未選択項目は選択不可にする。選択済み項目は選択解除が可能で、解除されて上限数を下回ったら未選択項目は再び選択可能になる。
  2. 項目を選択したとき、すでに上限数と同じ数だけ選択されていたら、新しく選択した項目の選択を解除する。そうでなければ後続に選択項目を流す。

前者の方法では上限数と現在の選択数に応じてListBoxにおける各項目の選択可否を制御しなければなりません。後者の場合、とりあえず選択可能にしておいてあとから選択を解除し直せばよいことになります。ここでは後者のほうが簡単なので後者で実装します。

「項目を選択したとき」

準備段階でIsSelectedプロパティに項目の選択状態をバインドしているので、データ全体を持つReadOnlyReactiveCollectionから各要素のIsSelectedの変更を監視します。それにはRxのObserveElementPropertyを使います。

ViewModel.cs

Items.ObserveElementProperty(item => item.IsSelected).Subscribe(x => { /* コレクションのどれかの要素のチェックが変更されたときの処理 */ });

Subscribeに流れてくるのは、この例ではPropertyPack<ItemData, bool>の変数となります。これは「ObserveElementPropertyで監視しているプロパティが属するインスタンス(ItemData)」と「ObserveElementPropertyで監視しているプロパティの変更後の値(bool)」を持つインスタンスです。

「すでに上限数と同じ数だけ選択されていたら、新しく選択した項目の選択を解除する」

現在の選択項目を取得するには、コレクション全体をなめてIsSelected == trueなもののみ抽出すればよいでしょう。その項目の数を上限値と比較して超えていたらIsSelectedをfalseに上書きします。

なお、上限値はint型のプロパティで持っておきましょう。未設定は無制限としておけばよいと思います。

ViewModel.cs

public int MaxSelection { get; set; } = int.MaxValue; ~~ Items.ObserveElementProperty(item => item.IsSelected).Subscribe(x => { var selected = Items.Where(w => w.IsSelected); var count = selected.Count(); if (count > MaxSelection) { // IsSelectedが変更されたインスタンスはx.Instanceで取得できる // x.Value は変更後のIsSelectedの値なのでx.Value = false;としても無意味 x.Instance.IsSelected = false; return; } });

後続に選択項目を流す

選択されている項目を使ってReactiveProperty<IEnumerable<T>>やReactiveCollection<T>を使いたい場合、Subscribe内でそれらのプロパティに値を設定してやる必要があります。しかしそのようなケースだと内容は選択状態に応じて自動で決まるべき(ReadOnlyであるべき)ケースが多いです。これら2つを実現するにはSystem.Reactive.SubjectsのSubject<T>を使ってやると簡単です。

ViewModel.cs

// バインドするデータ -> Modelから取得する public class ItemData : BindableBase { public bool IsSelected { get => _isSelected; set => SetProperty(ref _isSelected; value); } private bool _isSelected; } public class ViewModel { // ListBoxに設定するItemSource public ReadOnlyReactiveCollection<ItemData> Items { get; } // 元データの取得処理のModel private readonly IModel _model; // 最大選択可能数 public int MaxSelection { get; set; } = int.MaxValue; // 選択項目の文字列表現を持つReactiveProperty public ReadOnlyReactivePropertySlim<IEnumerable<string>> SelectedItemTexts { get; } // 現在選択されている項目の文字列表現を流すSubject private Subject<IEnumerable<ItemData>> _selectedItemSubjects; public ViewModel(IModel model) { _model = model; // もしModelがObservableCollection<T>で持つなら Items = _model.Items.ToReadOnlyReactiveCollection(/* 変換処理 */); // もしModelがIEnumerable<T>で持つなら Items = _model.ObserveProperty(m => m.Items). SelectMany(item => /* 変換処理 */). ToReadOnlyReactiveCollection(); // 正しい選択項目はSubject経由で流れてくるのでそれをもとにReactivePropertyを作る _selectedItemSubjects = new Subject<ItemData>(); SelectedItemTexts = _selectedItemSubjects. SelectMany(s => s.ToString()). ToReadOnlyReactivePropertySlim(); Items.ObserveElementProperty(item => item.IsSelected).Subscribe(x => { var selected = Items.Where(w => w.IsSelected); var count = selected.Count(); if (count > MaxSelection) { // IsSelectedが変更されたインスタンスはx.Instanceで取得できる // x.Value は変更後のIsSelectedの値なのでx.Value = false;としても無意味 x.Instance.IsSelected = false; return; } // ここまで来たら現在の選択状態は正当なので後続に選択項目を流す _selectedItemSubjects.OnNext(selected); }); } }

これで「MaxSelectionに指定された選択可能な上限まで選択可能」「上限を超えて選択しようとしたら選択が解除される」「選択状態が変わったとき、正当なときだけ値が流れてくる」を実現できました。

変更の通知について

ObserveElementPropety(x => x.IsSelected)は変更状態の監視をするIObservableの生成です。上限超過だとSubscribe内でfalseに値を更新しなおすので、Subscribeにもう一度値が流れてきます。具体的には、上限数が2のときに3つめを選択すると

  1. 新しく選択されたためObserveElementProperty(~).Subscribe(~)が3項目選択状態で流れる。内部で選択を解除
  2. 選択解除によりIsSelectedが変更されたので、ObserveElementProperty(~).Subscribe(~)に2項目の選択状態で流れてくる。これは3項目を選択する前と同じ状態。
  3. 選択内容が変わっていない状態でOnNextするが、OnNextに流すIEnumerable<ItemData>を作り直しているため、要素が実質同じでも後続のReactivePropertyで値変更イベントが起きる。

という動きになります。最初の1・2については選択解除の都合上仕方がないのですが、3の「実質同じでも後続のReactivePropertyで値変更が起きる」は無駄です。Subjectにぶら下がる後続が多い場合などこれが気に入らないなら、現在の選択数を別にフィールドで保持(ローカル変数でもよい?)して同じ値ならOnNextしない、という実装が必要になります。

// 現在選択中の数 選択前と選択後で数値が同じなら選択状態は変わっていない private int _selectedCount; ~~ Items.ObserveElementProperty(x => x.IsSelected).Subscribe(x => { var selected = Items.Where(w => w.IsSelected); var count = selected.Count(); if (count > MaxSelection) { x.Instance.IsSelected = false; // 変更をもとに戻す } else if (_selectedCount != count) { _selectedCount = count; _selectedItemSubjects.OnNext(selected); } })

こうしておくと上限以上を選択しようとしたときにSubscribeは選択→選択解除で2回起きますが、後続には値が流れません。