# 前言

上次开始研究编辑器开发的时候,扩展右键菜单使用的是一个叫做 FExtender 的东西,后面发现其实还有一种统一的,更加简洁的菜单扩展方式 ——UToolMenus。

也就是说,貌似目前扩展编辑器分为了 Extender 方式和统一的 UToolMenus 方式,听说 UToolMenus 是新版本的方式,经过一顿研究下来,也确实感觉
UToolMenus 明显好用不少。

注:另外听说 Extender 扩展貌似属于过时方式了。

# 示例

在具体开始之前,可以先看看一个简单的示例:以最简单的,想为关卡编辑器添加一个菜单项为例。

UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu");
FToolMenuSection& Section = Menu->FindOrAddSection(NAME_None);
FToolMenuEntry& BuildEntry = Menu.AddSubMenu
(
    "MySubMenu",
    LOCTEXT("MySubMenu", "MySubMenu"),
    LOCTEXT("MySubMenu_ToolTip", "MySubMenu Options"),
    FOnGetContent::CreateStatic(&UCryLevelEditorMenu::GetMenuContent)
);
// 可选,调整插入位置
BuildEntry.InsertPosition = FToolMenuInsert("Actions", EToolMenuInsertType::After);

上述代码可以在菜单栏的帮助菜单的前一项,增加一个名为 MySubMenu 的菜单项。

效果如下:

简单来说大概步骤可以分别三步:

  1. 注册 / 创建菜单
  2. 查找 / 添加节点
  3. 添加自定义菜单 / 按钮

而 Extender 方式则是:

  1. 加载需要扩展的模块 (如 FContentBrowserModule)
  2. 获取需要扩展的菜单 (如 ContentBrowserModule.GetAllAssetContextMenuExtenders,不能错)
  3. 注册扩展器

... 虽然看着好像差不多,但是 Extender 的方式 1、2 步其实都挺容易出错的。 至少个人对比使用下来,感觉还是 UToolMenu 方式更好用。


补充下上面代码的 UCryLevelEditorMenu::GetMenuContent 方法,其中可以选择创建点击后的一系列子菜单等:

TSharedRef<SWidget> UCryLevelEditorMenu::GetMenuContent()
{
	FMenuBuilder MenuBuilder(true, nullptr);
	const FText DisplayName = FText::FromString("Test");
	MenuBuilder.AddMenuEntry(
		DisplayName,
		LOCTEXT("Description", "Opens this in the editor"),
		IconLevel(),
		FUIAction(FExecuteAction::CreateLambda([]()
		{
			ShowNotification(LOCTEXT("Tips", "Notification Tips!"));
		}))
	);
	return MenuBuilder.MakeWidget();
}

注 1:并不一定需要用 GetMenuContent 方式添加子菜单,直接使用 AddMenuEntry 也可以。
注 2:... 唉呀,怎么感觉其实都挺麻烦的

# 详情

# UToolMenus

在上面示例中, 最开始有一句 UToolMenu* Menu = UToolMenus::Get()->ExtendMenu("LevelEditor.MainMenu"); 代码,这句话的作用是:为
LevelEditor.MainMenu 菜单扩展,除了 ExtendMenu 还有个 RegisterMenu 方法,两者区别如下:

  • RegisterMenu = “注册 / 继承”—— 适合新建或需要父子继承的菜单

    • 可以选择传入一个 “父菜单” 名,把父菜单的当前内容复制一份给新菜单,新菜单自带一套 “初始模板”,之后改新菜单不影响父菜单。
    • 如果没有拿到对象就想扩展,还是得 ExtendMenu。
  • ExtendMenu = “就地扩展”—— 适合给已有菜单追加内容或临时创建空壳。

    • 直接取同名菜单实例(没有就现建一个空壳),不会复制任何其他菜单的内容。

因此一般来说,应该:

  • 优先用 ExtendMenu,而不是 RegisterMenu。
  • 理由:ExtendMenu 才是 “真正往已有菜单里加东西”,RegisterMenu 应当只是 “登记一个全新的扩展点”。

看源码 RegisterMenu 甚至有个可选注册时发现菜单已注册的提示:

UToolMenu* UToolMenus::RegisterMenu(const FName InName, const FName InParent, EMultiBoxType InType, bool bWarnIfAlreadyRegistered)
{
	if (UToolMenu* Found = FindMenu(InName))
	{
		if (!Found->bRegistered)
		{
			Found->MenuParent = InParent;
			Found->MenuType = InType;
			Found->MenuOwner = CurrentOwner();
			Found->bRegistered = true;
			Found->bIsRegistering = true;
			for (FToolMenuSection& Section : Found->Sections)
			{
				Section.bIsRegistering = Found->bIsRegistering;
			}
		}
		else if (bWarnIfAlreadyRegistered)
		{
			UE_LOG(LogToolMenus, Warning, TEXT("Menu already registered : %s"), *InName.ToString());
		}
		return Found;
	}
	UToolMenu* ToolMenu = NewToolMenuObject(FName(TEXT("RegisteredMenu")), InName);
	ToolMenu->InitMenu(CurrentOwner(), InName, InParent, InType);
	ToolMenu->bRegistered = true;
	ToolMenu->bIsRegistering = true;
	Menus.Add(InName, ToolMenu);
	return ToolMenu;
}
UToolMenu* UToolMenus::ExtendMenu(const FName InName)
{
	if (UToolMenu* Found = FindMenu(InName))
	{
		Found->bIsRegistering = false;
		for (FToolMenuSection& Section : Found->Sections)
		{
			Section.bIsRegistering = Found->bIsRegistering;
		}
		// Refresh all widgets because this could be child of another menu being displayed
		RefreshAllWidgets();
		return Found;
	}
	UToolMenu* ToolMenu = NewToolMenuObject(FName(TEXT("RegisteredMenu")), InName);
	ToolMenu->bRegistered = false;
	ToolMenu->bIsRegistering = false;
	Menus.Add(InName, ToolMenu);
	return ToolMenu;
}

