M-V-VM下实现数据项的选择(二)

WPF Best Practices

Posted by eagleboost on August 25, 2019

1. ISelectionContainer接口

先解释一下上一篇结尾定义的ISelectionContainer接口。

该接口提供了支持数据项选择的所有功能。SelectedItems很简单,用于返回当前选中的所有数据项。Clear/Select/Unselect几个方法用于改变选中列表,比如当界面控件的SelectionChanged事件发生时可以调用这几个方法把数据传递到ViewModel。而ItemsCleared/ItemsSelected/ItemsUnselected事件则用于在ViewModel改变选中的数据项触发事件通知界面控件更新选中状态。

需要重点提到的是索引器。代码中常常需要检查某个数据项是否被选中,传统方式有:

  • 调用扩展方法Contains检查SelectedItems。效率通常不高,因为SelectedItems的具体实现一般没有对索引做优化。

  • 提供IsSelected(object item)方法。此方法最常见,但我们都知道方法调用对于数据绑定并不友好。就算我们通过一些蹩脚的方式在XAML里面调用了IsSelected方法,如果选中状态发生了改变需要更新界面还是会头疼。

使用索引器的好处在于WPF的数据绑定对索引器有原生支持,所以只要能想办法创建数据绑定,ViewModel触发的PropertyChanged事件就能被界面代码接收到,界面更新便顺理成章实现了。

作为可选项,也可以对ISelectionContainer做一些修改,比如把几个事件分离到一个单独的接口使得接口的作用更细化和明确,也可以添加Command以便界面上的按钮或者菜单调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
  /// <summary>
  /// ISelectionContainer - 非泛型接口便于UI调用
  /// </summary>
  public interface ISelectionContainer
  {
    /// <summary>
    /// 返回当前选中的所有数据项,具体代码可以选择实现INotifyCollectionChanged接口
    /// </summary>
    IEnumerable SelectedItems { get; }

    /// <summary>
    /// 返回参数传入的item是否被选中。使用Indexer的目的在于方便数据绑定,如果用I是Selected(object item)之类的方法则无法支持绑定
    /// </summary>
    bool this[object item] { get; }

    /// <summary>
    /// 清除当前选中的列表,执行过后SelectedItems应该为空
    /// </summary>
    void Clear();

    /// <summary>
    /// 把items加入选中的列表
    /// </summary>
    void Select(ICollection items);

    /// <summary>
    /// 把items从选中的列表中删除
    /// </summary>
    void Unselect(ICollection items);

    event EventHandler ItemsCleared;

    event EventHandler<ItemsSelectedEventArgs> ItemsSelected;

    event EventHandler<ItemsUnselectedEventArgs> ItemsUnselected;
  }

  /// <summary>
  /// ISelectionContainer - 泛型接口用于实现组件
  /// </summary>
  /// <typeparam name="T"></typeparam>
  public interface ISelectionContainer<T>
  {
    IReadOnlyCollection<T> SelectedItems { get; }

    bool this[T item] { get; }

    void Clear();

    void Select(ICollection<T> items);

    void Unselect(ICollection<T> items);
  }

2. 实现

接口定义清楚具体实现很简单。我们需要三个版本,分别对应单选,互斥单选和多选。

  • SingleSelectionContainer<T>

初始化时可以为空,也可以包含一个已选中的数据项。当Select被调用时新的数据项就替换已有的数据项,但Unselect被调用时则把已有的数据项删除。

  • RadioSelectionContainer<T>

初始化时一般不为空,少数情况为空的情况可以用来表示一个Nullable。也可以包含一个已选中的数据项。当Select被调用时新的数据项就替换已有的数据项,但Unselect被调用时则什么都不做。

  • MultipleSelectionContainer<T>

初始化时根据实际情况可以包含0到n个数据项。当Select被调用时新的数据项被添加到列表中,Unselect被调用时则从列表中移除数据项。选择数据项顺序无关时可以选用HashSet作为内部数据项容器的实现,需要保持顺序时可以用别的数据结构。

这样一来暴露给UI的接口不变,只需要ViewModel给出不同版本就能实现不同方式的数据项选择。具体代码不再赘述,请移步github

3. 应用

先来一个最常见的例子,即前文说到SelectedItemsViewViewModel之间双向传递。完成之后XAML里面的绑定可以这样写。

1
2
3
4
5
<ListBox Grid.Row="1" ItemsSource="{Binding Items}" SelectionMode="Extended" SelectedItem="{Binding SelectedItem}">
  <i:Interaction.Behaviors>
    <SelectionContainerListBoxSync SelectionContainer="{Binding MultipleSelectionContainer}"/>
  </i:Interaction.Behaviors>
</ListBox>

