WPF/MVVM 选择管理器






4.89/5 (6投票s)
本文提出了一种在不同线性结构和分层结构中仅管理一个元素选择的类及其实现思路。
源代码
引言
在 UI 中显示大量不同类型的元素(文本块、图像、图形等),并且以不同的方式组织(列表、树等),但同一时间只能选择其中一个元素,这种情况很常见。
在本文中,我将尝试创建一个类来帮助处理选择。WPF 用于演示,但这种方法也可以用于 UWP、Xamarin、Windows Forms,甚至其他一些技术。
接口
要由选择管理器处理的对象应实现 ISelectableElement
接口
/// <summary>
/// Classes must implement this interface to be handled by <see cref="SelectionManager"/>
/// <remarks>Property <see cref="Selected"/> have to fire PropertyChanged event./></remarks>
/// </summary>
public interface ISelectableElement: INotifyPropertyChanged
{
/// <summary>
/// Selection flag.
/// </summary>
bool Selected { get; set; }
}
SelectionManager
实现 ISelectionManager
接口,以便能够使用依赖注入模式
/// <summary>
/// Manages SelectedElement in hierarchical collection of elements (only one element selected at the particular moment).
/// </summary>
public interface ISelectionManager: INotifyPropertyChanged
{
/// <summary>
/// Gets and sets selected element
/// </summary>
ISelectableElement SelectedElement { get; set; }
/// <summary>
/// Adds collection of the objects to manager
/// </summary>
/// <param name="collection">The collection to be added</param>
void AddCollection(INotifyCollectionChanged collection);
/// <summary>
/// Removes collection of the objects from manager
/// </summary>
/// <param name="collection">The collection to be removed</param>
void RemoveCollection(INotifyCollectionChanged collection);
}
辅助函数
PropertyHelper
用于获取属性名称
internal class PropertyHelper
{
public static string GetPropertyName<T>(Expression<Func<T>> propertyLambda)
{
var me = propertyLambda.Body as MemberExpression;
if (me == null)
{
throw new ArgumentException(
"You must pass a lambda of the form: '() => Class.Property' or '() => object.Property'");
}
return me.Member.Name;
}
}
ObservableCollection
在调用 Clear()
后不会触发包含已删除(旧)项目列表的 CollectionChanged
。 可以使用 ObservableCollection
并且不使用 Clear()
方法,或者使用 ObservableCollectionEx
以便能够使用 Clear()
方法。
/// <summary>
/// Works the same as <see cref="ObservableCollection{T}"/>.
/// Fires <see cref="ObservableCollection{T}.CollectionChanged"/> event with <see cref="NotifyCollectionChangedEventArgs.Action"/> equal to <see cref="NotifyCollectionChangedAction.Remove"/> after calling <see cref="Collection{T}.Clear"/> methods.
/// <see cref="ObservableCollection{T}"/> fires event with <see cref="NotifyCollectionChangedEventArgs.Action"/> equal to <see cref="NotifyCollectionChangedAction.Reset"/> and empty list of old items./>
/// </summary>
/// <typeparam name="T">The type of elements in the list.</typeparam>
public class ObservableCollectionEx<T> : ObservableCollection<T>
{
/// <summary>
/// Removes all items from the collection and fire CollectionChanged
/// </summary>
protected override void ClearItems()
{
var items = new List<T>(Items);
base.ClearItems();
OnCollectionChanged(
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, items));
}
}
选择管理器
AddCollection
将集合中的所有元素添加到内部列表,并使用反射搜索子元素(如果某些元素属性实现 ObservableCollection<>
并且该集合的元素实现 ISelectableElement
,则该集合也将由 SelectionManager
管理)。
RemoveCollection
从 SelectionManager
中删除所有元素和子元素。
SelectionManager
将自动处理添加和删除子元素。
public class SelectionManager : ISelectionManager
{
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Gets and sets selected element
/// </summary>
public ISelectableElement SelectedElement
{
get
{
return _selectedElement;
}
set
{
_selectedElement = value;
OnPropertyChanged();
}
}
private ISelectableElement _selectedElement;
private readonly List<ISelectableElement> _elements = new List<ISelectableElement>();
/// <summary>
/// Adds collection of the objects to manager
/// </summary>
/// <param name="collection">The collection to be added</param>
public void AddCollection(INotifyCollectionChanged collection)
{
collection.CollectionChanged += collection_CollectionChanged;
foreach (var element in (ICollection)collection)
{
var selectableElement = element as ISelectableElement;
if (selectableElement != null)
{
AddElement(selectableElement);
}
}
}
/// <summary>
/// Removes collection of the objects from manager
/// </summary>
/// <param name="collection">The collection to be removed</param>
public void RemoveCollection(INotifyCollectionChanged collection)
{
collection.CollectionChanged -= collection_CollectionChanged;
foreach (var element in (ICollection)collection)
{
var selectableElement = element as ISelectableElement;
if (selectableElement != null)
{
RemoveElement(selectableElement);
}
}
}
private void OnPropertyChanged([CallerMemberName] string property = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
}
private void AddElement(ISelectableElement element)
{
_elements.Add(element);
element.PropertyChanged += element_PropertyChanged;
AddSelectableElements(element);
if (_elements.Any() && _elements.All(e => !e.Selected))
{
_elements[0].Selected = true;
}
}
private void RemoveElement(ISelectableElement element)
{
_elements.Remove(element);
RemoveSelectableElements(element);
element.PropertyChanged -= element_PropertyChanged;
if (SelectedElement == element)
{
SelectedElement = null;
if (_elements.Count > 0)
{
_elements[0].Selected = true;
}
}
}
private void element_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
var currentElement = (ISelectableElement)sender;
if (e.PropertyName != PropertyHelper.GetPropertyName(() => currentElement.Selected))
{
return;
}
if (currentElement.Selected)
{
foreach (var selectedElement in _elements
.Where(element => element != currentElement && element.Selected))
{
selectedElement.Selected = false;
}
SelectedElement = currentElement;
}
else
{
SelectedElement = null;
}
}
private void collection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
{
foreach (var item in e.NewItems)
{
if (e.OldItems == null || !e.OldItems.Contains(item))
{
var element = item as ISelectableElement;
if (element != null)
{
AddElement(element);
}
}
}
}
if (e.OldItems != null)
{
foreach (var item in e.OldItems)
{
if (e.NewItems == null || !e.NewItems.Contains(item))
{
var element = item as ISelectableElement;
if (element != null)
{
RemoveElement(element);
}
}
}
}
}
private void AddSelectableElements(ISelectableElement rootElement)
{
foreach (var prop in rootElement.GetType().GetProperties().Where(IsPropertyObservable))
{
var value = (INotifyCollectionChanged)prop.GetValue(rootElement);
AddCollection(value);
}
}
private void RemoveSelectableElements(ISelectableElement rootElement)
{
foreach (var prop in rootElement.GetType().GetProperties().Where(IsPropertyObservable))
{
var value = (INotifyCollectionChanged)prop.GetValue(rootElement);
RemoveCollection(value);
}
}
private bool IsPropertyObservable(PropertyInfo prop)
{
if (!prop.PropertyType.IsGenericType)
{
return false;
}
var observableCollectionType = GetObservableCollectionType(prop.PropertyType);
if (observableCollectionType != null &&
typeof(ISelectableElement).IsAssignableFrom(observableCollectionType.GenericTypeArguments[0]))
{
return true;
}
return false;
}
private Type GetObservableCollectionType(Type type)
{
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ObservableCollection<>))
{
return type;
}
if (type.BaseType == null)
{
return null;
}
return GetObservableCollectionType(type.BaseType);
}
}
测试
NUnit 和 NSubstitute 用于编写单元测试。
演示
演示应用程序包含列表和树。选择由 SelectionManager
管理。
MVVM Light 框架用于使代码更简洁明了。
有两种类型的对象支持选择。
class ListElementViewModel : ViewModelBase, ISelectableElement { private string _description; public string Description { get { return _description; } set { Set(ref _description, value); } } private bool _selected; public bool Selected { get { return _selected; } set { Set(ref _selected, value); } } } class HierarchicalElementViewModel: ViewModelBase, ISelectableElement { private string _name; public string Name { get { return _name; } set { Set(ref _name, value); } } public ObservableCollection<HierarchicalElementViewModel> Subitems { get; set; } private bool _selected; public bool Selected { get { return _selected; } set { Set(ref _selected, value); } } public ICommand AddSubitemCommand { get; } public ICommand RemoveCommand { get; } public HierarchicalElementViewModel ParentViewModel { get; } public HierarchicalElementViewModel(HierarchicalElementViewModel parentViewModel) { ParentViewModel = parentViewModel; Subitems = new ObservableCollection<HierarchicalElementViewModel>(); AddSubitemCommand = new RelayCommand(Add); RemoveCommand = new RelayCommand(Remove, () => ParentViewModel != null); } private void Add() { Subitems.Add(new HierarchicalElementViewModel(this) { Name = "Child Element" }); } private void Remove() { ParentViewModel.Subitems.Remove(this); } }
MainViewModel
包含这两种元素的两个集合。
class MainViewModel : ViewModelBase
{
public ObservableCollection<HierarchicalElementViewModel> HierarchicalElements { get; }
public ObservableCollection<ListElementViewModel> ListElements { get; }
public RelayCommand AddHierarchicalElementCommand { get; }
public RelayCommand RemoveHierarchicalElementCommand { get; }
public RelayCommand AddListElementCommand { get; }
public RelayCommand RemoveListElementCommand { get; }
public ISelectionManager Manager { get; }
public MainViewModel()
{
HierarchicalElements = new ObservableCollection<HierarchicalElementViewModel>();
ListElements = new ObservableCollection<ListElementViewModel>();
AddHierarchicalElementCommand = new RelayCommand(AddHierarchicalElement);
RemoveHierarchicalElementCommand = new RelayCommand(
RemoveHierarchicalElement,
() => Manager.SelectedElement is HierarchicalElementViewModel);
AddListElementCommand = new RelayCommand(AddListElement);
RemoveListElementCommand = new RelayCommand(
RemoveListElement,
() => Manager.SelectedElement is ListElementViewModel);
Manager = new SelectionManager.SelectionManager();
Manager.PropertyChanged += ManagerOnPropertyChanged;
Manager.AddCollection(HierarchicalElements);
Manager.AddCollection(ListElements);
}
private void AddHierarchicalElement()
{
var selectedHierarchicalElement = Manager.SelectedElement as HierarchicalElementViewModel;
if (selectedHierarchicalElement != null)
{
var newItem = new HierarchicalElementViewModel(selectedHierarchicalElement) { Name = "Child Element" };
selectedHierarchicalElement.Subitems.Add(newItem);
newItem.Selected = true;
}
else
{
var newItem = new HierarchicalElementViewModel(null) { Name = "Root Element" };
HierarchicalElements.Add(newItem);
newItem.Selected = true;
}
}
private void RemoveHierarchicalElement()
{
var hierarchicalElement = Manager.SelectedElement as HierarchicalElementViewModel;
if (hierarchicalElement?.ParentViewModel != null)
{
hierarchicalElement.ParentViewModel.Subitems.Remove(hierarchicalElement);
}
else
{
HierarchicalElements.Remove(hierarchicalElement);
}
}
private void AddListElement()
{
var newItem = new ListElementViewModel { Description = "List Element" };
ListElements.Add(newItem);
newItem.Selected = true;
}
private void RemoveListElement()
{
ListElements.Remove((ListElementViewModel)Manager.SelectedElement);
}
private void ManagerOnPropertyChanged(object sender, PropertyChangedEventArgs propertyChangedEventArgs)
{
RemoveHierarchicalElementCommand.RaiseCanExecuteChanged();
RemoveListElementCommand.RaiseCanExecuteChanged();
}
}
MainForm
xaml 代码。
<Window x:Class="SelectionManagerDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModel="clr-namespace:SelectionManagerDemo.ViewModel"
mc:Ignorable="d"
Title="Selection Manager Demo"
Height="350"
Width="525"
d:DataContext="{d:DesignInstance IsDesignTimeCreatable=False, d:Type=viewModel:MainViewModel}">
<Grid>
<Grid.Resources>
<DataTemplate DataType="{x:Type viewModel:ListElementViewModel}">
<StackPanel Orientation="Horizontal">
<Ellipse Fill="AliceBlue"
Height="15"
Width="15"
Stroke="Blue"
StrokeThickness="2"
Margin="5"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Description}"
VerticalAlignment="Center"
Margin="5" />
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type viewModel:HierarchicalElementViewModel}">
<StackPanel Orientation="Horizontal">
<Polygon Points="0,0 15,0 15,15 0,15"
Stroke="Crimson"
StrokeThickness="2"
Margin="5"
VerticalAlignment="Center"
Fill="AliceBlue" />
<TextBlock Text="{Binding Name}"
VerticalAlignment="Center"
Margin="5" />
</StackPanel>
</DataTemplate>
</Grid.Resources>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="List Elements"
VerticalAlignment="Center"
Margin="3" />
<Button Content="+"
Margin="3"
Width="25"
Height="25"
Command="{Binding AddListElementCommand}" />
<Button Content="-"
Margin="3"
Width="25"
Height="25"
Command="{Binding RemoveListElementCommand}" />
</StackPanel>
<StackPanel Grid.Row="0"
Grid.Column="1"
Orientation="Horizontal">
<TextBlock Text="Hierarchical Elements"
VerticalAlignment="Center" />
<Button Content="+"
Margin="3"
Width="25"
Height="25"
Command="{Binding AddHierarchicalElementCommand}" />
<Button Content="-"
Margin="3"
Width="25"
Height="25"
Command="{Binding RemoveHierarchicalElementCommand}" />
</StackPanel>
<ListBox Grid.Row="1"
Grid.Column="0"
ItemsSource="{Binding ListElements}">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}"
d:DataContext="{d:DesignInstance viewModel:ListElementViewModel}">
<Setter Property="IsSelected"
Value="{Binding Selected, Mode=TwoWay}" />
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<TreeView Grid.Row="1"
Grid.Column="1"
ItemsSource="{Binding HierarchicalElements}">
<TreeView.ItemContainerStyle>
<Style TargetType="{x:Type TreeViewItem}"
d:DataContext="{d:DesignInstance viewModel:HierarchicalElementViewModel}">
<Setter Property="IsSelected"
Value="{Binding Selected, Mode=TwoWay}" />
<Setter Property="IsExpanded"
Value="True" />
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Subitems}">
<ContentPresenter Content="{Binding}" />
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<StackPanel Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="2"
Orientation="Horizontal">
<TextBlock Text="Selected Element:"
Margin="5"
VerticalAlignment="Center" />
<ContentPresenter Content="{Binding Manager.SelectedElement}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</Window>
演示效果如下所示。