什么时候才需要 RegisterMenu?

  • 正在写一个全新的面板 / 窗口,需要一个全新的顶级菜单名,而且确定它还没被任何模块注册过 —— 这时才先 RegisterMenu,再
    ExtendMenu。
  • 对 UE 自带的菜单(ContentBrowser、MainMenu、LevelEditor 工具栏等)一律跳过注册,直接 Extend。

也就是说:除非在造全新菜单,否则忘掉 RegisterMenu;一律 ExtendMenu 起手即可。

# FToolMenuSection

上面的示例中,取到扩展菜后,通过 FindOrAddSection 方法找到货添加一个空的节点,然后实现一个子菜单。

其实 FindOrAddSection 返回的对象有多个添加节点的方法:

方法核心用途动态性返回类型
AddEntry添加预构建的完整菜单项静态void
AddMenuEntry添加标准菜单按钮静态FToolMenuEntry&
AddDynamicEntry动态生成菜单内容动态void
AddSeparator添加视觉分隔线静态FToolMenuEntry&
AddSubMenu添加嵌套子菜单子菜单可动态FToolMenuEntry&

# 添加方法

# 1. AddEntry

  • 最底层的通用方法,直接添加一个预构建的 FToolMenuEntry 对象
  • 需要手动构造完整的菜单项对象(使用 FToolMenuEntry::InitXxx 系列方法)
  • 提供最大灵活性,可设置所有属性(图标、快捷键、动态行为等)
Section.AddEntry(
    FToolMenuEntry::InitMenuEntry(
        "DeleteAsset", 
        LOCTEXT("Delete", "删除"),
        LOCTEXT("DeleteTooltip", "删除选中资源"),
        FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Delete"),
        FUIAction(FExecuteAction::CreateLambda([](){ /* 删除逻辑 */ }))
    )
);

# 2. AddMenuEntry

  • 添加标准菜单按钮的快捷方法
  • 自动处理 FToolMenuEntry 的构造
  • 支持设置基础属性:名称、标签、工具提示、图标、点击动作
  • 返回菜单项引用便于后续修改
auto& Entry = Section.AddMenuEntry(
    "SaveLevel",
    LOCTEXT("Save", "保存"),
    LOCTEXT("SaveTooltip", "保存当前关卡"),
    FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Save"),
    FUIAction(FExecuteAction::CreateStatic(&ULevelEditorAction::Save))
);

# 3. AddDynamicEntry

  • 动态生成菜单项(运行时根据条件创建)
  • 接受一个委托,该委托在菜单实际显示时被调用
  • 用于条件菜单项(如根据选中对象类型显示不同选项)
  • 避免提前创建不会被显示的菜单项,优化性能
Section.AddDynamicEntry("DynamicTools", FNewToolMenuDelegate::CreateLambda(
    [](FToolMenuSection& DynamicSection){
        if(GetSelectedObjects().Num() > 0){
            DynamicSection.AddMenuEntry(...);
        }
    }
));

# 4. AddSeparator

  • 添加菜单分隔线(水平分割线):Section.AddSeparator (NAME_None)
  • 用于视觉分组相关菜单项

# 5. AddSubMenu

  • 添加嵌套子菜单
  • 接受一个委托用于构建子菜单内容
  • 可附加主菜单项的点击动作( FNewToolMenuChoice
Section.AddSubMenu(
    "ExportSubMenu",
    LOCTEXT("Export", "导出"),
    LOCTEXT("ExportTooltip", "导出选项"),
    FSlateIcon(),
    FNewToolMenuDelegate::CreateLambda([](UToolMenu* SubMenu){
        FToolMenuSection& SubSection = SubMenu->AddSection("ExportFormats");
        SubSection.AddMenuEntry("ExportPNG", ...);
        SubSection.AddMenuEntry("ExportJPG", ...);
    })
);

注:FNewToolMenuChoice 接受 FOnGetContent、FNewToolMenuWidget、FNewToolMenuDelegate、FNewMenuDelegate

# 使用策略

  1. 优先用 AddMenuEntry
    适用于大多数标准菜单按钮场景
  2. 需要动态内容时用 AddDynamicEntry
    特别是菜单项需要根据运行时状态变化时,例如通常用于扩展内容浏览器的右键资源菜单
  3. 复杂菜单项用 AddEntry
    当需要设置高级属性(如自定义控件、动态图标等)
  4. 组织菜单用 AddSeparator + AddSubMenu

# 扩展区域

这里列一下几个常见的扩展点:

  • LevelEditor.MainMenu:关卡编辑器主菜单
  • LevelEditor.LevelEditorToolBar.PlayToolBar:关卡编辑器工具栏
  • ContentBrowser.AssetContextMenu:内容浏览器右键资源菜单
  • ContentBrowser.FolderContextMenu:内容浏览器右键文件夹 / 路径菜单

如果想扩展更多其他的扩展点,可以直接源码全局搜索:LevelEditor.MainMenu 之类的关键字,找到对应的扩展点,然后使用对应的扩展点名字通过
ExtendMenu 注册扩展器即可。

# 参考文档

  • ttps://zhuanlan.zhihu.com/p/605181368
  • ED04.ToolMenus | 在任意扩展点插入自定义按钮