# Unreal Detail View > [!Note] > 本教學使用 Unreal 5.4 > [!Caution] > 應學長要求,需使用 UStruct 來包裝要設定的參數,並透過 `IStructureDetailsView` 來將物件顯示在 Detail 面版上。 Unreal 中的 Detail 面版其實是由 PropertyEditor 這個模組建立的。你可以在 Unreal Engine 原始碼中的`Engine/Source/Editor/PropertyEditor`下找到該模組。 Detail 面版的功用: - 顯示特定 UObject 或 UStruct 物件的 property,並提供介面來編輯它 - 預設生成的 widget 會運用 `UPROPERTY()` 的內容,可簡化部分 widget 的撰寫速度 [All UPROPERTY Specifiers](https://benui.ca/unreal/uproperty/) 該模組主要包含 - **PropertyEditorModule** - **IDetailsView** - 給 UObject 用的 Detail 面版 - **FDetailsViewArgs** - 初始化 Detail 面版的參數 - **IStructureDetailsView** - 給 UStruct 用的 Detail 面版 - **FStructureDetailsViewArgs** - 初始化 `IStructureDetailsView`的額外參數 ## 快速入門 1. 定義一個 struct,包含 GUI 下所有的參數,並寫上 `UPROPERTY()` 2. 將 struct 丟給 `IStructureDetailsView` 顯示 3. 如果不滿意預設的介面,再去看 `IDetailCustomization` 4. 如果某個自定義型別時常被當作 property 出現在 struct 內,再考慮用 `IPropertyTypeCustomization` 在最簡單的情況下,只需要做前2步即可。 ## Dependency 在專案的`***.Build.cs`中的Dependency內加入`"PropertyEditor"`: ```csharp= PrivateDependencyModuleNames.AddRange( // 新增私有依賴 new string[] { // ... "PropertyEditor", // ... } ); ``` ## 生成 Detail View Detail View 可以透過`FPropertyEditorModule::CreateDetailView`來生成。範例如下: ```cpp= // 取得Module FPropertyEditorModule& M = FModuleManager::Get().LoadModuleChecked<FPropertyEditorModule>("PropertyEditor"); // 設定參數 FDetailsViewArgs DetailArgs; DetailArgs.bAllowSearch = false; DetailArgs.bUpdatesFromSelection = false; DetailArgs.bCustomNameAreaLocation = true; // 生成Detail面版 auto DetailPanel = M.CreateDetailView(DetailArgs); // 設定要顯示的物件 UData1* DataKeeper = NewObject<UData1>(); // 將 UData1 替換成自己的類別 DetailPanel->SetObject(DataKeeper); ``` `IDetailsView`是`SWidget`的子類,你可以把它和其他widget放在一起,如: ```cpp= auto VBox = SNew(SVerticalBox) + SVerticalBox::Slot() .AutoHeight() [ DetailPanel ]; ``` ## 生成 `IStructureDetailsView` `IStructureDetailsView` 是用來顯示 UStruct 物件的,要注意的是需要將「要顯示的物件」用`FStructOnScope`來進行包裝,並且在`FPropertyEditorModule::CreateStructureDetailView`時當作參數傳遞過去。 ```cpp= // Get module FPropertyEditorModule& M = FModuleManager::Get().LoadModuleChecked<FPropertyEditorModule>("PropertyEditor"); // arguments FDetailsViewArgs DetailArgs; DetailArgs.bAllowSearch = false; DetailArgs.bUpdatesFromSelection = false; DetailArgs.bCustomNameAreaLocation = true; DetailArgs.bShowScrollBar = false; FStructureDetailsViewArgs StructArgs; // 設定要顯示的物件 // 將 FStructFoo 替換成自己的類別,將 Foo 替換成自己的物件 TSharedPtr<FStructOnScope> Data = MakeShareable(new FStructOnScope(FStructFoo::StaticStruct(), (uint8*)&Foo)); // create panel auto DetailPanel = M.CreateStructureDetailView(DetailArgs, StructArgs, Data); ``` `FStructOnScope`的建構子有兩個參數,第一個是物件的型別,第二個是物件的記憶體位址。經過封裝後,對`FStructOnScope`進行修改都會寫回`InData`的位址。 ```cpp= FStructOnScope::FStructOnScope(const UStruct* InScriptStruct, uint8* InData) ``` `IStructureDetailsView`也能和其他 widget 放在一起使用,如: ```cpp= SNew(SVerticalBox) + SVerticalBox::Slot().FillHeight(1) [ DetailPanel->GetWidget().ToSharedRef() ] ``` # 客製化 Detail View Property Editor 提供了兩種用來客製化的介面,使用時需要繼承其中一個介面並且向 PropertyEditorModule 註冊。這樣 Property Editor 就會在適當的時機調用你註冊的類別來改變 Detail View 的樣貌: - **IPropertyTypeCustomization** - 客製化 property 的顯示方式(單個property) - **IDetailCustomization** - 針對 Detail View 顯示的物件客製化(整個面版) ### 以 Struct Property 為例 接下來會針對下面這個 Struct 進行客製化。 ```cpp= USTRUCT() struct FCoolNumber { GENERATED_BODY() UPROPERTY(EditAnywhere) int ThisNumberIsCool; }; ``` 首先,繼承`IPropertyTypeCustomization`並定義`FCoolNumber_Customization`。 ```cpp= class FCoolNumber_Customization : public IPropertyTypeCustomization { public: /// 對 Header 區塊客製化 /// @param PropertyHandle - 指向要客製化的 Struct 變數 /// @param HeaderRow - 將自定的 Widget 加入 HeaderRow 的 NameContent() 和 ValueContent() /// @param CustomizationUtils - 包含預設的字型 void CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) override; /// 對 Children 區塊客製化 /// @param PropertyHandle - 指向要客製化的 Struct 變數 /// @param ChildBuilder - 用來建立 Children 區塊(藉由AddCustomRow()、AddProperty()、...) /// @param CustomizationUtils - 包含預設的字型 void CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) override; // 建立一個 FCoolNumber_Customization 的實例,之後「註冊」時會用到 static TSharedRef<IPropertyTypeCustomization> CreateInstance() { return MakeShareable(new FCoolNumber_Customization); } private: // 儲存 FCoolNumber::ThisNumberIsCool 的 Property Handle TSharedPtr<IPropertyHandle> ThisNumberIsCool; }; ``` 在這個類別中要 override 兩個函數: - `CustomizeHeader` - `CustomizeChildren` Header 指的是有向下箭號的那一列;而 Children 則是在 Header 下方,包含「設定 struct 的成員」的 widget。 :::spoiler CustomizeHeader 示例 在 `CustomizeHeader` 中,要將客製化的介面寫入參數`HeaderRow.NameContent()`內。 下面範例直接使用`CreatePropertyNameWidget`來生成預設的 Header。 ```cpp= void FCoolNumber_Customization::CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) { HeaderRow.NameContent() [ PropertyHandle->CreatePropertyNameWidget() ]; } ``` ::: :::spoiler CustomizeChildren 示例 在 `CustomizeChildren` 內,要透過參數`ChildBuilder`來建立介面。 下方的示例是使用`ChildBuilder.AddCustomRow`來新增一個列,並分別在`NameContent()`和`ValueContent()`內加入內容。 ※ 你可以用 `CreatePropertyNameWidget` 和 `CreatePropertyValueWidget` 來建立預設的 Name Content 和 Value Content ```cpp= void FCoolNumber_Customization::CustomizeChildren(TSharedRef<IPropertyHandle> PropertyHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) { // 拿到成員變數 ThisNumberIsCool 的 Property Handle ThisNumberIsCool = PropertyHandle->GetChildHandle("ThisNumberIsCool"); // 檢查 Property Handle 是否合法 if (ThisNumberIsCool.IsValid()) { ChildBuilder.AddCustomRow(FText::FromString("ThisNumberIsCool")) .NameContent() [ SNew(STextBlock) .Text(FText::FromString("Wow a cool number")) // 設定字體 .Font(CustomizationUtils.GetRegularFont()) ] .ValueContent() [ SNew(SSlider) .MinValue(0) .MaxValue(100) .Value_Lambda([this]() { // 從 Property Handle 讀值並顯示 int n; ThisNumberIsCool->GetValue(n); return n; }) .OnValueChanged_Lambda([this](float n) { // 將新的值寫回 Property ThisNumberIsCool->SetValue((int)n); }) ]; } } ``` ::: --- 寫好 Customization 類別後,要把它註冊在 PropertyEditorModule。通常註冊的時機是在「你寫的模組」載入時。 ```cpp= class MyGameModule : public FDefaultGameModuleImpl { virtual void StartupModule() override; virtual void ShutdownModule() override; }; ``` ```cpp= void MyGameModule::StartupModule() { FPropertyEditorModule& M = FModuleManager::Get().LoadModuleChecked<FPropertyEditorModule>("PropertyEditor"); // 啟用 module 時註冊 M.RegisterCustomPropertyTypeLayout(FCoolNumber::StaticStruct()->GetFName(), FOnGetPropertyTypeCustomizationInstance::CreateStatic( &FCoolNumber_Customization::CreateInstance )); } void MyGameModule::ShutdownModule() { FPropertyEditorModule& M = FModuleManager::Get().LoadModuleChecked<FPropertyEditorModule>("PropertyEditor"); // 關閉 module 時取消註冊 M.UnregisterCustomPropertyTypeLayout(FCoolNumber::StaticStruct()->GetFName()); } // 將 FooProject 替換成你的專案的名稱 // 如果你在寫Plugin請忽略下面這行 IMPLEMENT_PRIMARY_GAME_MODULE(MyGameModule, FooProject, "FooProject"); ``` 按照上面的示例完成後,只要你在 UObject 內加入型別為 `FCoolNumber` 的 Property,那麼 Detail View 會這樣顯示。 ![image](https://hackmd.io/_uploads/H1o2CSy4kl.png) ※ Header 中的 Wow 是變數的名稱,這是`PropertyHandle->CreatePropertyNameWidget()` 建立的 widget 的預設行為 ### 客製化整個Detail View 教學:<https://github.com/ibbles/LearningUnrealEngine/blob/master/Details%20Customization.md> **Note:** Unreal會先生成一個 Detail 面版 (使用 `UPROPERTY()` 的參數 + `IPropertyTypeCustomization`) ,然後才丟給 `IDetailCustomization` 的子類做進一步的客製化,所以在 `IDetailCustomization::CustomizeDetails` 內可以只針對要修改的 property 呼叫 `HideProperty` 然後再用 `AddProperty()`、`AddCustomRow()`等重新加入 GUI。 ### 小結 不論是繼承`IPropertyTypeCustomization`還是`IDetailCustomization`,寫的方式其實蠻像的,主要是因為`IDetailChildrenBuilder`和`IDetailCategoryBuilder`有相似的介面。 - `AddProperty()` 新增特定 property 的介面 - `AddProperty().CustomWidget()` 新增 property,但`NameContent()`和`ValueContent()`的內容要自己寫 和`AddCustomRow()`的差別在於: - `AddProperty().CustomWidget()` 會自動顯示 Reset to Default 的按鈕 - `AddCustomRow()` 要自行設置 ```cpp Builder.AddProperty(...).CustomWidget() .NameContent() [ //... ] .ValueContent() [ //... ]; ``` - `AddCustomRow()` 新增自定義的列 每一列中的常見設定,例如: ```cpp Builder.AddCustomRow(...) .NameContent() [ // 左邊(通常顯示 property 的名字) ] .ValueContent() [ // 右邊(通常顯示修改 property 的值的 widget) ] .ExtensionContent() [ // 右邊(放在 ValueContent() 中靠右的地方) ] .WholeRowContent() [ // 直接讓 Widget 佔滿整列 ] // 覆寫 Reset to Default 的行為 .OverrideResetToDefault(FResetToDefault::Create(...)); ``` # Reference - [Medium - 建立Detail View](https://medium.com/@codekittah/custom-details-panels-in-unreal-engine-fpropertyeditormodule-6fe41ba7c339) - [Unreal 4.27 官方教學 - Details面版自定義](https://dev.epicgames.com/documentation/zh-cn/unreal-engine/details-panel-customization?application_version=4.27) 版本較舊,且4.27版不能單獨客製化Property,僅供參考 - [Learning Unreal Engine - Detail Customization](https://github.com/ibbles/LearningUnrealEngine/blob/master/Details%20Customization.md) # FAQ Q1: `IDetailLayoutBuilder::GetProperty()` 找不到Property - [IDetailLayoutBuilder.GetProperty Not Work](https://forums.unrealengine.com/t/idetaillayoutbuilder-getproperty-does-not-work-when-the-path-refers-to-members-of-a-class/503448) - [[UE5.4] [PropertyEditor] How to get property handle from IDetailLayoutBuilder?](https://forums.unrealengine.com/t/ue5-4-propertyeditor-how-to-get-property-handle-from-idetaillayoutbuilder/2176997) A:`UPROPERTY(EditAnywhere)` --- Q2. 如何做出像[TitleProperty](https://benui.ca/unreal/uproperty/#titleproperty)的效果 A: 當 struct 顯示在陣列內時,`HeaderRow.ValueContent()` 的內容會被放在原本 TitleProperty 會顯示的位置。範例如下: ```cpp= void Foo_Customization::CustomizeHeader(TSharedRef<IPropertyHandle> PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) { // 如果在陣列內,回傳的 index 不會是 INDEX_NONE if (PropertyHandle->GetArrayIndex() != INDEX_NONE) { HeaderRow .NameContent() [ // 包含陣列操作用的widget PropertyHandle->CreatePropertyNameWidget() ] .ValueContent() [ // 讓文字方塊垂直置中 SNew(SBox) .VAlign(VAlign_Center) [ // 更進階的做法是透過 PropertyHandle 來取得 Struct 內特定 Property 的值 SNew(STextBlock) .Text(FText::FromString("Title")) .Font(CustomizationUtils.GetRegularFont()) ] ]; } } ``` --- Q3. 如果 UStruct 只有一個成員,當它作為 property 顯示時,如何不顯示 Header Row? A: 有兩種作法 1. 直接將該成員的 value widget 放在 `HeaderRow.ValueContent()` 內。 可以參考`FBoneReferenceCustomization::CustomizeHeader`的作法, 2. 不在 HeaderRow 內加入任何東西,來隱藏 HeaderRow 第一種做法是 Unreal 慣用的做法,應該比較好。 --- Q4. 有條件的顯示/隱藏/啟用某個 property 的 GUI。 A: - [EditCondition](https://benui.ca/unreal/uproperty/#editcondition) - 只有滿足特定條件才能編輯 - [EditConditionHides](https://benui.ca/unreal/uproperty/#editconditionhides) - 如果條件不滿足,不但不能編輯,也不顯示 EditCondition 能寫的判斷非常有限,如:判斷某個 bool 型別的 property 是否為 true、判斷型別為 enum 的 property 是否為特定的值。 更複雜的判斷需要在 `IDetailCustomization` 中自行實作,例如可以透過 `AddProperty().Visibility(...)` 或 `AddCustomRow().Visibility(...)` 來設定某一列顯示的條件。 `Visibility()` 的參數型別為 `TAttribute<EVisibility>`,你可以直接傳遞型別為 `EVisibility` 的值,或者用 `TAttribute<EVisibility>::CreateLambda()` 等來將函數封裝成 `TAttribute<EVisibility>`。封裝函數會使 widget 的可見性為動態的,unreal 每隔一段時間會執行被封裝的函數以判斷 widget 是否可見。 - `EVisibility::Visible` - 可見 - `EVisibility::Hidden` - 不可見,但仍然佔用空間 - `EVisibility::Collapsed` - 不可見,且不佔用空間