UE 自定义树形结构控件

发布于 2023-04-10  105 次阅读


自定义树形结构控件

有时为了完成一些简单的需求,我们或许只是需要完成一些基础能力,但系统控件提供的能力又过于庞杂,又或是存在一些局限,这种时候,不可避免的要自定义 S 类控件以完成一些逻辑,笔者的需求需要提供类似于内容浏览器的功能,但需要显示的路径并不在项目路径下,也就是不在C++ Classes、Content、Engine、Plugin 目录下,因此内容浏览器不太适用,并且其功能也过于复杂,不太需要其提供的诸多功能,故而考虑自定义一控件完成此功能。

系统诸多控件提供的能力很完善,在可以的条件下还是推荐使用系统相关逻辑,业务密切相关的控件也很难抽象以适用于多种业务。在本文里,会模仿系统逻辑实现一个显示本地路径的树形结构,并提供一些文件夹文件的基础操作,以及版本控制的一些逻辑。同时控件也会考虑到业务的自定义需求,提供一定的拓展能力。

一、梳理

首先考虑,一个基础的树形结构控件,我们要完成的有哪几部分?

  • 上方的搜索框
  • 下方的树形列表:包括
    • 文件夹
    • 文件
  • 右键菜单
  • 悬浮提示

大致以上几条,就完成了一个树形结构的基本功能,因此我们可以考虑将 UI 细分到每一条目(这里的条目指的是一行,可能是文件或文件夹,后同),抽象一个具体条目的数据类,可能包括文件夹与否、名称等信息。条目与实体路径、文件一致,同时考虑定义一个针对条目的 UI,其来填充其自身可完成的功能,如条目的 UI 显示、悬浮提示、图标等。外部额外使用一个组织 UI,来操作组织管理各个条目,同时在这里完成筛选功能。系统中的 TreeView 控件正是此逻辑,在自定义需要的控件时,不妨先参考系统相关逻辑,再进行设计。
因此本文涉及以下三类:

  • FDirectoryTreeItem:条目对应的数据类,存储条目的信息。
  • SDirectoryTreeItem:条目对应的样式类,用于生成一行的条目 UI。
  • SDirectoryTreeView:树形结构的样式类,组织树形结构生成,并完成相关功能。

二、逻辑

这里先完成并梳理主体功能,后续再逐步添加对应功能:

1.FDirectoryTreeItem

数据类的设计如下:

class FDirectoryTreeItem
{
public:
    FDirectoryTreeItem(const FString& InPath);
    FDirectoryTreeItem(const FString& InPath, TSharedPtr<FDirectoryTreeItem>&InParent);
public:
    FString DiskPath;
    FString CleanName;
    bool bIsDirectory;
    TSharedPtr<FDirectoryTreeItem> Parent;
    TArray<TSharedPtr<FDirectoryTreeItem>> Children;
};
  • 与实体路径一致的树形结构,首先肯定要存储一个文件路径,DiskPath 这是其与实体目录的唯一映射,后续操作也多会通过此参数来处理。
  • 为了区分文件夹与文件,需要提供一个参数,因此使用 bIsDirectory 来确认数据类型。
  • 在用于显示时我们只需要显示条目名称即可,因此可以存储一个 CleanName,以避免生成 UI 过程中还需要重新解析。
  • 对于一个树形结构,每个条目还存在其父子关系,也要维护在数据类中。
  • 这里提供了两个构造方法,一个用于根目录构造,提供路径即可,另一提供父类,联系外部共同维护好条目父子关系。

2. SDirectoryTreeItem

对于自定义 S 类,我们需要继承 SCompoundWidget 并完成相应逻辑,来完成自定义控件功能。
需要完成的基础功能如下:

public:
    SLATE_BEGIN_ARGS(SDirectoryTreeItem)
            : _TreeItem(TSharedPtr<FDirectoryTreeItem>())
        {
        }

        SLATE_ARGUMENT(TSharedPtr<FDirectoryTreeItem>, TreeItem)
    SLATE_END_ARGS()

    void Construct(const FArguments& InArgs);
    ~SDirectoryTreeItem();

private:
    FText GetNameText() const;
    TSharedPtr<SWidget> CreateIcon() const;

private:
    TWeakPtr<FDirectoryTreeItem> TreeItem;

自定义 Slate 控件的写法如上,可以看到,有一些奇奇怪怪的宏,正是这些宏提供了 Slate 的标准用法。

  • SLATE_BEGIN_ARGS 与 SLATE_END_ARGS 包起来的这一部分是 S 类能力的核心,这两个宏,向外部提供了通过 SNew() 和 SAssignNew() 来创建控件的能力。同时将其中间的内容生成为一个 FArguments 类型的结构体,以供 Construct 时使用。

  • 结构体的内容,由内部的其他宏来控制,包括 SLATE_ARGUMENT、SLATE_ATTRIBUTE、SLATE_EVENT、SLATE_DEFAULT_SLOT 几种,这里用到了 SLATE_ARGUMENT,可以向 FArguments 中写入指定类型的参数。因而也为我们的控件提供了使用 SNew(A).B(XXX) 的能力来为 A 控件的 B 参数传参的能力。
    这里传的参并不会直接赋值到我们定义的变量中,在 Construct 中,我们需要读取 FArguments 中的对应参数并赋值到对应属性 this->TreeItem = InArgs._TreeItem;
    其他参数后续遇到后再详解。todo:或许此处该引用一篇详解 slate 的文章。

  • Construct 是处理上文中使用 Slate 宏定义参数并生成控件 UI 的位置。在这里,需要取出入参 FArguments 中的相关参数并赋值到类的成员变量,绑定相关事件回调,再通过 this->ChildSlot 方式完成 UI 样式的构建。

  • GetNameText 与 CreateIcon 为生成 UI 的功能方法。

  • TreeItem 用来引用 UI 样式对应的数据类,以提供条目单独刷新等能力。

