用WPF自定义iOS风格TreeView|含全过程视频讲解

科技   2024-08-21 08:03   江苏  
控件名称: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 { getset; }
    public string Path { getset; }
    public string Type { getset; }
    public string Extension { getset; }
    public long? Size { getset; }
    public int Depth { getset; }
    public List<FileItem> Children { getset; }
}

模型的属性组成是构建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 { getset; }

需要注意的是,这里没有处理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 { getset; }
    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 : MarkupExtensionIValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        int depth = (int)value;
        Thickness margin = new Thickness(depth * 20000);
        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参与和鼓励。

谢谢大家!

WPF开发者
「WPF开发者」现役微软MVP,专注 WPF 和 Avalonia 技术分享与传播。
 最新文章