控件名称:Cupertino TreeView
作者:Vicky&James
源码链接:https://github.com/JamesnetGroup/cupertino-treeview
教学视频:https://www.bilibili.com/video/BV1xz42187wV
这篇文章是对 WPF CupertinoTreeView 教程视频的技术回顾。
本文介绍了Vicky的第六个WPF教程系列视频的内容,该视频在哔哩哔哩上免费公开了一个小时的详细技术讲解教程。此外,还通过GitHub分享了源代码,欢迎大家通过点击Stars表示支持,也可以通过Forks参与该开源项目,并通过Discussions与我们进行沟通交流。
1.为什么需要在WPF中定制TreeView
在WPF中,TreeView/TreeViewItem与其他普通控件一样,默认提供了模板。然而,TreeView的表现方式多种多样,且其自由的层次结构布局没有限制,这使得默认提供的模板使用起来存在一定的局限性。因此,非常有必要详细了解和使用这个继承自ItemsControl的TreeView控件的机制和特性。
2.虽继承自ItemsControl,但与ListBox完全不同的TreeView机制
在继承自ItemsControl的典型控件中,ListBox是一个例子,它的父子层次结构机制分明。因此,虽然ListBox继承了ItemsControl,但ListBoxItem继承了ContentControl,这种继承结构使得层次结构直观易懂。
但是,对于TreeView/TreeViewItem,顶层父控件是TreeView,但其子控件TreeViewItem既是子控件,也可以拥有子控件,因此它既扮演子控件的角色,也扮演父控件的角色。
因此,TreeViewItem也继承了ItemsControl。这将是理解TreeView控件的基本概念。
3.通过ItemsSource属性和ItemsPresenter元素窥探TreeView的本质
如前所述,TreeView/TreeViewItem都继承自ItemsControl,因此这两个控件都有ItemsSource集合属性。因此,父控件在模板中必须指定ItemsPresenter元素的区域,但对于拥有递归结构的子控件,也需要绑定ItemsSource属性并创建ItemsPresenter区域。
总结的代码如下:
TreeView Template
<Style TargetType="{x:Type TreeView}">
<Setter Property="ItemsSource" Value="{Binding Files}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<ItemsPresenter/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
TreeViewItem Template
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="ItemsSource" Value="{Binding Children}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}"/>
<ItemsPresenter/>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
比较TreeView/TreeViewItem的两个模板源码,可以发现它们都共同使用了ItemsSource和ItemsPresenter,这种结构上的相似性使我们能够窥见其机制上的统一。
正是通过这些机制性的元素,TreeView控件才能在没有任何层次约束的情况下灵活且自由地扩展。
因此,如果我们能很好地理解并构建这些特性,原本看起来复杂的TreeView结构也会变得极其容易和快速地实现。
4.为TreeView控件设计数据模型
首先,我们需要选择用于TreeView控件层次表达的数据。在本次教程视频中,我们基于文件/文件夹结构来构建数据。这使我们很容易联想到Windows资源管理器或Mac的Finder,这也是一个非常适合表达递归层次结构的例子。
模型设计如下:
FileItem 模型
public class FileItem
{
public string Name { get; set; }
public string Path { get; set; }
public string Type { get; set; }
public string Extension { get; set; }
public long? Size { get; set; }
public int Depth { get; set; }
public List<FileItem> Children { get; set; }
}
模型的属性组成是构建Windows资源管理器或Finder的最少项,值得注意的属性是Path、Depth和Children。通过以下对属性的详细解释来仔细观察它们。
- Name: 文件/文件夹的名称,从Path中提取。
- Path: 文件/文件夹的完整路径。
- Type: 文件/文件夹的类型。
- Extension: 文件的扩展名。
- Size: 文件的大小。
- Depth: 当前项目的深度(级别)。
- Children: 子项列表。
5.创建演示数据
虽然可以像Windows资源管理器或Finder那样加载实际系统的目录,但在本次教程视频中,我们将直接在“我的文档”这样的公共访问空间中创建文件/文件夹结构,以确保安全执行。
因此,我们需要如下所示的文件/文件夹结构生成逻辑。
FileCreator.cs
public class FileCreator
{
public string BasePath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
public void Create()
{
string textData = "Vicky test file content.";
string[] tempFiles =
{
@"\Vicky\Microsoft\Visual Studio\solution.txt",
@"\Vicky\Microsoft\Visual Studio\debug.mp3",
@"\Vicky\Microsoft\Visual Studio\class.cs",
@"\Vicky\Microsoft\Sql Management Studio\query.txt",
@"\Vicky\Apple\iPhone\store.txt",
@"\Vicky\Apple\iPhone\calculator.mp3",
@"\Vicky\Apple\iPhone\safari.cs",
};
foreach (string file in tempFiles)
{
string fullPath = BasePath + file;
string dirName = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(dirName))
{
Directory.CreateDirectory(dirName);
}
File.WriteAllText(fullPath, textData);
}
}
}
查看源代码可以看到,基于“我的文档”(Environment.SpecialFolder.MyDocuments)路径创建多个文件夹和文件的逻辑。为了构建层次化的目录结构和各种扩展名的文件,配置了各种项。在这里,大家还可以额外创建更多的文件。
通过此生成逻辑,这些文件夹/文件将安全地创建在“我的文档”中。
6.MVVM模式
在本教程中,我们将首次构建用于MVVM模式的ViewModel。
在WPF中,MVVM模式的依赖性非常高,占有重要地位。
之所以在前五个教程中没有涉及MVVM,是因为在使用MVVM模式之前,我们希望首先充分学习WPF的基础,包括CustomControl、ControlTemplate以及ContentControl/ItemsControl
。之后再涉及MVVM。
因此,如果你希望打好WPF的基础,建议按照顺序先学习前五个WPF教程系列。
7.在ViewModel中生成基于文件夹/文件的列表
在本教程中,我们将首次使用MVVM模式的核心——ViewModel。
因此,我们将创建ViewModel类,并基于FileCreator.cs类中定义的BasePath物理路径,通过.NET的Directory类提供的GetDirectories和GetFiles方法,获取所有文件夹/文件列表并构建FileItem数据。
首先,创建要绑定到TreeView的ItemsSource的List属性。
绑定到ItemsSource的Files属性
public List<FileItem> Files { get; set; }
需要注意的是,这里没有处理Files属性的OnPropertyChanged。这意味着Files列表将在ViewModel的构造函数阶段生成。因此,使用init而不是set是一个不错的选择。
使用init代替set
public List<FileItem> Files { get; init; }
虽然是小细节,但这些因素共同构成了良好的代码。
接下来,需要实现一个方法,用于递归遍历文件夹并将文件夹/文件列表生成FileItem模型。
实现GetFiles方法
private void GetFiles(string root, List<FileItem> source, int depth)
{
string[] dirs = Directory.GetDirectories(root);
string[] files = Directory.GetFiles(root);
foreach (string dir in dirs)
{
FileItem item = new();
item.Name = Path.GetFileNameWithoutExtension(dir);
item.Path = dir;
item.Size = null;
item.Type = "Folder";
item.Depth = depth;
item.Children = new();
source.Add(item);
GetFiles(dir, item.Children, depth + 1);
}
foreach (string file in files)
{
FileItem item = new();
item.Name = Path.GetFileNameWithoutExtension(file);
item.Path = file;
item.Size = new FileInfo(file).Length;
item.Type = "File";
item.Extension = new FileInfo(file).Extension;
item.Depth = depth;
source.Add(item);
}
}
这段代码中首先要注意的是,仅在目标文件夹/文件项为目录时继续遍历下级文件夹。因此,递归调用的部分仅在dirs的foreach逻辑中调用
。在此逻辑中,通过Children继续查找并添加子项。
接下来是Depth
。此部分预先计算当前文件夹/文件的深度。虽然数据在逻辑上是层次结构的,但在XAML中进行TreeViewItem模板的设计时,需要知道项目的级别,因此这是一个非常重要的属性。因此,每次递归方法调用时,都会增加Depth值以区分
。其他元素则是用于传递视觉数据的,可以简单了解。
8.查看ViewModel的完整源代码
现在,通过查看ViewModel
的完整源代码,我们来看看Files
属性是如何在构造函数中生成的。
CupertinoViewModel.cs
public partial class CupertinoWindowViewModel : ObservableBase
{
public List<FileItem> Files { get; set; }
public CupertinoWindowViewModel()
{
FileCreator fileCreator = new();
fileCreator.Create();
int depth = 0;
string root = fileCreator.BasePath + "/Vicky";
List<FileItem> source = new();
GetFiles(root, source, depth);
Files = source;
}
private void GetFiles(string root, List<FileItem> source, int depth)
{
string[] dirs = Directory.GetDirectories(root);
string[] files = Directory.GetFiles(root);
foreach (string dir in dirs)
{
FileItem item = new();
item.Name = Path.GetFileNameWithoutExtension(dir);
item.Path = dir;
item.Size = null;
item.Type = "Folder";
item.Depth = depth;
item.Children = new();
source.Add(item);
GetFiles(dir, item.Children, depth + 1);
}
foreach (string file in files)
{
FileItem item = new();
item.Name = Path.GetFileNameWithoutExtension(file);
item.Path = file;
item.Size = new FileInfo(file).Length;
item.Type = "File";
item.Extension = new FileInfo(file).Extension;
item.Depth = depth;
source.Add(item);
}
}
}
从源代码中可以看到,为了构建数据,所有的预处理工作都在构造函数中实现了。
构造函数在Window的DataContext绑定到ViewModel之前运行,在这个阶段对绑定属性进行set处理。当然,如果数据量大,异步加载是更理想的方式,但在本教程中,我们基于少量数据来构建TreeView,因此在构造函数阶段处理这些数据。因此,理解并使用这些特性可以更深入地实现基于MVVM的WPF应用程序。
接下来,让我们详细看看在ViewModel的构造函数中定义Files属性的部分。
构造函数
FileCreator fileCreator = new();
fileCreator.Create();
int depth = 0;
string root = fileCreator.BasePath + "/Vicky";
List<FileItem> source = new();
GetFiles(root, source, depth);
Files = source;
构造函数的逻辑如下:
- fileCreator.Create: 在“我的文档”路径下创建示例演示数据。
- depth: 将第一个文件夹/文件项的深度初始化为0。
- root: “我的文档”路径的基础文件夹(在此可以再创建所需的文件夹)。
- source: 创建列表。
- GetFiles: 从“我的文档”路径基于root基础文件夹递归获取文件夹/文件列表。
- Files = source: 递归调用结束后,将source分配给Files。
至此,数据准备和构建、通过ViewModel进行TreeView ItemsSource数据绑定的所有准备工作都完成了。
9.绑定DataContext
在这次的Cupertino Treeview教程中,我们没有使用诸如Prism之类的MVVM框架功能,因此直接在窗口视图的构造函数中创建ViewModel并绑定到DataContext。
namespace Cupertino.Forms.UI.Views
{
public class CupertinoWindow : Window
{
static CupertinoWindow()
{
DefaultStyleKeyProperty
.OverrideMetadata(typeof(CupertinoWindow),
new FrameworkPropertyMetadata(typeof(CupertinoWindow)));
}
public CupertinoWindow()
{
DataContext = new CupertinoWindowViewModel();
}
}
}
如果在运行时数据没有绑定,可以从这里检查ViewModel绑定到DataContext的部分。
现在,后端代码的主要准备工作已经完成。
10.Cupertino TreeView ControlTemplate
TreeView ControlTemplate的实现与ListBox的实现非常相似
。最重要的元素是ItemsPresenter
。如果需要添加布局元素,如标题或分页处理,也在这里实现。在本教程中包含了标题,因此在TreeView的Template中与ItemsPresenter一起实现标题。
下面是最终实现的CupertinoTreeView CustomControl的Template的完整样子。
CupertinoTreeView.xaml
<Style TargetType="{x:Type units:CupertinoTreeView}">
<Setter Property="Width" Value="800"/>
<Setter Property="BorderBrush" Value="#AAAAAA"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Margin" Value="100"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type units:CupertinoTreeView}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid Grid.IsSharedSizeScope="True">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto" MinWidth="400" SharedSizeGroup="Path"/>
<ColumnDefinition Width="Auto" MinWidth="100" SharedSizeGroup="Size"/>
</Grid.ColumnDefinitions>
<Label Grid.Column="0" Content="Name"
Background="#FAFAFA" Padding="10"
BorderBrush="#AAAAAA" BorderThickness="0 0 1 1"/>
<Label Grid.Column="1" Content="Path"
Background="#FAFAFA" Padding="10"
BorderBrush="#AAAAAA" BorderThickness="0 0 1 1"/>
<Label Grid.Column="2" Content="Size"
Background="#FAFAFA" Padding="10"
BorderBrush="#AAAAAA" BorderThickness="0 0 1 1"/>
<units:MagicStackPanel Grid.Row="1" Grid.ColumnSpan="3"
VerticalAlignment="Top"
ItemHeight="{Binding ElementName=Items, Path=ActualHeight}">
</units:MagicStackPanel>
<ItemsPresenter x:Name="Items" Grid.Row="1" Grid.ColumnSpan="3"
VerticalAlignment="Top"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
这里需要注意的是Header的布局和ItemsPresenter元素的摆放
。TreeView控件默认不提供Header元素。此外,从用户界面的角度来看,TreeView通常不显示Header。但是,如果有需要,可以像现在这样直接实现Header。
ItemsPresenter上方的Header
为了使Header和ItemsPresenter中的TreeViewItem数据项的列(ColumnDefinition)大小保持一致,指定了SharedSizeGroup
。(Path, Size)这些元素在后面的TreeViewItem内容中也会出现。
总结来说,Header布局和子元素的ItemsPresenter摆放是TreeView控件Template实现的核心。
这比实现TreeViewItem要简单一些。
11.Cupertino TreeViewItem ControlTemplate
最后,最重要的TreeView控件核心元素——TreeViewItem Template的实现阶段
。
正如前面提到的,TreeViewItem既是子控件又是父控件,因此具有继承自ItemsControl的结构。因此,虽然需要像ListBoxItem一样构建Template,但为了放置子项,还需要包含ItemsPresenter元素。
虽然看起来复杂,但简化布局后如下:
<Border>
<StackPanel>
<!-- 文件名,文件大小等内容 -->
<Grid>
</Grid>
<!-- 子元素 -->
<ItemsPresenter/>
</StackPanel>
</Border>
接下来,我们将这一布局扩展为完整的TreeViewItem模板。
CupertinoTreeViewItem.xaml
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TreeViewItem}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<StackPanel>
<!-- 文件名,路径和大小 -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="Path"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="Size"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Name}" Padding="10"/>
<TextBlock Grid.Column="1" Text="{Binding Path}" Padding="10"/>
<TextBlock Grid.Column="2" Text="{Binding Size}" Padding="10"/>
</Grid>
<!-- 子元素 -->
<ItemsPresenter/>
</StackPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
通过这种方式,我们创建了一个包含文件名、路径和大小的Grid布局,并在其下方放置了一个ItemsPresenter,用于显示子元素。
到目前为止,我们已经实现了一个完整的Cupertino风格的TreeView和TreeViewItem。这些控件可以递归地展示文件夹和文件的层次结构,并且通过MVVM模式实现数据绑定和显示
。
以上就是我们这期Cupertino TreeView 视频教程中所涵盖的内容,通过这些内容,我们可以深入了解WPF中的TreeView控件,学习如何自定义和扩展它们以适应特定的需求
。希望这些信息对大家的开发工作有所帮助。
TreeViewItem的模板不仅要包含内容,还必须将ItemsPresenter元素正确地放置在合适的位置,这样在将层次数据绑定到ItemsSource时,才能构建出正确的TreeView
。
如果没有充分理解TreeViewItem的这种特殊机制,实现TreeView控件可能会相对困难。
本教程视频时长超过1小时,旨在确保大家对TreeView控件的概念有深刻理解,因此反复学习会非常有帮助。
下面是最终实现的TreeViewItem模板的完整代码。
CupertinoTreeItem.xaml
<Style TargetType="{x:Type units:CupertinoTreeItem}">
<Setter Property="SelectionCommand" Value="{Binding RelativeSource={RelativeSource AncestorType=units:CupertinoTreeView}, Path=DataContext.SelectionCommand}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="ItemsSource" Value="{Binding Children}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type units:CupertinoTreeItem}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<StackPanel>
<Grid x:Name="Item" Background="{TemplateBinding Background}" Height="36">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto" MinWidth="200" SharedSizeGroup="Path"/>
<ColumnDefinition Width="Auto" MinWidth="100" SharedSizeGroup="Size"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Margin="{Binding Depth, Converter={cnvts:DepthConverter}}">
<units:ChevronSwitch x:Name="Chevron" Margin="10"
IsChecked="{Binding RelativeSource={RelativeSource Templatedparent}, Path=IsExpanded}"/>
<units:FileIcon Type="{Binding Type}" Margin="10" Extension="{Binding Extension}"/>
<TextBlock Text="{Binding Name}" Margin="10"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding Path}" Margin="10"/>
<TextBlock Grid.Column="2" Text="{Binding Size, Converter={cnvts:SizeConverter}}" Margin="10"/>
</Grid>
<ItemsPresenter x:Name="Items" Visibility="Collapsed"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Setter TargetName="Items" Property="Visibility" Value="Visible"/>
</Trigger>
<DataTrigger Binding="{Binding ElementName=Item, Path=IsMouseOver}" Value="True">
<Setter TargetName="Item" Property="Background" Value="#D1E3FF"/>
</DataTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Item" Property="Background" Value="#004EFF"/>
<Setter TargetName="Item" Property="TextBlock.Foreground" Value="#FFFFFF"/>
</Trigger>
<DataTrigger Binding="{Binding Type}" Value="File">
<Setter TargetName="Chevron" Property="Visibility" Value="Hidden"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
这段代码实现了TreeViewItem的文件名、路径、文件大小、扩展名图标的显示,以及用于显示子项的ItemsPresenter元素。
接下来,查看运行结果并确认实际目录在“我的文档”中是否正确匹配。
12.设计元素
在本教程中,没有使用特别的设计元素。仅通过简单的Border的Background/BorderBrush颜色和Margin/Padding等布局设计元素,就可以实现出色的结果。
最重要的核心是Margin和Padding
。所有视觉元素的Margin和Padding间距应保持一致,通过多次重复调整,可以制作出美观的控件。因此,为了训练大家的视觉感官,需要反复修改和检查这些属性。如果大家能重视这种训练,很快就能提升自己的感官。
13.Depth Converter
由于已经计算了所有文件项的Depth值,可以将其转换为每个项的左侧边距,从而在视觉上表示父子关系的层次结构。在TreeView中,通过使用每个项的Depth值,设置与该Depth成比例的左侧边距,可以清晰地表达项之间的层次关系。
下面是继承自IValueConverter的DepthConverter。
DepthConverter.cs
public class DepthConverter : MarkupExtension, IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
int depth = (int)value;
Thickness margin = new Thickness(depth * 20, 0, 0, 0);
return margin;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
public override object ProvideValue(IServiceProvider serviceProvider)
{
return this;
}
}
上述代码包括将Depth值转换为Thickness结构体以计算左侧边距的逻辑。此外,继承自MarkupExtension使其在XAML中更易于使用
。
使用DepthConverter的示例
<StackPanel Orientation="Horizontal" Margin="{Binding Depth, Converter={local:DepthConverter}}">
最终,Depth值转换为左侧边距并应用的效果如下图所示。
通过Depth应用左侧边距的效果
图片中通过红色分隔线可以清晰地看到Depth值是如何应用的。另外,可以尝试更改DepthConverter逻辑中的depth * 20
部分的值,看看会产生什么变化。
14.总结
本文未包含的更多关于Depth的深入内容在教程视频中通过动画进行了详细解释。此外,视频还详细介绍了如何使用ICommand方式在ViewModel中接收TreeView控件的选定项,以及解决事件冒泡问题的过程。
这次的教程视频超过1小时,内容详细且深入。大家可以通过哔哩哔哩观看完整版中文版教程。请关注、点赞,并分享给更多的开发者朋友。GitHub 仓库提供了开源代码,欢迎通过Stars/Forks,以及Discussions参与和鼓励。
谢谢大家!