实现如下:

void SDirectoryTreeItem::Construct(const FArguments& InArgs)
{
    this->TreeItem = InArgs._TreeItem;
    const FSlateBrush* IconBrush = FAppStyle::GetBrush("ContentBrowser.AssetTreeFolderOpen");
    const float IconOverlaySize = IconBrush->ImageSize.X * 0.6f;

    this->ChildSlot
    [
        SNew(SBox)
        .WidthOverride(200.0f)
        [
            SNew(SHorizontalBox)
            + SHorizontalBox::Slot()
              .VAlign(VAlign_Center)
              .AutoWidth()
              .Padding(0, 0, 4, 0)
            [
                SNew(SOverlay)
                + SOverlay::Slot()
                [
                    this->CreateIcon().ToSharedRef()
                ]
            ]
            + SHorizontalBox::Slot()
              .VAlign(VAlign_Center)
              .AutoWidth()
            [
                SNew(SInlineEditableTextBlock)
                .Text(this, &SDirectoryTreeItem::GetNameText)
            ]
        ]
    ];
}

SDirectoryTreeItem::~SDirectoryTreeItem()
{
}

FText SDirectoryTreeItem::GetNameText() const
{
    if (TSharedPtr<FDirectoryTreeItem> TreeItemPin = this->TreeItem.Pin())
    {
        return FText::FromString(TreeItemPin->CleanName);
    }
    return FText();
}

TSharedPtr<SWidget> SDirectoryTreeItem::CreateIcon() const
{
    FSlateColor IconColor = FSlateColor::UseForeground();
    const FSlateBrush* Brush = FAppStyle::GetBrush(this->TreeItem.Pin()->bIsDirectory
                                                       ? "ContentBrowser.AssetTreeFolderOpen"
                                                       : "ContentBrowser.ColumnViewAssetIcon");
    return SNew(SImage).Image(Brush).ColorAndOpacity(IconColor);
}

3. SDirectoryTreeView

class FDirectoryTreeItem;
typedef TSharedPtr<FDirectoryTreeItem> FDirTreeInstanceItem;
typedef TWeakPtr<FDirectoryTreeItem> FDirTreeWeakInstanceItem;
typedef STreeView<FDirTreeInstanceItem> SDirTreeViewPtr;

class DIRECTORYTREEVIEWEDITOR_API SDirectoryTreeView : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS(SDirectoryTreeView) :
            _RootPath(""),
            _SelectionMode(ESelectionMode::Single)
            {
            }

    /* 依赖的物理根路径 */
    SLATE_ARGUMENT(FString, RootPath)

    /* 节点如何被选中 */
    SLATE_ATTRIBUTE(ESelectionMode::Type, SelectionMode)

    SLATE_END_ARGS()

public:
    virtual void Construct(const FArguments& InArgs);
    virtual ~SDirectoryTreeView() override;

private:
    /* 获取合法的物理路径 */
    FString GetLegalPath(const FString& InRootPath);

    /* 根据根目录路径初始化树形节点 */
    void InitTreeView(const FString& InRootPath);

    /* 递归的构造树形结构 */
    void ConstructChildrenRecursively(FDirTreeInstanceItem TreeItem);

    /* 具体创建 TreeItem UI 的函数 */
    TSharedRef<ITableRow> GenerateTreeRow(FDirTreeInstanceItem TreeItem, const TSharedRef<STableViewBase>& OwnerTable);

    /* 获取某个节点的子节点信息 */
    void GetChildrenForTree(FDirTreeInstanceItem TreeItem, TArray< FDirTreeInstanceItem >& OutChildren);

private:
    TSharedPtr<SDirTreeViewPtr> TreeViewPtr;
    TArray<FDirTreeInstanceItem> TreeRootItems;
    FString CurrentRootPath;
};
  • CurrentRootPath 存储树形结构根节点路径,生成此路径下文件夹及文件的树形结构。通过 SLATE_ARGUMENT 声明的参数在生成控件时传参,并在 Construct 中赋值。
  • TreeViewPtr 实际的生成的控件,这里我们使用系统提供的 STreeView 树形控件来完成我们的功能。
  • 使用 InitTreeView 来初始化 UI 控件,调用 ConstructChildrenRecursively 递归遍历 RootPath 下所有文件夹及文件,创建对应节点,并维护构造各个节点关系,构造树形结构。
  • GenerateTreeRow 方法是系统的 STreeView 控件的事件代理,会在生成子条目时调用,需要在此处完成生成一行树形结构 UI 的逻辑,并完成相应节点。
  • GetChildrenForTree 是获取一个节点的子节点时的事件代理,会在例如展开文件夹时调用,也正是为了完成此方法,所以我们需要维护好各个条目的父子关系。
  • Construct 与上述一致,仍是取出 FArguments 中参数并赋值到类的成员变量的位置,并且会通过this->ChildSlot 方式完成 UI 样式的构建。STreeView 里的 SelectionMode 这里可以指定可以选中的形式,如可多选、不可选中等等,本文中为方便测试,采用只能单选来处理。
void SDirectoryTreeView::Construct(const FArguments& InArgs)
{
    this->CurrentRootPath = this->GetLegalPath(InArgs._RootPath);
    this->InitTreeView(this->CurrentRootPath);

    SAssignNew(this->TreeViewPtr, SDirTreeViewPtr)
    .TreeItemsSource(&this->TreeRootItems)
    .ClearSelectionOnClick(false)
    .SelectionMode(InArgs._SelectionMode)
    .HighlightParentNodesForSelection(true)
    .OnGenerateRow(this, &SDirectoryTreeView::GenerateTreeRow)
    .OnGetChildren(this, &SDirectoryTreeView::GetChildrenForTree);

    this->ChildSlot
    [
        SNew(SVerticalBox)
        + SVerticalBox::Slot()
        .AutoHeight()
        [
            SNew(SSearchBox)
                .ToolTipText(LOCTEXT("FilterSearchToolTip", "Type here to search file"))
                .HintText(LOCTEXT("FilterSearchHint", "Search File"))
        ]
        + SVerticalBox::Slot()
        [
            this->TreeViewPtr.ToSharedRef()
        ]
    ];
}

