# 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 會這樣顯示。

※ 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` - 不可見,且不佔用空間