WPF:改进了选择






4.97/5 (44投票s)
为用户提供更好的选择选项。
引言
我离开了一段时间,在构思写一本书;对于所有在我的博客上留下鼓励话语的朋友们,我非常感谢。这对我意义重大。不幸的是,出版商非常狭隘,看不到更大的图景,也看不到我们的书会是什么样子。所以书的项目就此搁置。
因此,这篇文章是我重返 CodeProject usual article tirade 的第一篇文章,这里我感觉才是我的真正归宿。
这是一篇短文,但我可以保证,接下来会有一系列非常重要的文章,内容是我认为迄今为止我最好的、最有用的作品。即将发布的文章系列将围绕一个用于处理 WPF 的 MVVM 框架展开;它实际上解决了我在使用 WPF、测试和 MVVM 模式时遇到的所有问题/不足,所以请持续关注这个系列。
但我们现在所处的位置就是这里,这就是这篇文章,那么这篇文章到底做了什么呢?嗯,它相当简单。我们在 UI 中大量使用组合框 (comboboxes) 来让用户选择值并显示选定的值,这很好,所以我们有类似这样的东西
这非常有效,前提是你的数据量相对较少且复杂度不高。请记住,在 WinForms 和 WPF 中,你可以将任何对象的列表作为组合框的数据源,所以将复杂类的列表作为 ItemsSource 并非不可想象。上面的选择/显示方法可能无法满足需求,因此可能需要一些更高级的功能。
如果我们能将当前选定的项目保持为一个简单的字符串,并允许用户看到一个 DataGrid
或 ListView
来从中选择当前项目,那不是很好吗?
幸运的是,WPF 非常强大,我们可以做到这一点。
这是我提出的方案
我们看到的是,当前项目只是选中整个对象的简短属性表示,但当用户想要进行新选择时,他们会在一个合适的显示容器中看到整个对象;在我的示例中,我使用的是标准的 (Styled) WPF ListView
,但你可以使用任何你喜欢的东西。
这一切是如何工作的?如果你想知道,请继续阅读。
工作原理
首先要理解的是,组合框 (combobox) 用来选择的是什么。在这个简单的演示代码(已附带)中,我为 ComboxBox.ItemsSource
使用了一个 ObservableCollection<Person>
,但这可以是任何 IEnumerable
,所以像 List<Person>
这样也可以。
我通过 ViewModel 中的绑定来设置 ComboBox.ItemSource
,该 ViewModel 用于演示代码的 Window 的 DataContext。这是完整的 ViewModel 代码。虽然这个 ViewModel 代码实际上并不重要,你只需要了解的是 ComboxBox.ItemsSource
被绑定到 ViewModel 的 People
属性,而该属性是一个 ObservableCollection<Person>
。
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Windows.Data;
namespace WpfApplication1
{
public class PeopleViewModel : INotifyPropertyChanged
{
private Person currentPerson = null;
private ObservableCollection<Person> people =
new ObservableCollection<Person>();
private ICollectionView peopleCV = null;
public PeopleViewModel()
{
this.people.Add(new Person
{
FirstName = "sacha",
MiddleName = "",
LastName = "Barber1"
});
this.people.Add(new Person
{
FirstName = "leanne",
MiddleName = "riddley",
LastName = "rymes"
});
peopleCV = CollectionViewSource.GetDefaultView(people);
peopleCV.MoveCurrentToPosition(-1);
}
public Person CurrentPerson
{
get { return currentPerson; }
set
{
if (currentPerson != value)
{
currentPerson = value;
NotifyChanged("CurrentPerson");
}
else
return;
}
}
public ObservableCollection<Person> People
{
get { return people; }
set
{
if (people != value)
{
people = value;
peopleCV = CollectionViewSource.GetDefaultView(people);
peopleCV.MoveCurrentToPosition(-1);
NotifyChanged("People");
}
else
return;
}
}
#region INotifyPropertyChanged Implementation
/// <summary>
/// Occurs when any properties are changed on this object.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// A helper method that raises the PropertyChanged event for a property.
/// </summary>
/// <param name="propertyNames">The names
/// of the properties that changed.</param>
protected virtual void NotifyChanged(params string[] propertyNames)
{
foreach (string name in propertyNames)
{
OnPropertyChanged(new PropertyChangedEventArgs(name));
}
}
/// <summary>
/// Raises the PropertyChanged event.
/// </summary>
/// <param name="e">Event arguments.</param>
protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
{
if (this.PropertyChanged != null)
{
this.PropertyChanged(this, e);
}
}
#endregion
}
}
这是一个 Person
对象实际的样子
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
namespace WpfApplication1
{
public class Person : INotifyPropertyChanged
{
private String firstName = String.Empty;
private String middleName = String.Empty;
private String lastName = String.Empty;
public String FormattedName
{
get
{
return FirstName.Substring(0, 1) + "." +
LastName;
}
}
public String FirstName
{
get { return firstName; }
set
{
if (firstName != value)
{
firstName = value;
NotifyChanged("FirstName");
}
else
return;
}
}
public String MiddleName
{
get { return middleName; }
set
{
if (middleName != value)
{
middleName = value;
NotifyChanged("MiddleName");
}
else
return;
}
}
public String LastName
{
get { return lastName; }
set
{
if (lastName != value)
{
lastName = value;
NotifyChanged("LastName");
}
else
return;
}
}
#region INotifyPropertyChanged Implementation
/// <summary>
/// Occurs when any properties are changed on this object.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// A helper method that raises the PropertyChanged event for a property.
/// </summary>
/// <param name="propertyNames">
/// The names of the properties that changed.</param>
protected virtual void NotifyChanged(params string[] propertyNames)
{
foreach (string name in propertyNames)
{
OnPropertyChanged(new PropertyChangedEventArgs(name));
}
}
/// <summary>
/// Raises the PropertyChanged event.
/// </summary>
/// <param name="e">Event arguments.</param>
protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
{
if (this.PropertyChanged != null)
{
this.PropertyChanged(this, e);
}
}
#endregion
}
}
这里有一个重要的点需要注意,我在 Person
类上有一个 FormattedName
属性,它代表了整个对象的简短显示表示。我正是使用这个 FormattedName
属性来显示 ComboBox
中当前选定的项目;基本上,它只是对选定对象的简短表示。
到目前为止,我们已经将 ComboBox
绑定到一个 ObservableCollection<Person>
;很好,那么其余部分是如何工作的呢?嗯,要深入了解,我们需要理解 ComboBox
的 ControlTemplate 是如何工作的。ControlTemplate 中有一个弹出窗口,用于容纳项目,还有一个 Content
属性,用于显示当前选定的项目。了解了这一点,我们就可以开始让 ComboBox
按照我们想要的方式工作了。
让 ComboBox 显示 Grid 作为其 ItemPresenter
第一步是让它显示一个 DataGrid
或 ListView
作为其项目。我们怎么做到这一点呢?我提出的解决方案是“作弊”。我们像往常一样使用 ComboBox
,但我们将一个 DataGrid
或 ListView
放在它的第一个项目位置,并给它一个负的 Margin
,这样我们就永远看不到 ComboBox
项目周围的标准 ComboBox
选择颜色(它实际上是我们的 DataGrid
或 ListView
)。
这是我正在做的事情
<local:ComboBoxEx >
.......
.......
.......
.......
<local:ComboBoxEx.Items>
<ComboBoxItem>
<ListView AlternationCount="0"
Margin="-5,-2,-5,-2"
Background="White"
Height="200"
ItemsSource="{Binding Path=People}"
SelectedValue="{Binding Path=CurrentPerson}"
ItemContainerStyle="{DynamicResource ListItemStyle}"
BorderBrush="Transparent"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
IsSynchronizedWithCurrentItem="True"
local:SortableList.IsGridSortable="True"
FontSize="12"
SelectionMode="Single">
<ListView.Resources>
<Style x:Key="ListItemStyle"
TargetType="{x:Type ListViewItem}">
<Setter Property="Template"
Value="{StaticResource EntityListViewItemTemplate}" />
<Setter Property="HorizontalContentAlignment"
Value="Left" />
</Style>
</ListView.Resources>
<ListView.View>
<GridView ColumnHeaderContainerStyle="{StaticResource
GridViewColumnHeaderStyle}">
<GridViewColumn Header="FirstName"
DisplayMemberBinding="{Binding FirstName}" />
<GridViewColumn Header="Middle Name"
DisplayMemberBinding="{Binding MiddleName}" />
<GridViewColumn Header="Last Name"
DisplayMemberBinding="{Binding LastName}" />
</GridView>
</ListView.View>
</ListView>
</ComboBoxItem>
</local:ComboBoxEx.Items>
</local:ComboBoxEx>
眼尖的读者会注意到,我没有使用标准的 WPF ComboBox
,而是使用了 ComboBoxEx
;不用担心,我稍后会讲到。现在,请理解我们正在使用标准的 WPF ComboBox.Items
集合,并像标准 WPF ComboBox
一样提供一个 ComboBoxItem
。碰巧的是,ComboBox
只有一个项目,那就是我们的 DataGrid
或 ListView
。
这就解释了我们如何让 DataGrid
或 ListView
显示出来。那么选中的项目呢?既然当前项目实际上是 DataGrid
或 ListView
,那么选中的项目肯定也是 DataGrid
或 ListView
。是的,在正常情况下,确实是这样,而且看起来会很奇怪;它会是这样的
这实际上并不是我们想要的。我们该如何解决呢?这确实需要对 ComboBox
的 ControlTemplate 工作原理有更多的了解。当你查看它时,你会发现有一个 ContentPresenter
用于表示当前选定项目的 Content
,它默认使用 TemplateBinding
来绑定到 Content
。这解释了我们上面看到的情况;Content
实际上是一个 DataGrid
或 ListView
。这很有趣,所以也许,如果我们使用另一个属性,我们可以让它显示别的东西。
<ContentPresenter x:Name="item"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
Grid.Column="1"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding Content}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" />
更改选定项目的内容
当我第一次研究这个问题时,我希望能够将 ContentTemplate="{TemplateBinding Content}"
替换为一个附加的 DP,但这似乎不起作用,因为 DP 实际上并未被视为 TemplateBinding
标记扩展的标准可用属性。我当时想,好吧,我们只能子类化 ComboBox
并添加一个我们想要用于 Content
的属性,并在 ControlTemplate 中使用它。
这正是我所做的;这是 ComboBoxEx
的完整代码
public class ComboBoxEx : ComboBox
{
#region SelectedTemplateOverride
/// <summary>
/// SelectedTemplateOverride Dependency Property
/// </summary>
public static readonly DependencyProperty SelectedTemplateOverrideProperty =
DependencyProperty.Register("SelectedTemplateOverride",
typeof(DataTemplate), typeof(ComboBoxEx),
new FrameworkPropertyMetadata((DataTemplate)null));
/// <summary>
/// Gets or sets the SelectedTemplateOverride property.
/// </summary>
public DataTemplate SelectedTemplateOverride
{
get { return (DataTemplate)GetValue(SelectedTemplateOverrideProperty); }
set { SetValue(SelectedTemplateOverrideProperty, value); }
}
#endregion
}
正如我所说,如果能使用附加 DP 就好了,但没办法。
因此,有了这个 ComboBoxEx
类,我们就可以更改应用于它的标准 ControlTemplate,以使用我们新的 SelectedTemplateOverride
DP。让我们在 ComboBoxEx
ControlTemplate 的相关部分看看。
<ContentPresenter x:Name="item"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
Grid.Column="1"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectedTemplateOverride}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" />
请注意,我们不再使用 ContentTemplate="{TemplateBinding Content}"
,而是使用 ContentTemplate="{TemplateBinding SelectedTemplateOverride}"
,这是我们在 ComboBoxEx
类中引入的新 DP。
所以,我们现在需要做的就是往 SelectedTemplateOverride
DP 中放入一些东西;在 XAML 中使用 ComboBoxEx
对象实际实例的地方可以做到这一点。
这是相关的 XAML 片段
<local:ComboBoxEx.SelectedTemplateOverride>
<DataTemplate>
<Label DataContext="{Binding ElementName=theView, Path=DataContext}"
Content="{Binding Path=CurrentPerson.FormattedName,
UpdateSourceTrigger=PropertyChanged, Mode=OneWay}"
VerticalContentAlignment="Center"
Padding="0"
Margin="2,0,0,0" />
</DataTemplate>
</local:ComboBoxEx.SelectedTemplateOverride>
请注意,它只是一个 DataTemplate,因为 SelectedTemplateOverride
DP 的类型就是如此。另外两点值得注意
- 我们正在使用我们特殊的、简化的
FormattedName
属性,我们在前面讨论演示 (Person
) 类时已经看到了。 - 我必须从某个地方获取
DataContext
来用于标签,这样绑定才能工作,所以我从宿主Window
获取它,因为整个 ViewModel 已经设置为它的DataContext
。ViewModel 实际上知道当前的项目是哪个,这是通过ICollectionView
和IsSynchronizedWithCurrentItem="True"
的魔力实现的,而后者在演示代码的ListView
(CombBox
的单个项目) 上设置了。我没有讨论ICollectionView
和IsSynchronizedWithCurrentItem="True"
,它们所做的只是保持ListView
中的选择与 ViewModel 中的ICollectionView
同步,这让我可以从DataContext
(从 Window,因为它有 ViewModel 作为DataContext
)中获取当前选定的项目。
所以,随着这个拼图的最后一块解决,我们最终得到的结果是,选定项目是 ListView
中当前选定的 Person,它被用作 CombBox
中选定项目的 Contemt
。
奖励
附带的代码还演示了如何使用一个名为 SortableList
的附加 DP 来对 ListView
列进行排序,你像这样将其设置在你的 ListView
上
<ListView local:SortableList.IsGridSortable="True" SelectionMode="Single">
你可以深入研究 SortableList
来了解它的工作原理。
结束
总之,我希望一切都清楚了。如果你喜欢,可以留下一个投票和评论,那将很好。
享受。