FString SDirectoryTreeView::GetLegalPath(const FString& InRootPath)
{
    if (InRootPath.IsEmpty())
    {
        return FPaths::ProjectContentDir();
    }
    FString Result = InRootPath;
    Result = Result.Replace(TEXT("\\"), TEXT("/"));
    Result.RemoveFromEnd("/");
    return Result;
}

void SDirectoryTreeView::InitTreeView(const FString& InRootPath)
{
    FDirTreeInstanceItem Root = FDirTreeInstanceItem(new FDirectoryTreeItem(InRootPath));
    this->TreeRootItems.Add(Root);
    this->ConstructChildrenRecursively(Root);
}

void SDirectoryTreeView::ConstructChildrenRecursively(FDirTreeInstanceItem TreeItem)
{
    if (TreeItem.IsValid())
    {
        TArray<FString> FindedFiles;
        FString SearchFile = TreeItem->DiskPath + "/*.*";
        IFileManager::Get().FindFiles(FindedFiles, *SearchFile, true, true);

        for (auto& Element : FindedFiles)
        {
            FString FullPath = TreeItem->DiskPath + "/" + Element;
            FDirTreeInstanceItem Child = FDirTreeInstanceItem(new FDirectoryTreeItem(FullPath, TreeItem));
            TreeItem->Children.Add(Child);
            if (Child->bIsDirectory)
            {
                this->ConstructChildrenRecursively(Child);
            }
        }
    }
}

TSharedRef<ITableRow> SDirectoryTreeView::GenerateTreeRow(FDirTreeInstanceItem TreeItem,
                                                          const TSharedRef<STableViewBase>& OwnerTable)
{
    check(TreeItem.IsValid());
    return
        SNew(STableRow<FDirTreeInstanceItem>, OwnerTable)
        [
            SNew(SDirectoryTreeItem)
            .TreeItem(TreeItem)
        ];
}

void SDirectoryTreeView::GetChildrenForTree(FDirTreeInstanceItem TreeItem, TArray<FDirTreeInstanceItem>& OutChildren)
{
    //获取TreeItem的子节点信息
    if (TreeItem.IsValid())
    {
        OutChildren = TreeItem->Children;
    }
}

三、补充逻辑

完成上述功能后,一个基础的树形结构目录就已经完成了,如下图:

然而这只是完成了 UI 的构建与显示,有很多功能还没有完善,例如上方的搜索框、文件的操作、版本控制的逻辑等等,笔者会在后文中逐个梳理添加。

1. 搜索框(筛选框)

搜索框的 UI 在上述代码中以及包含了,也就是

        SNew(SSearchBox)
            .ToolTipText(LOCTEXT("FilterSearchToolTip", "Type here to search file"))
            .HintText(LOCTEXT("FilterSearchHint", "Search File"))

这里已经创建了 SSearchBox,再添加筛选逻辑即可完成相应功能。
首先对 SSearchBox 的 OnTextChanged 事件绑定回调方法,这里也可以使用 OnTextCommitted,Committed 会在按下 Enter 时回调,Changed 则是每次输入均会回调。FOnTextChanged 代理包含一个参数 FText,因此我们定义一个回调时的处理方法 OnFilterTextChanged 并绑定

SNew(SSearchBox)
    .ToolTipText(LOCTEXT("FilterSearchToolTip", "Type here to search file"))
    .HintText(LOCTEXT("FilterSearchHint", "Search File"))
    .OnTextChanged(this, &SDirTreeView::OnFilterTextChanged)

void SDirectoryTreeView::OnFilterTextChanged(const FText& InFilterText)
{
}

方法中,如何实现筛选逻辑呢?当输入内容发生改变时,我们需要使用输入的文字,过滤掉不需要的节点,然后重新生成 UI,STreeView 的 RequestTreeRefresh 可以完成 UI 刷新的工作,因此我们完成条目数据的组织即可,逻辑如下:

void SDirectoryTreeView::RebuildDirTreeView()
{
    this->TreeRootItems.Empty();
    this->InitTreeView(CurrentRootPath);
    this->TreeViewPtr->RequestTreeRefresh();
}

void SDirectoryTreeView::ConstructTreeByFilterText(FString DiskPath, const FText& InFilterText)
{
    TArray<FString> FindedFiles;
    FString SearchFile = DiskPath + "/*.*";
    IFileManager::Get().FindFiles(FindedFiles, *SearchFile, true, true);
    for (auto& Element : FindedFiles)
    {
        FString ItemPath = DiskPath + "/" + Element;
        FDirTreeInstanceItem Child = FDirTreeInstanceItem(new FDirectoryTreeItem(ItemPath));
        if (Child->bIsDirectory)
        {
            this->ConstructTreeByFilterText(ItemPath, InFilterText);
            continue;
        }
        if (!Child->bIsDirectory && Element.Contains(InFilterText.ToString()))
        {
            this->TreeRootItems.Add(Child);
        }
    }
}

void SDirectoryTreeView::OnFilterTextChanged(const FText& InFilterText)
{
    if (InFilterText.IsEmpty())
    {
        this->RebuildDirTreeView();
        return ;
    }
    this->TreeRootItems.Empty();
    this->ConstructTreeByFilterText(CurrentRootPath, InFilterText);
    this->TreeViewPtr->RequestTreeRefresh();
}

