WPF 的 Miller 列





5.00/5 (7投票s)
Miller 列(级联列)控件的简单实现。
免责声明
这些测试项目是使用 Visual Studio 2019 为 .NET Framework 4.5 编写的。早期版本可能也可以使用,但需要一些调整。
我在项目中不使用可空引用类型。我并非反对它们,只是碰巧如此。
项目中的所有源代码均属于公共领域。虽然我鼓励您使用它们,但我不能保证它们没有错误。
引言
很久以前,我读到了关于史蒂夫·乔布斯创立的新公司 NeXT 的第一篇期刊文章,它革命性的计算机 NeXT Cube 及其不同寻常的操作系统 NeXT OS。当时令我着迷的事情之一是这个新系统的文件浏览器图片——一个长长的、可滚动的列视图,其中包含文件名。
多年以后,我得到了苹果公司新的 Macintosh 电脑及其新的操作系统 Mac OS X,我知道它是基于 NeXT OS 的。默认的文件浏览器视图是空间视图,但我很快找到了切换到列视图并进行测试的方法。它确实很不寻常,但对我来说非常逻辑和方便。
去年,我开始参与一个处理分层数据集的项目。这是一个 Windows 应用程序,现在使用 WPF 作为其 UI 引擎。我的第一个解决方案是使用标准的 TreeView
在屏幕上表示数据,起初效果很好,直到我得到了一个巨大的数据集进行测试。
请不要误会,应用程序本身处理数百万个数据项效果很好,问题在于树变得太大了,无法理解。然后我想起了 NeXT/Mac 文件浏览器。它通过缩小屏幕上的项目数量来处理文件和文件夹的层次结构,并帮助找到它们之间的关系。不幸的是,WPF 没有提供实现这种级联列或 Miller 列的控件。我决定自己实现这样一个控件。
我承认,在我开始我的小项目时,我过于乐观了。我认为 WPF 自定义控件很容易创建。总的来说它们是,但这个特定的控件似乎是个棘手的难题。在我创建出任何可用东西之前,我想,我从零开始开始了四个项目。每次我都会遇到一些微小而不起眼的问题,最终导致我重新开始。
帮助我完成任务的词是 **MVVM**。我的控件包含自己的子控件内部视图模型,并将列管理委托给绑定子系统。
让我们用几个简单的步骤来回顾这个控件的创建。
步骤 1
测试解决方案
创建一个 WPF 应用程序项目。
添加一个支持层次结构且可以绑定到 TreeView
的新数据对象类。
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
namespace Pvax.App.CascadingColumns
{
public class DataNode
{
[DebuggerStepThrough]
public DataNode()
{
SubNodes = new ObservableCollection<DataNode>();
}
public DataNode(string title) :
this()
{
Title = title;
}
public DataNode(string title, IEnumerable<DataNode> subNodes) :
this(title)
{
foreach(DataNode subNode in subNodes)
{
SubNodes.Add(subNode);
}
}
public string Title
{
get;
set;
}
public ObservableCollection<DataNode> SubNodes
{
get;
}
}
}
请注意,我故意将子节点集合命名为 SubNode
,而不是 Children
。将一个预填充的 ObservableCollection<DataNode>
实例作为主窗口的 DataContext
。
public MainWindow()
{
InitializeComponent();
DataContext = new ObservableCollection<DataNode>
{
new DataNode("First root", new[]
{
new DataNode("Folder 1", new[]
{
. . . . .
},
}),
new DataNode("Third root", new[]
{
new DataNode("Folder 1"),
new DataNode("Folder 2"),
new DataNode("Folder 3"),
new DataNode("Folder 4"),
}),
};
}
将主窗口分成两个水平区域。
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
</Grid.ColumnDefinitions>
</Grid>
将 TreeView
放在顶行,并将其绑定到我们的 DataContext
。
<TreeView ItemsSource="{Binding}">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding SubNodes}">
<TreeViewItem Header="{Binding Title}" />
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
此 treeview
用作数据层次结构参考,并与 Miller 列视图的标准控件进行比较。
创建一个新的 public class
MillerView
,将其祖先声明为 Control
,并添加静态和默认构造函数。
public class MillerView : Control
{
static MillerView()
{
}
public MillerView()
{
}
}
将此控件的一个实例放在我们主窗口的第二行。
<local:MillerView Grid.Row="1">
</local:MillerView>
从这一点开始,您可以编译并运行测试项目。
添加一个新的项目文件夹,*Themes*。
在新文件夹中添加一个 XAML 资源文件,*Generic.xaml*。
在 *Generic.xaml* 中为 MillerView
控件添加一个空的样式。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Pvax.App.CascadingColumns">
<Style TargetType="{x:Type local:MillerView}">
</Style>
</ResourceDictionary>
然后通过修改 static
构造函数将 MillerView
控件与此样式关联起来。
static MillerView()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(MillerView),
new FrameworkPropertyMetadata(typeof(MillerView)));
}
编译并运行程序。您将看到类似这样的结果。
MillerView
存在,但它是不可见的。
第二步
最小的级联列实现
让我们设计控件的模板。
<Style TargetType="{x:Type local:MillerView}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:MillerView}">
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
MillerView
需要一个列的容器。这些列将是列表框或列表视图,容器应该支持水平滚动。我尝试使用 ListBox
作为容器,但遇到了许多小问题。解决这些问题花费了太多精力,使项目难以管理和维护。所以我选择了 ItemsControl
作为容器。这个容器将成为控件的一个命名部分。
<Style TargetType="{x:Type local:MillerView}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:MillerView}">
<ItemsControl x:Name="PART_Columns">
</ItemsControl>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
现在在 MillerView
类中添加该部分的名称。
[TemplatePart(Name = "PART_Columns", Type = typeof(ItemsControl))]
public class MillerView : Control
{
. . . . .
}
现在,我引入一个字段 visualColumns
,并通过重写 OnApplyTemplate()
方法将此字段附加到命名部分。
private ItemsControl visualColumns;
public override void OnApplyTemplate()
{
if(null != visualColumns)
{
}
visualColumns = (ItemsControl)GetTemplateChild("PART_Columns");
if(null != visualColumns)
{
}
}
关于 null
检查。它们很快就会被填充。第一个是必需的清理——在控件的生命周期中,OnApplyTemplate()
方法可能会被调用不止一次。
现在,重要部分。我们的列将是 Selector
类后代。这包括 ListBox
和 ListView
类。这些类继承了 ItemsSource
依赖属性,该属性将集合映射到列表。ListBox
和 ListView
使用 DataTemplate
类来定义它们的项属性,但 DataTemplate
不了解我们全局视图模型中的子集合。TreeView
相反使用 HierarchicalDataTemplate
。我不认为 Miller 列的情况可以这样实现。
所以,我创建了自己的列模型类,作为 MillerView
的嵌套类。
private sealed class Column
{
public IEnumerable ColumnItems
{
get;
set;
}
}
并添加了这个视图模型的列表。
private readonly ObservableCollection<Column> dataColumns;
public MillerView()
{
dataColumns = new ObservableCollection<Column>();
}
现在这个 dataColumns
模型应该成为我们列容器的项源。修改我们的 OnApplyTemplate()
方法。
public override void OnApplyTemplate()
{
if(null != visualColumns)
{
visualColumns.ItemsSource = null;
}
visualColumns = (ItemsControl)GetTemplateChild("PART_Columns");
if(null != visualColumns)
{
visualColumns.ItemsSource = dataColumns;
}
}
现在我们需要向 dataColumns
集合添加和删除 Column
项,绑定系统将为我们创建和销毁列。
但是,我们仍然需要某种方式来访问视图模型数据。我在 MillerView
中创建了一个依赖属性 ItemsSource
,但有一个技巧——我没有提供完整的实现,而是使用了 ItemsControl
类中的 ItemsSourceProperty
。如果 WPF 的下一个版本更改了此属性,这些更改将自动传播到 MillerView
。
public IEnumerable ItemsSource
{
get => (IEnumerable)GetValue(ItemsSourceProperty);
set => SetValue(ItemsSourceProperty, value);
}
public static readonly DependencyProperty ItemsSourceProperty =
ItemsControl.ItemsSourceProperty.AddOwner(
typeof(MillerView),
new FrameworkPropertyMetadata
(null, (d, e) => ((MillerView)d).OnItemsSourceChanged(e.OldValue, e.NewValue)));
private void OnItemsSourceChanged(object oldValue, object newValue)
{
dataColumns.Clear();
PopulateItemsSource(newValue);
}
哦,我忘记了两个细节。我特别为 MillerView
替换了属性元数据,并且有一个我需要实现的 PopulateItemsSource()
方法。下面是实现方法。
private void PopulateItemsSource(object newItems)
{
if(null != newItems)
{
var newList = newItems as IEnumerable;
if(!IsEmpty(newList))
{
dataColumns.Add(new Column { ColumnItems = newList });
}
}
}
这个 IsEmpty()
方法检查一个 IEnumerable
是否为空。我可以使用 LINQ 进行此类测试,但这里是实现方法。
private static bool IsEmpty(IEnumerable list)
{
if(null != list)
{
IEnumerator enumerator = list.GetEnumerator();
bool result = !enumerator.MoveNext();
if(enumerator is IDisposable disposable)
{
disposable.Dispose();
}
return result;
}
else
{
return false;
}
}
Control
类为继承类声明了一些属性,包括 Background
、BorderBush
和 BorderThickness
。正如您在运行我们的测试程序时所看到的,TreeView
使用这些属性在其工作区域周围绘制边框。让我们通过修改控件的模板为 MillerView
做同样的事情。也为 ItemSource
属性提供绑定。
<Style TargetType="{x:Type local:MillerView}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:MillerView}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ItemsControl x:Name="PART_Columns" ItemsSource="{Binding}">
</ItemsControl>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
现在,我们可以将 MillerView
绑定到与我们的 TreeView
相同的集合。我还对视图进行了一些装饰。
<local:MillerView Grid.Row="1" ItemsSource="{Binding}"
BorderThickness="1" BorderBrush="{DynamicResource
{x:Static SystemColors.MenuHighlightBrushKey}}">
</local:MillerView>
这是我们的应用程序现在的样子。
WPF 不知道如何在屏幕上呈现 MillerView
。让我们通过一个项模板来提供这些信息。
属性先来。
public static DataTemplate GetItemTemplate(DependencyObject obj)
{
return (DataTemplate)obj.GetValue(ItemTemplateProperty);
}
public static void SetItemTemplate(DependencyObject obj, DataTemplate value)
{
obj.SetValue(ItemTemplateProperty, value);
}
public static readonly DependencyProperty ItemTemplateProperty =
ItemsControl.ItemTemplateProperty.AddOwner(
typeof(MillerView));
我再次使用了 DependencyProperty.AddOwner()
方法。
现在将此属性添加到模板中。
<ItemsControl x:Name="PART_Columns" ItemsSource="{Binding}"
ItemTemplate="{TemplateBinding ItemTemplate}">
</ItemsControl>
并为此属性提供默认值。
<Setter Property="ItemTemplate" >
<Setter.Value>
<DataTemplate>
<ListBox MinWidth="120" ItemsSource="{Binding ColumnItems}"
local:MillerView.ColumnItemChildren="{Binding SelectedItem.Children,
RelativeSource={RelativeSource Self}}" />
</DataTemplate>
</Setter.Value>
</Setter>
哎呀!这不会编译,系统不知道 MillerView.ColumnItemChildren
附加属性。让我们添加它。
public static IEnumerable GetColumnItemChildren(DependencyObject obj)
{
return (IEnumerable)obj.GetValue(ColumnItemChildrenProperty);
}
public static void SetColumnItemChildren(DependencyObject obj, IEnumerable value)
{
obj.SetValue(ColumnItemChildrenProperty, value);
}
public static readonly DependencyProperty ColumnItemChildrenProperty =
DependencyProperty.RegisterAttached(
"ColumnItemChildren",
typeof(IEnumerable),
typeof(MillerView),
new PropertyMetadata(null, OnColumnItemChildrenChanged));
private static void OnColumnItemChildrenChanged
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var selector = (Selector)d;
MillerView millerView = GetMillerView(selector);
if(null != millerView)
{
millerView.OnColumnItemChildrenChanged(selector, e.OldValue, e.NewValue);
}
}
private void OnColumnItemChildrenChanged
(Selector selector, object oldValue, object newValue)
{
RemoveColumns(selector);
PopulateItemsSource(newValue);
}
这个属性是一个完整的附加属性,它允许我通过 XAML 为每个列提供数据属性名称,并避免约定或硬编码的名称。
仍然无法编译,我需要实现两个实用方法:GetMillerView()
和 RemoveColumns()
。两者都相当简单——在视觉树中查找我们的控件,然后分别从 dataColumns
集合中删除“多余”的项。
private static MillerView GetMillerView(Selector selector)
{
DependencyObject current = selector;
while(null != current)
{
if(current is MillerView millerView)
{
return millerView;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
private void RemoveColumns(Selector selector)
{
int count = dataColumns.Count;
for(int i = count - 1; i > 0; i--)
{
if(dataColumns[i].ColumnItems == selector.ItemsSource)
{
break;
}
dataColumns.RemoveAt(i);
}
}
哦,还有一件事——向控件添加一个新字段。
private Selector selectedSelector;
在默认模板中,绑定到 MillerView.ColumnItemChildren
的属性名称是 Children
,但我们 DataNode
类的实际名称是 SubNodes
。XAML 来帮忙,我修改了 *MainWindow.xaml* 文件中我们控件的项模板。
<local:MillerView Grid.Row="1" ItemsSource="{Binding}"
BorderThickness="1" BorderBrush="{DynamicResource
{x:Static SystemColors.MenuHighlightBrushKey}}">
<local:MillerView.ItemTemplate>
<DataTemplate>
<ListBox MinWidth="120" ItemsSource="{Binding ColumnItems}"
local:MillerView.ColumnItemChildren="{Binding SelectedItem.SubNodes,
RelativeSource={RelativeSource Self}}" >
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DataTemplate>
</local:MillerView.ItemTemplate>
</local:MillerView>
同时,我将 ListBox
的项绑定到了我们 DataNode
类的 Title
属性。让我们看看结果。
接近了。我需要使主项控件水平对齐,并使所有子列表框的高度相同。让我们修改控件的样式。
<Style TargetType="{x:Type local:MillerView}">
<Setter Property="ItemTemplate" >
<Setter.Value>
<DataTemplate>
<ListBox MinWidth="120" ItemsSource="{Binding ColumnItems}"
local:MillerView.ColumnItemChildren="{Binding SelectedItem.Children,
RelativeSource={RelativeSource Self}}" />
</DataTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel IsItemsHost="True" Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:MillerView}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ItemsControl x:Name="PART_Columns" ItemsSource="{Binding}"
ItemTemplate="{TemplateBinding ItemTemplate}"
ItemsPanel="{TemplateBinding ItemsPanel}" >
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer HorizontalScrollBarVisibility="Auto">
<ItemsPresenter />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
</ItemsControl>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
我还需要从 ItemsControl
“借用”另一个属性 ItemsPanel
。
public static ItemsPanelTemplate GetItemsPanel(DependencyObject obj)
{
return (ItemsPanelTemplate)obj.GetValue(ItemsPanelProperty);
}
public static void SetItemsPanel(DependencyObject obj, ItemsPanelTemplate value)
{
obj.SetValue(ItemsPanelProperty, value);
}
public static readonly DependencyProperty ItemsPanelProperty =
ItemsControl.ItemsPanelProperty.AddOwner(
typeof(MillerView));
这是控件现在的样子。
您可以点击列项,控件会显示子节点集合(如果存在)。如果您深入到层次结构然后返回,列会消失……但是等等。
如果您从第一列的第一个项开始,深入然后点击第一列的第一个项,什么都不会发生。为什么?因为从绑定系统的角度来看,没有什么变化。我需要修复它。
步骤 3
修复最后一个讨厌的问题。
首先,我们需要拦截列上的鼠标点击。我决定使用“鼠标抬起”事件,因为它发生在其他鼠标按钮事件之后。
public override void OnApplyTemplate()
{
if(null != visualColumns)
{
visualColumns.MouseUp -= VisualColumns_MouseUp;
visualColumns.ItemsSource = null;
}
selectedSelector = null;
visualColumns = (ItemsControl)GetTemplateChild("PART_Columns");
if(null != visualColumns)
{
visualColumns.ItemsSource = dataColumns;
visualColumns.MouseUp += VisualColumns_MouseUp;
}
}
private void VisualColumns_MouseUp(object sender, MouseButtonEventArgs e)
{
}
现在我需要找到鼠标点击的列。
- 获取鼠标位置。
- 将其转换为本地坐标。
- 获取鼠标光标下方的元素。
- 向上遍历视觉树,直到找到父选择器。
让我们来实现这个。
private static Selector FindParentSelector(DependencyObject currentElement)
{
while(null != currentElement)
{
if(currentElement is Selector selector)
{
return selector;
}
currentElement = VisualTreeHelper.GetParent(currentElement);
}
return null;
}
现在在 VisualColumns_MouseUp
方法中找到选择器。
Point relativeToColumnsSet = e.GetPosition(visualColumns);
Selector currentSelector = FindParentSelector
(visualColumns.InputHitTest(relativeToColumnsSet) as DependencyObject);
然后我需要检查数据。
if(null == currentSelector)
{
return;
}
如果用户点击了任何列之外的地方,则什么都不做。
if(null == selectedSelector)
{
selectedSelector = currentSelector;
return;
}
如果 selectedSelector
字段是 null
,则表示这是第一次接收到点击。然后我将其设置为当前选择器,同样,我们的工作就完成了。
现在有趣的部分来了。这些选择器包含我们类型为 Column
的内部视图模型对象的引用。如果两个选择器引用同一个视图模型,我认为它们是相等的。
if(ReferenceEquals(selectedSelector.DataContext, currentSelector.DataContext))
{
return;
}
最后一个检查是比较 dataColumns
集合中视图模型的索引。为此,我需要先找到它们。
var selectedColumn = selectedSelector.DataContext
as Column; // NB: DataContext may contain something other than a Column here
var currentColumn = (Column)currentSelector.DataContext;
int selectedColumnIndex = dataColumns.IndexOf(selectedColumn);
int currentColumnIndex = dataColumns.IndexOf(currentColumn);
再次,有一个陷阱——我使用了 IndexOf()
方法,它们又使用集合项的 Equals()
方法。默认情况下,Equals()
按引用比较对象,但我需要比较 ColumnItems
属性,即使也是按引用。为了解决这个问题,让我们重写我们 Column
类的 Equals()
和 GetHashCode()
方法。
public override bool Equals(object obj)
{
return obj is Column column &&
EqualityComparer<IEnumerable>.Default.Equals(ColumnItems, column.ColumnItems);
}
public override int GetHashCode()
{
return 939713329 + EqualityComparer<IEnumerable>.Default.GetHashCode(ColumnItems);
}
这里我使用了 VS 的标准代码生成器。
然后检查,如果 currentColumnIndex
小于 selectedColumnIndex
,则从集合中移除过时的列。
if(currentColumnIndex < selectedColumnIndex)
{
for(int i = dataColumns.Count - 1; i > currentColumnIndex + 1; i--)
{
dataColumns.RemoveAt(i);
}
Selector nextSelector = FindChildSelector
(visualColumns.ItemContainerGenerator.ContainerFromItem
(dataColumns[currentColumnIndex + 1]));
if(null != nextSelector)
{
nextSelector.SelectedIndex = -1;
}
}
注意高亮显示的行。如果当前列的右边有一个列,则其项选择应消失。
在完成实现之前,我需要实现 FindChildSelector()
方法。
private static Selector FindChildSelector(DependencyObject rootElement)
{
if(rootElement is Selector selector1)
{
return selector1;
}
// Breadth first search
for(int i = 0; i < VisualTreeHelper.GetChildrenCount(rootElement); i++)
{
DependencyObject currentElement = VisualTreeHelper.GetChild(rootElement, i);
if(currentElement is Selector selector2)
{
return selector2;
}
}
for(int i = 0; i < VisualTreeHelper.GetChildrenCount(rootElement); i++)
{
DependencyObject currentElement = VisualTreeHelper.GetChild(rootElement, i);
Selector selector3 = FindChildSelector(currentElement);
if(null != selector3)
{
return selector3;
}
}
return null;
}
我们的事件处理程序实现的最后一步是 selectedSelector
的赋值。
selectedSelector = currentSelector;
现在编译并运行应用程序,看看它的效果。
结论
本文介绍的 Miller 列实现只是一个基本的示例。有很多易于完成的任务,例如:
- 通过修改其默认模板来改进控件的设计——为单个列添加垂直滚动条,而不是
ItemsContol
。 - 添加暴露所选列及其所选项的属性。
- 添加通知用户交互的事件。
- 还有更多改进。
然而,我想介绍一个有用的 UI 元素,您可以在其基础上创建自己的高级解决方案。
感谢您的关注和时间!
历史
- 2022年12月31日:初始版本