使用适配器模式显示清单, 以最大限度地减少所需的 ViewModel 数量
使用适配器模式显示复选框列表,以最大限度地减少所需的视图模型数量。
引言
最近我想显示多个复选框项的 listview,这些项属于不同的类型。我越是思考这个问题,就越不想为每种类型创建一个单独的DataTemplate
和/或特定的视图模型,尤其是我要选择的类型都是“简单”类型,通常不会被包装成视图模型。例如:
事件类型是一个枚举,注册的源是字符串。
复选框列表项DataTemplate
中元素的绑定路径将在 XAML 中定义,因此我需要一种方法将任何任意类型的属性映射到预期的DataTemplate
。
详细说明
数据模板
可选项目的模板非常简单。它期望项具有IsSelected
属性和DisplayString
属性。
<datatemplate x:key="CheckedListItem">
<stackpanel orientation="Horizontal">
<checkbox margin="5,0,0,0"
ischecked="{Binding Path=IsSelected,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}" />
<textblock margin="5,0,0,0"
text="{Binding Path=DisplayString}" />
</stackpanel>
</datatemplate>
... 并将呈现为 ...
问题在于,字符串、枚举以及您可能想要选择的几乎任何其他类型都没有现成的IsSelected
或DisplayString
属性。那么该怎么办?
当然,您可以在应用程序中的所有模型中添加IsSelected
属性和DisplayString
属性,然后就不用管它了。但是,如果您不知道哪些模型最终会出现在选择列表视图中呢?您是否为了以防万一而在每个类中都添加IsSelected
和DisplayString
?这有点繁琐。
再说,如果您想显示枚举的选择列表?或者一个第三方库中密封类型的列表,而您没有该库的源代码?
我们需要一个适配器或包装器。一个可以将任何任意类型转换为可以与CheckedListItem
数据模板通信的东西。
适配器
SelectableItemVM
是执行繁重工作的类。它具有数据模板所需的IsSelected
和DisplayString
属性,以及多种将被包装类型的一个或多个属性转换为DisplayString
的方法。
BaseVM
不一定是必需的,但它通过为从中派生的任何视图模型实现INotifyPropertyChanged
接口,并为派生的视图模型提供现成的触发属性更改事件的方法,从而节省了一些输入。
SelectableItemVM<T>
接受类型T
的实例,并将选定的字符串属性或该类型的属性映射到DisplayString
,并且通过实现ISelectable
接口,为我们提供了IsSelected
属性。
方法 | 范围 | Returns | 注释 |
SelectableItemVM(T Item) |
Public | 构造函数:为类型 T 创建 VM 实例,该实例使用类型的默认ToString 方法返回列表项的复选框文本。 |
|
SelectableItemVM(T Item, string PropertyName) |
Public | 构造函数:为类型T 创建 VM 实例,该实例使用该类型的命名属性来返回列表项的复选框文本。命名属性必须是返回字符串的类型的公共实例属性,并且应用程序必须以足够的信任运行,才能使用反射访问类型的属性。 |
|
SelectableItemVM(T Item, GetDisplayText getTextMethod) |
Public | 构造函数:为类型T 创建 VM 实例,该实例使用提供的委托函数返回列表项的复选框文本。用于没有现有文本属性的类型,或者需要组合类型的某些属性,或者应用程序没有以足够的信任运行来通过反射访问类型的属性。 |
|
IsSelected |
Public | 布尔值 | Property: This is bound to the checkbox in the CheckedListItem DataTemplate and will be set true or false according to the state of the checkbox. |
Item<T> |
Public | <T> | Property: The item of type T represented by the VM. |
DisplayPropertyName |
Public | 字符串 | Property: The name of the public instance property that returns the text representation of the type T represented by the VM. |
DisplayString |
Public | 字符串 | Property: Returns the value of the property named by DisplayPropertyName If DisplayPropertyName is not set or is not the name of a public instance property of the type or no delegate is specified the value of the type T 's ToString method is returned. |
ToString |
Public | 字符串 | Function: Returns the value of DisplayString . Allows default bindings that use a type T 's string description. |
ISelectableItem
Is, in its entirety...
/// <summary>
/// Any item that can be selected.
/// </summary>
interface ISelectable {
Boolean IsSelected {get; set;}
}
ISelectable
not absolutely necessary, but by creating the interface we make it easier to walk lists of selectable items without having to know their underlying types. This is shown in more detail later on.
示例
1. A simple list of String
For a view model, MainVM, that has some property returning a list of strings one or more of which can be selected.
Because the ToString
method for String
returns a... , go on guess, we don't need to provide any additional information about how to work out DisplayString
.
/// <summary>
/// Which event sources are available for selection?
/// </summary>
/// <remarks>
/// A simple list of strings.
/// </remarks>
public List<SelectableItemVM<String>> RegisteredSources {
get {
if (registeredSources == null) {
registeredSources = new List<SelectableItemVM<String>>();
var qrySources = from string source in eventLog.AvailableSources
orderby source ascending
select new SelectableItemVM(source);
registeredSources.AddRange(qrySources.ToList());
}
return registeredSources;
}
}
private List<SelectableItemVM<String>> registeredSources;
public const string RegisteredSourcesProperty = @"RegisteredSources";
To set the view binding in code
// Make sure we have the correct data template for the list items.
filterSources.ItemTemplate = (DataTemplate)FindResource(@"CheckedListItem");
filterSources.SetBinding(ListView.ItemsSourceProperty, MainVM.RegisteredSourcesProperty);
Having done this it becomes very easy to act on the selected items in methods or commands in MainVM, for example..
private void actOnSelectedSources {
var qrySelectedSources = from SelectableItemVM<string> source in RegisteredSources
where source.IsSelected
select source.Item;
// Do something useful with the list of selected strings.
}
2. Models with a usable text property
For a view model, MainVM, that has some property returning a list of sales contacts one or more of which can be selected, where SalesContact
may look something like...
class SalesContact {
public string ContactName {get; set;} // <--- We want to display this property.
public string Telephone {get; set;}
public string EmailAddress {get; set}
public bool CreditWorthy {get; set;}
:
:
}
Because the SalesContact
ToString
method will return something like "SomeNamespace.SalesContact"
we have to tell the adaptor what it should pipe through to its DisplayString
property. For this example we'll specify "ContactName"
.
/// <summary>
/// Credit worthy sales contacts.
/// </summary>
public List<SelectableItemVM<SalesContact>> CreditWorthy {
get {
if (creditWorthy == null) {
creditWorthy = new List<SelectableItemVM<SalesContact>>();
var qryContacts = from SalesContact contact in GetSalesContacts()
where contact.CreditWorthy()
orderby contact.ContactName ascending
select new SelectableItemVM<SalesContact>(contact, "ContactName");
creditWorthy.AddRange(qryContacts.ToList<SelectableItemVM<SalesContact>>());
}
return creditWorthy;
}
}
private List<SelectableItemVM<SalesContact>> creditWorthy;
public const string CreditWorthyContactsProperty = @"CreditWorthy";
Setting the view binding in code is exactly as shown above for the list of strings.
contacts.ItemTemplate = (DataTemplate)FindResource(@"CheckedListItem");
contacts.SetBinding(ListView.ItemsSourceProperty, MainVM.CreditWorthyContactsProperty);
And as with the list of strings you have direct access to the selected item ...
private void actOnSelectedContacts {
var qryCreditWorthy = from SelectableItemVM<SalesContact> prospect in CreditWorthy
where prospect.IsSelected
select prospect.Item;
// Send a "personalised" e-mail...
sendEnticingOffer(qryCreditWorthy.ToList<SalesContact>());
}
3. Models without a usable text property
There are three ways around this
- If you have access to the model's source code and can add a text property and want to do so then use as example 2 above.
- Create a delegate method.
- Create a derivation of
SelectableItemVM
.
Either of the latter two approaches is suitable for use when
- You don't want to start burdening your classes with properties that have nothing to do with what the class is modelling.
- You don't have access to a third party type's source code.
- You want to combine one or more text properties to create your description text.
- The application isn't running with sufficient trust to use reflection to access properties in the type.
Of the two I would choose the delegate method approach. It keeps the number of view models down, which was the main reason for using an adaptor and it also means that should you choose to change the content/format of the text you want to display in checklists you only have to change it in one place.
The Delegate Solution
// Somewhere else in your project. Possibly in the class in question or perhaps
// some common library class.
public static string GetContactName(SalesContact contact) {
return contact.ContactName;
}
And SelectableItemVM
construction for SalesContact
becomes ...
// Assuming we've put all our delegate methods in a class called Delegates
var qryContacts = from SalesContact contact in GetSalesContacts()
where contact.CreditWorthy()
orderby contact.ContactName ascending
select new SelectableItemVM<SalesContact>(SalesContact, Delegates.GetContactName);
If you wanted to show both name and 'phone No. the delegate becomes
public static string GetContactName(SalesContact contact) {
return string.format("{0} - {1}") contact.ContactName, contact.Telephone) ;
}
The Derived VM class Solution
I can't see much point to doing this, but if you have some compelling reason to go this way feel free.
If you want to use SelectableItemVM
and you don't want your colleagues doing this then seal the class and don't allow the override of DisplayString
.
/// <summary>
/// A selectable event type.
/// </summary>
class EventTypeVM : SelectableItemVM<EventLogEntryType> {
public EventTypeVM(EventLogEntryType eventType) : base(eventType) {/*Nothing to do here.*/}
/// <summary>
/// Convert some of the camel cased enumerations to space separated words.
/// </summary>
public override string DisplayString {
get {
string legend = "Unknown";
switch(Item) {
case EventLogEntryType.SuccessAudit:
legend = "Success Audit";
break;
case EventLogEntryType.FailureAudit:
legend = "Failure Audit";
break;
default:
legend = Item.ToString();
break;
}
return legend;
}
}
}
Which can then be used as below ...
/// <summary>
/// The sort of events (error, information etc) to retrieve.
/// </summary>
public List<EventTypeVM> EventTypes {
get {
if (eventTypes == null) {
eventTypes = new List<EventTypeVM>();
var qryEventTypes = from EventLogEntryType eType in Enum.GetValues(typeof(EventLogEntryType))
orderby eType.ToString() ascending
select new EventTypeVM(eType);
eventTypes.AddRange(qryEventTypes.ToList<EventTypeVM>());
}
return eventTypes;
}
}
private List<EventTypeVM> eventTypes;
public const string EventTypesProperty = @"EventTypes";
This example uses a one-time assigment because the list is unchanging but if it were a volatile list you would set the binding as in the previous examples.
filterEventTypes.ItemTemplate = (DataTemplate)FindResource("CheckedListItem");
filterEventTypes.ItemsSource = ViewModel.EventTypes;
Points of Interest/Limitations
Only Intended for 'Passive' Use
If the MainVM wants to know what the selection states of one or more items are it has to ask as shown in example 1.
Use of Reflection - DisplayPropertyName
Although there are potential trust issues when using Reflection to access a named property it seems likely that in the majority of cases the ability to specify a named text property without going to the effort of providing a separate delegate method is too useful to ignore (read: I am a lazy so and so).
For more information see
What's the point of ISelectable?
Good question. The solution started out without it, but I found that there were times when all I needed to was find the number of selected items, clear blocks or set blocks of selected items and where the underlying type was irrelevant. This sort of thing
/// <summary>
/// Set all items to selected in the referenced control.
/// </summary>
/// <param name="CommandParameter">
/// A listview having a list of items that implement ISelectable.
/// </param>
private void CheckboxSet(object CommandParameter) {
ListView control = (ListView)CommandParameter;
var qryAllItems = from ISelectable item in control.ItemsSource
select item;
foreach (ISelectable item in qryAllItems) {
item.IsSelected = true;
}
}
If you try and iterate over SelectableItemVM
you have to know the type T
used to instantiate SelectableItemVM
which can be awkward in a general purpose method, especially if you are using a VM derived from SelectableItemVM
.
Why haven't you shown XAML bindings?
Because I find life very much less troublesome specifying bindings in code. It also makes life very much easier for those who have to maintain stuff I write because they don't have to be XAML gurus to understand what's going on. More importantly; neither do I.
A Final Thought
Something for you to ponder. Considered as a breed, are view-models Adaptors or are they Facades? Discuss using only one side of the VDU and show your working. :)
历史
日期 | 备注 |
Mar 2015 | First cut for publication. |