RebuildDirTreeView 方法是重建根目录下所有节点的方法,这里在删除输入的所有内容的时候会触发,考虑到后续拓展,提到一个方法中。
在 OnFilterTextChanged 回调方法中,我们调用了 ConstructTreeByFilterText 方法,这个方法与上文中的 ConstructChildrenRecursively 逻辑基本一致,只是会避开目录条目的创建,并且在文件条目创建的时候会过滤掉不包含所输入字符的条目,最终调用 STreeView 的 RequestTreeRefresh 刷新 UI,通过这里的逻辑,也就完成了筛选框的功能。
表现如下,过滤掉文件夹,并且筛选匹配输入的文件条目:

2. 双击逻辑

单击双击逻辑及各类回调类逻辑较为简单,在 STreeView 的回调中绑定回调事件,完成处理逻辑即可,如笔者这里给文件夹的双击添加了展开与折叠文件夹的逻辑:

    SAssignNew(this->TreeViewPtr, SDirTreeViewPtr)
    .TreeItemsSource(&this->TreeRootItems)
    .ClearSelectionOnClick(false)
    .SelectionMode(InArgs._SelectionMode)
    .HighlightParentNodesForSelection(true)
    .OnGenerateRow(this, &SDirectoryTreeView::GenerateTreeRow)
    .OnGetChildren(this, &SDirectoryTreeView::GetChildrenForTree)
    .OnMouseButtonClick(this, &SDirectoryTreeView::OnMouseClicked)
    .OnMouseButtonDoubleClick(this, &SDirectoryTreeView::OnMouseDoubleClicked);

OnMouseDoubleClicked 回调中,变更展开折叠状态即可。

void SDirectoryTreeView::OnMouseDoubleClicked(TSharedPtr<FDirectoryTreeItem> Item)
{
    if (Item->bIsDirectory)
    {
        this->TreeViewPtr->SetItemExpansion(Item, !this->TreeViewPtr->IsItemExpanded(Item));
        UE_LOG(LogTemp, Log, TEXT("SDirectoryTreeView, Mouse button double clicked."));
    }
}

其他逻辑不再赘述。

2. 添加右键菜单

右键菜单功能可以通过 STreeView 的 OnContextMenuOpening 回调来实现,这一事件需要返回一个 SWidget 类型的返回值,因此我们绑定对应的方法:

    SAssignNew(this->TreeViewPtr, SDirTreeViewPtr)
    .TreeItemsSource(&this->TreeRootItems)
    .ClearSelectionOnClick(false)
    .SelectionMode(InArgs._SelectionMode)
    .HighlightParentNodesForSelection(true)
    .OnGenerateRow(this, &SDirectoryTreeView::GenerateTreeRow)
    .OnGetChildren(this, &SDirectoryTreeView::GetChildrenForTree)
    .OnMouseButtonDoubleClick(this, &SDirectoryTreeView::OnMouseDoubleClicked)
    .OnContextMenuOpening(this, &SDirectoryTreeView::OpenContextMenu);

查阅源码,系统中的右键菜单多使用 FMenuBuilder 来完成,因此这里也考虑使用此方案。

TSharedPtr<SWidget> SDirectoryTreeView::OpenContextMenu()
{
    FMenuBuilder MenuBuilder(/*bInShouldCloseWindowAfterMenuSelection=*/true, NULL);
    if (this->TreeViewPtr->GetSelectedItems().IsEmpty())
    {
        return MenuBuilder.MakeWidget();
    }
    if (this->TreeViewPtr->GetSelectedItems()[0]->bIsDirectory)
    {
        this->CreateFolderMenu(MenuBuilder);
    }
    else
    {
        this->CreateFileMenu(MenuBuilder);
    }
    return MenuBuilder.MakeWidget();
}

这里对文件夹和文件进行了区分,分别生成不同的右键菜单 UI。

TSharedPtr<SWidget> SDirectoryTreeView::OpenContextMenu()
{
    FMenuBuilder MenuBuilder(/*bInShouldCloseWindowAfterMenuSelection=*/true, NULL);
    // if (OnGetMenuExtensions.IsBound())
    // {
    //  MenuBuilder = this->OnGetMenuExtensions.Execute(TreeViewPtr->GetSelectedItems());
    // }
    if (this->TreeViewPtr->GetSelectedItems().IsEmpty())
    {
        return MenuBuilder.MakeWidget();
    }
    if (this->TreeViewPtr->GetSelectedItems()[0]->bIsDirectory)
    {
        this->CreateFolderMenu(MenuBuilder);
    }
    else
    {
        this->CreateFileMenu(MenuBuilder);
    }
    return MenuBuilder.MakeWidget();
}

void SDirectoryTreeView::CreateFolderMenu(FMenuBuilder& MenuBuilder)
{

    MenuBuilder.AddMenuEntry(
        LOCTEXT("NewFolder", "新建文件夹"),
        LOCTEXT("NewFolderTooltip", "在当前目录下新建文件夹."),
        FSlateIcon(FAppStyle::GetAppStyleSetName(), "ContentBrowser.NewFolderIcon"),
        FUIAction(FExecuteAction::CreateSP(this, &SDirectoryTreeView::OnNewFolderClicked))
    );

}

void SDirectoryTreeView::CreateFileMenu(FMenuBuilder& MenuBuilder)
{
    MenuBuilder.AddMenuEntry(
    LOCTEXT("DeleteFile", "删除"),
    LOCTEXT("DeleteFileTooltip", "删除此文件."),
    FSlateIcon(FAppStyle::GetAppStyleSetName(), "ContentBrowser.AssetActions.Delete"),
    FUIAction(FExecuteAction::CreateSP(this, &SDirectoryTreeView::OnDeleteFileClicked))
    );
}

void SDirectoryTreeView::OnNewFolderClicked()
{
}

void SDirectoryTreeView::OnDeleteFileClicked()
{
}

