WPFのListBox
コントロールにはSelectionMode
プロパティがあり、Multiple
を指定すると複数項目の選択が可能です。
しかし、「最大で5件まで」のような制御をXAMLから設定することはできません。本稿では、ViewModel側で最大選択可能数の制御を行う方法について紹介したいと思います。
事前準備
-
ListBox
にバインドするデータに、bool
型のIsSelected
プロパティを生やします。このプロパティは変更時にPropertyChanged
が起きるようにしてください。 - 生やした
IsSelected
プロパティをListBox
にバインドし、SelectedItemsChanged
イベントが起きるたびに選んだ項目のIsSelected
が変更されるようにします。具体的には、以下のように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
プロパティで画面表示するデータの元を持っているとします。
// バインドするデータ -> 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
の変更状態を監視する必要があるためです。
上限の実装方法
「選択可能な数に上限を設定する」を実現するには、以下の方法が思いつきます。
- 現在の選択数が上限数に達したら、未選択項目は選択不可にする。選択済み項目は選択解除が可能で、解除されて上限数を下回ったら未選択項目は再び選択可能になる。
- 項目を選択したとき、すでに上限数と同じ数だけ選択されていたら、新しく選択した項目の選択を解除する。そうでなければ後続に選択項目を流す。
前者の方法では上限数と現在の選択数に応じてListBox
における各項目の選択可否を制御しなければなりません。後者の場合、とりあえず選択可能にしておいてあとから選択を解除し直せばよいことになります。ここでは後者のほうが簡単なので後者で実装します。
「項目を選択したとき」
準備段階でIsSelected
プロパティに項目の選択状態をバインドしているので、データ全体を持つReadOnlyReactiveCollection
から各要素のIsSelected
の変更を監視します。それにはRxのObserveElementProperty
を使います。
Items.ObserveElementProperty(item => item.IsSelected).Subscribe(x =>
{
/* コレクションのどれかの要素のチェックが変更されたときの処理 */
});
Subscribe
に流れてくるのは、この例ではPropertyPack<ItemData, bool>
の変数となります。これは「ObserveElementProperty
で監視しているプロパティが属するインスタンス(ItemData
)」と「ObserveElementProperty
で監視しているプロパティの変更後の値(bool
)」を持つインスタンスです。
「すでに上限数と同じ数だけ選択されていたら、新しく選択した項目の選択を解除する」
現在の選択項目を取得するには、コレクション全体をなめてIsSelected == true
なもののみ抽出すればよいでしょう。その項目の数を上限値と比較して超えていたらIsSelected
をfalse
に上書きします。
なお、上限値はint
型のプロパティで持っておきましょう。未設定は無制限としておけばよいと思います。
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>
を使ってやると簡単です。
// バインドするデータ -> 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つめを選択すると
- 新しく選択されたため
ObserveElementProperty(~).Subscribe(~)
が3項目選択状態で流れる。内部で選択を解除 - 選択解除により
IsSelected
が変更されたので、ObserveElementProperty(~).Subscribe(~)
に2項目の選択状態で流れてくる。これは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回起きますが、後続には値が流れません。