FlowGraph 添加一个不依赖引擎的搜索功能
1. 背景
FlowGraph 插件是 Github 上的一个开源 UE 插件,插件主要提供了较为简洁的节点拓展、逻辑功能执行,对于一些任务管理、游戏流程控制等很有帮助,具体的使用本文不再赘述,网络上也有一些文章,读者可以自行搜索,也可以直接查看:FlowGraph - Github
FlowGraph 提供了一个搜索功能,但其依赖于两处引擎的修正:
/**
* Documentation: https://github.com/MothCocoon/FlowGraph/wiki/Asset-Search
* Set macro value to 1, if you made these changes to the engine: https://github.com/EpicGames/UnrealEngine/pull/9882
*/
#define ENABLE_JUMP_TO_INNER_OBJECT 0
/**
* Documentation: https://github.com/MothCocoon/FlowGraph/wiki/Asset-Search
* Set macro value to 1, if you made these changes to the engine: https://github.com/EpicGames/UnrealEngine/pull/9943
*/
#define ENABLE_SEARCH_IN_ASSET_EDITOR 0
作者已经提交了 pull request,但等待 UE 官方合入遥遥无期,项目使用又会带来额外的维护成本,因此考虑尝试不依赖引擎改动来添加一个搜索功能。
2. 方案
添加一个搜索功能其实很简单,说白了就是一个搜索框、一系列带有节点信息的列表,列表元素上定义一些操作并和原始节点关联起来,就完成了搜索的功能。
这里搜索框使用 UE 的 SSearchBox 完成,节点列表使用 TreeView 即可(TreeView 之前写过一篇 《自定义树形结构控件》,可以阅读相关拓展逻辑,本文也会梳理一遍涉及到的内容)。
UE 中的搜索框大多都是这一逻辑,例如 SFindInBlueprints、SFindInBT 等。
笔者这里也直接拷贝了 SFindInBT,修正相关逻辑来完成这一功能。
提交已经合并到主干,代码可以在这里看到:Pull request
笔者这里参考了 UE BehaviorTree 的搜索功能,先将 FindInBT 拷贝到了插件中,然后更名为 FindInFlow。
以下是详细修正内容及逻辑:
1. 注册入口
为了减少原有逻辑的修改,考虑便于持续更新,笔者这里没有删除旧的搜索逻辑,使用了原有的 ENABLE_SEARCH_IN_ASSET_EDITOR 宏隔离,默认状态使用 FindInFlow。
故原始的注册逻辑基本没有修正,还是注册 Tab 的流程,具体不再赘述,这里在创建界面内容时使用了新创建的 SFindInFlow:
// FlowAssetEditor.h
#if ENABLE_SEARCH_IN_ASSET_EDITOR
TSharedPtr<class SSearchBrowser> SearchBrowser;
#else
TSharedPtr<class SFindInFlow> SearchBrowser;
#endif
// FlowAssetEditor.cpp
#if ENABLE_SEARCH_IN_ASSET_EDITOR
SearchBrowser = SNew(SSearchBrowser, GetFlowAsset());
#else
SearchBrowser = SNew(SFindInFlow, SharedThis(this));
#endif
这里延用 FindInBT 的规则,传入了资产编辑器即 FlowAssetEditor 的 TShared 指针,这里相较于资产信息是更全面的,因为这里持有着当前打开的编辑器的相关信息,如:
/** The Flow Asset being edited */
TObjectPtr<UFlowAsset> FlowAsset;
TSharedPtr<class FFlowAssetToolbar> AssetToolbar;
TSharedPtr<SFlowGraphEditor> GraphEditor;
2. 主体 UI
Construct 这里删除了一些无用的逻辑,创建了搜索框、TreeView,并绑定了相关回调。
大体内容可以查看注释,后文也会详细描述拓展的逻辑:
void SFindInFlow::Construct( const FArguments& InArgs, TSharedPtr<FFlowAssetEditor> InFlowAssetEditor)
{
FlowAssetEditorPtr = InFlowAssetEditor;
this->ChildSlot
[
SNew(SVerticalBox)
+SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.FillWidth(1)
[
SAssignNew(SearchTextField, SSearchBox) // 创建搜索框
.HintText(LOCTEXT("FlowEditorSearchHint", "Enter text to find nodes..."))
.OnTextChanged(this, &SFindInFlow::OnSearchTextChanged) // 文字变动时的回调,键入内容即会回调
.OnTextCommitted(this, &SFindInFlow::OnSearchTextCommitted) // 文字提交时的回调,即回车时或焦点移开搜索框时
]
+SHorizontalBox::Slot()
.Padding(10,0,5,0)
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock) // 笔者这里在搜索框的后方添加了一个子图搜索功能的控制入口
.Text(LOCTEXT("FlowEditorSubGraphSearchText", "Find In SubGraph "))
]
+SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SCheckBox) // 可以通过这个勾选框来控制是否打开子图搜索
.OnCheckStateChanged(this, &SFindInFlow::OnFindInSubGraphStateChanged)
.ToolTipText(LOCTEXT("FlowEditorSubGraphSearchHint", "Checkin means search also in sub graph."))
]
]
+SVerticalBox::Slot()
.FillHeight(1.0f)
.Padding(0.f, 4.f, 0.f, 0.f)
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("Menu.Background"))
[
SAssignNew(TreeView, STreeViewType) // 基于 TreeView 创建搜索结果列表
.ItemHeight(24)
.TreeItemsSource(&ItemsFound) // 搜索出的内容
.OnGenerateRow(this, &SFindInFlow::OnGenerateRow) // 创建一行的 UI
.OnGetChildren(this, &SFindInFlow::OnGetChildren) // 获取元素的子元素,笔者这里即子图中的匹配节点
.OnSelectionChanged(this, &SFindInFlow::OnTreeSelectionChanged) // 当选中项更改时的回调
.OnMouseButtonDoubleClick(this, &SFindInFlow::OnTreeSelectionDoubleClicked) // 当双击元素时的回调
.SelectionMode(ESelectionMode::Multi) // 选中格式,因为也没拓展右键菜单之类的功能,这里不太重要
]
]
];
}
3. TreeView
SAssignNew(TreeView, STreeViewType) // 基于 TreeView 创建搜索结果列表
.ItemHeight(24)
.TreeItemsSource(&ItemsFound) // 搜索出的内容
.OnGenerateRow(this, &SFindInFlow::OnGenerateRow) // 创建一行的 UI
.OnGetChildren(this, &SFindInFlow::OnGetChildren) // 获取元素的子元素,笔者这里即子图中的匹配节点
.OnSelectionChanged(this, &SFindInFlow::OnTreeSelectionChanged) // 当选中项更改时的回调
.OnMouseButtonDoubleClick(this, &SFindInFlow::OnTreeSelectionDoubleClicked) // 当双击元素时的回调
.SelectionMode(ESelectionMode::Multi) // 选中格式,因为也没拓展右键菜单之类的功能,这里不太重要
这里的 STreeViewType 是头文件中定义的类型别名,定义如下:
typedef TSharedPtr<FFindInFlowResult> FSearchResult;
typedef STreeView<FSearchResult> STreeViewType;
FSearchResult 是一个指向 FFindInBTResult 对象的共享指针,因此 STreeViewType 也就是一个使用 FFindInBTResult 对象的共享指针作为节点类型的 TreeView 控件。
系统中的大部分 TreeView 控件往往都会定义这两个别名,来方便使用。
FFindInBTResult 是我们定义的每一项的元素,这里有一些小的改动:
/** Item that matched the search results */
class FFindInFlowResult
{
...
/** Create a flow node result */
FFindInFlowResult(const FString& InValue, TSharedPtr<FFindInFlowResult>& InParent, UEdGraphNode* InNode, bool bInIsSubGraphNode = false); // 添加 bInIsSubGraphNode 参数
...
FReply OnDoubleClick(TSharedPtr<FFindInFlowResult> Root); // 提供了一个双击的方法,可以跳转打开子图资产并聚焦到节点
...
/** Gets the description on flow node if any */
FString GetDescriptionText() const; // FlowGraph 中有一些关键信息往往会提供到 Description 中,这里也考虑提供该信息供搜索
/** Gets the node tool tip */
FText GetToolTipText() const; // 因为双击子图节点与非子图节点时行为不一致,所以考虑提供此方法以悬浮提示
...
/** Search result parent */
TWeakPtr<FFindInFlowResult> Parent; // 类型修正
/** Whether this item is a subgraph node */
bool bIsSubGraphNode = false; // 来控制当前是否为子图节点,以提供双击、悬浮时不同的行为
};
接下来再看 TreeView 的几个回调和参数。
(1) TreeItemsSource
/** This buffer stores the currently displayed results */
TArray<FSearchResult> ItemsFound;
TreeItemsSource 就是树形列表中要显示的内容,也就是组织好的我们定义好的的元素类型的数组,因为我们要搜索 FlowGraph 的节点,一般无键入内容时也不会显示内容,所以我们会在搜索框的相关回调中来组织这一数据。
OnGenerateRow 就是创建元素行时的回调,可以看到返回值是 TSharedRef
TSharedRef<ITableRow> SFindInFlow::OnGenerateRow( FSearchResult InItem, const TSharedRef<STableViewBase>& OwnerTable )
{
return SNew(STableRow< TSharedPtr<FFindInFlowResult> >, OwnerTable)
.ToolTip(SNew(SToolTip).Text(InItem->GetToolTipText()))
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.VAlign(VAlign_Center)
.AutoWidth()
[
SNew(SBox)
.MinDesiredWidth(300)
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.AutoWidth()
[
InItem->CreateIcon()
]
+SHorizontalBox::Slot()
.VAlign(VAlign_Center)
.AutoWidth()
.Padding(2, 0)
[
SNew(STextBlock)
.Text(FText::FromString(InItem->Value))
.HighlightText(HighlightText)
]
]
]
+SHorizontalBox::Slot()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(FText::FromString(InItem->GetDescriptionText()))
.HighlightText(HighlightText)
]
+SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(FText::FromString(InItem->GetNodeTypeText()))
.HighlightText(HighlightText)
]
+SHorizontalBox::Slot()
.HAlign(HAlign_Right)
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(FText::FromString(InItem->GetCommentText()))
.ColorAndOpacity(FLinearColor::Yellow)
.HighlightText(HighlightText)
]
];
}
逻辑很简单,就是创建了四个 STextBlock,第一个额外加了一个 Icon,最后一个改了一下颜色,其他逻辑都一致。
四个文本框的文字内容,分别调用了封装的不同方法,方法内部就是基于构造 FFindInFlowResult 时传入的 UEdGraphNode* InNode,来调用 FlowGraphNode 上的相关方法,搜集的相关文字内容,具体可以查看 Pull Request 中的代码。
这里的 HighlightText 会在搜索框提交的时候填入其值,在这里传递给文本框后,就可以完成搜索到的内容的高亮逻辑。
(2) OnGetChildren
void SFindInFlow::OnGetChildren(FSearchResult InItem, TArray< FSearchResult >& OutChildren)
{
OutChildren += InItem->Children;
}
OnGetChildren 用来获取当前元素的子元素,因为 TreeView 树型控件本身就是以分层列表形式来显示内容的,元素下可以有子元素。这里我们在构建 FFindInFlowResult 时填充了其成员变量 Children,当节点类型为 SubGraph 时,则搜集并填充其子类节点,具体逻辑在搜索框文字提交时的后续代码中执行。
(3) OnSelectionChanged
void SFindInFlow::OnTreeSelectionChanged(FSearchResult Item , ESelectInfo::Type)
{
if (Item.IsValid())
{
Item->OnClick(FlowAssetEditorPtr, RootSearchResult);
}
}
这里转调了 Item 的 OnClick 方法:
FReply FFindInFlowResult::OnClick(TWeakPtr<class FFlowAssetEditor> FlowAssetEditorPtr, TSharedPtr<FFindInFlowResult> Root)
{
if (FlowAssetEditorPtr.IsValid() && GraphNode.IsValid())
{
if (Parent.IsValid() && !bIsSubGraphNode)
{
FlowAssetEditorPtr.Pin()->JumpToNode(GraphNode.Get());
}
else
{
FlowAssetEditorPtr.Pin()->JumpToNode(Parent.Pin()->GraphNode.Get());
}
}
return FReply::Handled();
}
单击节点时会聚焦到对应节点,如果是 SubGraph 的子节点,会聚焦到 Parent 节点,也就是持有其的 SubGraph 节点。
JumpToNode 方法就是调用了 SGraphEditor 的 JumpToNode 方法:
void FFlowAssetEditor::JumpToNode(const UEdGraphNode* Node) const
{
if (GetFlowGraph().IsValid())
{
GetFlowGraph()->JumpToNode(Node, false);
}
}
(4) OnMouseButtonDoubleClicked
void SFindInFlow::OnTreeSelectionDoubleClicked(FSearchResult Item)
{
if (Item.IsValid())
{
Item->OnDoubleClick(RootSearchResult);
}
}
这里转调了 Item 的 OnDoubleClick 方法:
FReply FFindInFlowResult::OnDoubleClick(TSharedPtr<FFindInFlowResult> Root)
{
if (!Parent.IsValid() || !bIsSubGraphNode)
{
return FReply::Handled();
}
const UFlowGraphNode* ParentGraphNode = Cast<UFlowGraphNode>(Parent.Pin()->GraphNode);
if (!ParentGraphNode || !ParentGraphNode->GetFlowNode())
{
return FReply::Handled();
}
if (UObject* AssetToEdit = ParentGraphNode->GetFlowNode()->GetAssetToEdit())
{
UAssetEditorSubsystem* AssetEditorSubsystem = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>();
if (AssetEditorSubsystem->OpenEditorForAsset(AssetToEdit))
{
if (const TSharedPtr<FFlowAssetEditor> FlowAssetEditor = FFlowGraphUtils::GetFlowAssetEditor(GraphNode->GetGraph()))
{
FlowAssetEditor->JumpToNode(GraphNode.Get());
}
}
}
return FReply::Handled();
}
主体逻辑就是当选中双击的 Item 为 SubGraph 的子节点时,尝试打开 SubGraph 资产的编辑器,然后聚焦到对应资产中的对应节点。
4. 搜索框
ItemsFound 的组织就是在搜索框的相关回调中完成的,OnSearchTextChanged 和 OnSearchTextCommitted 两个回调都会调到同一个方法,方法逻辑是一致的,所以键入文字时就会刷新搜索列表。当然也可以改造成类似蓝图搜索的功能,只在 Committed 时执行列表元素的重建(蓝图中还判断了 CommitType 的类型,只在 Enter 时重建列表)。
void SFindInFlow::OnSearchTextChanged(const FText& Text)
{
SearchValue = Text.ToString();
InitiateSearch();
}
void SFindInFlow::OnSearchTextCommitted(const FText& Text, ETextCommit::Type CommitType)
{
OnSearchTextChanged(Text);
}
回调后,首先缓存了键入的字符到 SearchValue 中,然后在 InitiateSearch 方法中,又将文本填充到了 HighlightText 中。
void SFindInFlow::InitiateSearch()
{
TArray<FString> Tokens;
SearchValue.ParseIntoArray(Tokens, TEXT(" "), true);
for (auto It(ItemsFound.CreateIterator()); It; ++It)
{
TreeView->SetItemExpansion(*It, false);
}
ItemsFound.Empty();
if (Tokens.Num() > 0)
{
HighlightText = FText::FromString(SearchValue);
MatchTokens(Tokens);
}
// Insert a fake result to inform user if none found
if (ItemsFound.Num() == 0)
{
ItemsFound.Add(MakeShared<FFindInFlowResult>(LOCTEXT("FlowEditorSearchNoResults", "No Results found").ToString()));
}
TreeView->RequestTreeRefresh();
for (auto It(ItemsFound.CreateIterator()); It; ++It)
{
TreeView->SetItemExpansion(*It, true);
}
}
这里的 SetItemExpansion 是控制元素子元素的折叠展开状态。
核心方法在 MatchTokens 中,主体逻辑就是遍历当前资产中的节点,将其要显示的文字组合起来,查看是否在 Tokens 中存在匹配项,存在时,则构建对应的 FSearchResult 并填充到 ItemsFound 数组中。
void SFindInFlow::MatchTokens(const TArray<FString>& Tokens)
{
...
for (auto It(Graph->Nodes.CreateConstIterator()); It; ++It)
{
UEdGraphNode* Node = *It;
const FString NodeName = Node->GetNodeTitle(ENodeTitleType::ListView).ToString();
FSearchResult NodeResult(new FFindInFlowResult(NodeName, RootSearchResult, Node));
FString NodeSearchString = NodeName + Node->GetClass()->GetName() + Node->NodeComment;
if (const UFlowGraphNode* FlowGraphNode = Cast<UFlowGraphNode>(Node))
{
FString NodeDescription = FlowGraphNode->GetNodeDescription();
NodeSearchString += NodeDescription;
UFlowNode_SubGraph* SubGraphNode = Cast<UFlowNode_SubGraph>(FlowGraphNode->GetFlowNode());
if (bFindInSubGraph && SubGraphNode)
{
if (const UFlowAsset* FlowAsset = Cast<UFlowAsset>(SubGraphNode->GetAssetToEdit()); FlowAsset && FlowAsset->GetGraph())
{
for (auto ChildIt(FlowAsset->GetGraph()->Nodes.CreateConstIterator()); ChildIt; ++ChildIt)
{
MatchTokensInChild(Tokens, *ChildIt, NodeResult);
}
}
}
}
NodeSearchString = NodeSearchString.Replace(TEXT(" "), TEXT(""));
const bool bNodeMatchesSearch = StringMatchesSearchTokens(Tokens, NodeSearchString);
if ((NodeResult->Children.Num() > 0) || bNodeMatchesSearch)
{
ItemsFound.Add(NodeResult);
}
}
}
中间额外对 SubGraph 进行了处理,收集了匹配的子节点。这里没有考虑递归的创建,因为笔者这里只做了一层的搜索,只能搜索当前图内的子图节点,子图内的子图则没有提供。
void SFindInFlow::MatchTokensInChild(const TArray<FString>& Tokens, UEdGraphNode* Child, FSearchResult ParentNode)
{
if (Child == nullptr)
{
return;
}
const FString ChildName = Child->GetNodeTitle(ENodeTitleType::ListView).ToString();
FString ChildSearchString = ChildName + Child->GetClass()->GetName() + Child->NodeComment;
if (const UFlowGraphNode* FlowGraphNode = Cast<UFlowGraphNode>(Child))
{
FString NodeDescription = FlowGraphNode->GetNodeDescription();
ChildSearchString += NodeDescription;
}
ChildSearchString = ChildSearchString.Replace(TEXT(" "), TEXT(""));
if (StringMatchesSearchTokens(Tokens, ChildSearchString))
{
const FSearchResult DecoratorResult(new FFindInFlowResult(ChildName, ParentNode, Child, true));
ParentNode->Children.Add(DecoratorResult);
}
}
StringMatchesSearchTokens 方法就是检测组合的字符串中是否 Contains 搜索框输入的 Tokens:
bool SFindInFlow::StringMatchesSearchTokens(const TArray<FString>& Tokens, const FString& ComparisonString)
{
bool bFoundAllTokens = true;
//search the entry for each token, it must have all of them to pass
for (auto TokItr(Tokens.CreateConstIterator()); TokItr; ++TokItr)
{
const FString& Token = *TokItr;
if (!ComparisonString.Contains(Token))
{
bFoundAllTokens = false;
break;
}
}
return bFoundAllTokens;
}
总结
通过如上拓展,我们就添加了一个自定义的搜索功能,目前已经合并到了主干。
Pull request 链接:Add a search functionality that does not rely on engine modifications.
TreeView 的使用之前写过一篇:《自定义树形结构控件》。
Comments NOTHING