FMenuBuilder 的使用方式如上,这里暂时对两操作绑定了两个空方法,会在后续内容中填充方法内逻辑,这里的 UI 是笔者在系统内随便找的几个,不必太过在意。
如此这般,就完成了右键菜单的添加,并且准备好了功能的回调,填充相应逻辑即可完成对应功能了,效果如下:

右键菜单应该是较为核心的能力,按此逻辑完成相应功能即可。

3. 添加悬浮提示

为了实现悬浮提示的功能,我们可以在生成每一行时,指定每一条目的 ToolTip 来完成悬浮提示的功能,这里以悬浮提示 CleanName 示例:

TSharedRef<ITableRow> SDirectoryTreeView::GenerateTreeRow(FDirTreeInstanceItem TreeItem,
                                                          const TSharedRef<STableViewBase>& OwnerTable)
{
    check(TreeItem.IsValid());
    TSharedPtr<IToolTip> ItemToolTip = SNew(SToolTip).Text(TreeItem->CleanName);
    return
        SNew(STableRow<FDirTreeInstanceItem>, OwnerTable)
        .ToolTip(ItemToolTip)
        [
            SNew(SDirectoryTreeItem)
            .TreeItem(TreeItem)
        ];
}

效果如下:

笔者这里不再过于复杂的拓展,而是在 4 中考虑以悬浮提示,示例如何将功能拓展给使用者来完成。

4. 如何提供拓展能力给使用者

这里以 3 中的悬浮提示为例,示例如何将能力拓展给使用者,其他拓展均可按此逻辑完成,读者可自行拓展。
首先,我们需要声明一个适当参数的代理,来提供业务绑定的能力,如:

DECLARE_DELEGATE_RetVal_OneParam(TSharedPtr<IToolTip>, FOnGetRowCustomToolTips, const TSharedPtr<FDirectoryTreeItem>);

/* 创建自定义节点悬浮提示函数 */
SLATE_EVENT(FOnGetRowCustomToolTips, OnGetRowCustomToolTips)

/* 自定义节点悬浮提示代理 */
FOnGetRowCustomToolTips OnGetRowCustomToolTips;

这里共完成了三处逻辑,首先是声明了代理,其次使用 SLATE_EVENT 宏,向 FArguments 中写入了指定类型的代理,使得使用者可以通过 SNew(A).B(&Func()) 的形式,绑定对应业务的方法,而成员变量 OnGetRowCustomToolTips,则是我们取出 FArguments 绑定方法,并且后续调用的位置。
这里声明的代理,我们提供了一个 TSharedPtr 类型的参数,以使得业务了解需要处理的节点,一个 TSharedPtr 类型的返回值,待业务生成悬浮提示后返回。

头文件添加以上三处后,还需要在 Construct 赋值到类的成员变量,并在合适的时机调用,调用方式如下:

void SDirectoryTreeView::Construct(const FArguments& InArgs)
{
    this->CurrentRootPath = this->GetLegalPath(InArgs._RootPath);
    this->InitTreeView(this->CurrentRootPath);
    this->OnGetRowCustomToolTips = InArgs._OnGetRowCustomToolTips;
    ...
}

TSharedRef<ITableRow> SDirectoryTreeView::GenerateTreeRow(FDirTreeInstanceItem TreeItem,
                                                          const TSharedRef<STableViewBase>& OwnerTable)
{
    check(TreeItem.IsValid());
    TSharedPtr<IToolTip> ItemToolTip = SNew(SToolTip);
    if (this->OnGetRowCustomToolTips.IsBound())
    {
        ItemToolTip = this->OnGetRowCustomToolTips.Execute(TreeItem);
    }
    return
        SNew(STableRow<FDirTreeInstanceItem>, OwnerTable)
        .ToolTip(ItemToolTip)
        [
            SNew(SDirectoryTreeItem)
            .TreeItem(TreeItem)
        ];
}

我们定义的类的成员变量的代理已经在 Construct 中赋了外部绑定的方法,因此这里通过 OnGetRowCustomToolTips.Execute 调用即可,在调用前需要判断是否存在绑定,或使用 ExecuteIfBound 等。

完成如上逻辑后,就可以在生成控件时绑定生成悬浮提示的逻辑,来实现使用者的拓展,如下:

    SNew(SDirectoryTreeView)
    .RootPath(DirPath)
    .OnGetRowCustomToolTips_Raw(this, &FDirectoryTreeViewEditorModule::GetItemCustomToolTips)

这里测试绑定到了 FDirectoryTreeViewEditorModule 的 GetItemCustomToolTips 方法,我们随便写个 UI 测试一下:

TSharedPtr<IToolTip> FDirectoryTreeViewEditorModule::GetItemCustomToolTips(const TSharedPtr<FDirectoryTreeItem> Item)
{
    return SNew(SToolTip)
    [
        SNew(SButton)
        .Text(LOCTEXT("TestButton", "测试按钮"))
    ];
}

实现效果如下(当然应该不会有人使用按钮作为悬浮提示哈哈),可以看到业务拓展了悬浮提示:

5. 文件及目录相关操作

上文中我们在实现右键菜单的时候针对文件夹添加了新建文件夹功能,针对文件添加了删除功能,但右键的回调方法中没有添加相应的逻辑,在这里我们添加完成这一部分,使用系统提供的 IFileManager 完成相应功能即可:

void SDirectoryTreeView::OnNewFolderClicked()
{
    FDirTreeInstanceItem Item = this->TreeViewPtr->GetSelectedItems()[0];
    FString FolderPath = GetValidNewFolderPath(Item->DiskPath);
    bool State = IFileManager::Get().MakeDirectory(*FolderPath, true);
    UE_LOG(LogTemp, Log, TEXT("SDirectoryTreeView, Create %s"), *(FolderPath + (State ? " Success" : " Fail")));  
}

