65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 的 Miller 列

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2023年1月4日

公共领域

9分钟阅读

viewsIcon

15259

downloadIcon

194

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>

从这一点开始,您可以编译并运行测试项目。

Initial project

添加一个新的项目文件夹,*Themes*。

Themes project folder

在新文件夹中添加一个 XAML 资源文件,*Generic.xaml*。

Generic resource dictionary

在 *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)));
}

编译并运行程序。您将看到类似这样的结果。

Sample application, take 1

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 类后代。这包括 ListBoxListView 类。这些类继承了 ItemsSource 依赖属性,该属性将集合映射到列表。ListBoxListView 使用 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 类为继承类声明了一些属性,包括 BackgroundBorderBushBorderThickness。正如您在运行我们的测试程序时所看到的,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>

这是我们的应用程序现在的样子。

Test application, take 2

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 属性。让我们看看结果。

Initial project

接近了。我需要使主项控件水平对齐,并使所有子列表框的高度相同。让我们修改控件的样式。

<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));

这是控件现在的样子。

Initial project

您可以点击列项,控件会显示子节点集合(如果存在)。如果您深入到层次结构然后返回,列会消失……但是等等。

Initial project

如果您从第一列的第一个项开始,深入然后点击第一列的第一个项,什么都不会发生。为什么?因为从绑定系统的角度来看,没有什么变化。我需要修复它。

步骤 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;

现在编译并运行应用程序,看看它的效果。

Finished application

结论

本文介绍的 Miller 列实现只是一个基本的示例。有很多易于完成的任务,例如:

  • 通过修改其默认模板来改进控件的设计——为单个列添加垂直滚动条,而不是 ItemsContol
  • 添加暴露所选列及其所选项的属性。
  • 添加通知用户交互的事件。
  • 还有更多改进。

然而,我想介绍一个有用的 UI 元素,您可以在其基础上创建自己的高级解决方案。

感谢您的关注和时间!

历史

  • 2022年12月31日:初始版本
© . All rights reserved.