WPF教学视频解析|用WPF重建一个iOS风格的TreeView自定义控件
小李趣味多
2024年08月21日 14:18
收录于文集
共7篇

本内容是对WPF教学视频第6期《WPF教学|从0构建一个iOS风格的TreeView自定义控件》的文字梳理和内容补充。

*完整教学视频可以在主页观看。

*由于Bilibili专栏编辑功能有限,项目代码部分展现不够完善,可以在GitHub:https://github.com/JamesnetGroup/cupertino-treeview下载源代码。

*也可以登陆我们的个人网站:Jamesnet.dev 查看原文和更多关于我们的视频、文章等内容合集。

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参与和鼓励。

谢谢大家!