FString SDirectoryTreeView::GetValidNewFolderPath(FString ParentFolderPath)
{
    FString FolderName = TEXT("/新建文件夹");
    FString NewFolderPath = ParentFolderPath + FolderName;
    for (int32 Suffix = 1; IFileManager::Get().DirectoryExists(*NewFolderPath); Suffix++)
    {
        NewFolderPath = ParentFolderPath + FolderName + FString::FromInt(Suffix);
    }
    return NewFolderPath;
}

void SDirectoryTreeView::OnDeleteFileClicked()
{
    FDirTreeInstanceItem Item = this->TreeViewPtr->GetSelectedItems()[0];
    FString FilePath = Item->DiskPath;
    bool State = IFileManager::Get().Delete(*FilePath, true, true);
    UE_LOG(LogTemp, Log, TEXT("SDirectoryTreeView, Delete %s"), *(FilePath + (State ? " Success" : " Fail")));
}

然而,这样实现以后功能似乎表现不太合适,因为实体的文件夹确实创建、文件也确实删除了,但 UI 上的节点仍然存在,很明显还需要一个刷新的逻辑。
调用上文中的 this->TreeViewPtr->RequestTreeRefresh() ? 显然不会生效,因为其不会重建节点数据,只会利用已有的节点数据完成 UI 的刷新工作。
在这里可以不必过多考虑,后文中会提到监听文件变动的方法。但笔者这里推荐这种能够自己控制的节点,程序完全控制的节点,自己完成自己的业务,不要再去借用监听刷新逻辑完成相应逻辑,在监听处设置状态位,避开此次操作。这边的刷新本质上只需要重建当前节点的父节点即可,本文中不再实现此逻辑。

6. 重命名操作

这里笔者模仿系统流程完成了重命名操作,较为复杂,因此单独写到此处,没有写到右键菜单中,与上述菜单同样逻辑添加对应的右键菜单:

void SDirectoryTreeView::CreateFolderMenu(FMenuBuilder& MenuBuilder)
{
    ...
    MenuBuilder.AddMenuEntry(
    LOCTEXT("RenameFolder", "重命名文件夹"),
    LOCTEXT("RenameTooltip", "重命名此文件夹."),
    FSlateIcon(FAppStyle::GetAppStyleSetName(), "ContentBrowser.AssetActions.Rename"),
    FUIAction(FExecuteAction::CreateSP(this, &SDirectoryTreeView::OnRenameFolderClicked))
    );
}

void SDirectoryTreeView::OnRenameFolderClicked()
{
    FDirTreeInstanceItem Item = this->TreeViewPtr->GetSelectedItems()[0];
    if (TreeRootItems.Contains(Item))
    {
        return;
    }
    Item->OnRenameRequested().Broadcast();
}

OnRenameFolderClicked 方法中 调用了 FDirectoryTreeItem 的 OnRenameRequested 方法,这个方法会返回一个 FSimpleMulticastDelegate 类型的代理的引用。

// .h
FSimpleMulticastDelegate& OnRenameRequested();
FSimpleMulticastDelegate RenameRequestedEvent;

// .cpp
FSimpleMulticastDelegate& FDirectoryTreeItem::OnRenameRequested()
{
    return RenameRequestedEvent;
}

这个代理会在构建条目 UI 的时候绑定,绑定到我们生成的 SInlineEditableTextBlock 的 EnterEditingMode 方法,为了方便处理,我们这里将输入框提升为条目 S 类的成员变量,具体逻辑如下:

// .h
    DECLARE_DELEGATE_FourParams(FOnNameChanged, const TSharedPtr<FDirectoryTreeItem>&, const FString&, const FVector2D&, const ETextCommit::Type);
    // DECLARE_DELEGATE_RetVal_ThreeParams(bool, FOnVerifyNameChanged, const TSharedPtr<FDirectoryTreeItem>&, const FString&, FText&);
class SDirectoryTreeItem : public SCompoundWidget
{
    ...
    SLATE_EVENT(FOnNameChanged, OnNameChanged)
    SLATE_EVENT(FOnVerifyNameChanged, OnVerifyNameChanged)
    ...
    void HandleNameCommitted(const FText& NewText, ETextCommit::Type /*CommitInfo*/);
    ...
    FOnNameChanged OnNameChanged;
    TSharedPtr<SInlineEditableTextBlock> InlineRenameWidget;
    ...
}

SDirectoryTreeItem.cpp 中创建控件 UI,并注册相应回调:

void SDirectoryTreeItem::Construct(const FArguments& InArgs)
{
    ...
    this->OnNameChanged = InArgs._OnNameChanged;
    ...
    + SHorizontalBox::Slot()
        .VAlign(VAlign_Center)
        .AutoWidth()
    [
        SAssignNew(this->InlineRenameWidget, SInlineEditableTextBlock)
        .Text(this, &SDirectoryTreeItem::GetNameText)
        .OnTextCommitted(this, &SDirectoryTreeItem::HandleNameCommitted)
        // .OnVerifyTextChanged(this, &SAssetTreeItem::VerifyNameChanged)
    ]
    ...
    if (this->InlineRenameWidget.IsValid())
    {
        this->EnterEditingModeDelegateHandle = this->TreeItem.Pin()->OnRenameRequested().AddSP(
            this->InlineRenameWidget.Get(), &SInlineEditableTextBlock::EnterEditingMode);
    }
}

void SDirectoryTreeItem::HandleNameCommitted(const FText& NewText, ETextCommit::Type CommitInfo)
{
    if (this->TreeItem.IsValid())
    {
        TSharedPtr<FDirectoryTreeItem> TreeItemPtr = TreeItem.Pin();
        const FGeometry LastGeometry = GetTickSpaceGeometry();
        FVector2D MessageLoc;
        MessageLoc.X = LastGeometry.AbsolutePosition.X;
        MessageLoc.Y = LastGeometry.AbsolutePosition.Y + LastGeometry.Size.Y * LastGeometry.Scale;
        this->OnNameChanged.ExecuteIfBound(TreeItemPtr, NewText.ToString(), MessageLoc, CommitInfo);
    }
}