ListBox本身是一个Selector,因此我们先实现一个针对Selector的通用基类,用来在SelectorSelectionChanged事件触发的时候把数据项传递到SelectionContainer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class SelectionContainerSync<T> : Behavior<T> where T : Selector
{
  #region Dependency Properties
  #region SelectionContainer
  public static readonly DependencyProperty SelectionContainerProperty = DependencyProperty.Register(
    "SelectionContainer", typeof(ISelectionContainer), typeof(SelectionContainerSync<T>), new PropertyMetadata(OnSelectionContainerChanged));

  public ISelectionContainer SelectionContainer
  {
    get { return (ISelectionContainer) GetValue(SelectionContainerProperty); }
    set { SetValue(SelectionContainerProperty, value); }
  }

  private static void OnSelectionContainerChanged(DependencyObject obj, Dependency`PropertyChanged`EventArgs e)
  {
    ((SelectionContainerSync<T>)obj).OnSelectionContainerChanged();
  }
  #endregion SelectionContainer
  #endregion Dependency Properties

  #region Overrides
  protected override void OnAttached()
  {
    base.OnAttached();

    var selector = AssociatedObject;
    selector.SelectionChanged += HandleSelectionChanged;
  }

  protected override void OnDetaching()
  {
    var selector = AssociatedObject;
    if (selector != null)
    {
      selector.SelectionChanged -= HandleSelectionChanged;
    }

    base.OnDetaching();
  }
  #endregion Overrides

  #region Virtuals
  protected virtual void OnSelectionContainerChanged()
  {
  }

  protected virtual void OnSelectorSelectionChanged(SelectionChangedEventArgs e)
  {
    var c = SelectionContainer;
    foreach (var removed in e.RemovedItems)
    {
      c.Unselect(removed);
    }

    foreach (var added in e.AddedItems)
    {
      c.Select(added);
    }
  }
  #endregion Virtuals

  #region Event Handlers
  private void HandleSelectionChanged(object sender, SelectionChangedEventArgs e)
  {
    var c = SelectionContainer;
    if (c == null)
    {
      Detach();
      return;
    }

    OnSelectorSelectionChanged(e);
  }
  #endregion Event Handlers
}

SelectionContainerListBoxSync则继承SelectionContainerSync并实现为ListBox的特例,用来监听SelectionContainer的三个事件,在事件触发时把ViewModel的数据项选择传递给ListBox

同理针对DataGrid的实现代码几乎一模一样,也可以用一些技巧来减少代码重复,本文不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
public class SelectionContainerListBoxSync : SelectionContainerSync<ListBox>
{
  #region Overrides
  protected override void OnAttached()
  {
    base.OnAttached();

    InitializeSelection();
  }

  protected override void OnDetaching()
  {
    UnhookSelectionContainer();

    base.OnDetaching();
  }

  protected override void OnSelectionContainerChanged()
  {
    var c = SelectionContainer;
    if (c != null)
    {
      InitializeSelection();
      HookSelectionContainer();
    }
  }

  protected override void OnSelectorSelectionChanged(SelectionChangedEventArgs e)
  {
    UnhookSelectionContainer();

    base.OnSelectorSelectionChanged(e);

    HookSelectionContainer();
  }
  #endregion Overrides

  #region Private Methods
  private void HookSelectionContainer()
  {
    var c = SelectionContainer;
    if (c != null)
    {
      c.ItemsSelected += HandleItemsSelected;
      c.ItemsUnselected += HandleItemsUnselected;
      c.ItemsCleared += HandleItemsCleared;
    }
  }

  private void UnhookSelectionContainer()
  {
    var c = SelectionContainer;
    if (c != null)
    {
      c.ItemsSelected -= HandleItemsSelected;
      c.ItemsUnselected -= HandleItemsUnselected;
      c.ItemsCleared -= HandleItemsCleared;
    }
  }

  private void InitializeSelection()
  {
    var listBox = AssociatedObject;
    var c = SelectionContainer;
    if (listBox == null || c == null)
    {
      return;
    }

    if (listBox.SelectedItems.Count == 0)
    {
      foreach (var i in c.SelectedItems)
      {
        listBox.SelectedItems.Add(i);
      }
    }
  }
  #endregion Private Methods

  #region Event Handlers
  private void HandleItemsCleared(object sender, EventArgs e)
  {
    var listBox = AssociatedObject;
    if (listBox != null)
    {
      listBox.SelectedItems.Clear();
    }
  }

  private void HandleItemsSelected(object sender, ItemsSelectedEventArgs e)
  {
    var listBox = AssociatedObject;
    if (listBox != null)
    {
      foreach (var i in e.Items)
      {
        listBox.SelectedItems.Add(i);
      }
    }
  }

  private void HandleItemsUnselected(object sender, ItemsUnselectedEventArgs e)
  {
    var listBox = AssociatedObject;
    if (listBox != null)
    {
      foreach (var i in e.Items)
      {
        listBox.SelectedItems.Remove(i);
      }
    }
  }
  #endregion Event Handlers
}

下一篇文章将演示几个更高级的使用场景,比如用ListBox来显示CheckBoxRadioBox,以及一个使用SelectionContainer来简化界面设计的例子。