SDirectoryTreeItem::~SDirectoryTreeItem()
{
    if (this->InlineRenameWidget.IsValid())
    {
        this->TreeItem.Pin()->OnRenameRequested().Remove(this->EnterEditingModeDelegateHandle);
    }
}

SDirectoryTreeView.cpp 中注册相应方法:

TSharedRef<ITableRow> SDirectoryTreeView::GenerateTreeRow(FDirTreeInstanceItem TreeItem,
                                                          const TSharedRef<STableViewBase>& OwnerTable)
{
    check(TreeItem.IsValid());
    TSharedPtr<IToolTip> ItemToolTip = SNew(SToolTip);
    if (this->OnGetRowCustomToolTips.IsBound())
    {
        ItemToolTip = this->OnGetRowCustomToolTips.Execute(TreeItem);
    }
    return
        SNew(STableRow<FDirTreeInstanceItem>, OwnerTable)
        .ToolTip(ItemToolTip)
        [
            SNew(SDirectoryTreeItem)
            .TreeItem(TreeItem)
            .OnNameChanged(this, &SDirectoryTreeView::FolderNameChanged)
        ];
}

void SDirectoryTreeView::FolderNameChanged(const TSharedPtr<FDirectoryTreeItem>& Item, const FString& ProposedName,
    const FVector2D& MessageLocation, const ETextCommit::Type CommitType)
{
    FString FolderPath = FPaths::GetPath(Item->DiskPath) + "/" + ProposedName;
    if (IFileManager::Get().DirectoryExists(*FolderPath))
    {
        return;
    }
    bool State = IFileManager::Get().Move(*FolderPath, *Item->DiskPath, true, true);
    Item->CleanName = ProposedName;
    this->TreeViewPtr->RequestTreeRefresh();
    UE_LOG(LogTemp, Log, TEXT("SDirTreeView, Rename %s"), *(FolderPath +  + (State ? " Success" : " Fail")));
}

最终效果如下:

这样,同时调用 IFileManager 的 Move 方法,就完成了重命名的逻辑。

7. 版本控制角标显示

版本控制的逻辑可以看这里,笔者跟踪了一下源码分析:UE 版本控制逻辑分析
此处仅仅处理整理角标添加方案,对文件添加显示当前版本控制状态的角标。

    void HandleSourceControlProviderChanged(class ISourceControlProvider& OldProvider, class ISourceControlProvider& NewProvider);
    void HandleSourceControlStateChanged();

    TSharedPtr<SLayeredImage> SCCStateWidget;
    FDelegateHandle SourceControlStateChangedDelegateHandle;

头文件需要新增两个回调方法,一个角标对应的 UI,以及绑定回调的句柄,用于 UI 析构时解绑。使用方法如下:

void SDirTreeItem::Construct(const FArguments& InArgs)
{
    ...
    this->ChildSlot
    [
        SNew(SBox)
        .WidthOverride(200.0f)
        [
            SNew(SHorizontalBox)
            + SHorizontalBox::Slot()
              .VAlign(VAlign_Center)
              .AutoWidth()
              .Padding(0, 0, 4, 0)
            [
                SNew(SOverlay)
                + SOverlay::Slot()
                [
                    this->CreateIcon().ToSharedRef()
                ]
                + SOverlay::Slot()
                  .HAlign(HAlign_Right)
                  .VAlign(VAlign_Top)
                [
                    SNew(SBox)
                .WidthOverride(IconOverlaySize)
                .HeightOverride(IconOverlaySize)
                    [
                        SAssignNew(this->SCCStateWidget, SLayeredImage)
                        .Image(FStyleDefaults::GetNoBrush())
                    ]
                ]
            ]
            + SHorizontalBox::Slot()
              .VAlign(VAlign_Center)
              .AutoWidth()
            [
                SNew(SInlineEditableTextBlock)
                .Text(this, &SDirectoryTreeItem::GetNameText)
            ]
        ]
    ];
    ...
    ISourceControlModule::Get().RegisterProviderChanged(FSourceControlProviderChanged::FDelegate::CreateSP(this, &SDirTreeItem::HandleSourceControlProviderChanged));
    SourceControlStateChangedDelegateHandle = ISourceControlModule::Get().GetProvider().RegisterSourceControlStateChanged_Handle(FSourceControlStateChanged::FDelegate::CreateSP(this, &SDirTreeItem::HandleSourceControlStateChanged));

    this->HandleSourceControlStateChanged();
}

完成对应绑定工作。方法内逻辑如下:

void SDirectoryTreeItem::HandleSourceControlProviderChanged(ISourceControlProvider& OldProvider,
    ISourceControlProvider& NewProvider)
{
    OldProvider.UnregisterSourceControlStateChanged_Handle(SourceControlStateChangedDelegateHandle);
    SourceControlStateChangedDelegateHandle = NewProvider.RegisterSourceControlStateChanged_Handle(FSourceControlStateChanged::FDelegate::CreateSP(this, &SDirectoryTreeItem::HandleSourceControlStateChanged));
    // Reset this so the state will be queried from the new provider on the next Tick
    // SourceControlStateDelay = 0.0f;
    // bSourceControlStateRequested = false;
    HandleSourceControlStateChanged();
}

void SDirectoryTreeItem::HandleSourceControlStateChanged()
{
    if (this->TreeItem.Pin() && !this->TreeItem.Pin()->bIsDirectory && ISourceControlModule::Get().IsEnabled())
    {
        FString Path = this->TreeItem.Pin()->DiskPath;
        FSourceControlStatePtr SourceControlState = ISourceControlModule::Get().GetProvider().GetState(Path, EStateCacheUsage::Use);
        if (SourceControlState)
        {
            if (this->SCCStateWidget.IsValid())
            {
                this->SCCStateWidget->SetFromSlateIcon(SourceControlState->GetIcon());
            }
        }
    }
}

这里下方的 GetState 方法的 EStateCacheUsage 制定了使用缓存的 State,此处使用 Force 形式的话,第一次打开根据根目录文件数量,会有 500ms * FilesNumber 的耗时,当展开折叠时,也会十分卡顿,因此还是推荐使用 UseCache,转而在合适的时机提前强制同步一次。
笔者这里在 SDirectoryTreeView 的 Construct 方法里完成了强制同步的逻辑:

void SDirectoryTreeView::UpdatePathFilesSCCState()
{
    TArray<FString> FilesAddToSCC;
    FString SearchFile = "*.*";
    IFileManager::Get().FindFilesRecursive(FilesAddToSCC, *this->CurrentRootPath, *SearchFile, true, false);

    ISourceControlProvider& SourceControlProvider = ISourceControlModule::Get().GetProvider();
    TSharedPtr<FUpdateStatus, ESPMode::ThreadSafe> UpdateStatusOp = ISourceControlOperation::Create<FUpdateStatus>();
    UpdateStatusOp->SetForceUpdate(true);
    SourceControlProvider.Execute(UpdateStatusOp.ToSharedRef(), FilesAddToSCC, EConcurrency::Asynchronous);
}

最终效果如下:

8. 监听本地文件变动

当本地文件发生变动时,我们的树形结构里并不会收到相应并处理,也就是说,没有完成本地到自定义树形结构的同步,因此这里考虑添加一个 DirectoryWatcher,监听本地文件变动并执行相应处理操作,相关逻辑如下:

// .h
FDelegateHandle WatcherDelegate;

/* 本地目录变更 */
void OnDirectoryChanged(const TArray<FFileChangeData>& FileChanges);
void UnbindWatcher();

// cpp
void SDirectoryTreeView::Construct(const FArguments& InArgs)
{
    ...
    FDirectoryWatcherModule& DirectoryWatcherModule = FModuleManager::LoadModuleChecked<FDirectoryWatcherModule>("DirectoryWatcher");
    IDirectoryWatcher* DirectoryWatcher = DirectoryWatcherModule.Get();
    if (DirectoryWatcher != nullptr)
    {
        IDirectoryWatcher::FDirectoryChanged Callback = IDirectoryWatcher::FDirectoryChanged::CreateRaw(
            this, &SDirectoryTreeView::OnDirectoryChanged);
        DirectoryWatcher->RegisterDirectoryChangedCallback_Handle(this->CurrentRootPath, Callback, this->WatcherDelegate,
                                                                  IDirectoryWatcher::WatchOptions::IncludeDirectoryChanges);
    }
    ...
}

void SDirectoryTreeView::OnDirectoryChanged(const TArray<FFileChangeData>& FileChanges)
{

    this->RebuildDirTreeView();
}

void SDirectoryTreeView::UnbindWatcher()
{
    if (!this->WatcherDelegate.IsValid())
    {
        return;
    }
    if (FDirectoryWatcherModule* Module = FModuleManager::GetModulePtr<FDirectoryWatcherModule>(TEXT("DirectoryWatcher")))
    {
        if (IDirectoryWatcher* DirectoryWatcher = Module->Get())
        {
            DirectoryWatcher->UnregisterDirectoryChangedCallback_Handle(this->CurrentRootPath, this->WatcherDelegate);
        }
    }
    this->WatcherDelegate.Reset();
}

SDirectoryTreeView::~SDirectoryTreeView()
{
    this->UnbindWatcher();
}

这样我们就实现了当本地目录发生变动时,重建节点的逻辑,此处可以接入优化逻辑,例如不进行整体重建,或是某些程序内导致的操作不触发重建等等,笔者此处简单重建,不再设计优化方案。

9. 缓存展开折叠状态

上述诸多操作,以及在本地的文件、文件夹操作,均会使得已经展开的节点再次折叠,因此考虑需要缓存折叠展开状态。这里考虑使用 TSet 类型的成员变量存储一个被折叠的节点路径,在重建时若存在,则默认展开节点即可。代码如下:

void SDirectoryTreeView::Construct(const FArguments& InArgs)
{
    SAssignNew(this->TreeViewPtr, SDirTreeViewPtr)
    ...
    .OnExpansionChanged(this, &SDirectoryTreeView::OnExpansionChanged)
    ...
}

void SDirectoryTreeView::OnExpansionChanged(TSharedPtr<FDirectoryTreeItem> Item, bool bIsExpanded)
{
    if (bIsExpanded)
    {
        this->ExpansionItemPath.Add(Item->DiskPath);
    }
    else
    {
        this->ExpansionItemPath.Remove(Item->DiskPath);
    }
}

绑定代理回调 OnExpansionChanged 即可,所有的展开与否状态变化都会回调到此处,在此处维护好我们的已展开路径即可。

void SDirectoryTreeView::ConstructChildrenRecursively(FDirTreeInstanceItem TreeItem)
{
    if (this->ExpansionItemPath.Find(TreeItem->DiskPath))
    {
        this->TreeViewPtr->SetItemExpansion(TreeItem, true);
    }
    ...
}

在重建节点时,判断我们存储的 Set 中是否存在,并设置相应状态即可完成缓存节点状态功能。

为了方便梳理文章,本文新建了测试 Plugin,插件开发流程不再赘述,可参考本文:UE插件开发流程

代码为便于文章阅读及逻辑,未按照职责划分,按照文章顺序处理,切勿直接参考使用。
代码已上传:自定义通用树形控件开发 GitHub

如堕五里雾中
最后更新于 2023-07-31