细节面板的变量绑定逻辑

发布于 2025-02-05  222 次阅读


细节面板的变量绑定逻辑

很久之前在细节面板实现过一个变量编辑的逻辑,基于 FInstancedPropertyBag 和 FInstancedStruct 实现了在细节面板的一些计算逻辑,功能为先,制作时大多是通过 IPropertyTypeCustomization 来完成的,反射的细节面板并不是特别漂亮,虽然运算时提供了筛选,但左值的选择不太直观,只能通过命名来确定,因此最近考虑研究下 UE 里的细节面板的变量绑定的逻辑是如何实现的,考虑把样式部分搞过来参考下。

写在前面

UE 里这一块的功能都很特异化,不是很易于拓展,网上也几乎没有相关拓展的资料。

若要完成自定义的功能,能够复用的大抵只有一个 UI,核心的绑定逻辑都是需要自己来实现的(除非逻辑很一致,可以参考 FSmartObjectDefinitionBindingExtensionFStateTreeBindingExtension)。

最终效果:

UMG 绑定流程分析

UMG 绑定的整体流程很长,并且除了 UI 的部分其实大部分对完成后续的 Sample 没有太多帮助,也可以跳过这部分,直接查看示例说明(笔者看的头昏脑胀的 )。

系统中有很多面板存在绑定逻辑,例如 UMG、动画蓝图、Control Rig、StateTree 等,笔者这里以 UMG 为例。

UI 创建

大多变量绑定的 UI 都是由继承 IDetailPropertyExtensionHandler 来实现的,以针对单行来自定义 (如 FStateTreeBindingExtension, FAnimGraphNodeBindingExtension 等)。UMG 这里是基于细节面板的自定义拓展的,但最终逻辑都是一致的:

class FBlueprintWidgetCustomization : public IDetailCustomization
{
...

/** IDetailCustomization interface */
virtual void CustomizeDetails( IDetailLayoutBuilder& DetailLayout ) override;

/** Make a property binding widget */
static TSharedRef<SWidget> MakePropertyBindingWidget(TWeakPtr<FWidgetBlueprintEditor> InEditor, UFunction* SignatureFunction, TSharedRef<IPropertyHandle> InDelegatePropertyHandle, bool bInGeneratePureBindings, bool bAllowDetailsPanelLegacyBinding);

...

void CreateEventCustomization( IDetailLayoutBuilder& DetailLayout, FDelegateProperty* Property, UWidget* Widget );
...
};

UI 构建的逻辑在 MakePropertyBindingWidget 方法里,最终会调用到 IPropertyAccessEditor::MakePropertyBindingWidget 方法,这里有两个重载,UMG 用的首个需要传入 UBlueprint,我们自定义拓展时可以使用需要传入结构数组的,这里的核心就是 FPropertyBindingWidgetArgs 参数的构建,参数构建与业务密切相关,一会逐个详细解析,先整体捋一捋 UMG 绑定这一块的执行流程:

TSharedRef<SWidget> FBlueprintWidgetCustomization::MakePropertyBindingWidget(TWeakPtr<FWidgetBlueprintEditor> InEditor, UFunction* SignatureFunction, TSharedRef<IPropertyHandle> InPropertyHandle, bool bInGeneratePureBindings, bool bAllowDetailsPanelLegacyBinding)
{
...
    FPropertyBindingWidgetArgs Args;
    Args.MenuExtender = FExtender::Combine(MenuExtenders);
    Args.Property = InPropertyHandle->GetProperty();
    Args.BindableSignature = SignatureFunction;
    Args.BindButtonStyle = &FCoreStyle::Get().GetWidgetStyle<FButtonStyle>("Button");
...
    Args.OnAddBinding = FOnAddBinding::CreateLambda([InEditor, Objects](FName InPropertyName, const TArray<FBindingChainElement>& InBindingChain)
    Args.OnRemoveBinding = FOnRemoveBinding::CreateLambda([InEditor, Objects, InPropertyHandle, ActiveExtensions](FName InPropertyName)
...

    IPropertyAccessEditor& PropertyAccessEditor = IModularFeatures::Get().GetModularFeature<IPropertyAccessEditor>("PropertyAccessEditor");
    return PropertyAccessEditor.MakePropertyBindingWidget(InEditor.Pin()->GetBlueprintObj(), Args);
}

参数构建完成后,使用 IPropertyAccessEditor::MakePropertyBindingWidget 方法传入构建的参数即可完成 PropertyBinding SWidget 的构建,UMG 这里将返回的 SWidget 赋值到了 PropertyRow 的 ValueWidget 里,进而完成了细节面板反射 Value 部分的自定义:

void FBlueprintWidgetCustomization::CreateEventCustomization( IDetailLayoutBuilder& DetailLayout, FDelegateProperty* Property, UWidget* Widget )
{
...
    IDetailPropertyRow& PropertyRow = PropertyCategory.AddProperty(DelegatePropertyHandle);
    PropertyRow.OverrideResetToDefault(FResetToDefaultOverride::Create(FResetToDefaultHandler::CreateSP(this, &FBlueprintWidgetCustomization::ResetToDefault_RemoveBinding)));
...
    PropertyRow.CustomWidget(bShowChildren)
        .NameContent()
        [
...
        ]
        .ValueContent()
        .MinDesiredWidth(200)
        .MaxDesiredWidth(250)
        [
            MakePropertyBindingWidget(Editor.Pin(), Property->SignatureFunction, DelegatePropertyHandle, false, true)
        ];
}

Args 的构建

FPropertyBindingWidgetArgs Args 参数的构建需要我们创建一系列的代理、参数,以供 S 类的 UI 展示使用及交互时回调,

我们构建的参数最终会传递到 SPropertyBinding 的 成员 Args 中:

void SPropertyBinding::Construct(const FArguments& InArgs, UBlueprint* InBlueprint, const TArray<FBindingContextStruct>& InBindingContextStructs)
{
    Blueprint = InBlueprint;
    BindingContextStructs = InBindingContextStructs;

    Args = InArgs._Args;
...

在 UI 交互时,会借由 Args 转发并调用我们创建的代理来执行相关逻辑,例如:

void SPropertyBinding::HandleAddBinding(TArray<TSharedPtr<FBindingChainElement>> InBindingChain)
{
    if(Args.OnAddBinding.IsBound())
    {
        const FScopedTransaction Transaction(LOCTEXT("BindDelegate", "Set Binding"));

        TArray<FBindingChainElement> BindingChain;
        BindingChain.Reserve(InBindingChain.Num());
        Algo::Transform(InBindingChain, BindingChain, [](TSharedPtr<FBindingChainElement> InElement)
        {
            return *InElement.Get();
        });
        Args.OnAddBinding.Execute(PropertyName, BindingChain);
    }
}

或 TAttribute 类的:

FSlateColor SPropertyBinding::GetCurrentBindingColor() const
{
if(Args.CurrentBindingColor.IsSet())
    {
return Args.CurrentBindingColor.Get();
    }

return FLinearColor(0.25f, 0.25f, 0.25f);
}

因此核心的逻辑就是构建好这些代理的回调方法。

我们先以 UMG 中的 OnAddBinding 为例:

    Args.OnAddBinding = FOnAddBinding::CreateLambda([InEditor, Objects](FName InPropertyName, const TArray<FBindingChainElement>& InBindingChain)
    {
        UWidgetBlueprint* ThisBlueprint = InEditor.Pin()->GetWidgetBlueprintObj();
        UBlueprintGeneratedClass* SkeletonClass = Cast<UBlueprintGeneratedClass>(ThisBlueprint->SkeletonGeneratedClass);

        ThisBlueprint->Modify();

        TArray<FFieldVariant> FieldChain;
        Algo::Transform(InBindingChain, FieldChain, [](const FBindingChainElement& InElement)
        {
            return InElement.Field;
        });

        UFunction* Function = FieldChain.Last().Get<UFunction>();
        FProperty* Property = FieldChain.Last().Get<FProperty>();

        check(Function != nullptr || Property != nullptr);

        for (const TWeakObjectPtr<UObject>& ObjectPtr : Objects)
        {
            UObject* Object = ObjectPtr.Get();

            // Ignore null outer objects
            if ( Object == nullptr )
            {
                continue;
            }

            FDelegateEditorBinding Binding;
            Binding.ObjectName = Object->GetName();
            Binding.PropertyName = InPropertyName;
            Binding.SourcePath = FEditorPropertyPath(FieldChain);

            if ( Function != nullptr)
            {
                Binding.FunctionName = Function->GetFName();

                UBlueprint::GetGuidFromClassByFieldName<UFunction>(
                    Function->GetOwnerClass(),
                    Function->GetFName(),
                    Binding.MemberGuid);

                Binding.Kind = EBindingKind::Function;
            }
            else if( Property != nullptr )
            {
                Binding.SourceProperty = Property->GetFName();

                UBlueprint::GetGuidFromClassByFieldName<FProperty>(
                    SkeletonClass,
                    Property->GetFName(),
                    Binding.MemberGuid);

                Binding.Kind = EBindingKind::Property;
            }

            ThisBlueprint->Bindings.Remove(Binding);
            ThisBlueprint->Bindings.AddUnique(Binding);
        }

        FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(ThisBlueprint);
    });

OnAddBinding 提供了两个参数 DECLARE_DELEGATE_TwoParams(FOnAddBinding, FName /*InPropertyName*/, const TArray<FBindingChainElement>& /*InBindingChain*/);InPropertyName 是发起绑定的 Property 的 Name,InBindingChain 是选中的绑定目标的访问路径属性链,例如当我在 UMG 拖入一个进度条,并将 Percent 参数绑定到 UMG 蓝图中定义的一个 LinearColor 类型中的 R 时,传入的参数结果为(下文都以这一 Property 的绑定为例):

也正因此,这里获取最终的 Property 时取的是 Last()。

回调到这里,可以通用的绑定部分就已结束,剩下的就是如何具体完成绑定逻辑的构建了,绑定逻辑的这一块各个系统都不太一样,都是依赖这些来自 UI 回调的参数,以及我们在构建代理匿名函数时传入的信息,根据具体业务来具体实现的。

接下来来跟下 UMG 这一块的实现逻辑。

UMG 的绑定逻辑

(这里最好了解一些蓝图的基础知识,这里不再赘述,可以简单理解为 UWidgetBlueprint -> UMG 资源类;UBlueprintGeneratedClass -> 蓝图运行时类;UUserWidget -> 运行时对象。)

接上段的 OnAddBinding,会发现这一段代码的逻辑很简单,只是基于入参构造了一个 FDelegateEditorBinding Binding 对象,构建的参数值如:

然后将其添加到了 ThisBlueprint->Bindings 中,这里的 ThisBlueprint 就是当前正在编辑的 UI 蓝图对象。

存储到这里以后,当我们编译时,便会将这一绑定关系传递到 UWidgetBlueprintGeneratedClass 的 TArray< FDelegateRuntimeBinding > Bindings 成员中:

void FWidgetBlueprintCompilerContext::FinishCompilingClass(UClass* Class)
{
    if (Class == nullptr)
        return;

    UWidgetBlueprint* WidgetBP = WidgetBlueprint();
...
        // Only check bindings on a full compile.  Also don't check them if we're regenerating on load,
        // that has a nasty tendency to fail because the other dependent classes that may also be blueprints
        // might not be loaded yet.
        const bool bIsLoading = WidgetBP->bIsRegeneratingOnLoad;
        if ( bIsFullCompile )
        {
            SanitizeBindings(BPGClass);

            // Convert all editor time property bindings into a list of bindings
            // that will be applied at runtime.  Ensure all bindings are still valid.
            for ( const FDelegateEditorBinding& EditorBinding : WidgetBP->Bindings )
            {
                if ( bIsLoading || EditorBinding.IsBindingValid(Class, WidgetBP, MessageLog) )
                {
                    BPGClass->Bindings.Add(EditorBinding.ToRuntimeBinding(WidgetBP));
                }
            }

执行 UUserWidget::Initialize 时,便会依赖这一成员,逐步完成 UWidget 的 TArray<TObjectPtr <UPropertyBinding>> NativeBindings 的构建:

UUserWidget::Initialize

    UWidgetBlueprintGeneratedClass::InitializeWidget

    UWidgetBlueprintGeneratedClass::InitializeWidgetStatic

    UWidgetBlueprintGeneratedClass::InitializeBindingsStatic

        UWidget::AddBinding

最终调用到 UWidget::AddBinding 时参数如下:

BindingPath 可以看出,就是我们上文测试时绑定的参数路径。

DelegateProperty 是使用 Binding.PropertyName 组合 TEXT("Delegate") 得到的成员名获取的 FDelegateProperty:

void UWidgetBlueprintGeneratedClass::InitializeBindingsStatic(UUserWidget* UserWidget, const TArrayView<const FDelegateRuntimeBinding> InBindings, const TMap<FName, FObjectPropertyBase*>& InPropertyMap)
{
...
                FDelegateProperty* DelegateProperty = FindFProperty<FDelegateProperty>(Widget->GetClass(), FName(*(Binding.PropertyName.ToString() + TEXT("Delegate"))));
                if (!DelegateProperty)
                {
                    DelegateProperty = FindFProperty<FDelegateProperty>(Widget->GetClass(), Binding.PropertyName);
                }

                if (DelegateProperty)
                {
                    bool bSourcePathBound = false;

                    if (Binding.SourcePath.IsValid())
                    {
                        bSourcePathBound = Widget->AddBinding(DelegateProperty, UserWidget, Binding.SourcePath);
                    }

这里注意是用类获取的 FDelegateProperty 对象,可以理解为委托成员的反射元数据,借由这一数据后续才可以取到对象中真正的委托实例。

这也是为什么我们在实现自定义的 UWidget 控件时,要想提供绑定能力,还需要提供一个带对应返回值类型的代理成员变量的原因:

class UProgressBar : public UWidget
{
...
    /** Used to determine the fill position of the progress bar ranging 0..1 */
    UPROPERTY(EditAnywhere, BlueprintReadWrite, FieldNotify, Getter, Setter, BlueprintSetter="SetPercent", Category="Progress", meta = (UIMin = "0", UIMax = "1"))
    float Percent;
...
    /** A bindable delegate to allow logic to drive the text of the widget */
    UPROPERTY()
    FGetFloat PercentDelegate;
...

组织好这些信息后,最终会在 GenerateBinder 方法中,创建对应的 PropertyBinding 对象:

static UPropertyBinding* GenerateBinder(FDelegateProperty* DelegateProperty, UObject* Container, UObject* SourceObject, const FDynamicPropertyPath& BindingPath)
{
    FScriptDelegate* ScriptDelegate = DelegateProperty->GetPropertyValuePtr_InContainer(Container);
    if ( ScriptDelegate )
    {
        // Only delegates that take no parameters have native binders.
        UFunction* SignatureFunction = DelegateProperty->SignatureFunction;
        if ( SignatureFunction->NumParms == 1 )
        {
            if ( FProperty* ReturnProperty = SignatureFunction->GetReturnProperty() )
            {
                TSubclassOf<UPropertyBinding> BinderClass = UWidget::FindBinderClassForDestination(ReturnProperty);
                if ( BinderClass != nullptr )
                {
                    UPropertyBinding* Binder = NewObject<UPropertyBinding>(Container, BinderClass);
                    Binder->SourceObject = SourceObject;
                    Binder->SourcePath = BindingPath;
                    Binder->Bind(ReturnProperty, ScriptDelegate);

                    return Binder;
                }
            }
        }
    }

    return nullptr;
}

这一段代码首先借由委托元数据对象从 Container 中获取了实际的 FScriptDelegate,这个参数维护了真正的单播委托的绑定关系,其内存储了一个 UObject 指针和 FName 函数名,用于调用 UObject 的成员函数,此时这两个参数都为 NULL。

而后获取了 SignatureFunction,即指向该 FDelegateProperty 所代表的委托类型的 UFunction,这里确保了是无参有返回值的委托,然后基于委托返回值类型,查找应使用的绑定类:

TSubclassOf<UPropertyBinding> UWidget::FindBinderClassForDestination(FProperty* Property)
{
    if (BinderClasses.IsEmpty())
    {
        TArray<UClass*> PropertyBindingClasses;
        GetDerivedClasses(UPropertyBinding::StaticClass(), PropertyBindingClasses);
        BinderClasses.Reserve(PropertyBindingClasses.Num());
        for (UClass* PropertyBindingClass : PropertyBindingClasses)
        {
            BinderClasses.Emplace(PropertyBindingClass);
        }
    }

    for (TSubclassOf<UPropertyBinding>& BinderClass : BinderClasses)
    {
        if (BinderClass.GetDefaultObject()->IsSupportedDestination(Property))
        {
            return BinderClass;
        }
    }

    return nullptr;
}

这里就是获取了继承自 UPropertyBinding 的类,并取其 CDO 调用 IsSupportedDestination 方法来获取匹配的绑定类型。

例如本例中的,调用到 UFloatBinding 的相关方法时会匹配成功,返回该类:

bool UFloatBinding::IsSupportedDestination(FProperty* Property) const
{
    return IsSupportedSource(Property);
}

bool UFloatBinding::IsSupportedSource(FProperty* Property) const
{
    return IsConcreteTypeCompatibleWithReflectedType<float>(Property) || IsConcreteTypeCompatibleWithReflectedType<double>(Property);
}

而后基于返回的类,创建了 UFloatBinding 的对象,并传入 SourceObject 与 BindingPath,这里的 Outer 值定的是 UWdiegt 小控件。

最后执行 Binder->Bind(ReturnProperty, ScriptDelegate) 方法,来初始化一开始获取的 FScriptDelegate 对象。

方法内很简单:

void UPropertyBinding::Bind(FProperty* Property, FScriptDelegate* Delegate)
{
    static const FName BinderFunction(TEXT("GetValue"));
    Delegate->BindUFunction(this, BinderFunction);
}

UFloatBinding 这里没有重载 Bind 方法,故调用基类方法将这一代理绑定到了 GetValue 方法。

因为前面做了绑定类的筛选,所以理论上 GetValue 的返回类型是匹配的,UFloatBinding 的 GetValue 方法为:

float UFloatBinding::GetValue() const
{
    //SCOPE_CYCLE_COUNTER(STAT_UMGBinding);

    if ( UObject* Source = SourceObject.Get() )
    {
        // Since we can bind to either a float or double, we need to perform a narrowing conversion where necessary.
        // If this isn't a property, then we're assuming that a function is used to extract the float value.

        float FloatValue = 0.0f;

        if (SourcePath.Resolve(Source))
        {
            double DoubleValue = 0.0;
            if (SourcePath.GetValue<float>(Source, FloatValue))
            {
                return FloatValue;
            }
            else if (SourcePath.GetValue<double>(Source, DoubleValue))
            {
                FloatValue = static_cast<float>(DoubleValue);
                return FloatValue;
            }
        }
    }

    return 0.0f;
}

这里的 GetValue 是调用的 FDynamicPropertyPath 的方法:

USTRUCT()
struct FDynamicPropertyPath : public FCachedPropertyPath
{
    GENERATED_USTRUCT_BODY()

public:

    /** */
    UMG_API FDynamicPropertyPath();

    /** */
    UMG_API FDynamicPropertyPath(const FString& Path);

    /** */
    UMG_API FDynamicPropertyPath(const TArray<FString>& PropertyChain);

    /** Get the value represented by this property path */
    template<typename T>
    bool GetValue(UObject* InContainer, T& OutValue) const
    {
        FProperty* OutProperty;
        return GetValue<T>(InContainer, OutValue, OutProperty);
    }

    /** Get the value and the leaf property represented by this property path */
    template<typename T>
    bool GetValue(UObject* InContainer, T& OutValue, FProperty*& OutProperty) const
    {
        return PropertyPathHelpers::GetPropertyValue(InContainer, *this, OutValue, OutProperty);
    }
};

而后就将 this 传入了 PropertyPathHelpers::GetPropertyValue 方法来获取最后的值,这里后续的流程可以不再关心,因为从这开始就不再是 UMG 模块的逻辑了,后续的逻辑在 Runtime 部分,完成我们的逻辑时组织好 FCachedPropertyPath 直接调用相同方法即可。

这样我们将 UWidget 中的代理成员就绑定到了根据 PropertyPath 参数来获取其值的 GetValue 方法。

这样后续访问时直接使用这个代理访问即可,UMG 具体在 UProgressBar 的 SynchronizeProperties 方法中:

void UProgressBar::SynchronizeProperties()
{
    Super::SynchronizeProperties();

    if (!MyProgressBar.IsValid())
    {
        return;
    }

    TAttribute< TOptional<float> > PercentBinding = OPTIONAL_BINDING_CONVERT(float, Percent, TOptional<float>, ConvertFloatToOptionalFloat);
    TAttribute<FSlateColor> FillColorAndOpacityBinding = PROPERTY_BINDING(FSlateColor, FillColorAndOpacity);

    MyProgressBar->SetStyle(&WidgetStyle);

    MyProgressBar->SetBarFillType(BarFillType);
    MyProgressBar->SetBarFillStyle(BarFillStyle);
    MyProgressBar->SetPercent(bIsMarquee ? TOptional<float>() : PercentBinding);
    MyProgressBar->SetFillColorAndOpacity(FillColorAndOpacityBinding);
    MyProgressBar->SetBorderPadding(BorderPadding);
}

这里的 OPTIONAL_BINDING_CONVERT 宏展开后如下:

    TAttribute<TOptional<float>> PercentBinding =
        (PercentDelegate.IsBound() && !IsDesignTime())
        ? TAttribute<TOptional<float>>::Create(TAttribute<TOptional<float>>::FGetter::CreateUObject(this, &ThisClass::ConvertFloatToOptionalFloat, TAttribute<float>::Create(PercentDelegate.GetUObject(), PercentDelegate.GetFunctionName())))
        : ConvertFloatToOptionalFloat(TAttribute<float>(Percent));

其实就是基于前文的 PercentDelegate (已经绑定到了 GetValue 方法),构建了一个 TAttribute 并赋值到了 SProgressBar 的成员中,就此完成了绑定的逻辑,SProgressBar 中在 OnPaint 时使用 TAttribute::Get 获取即是通过 GetValue 获取到的绑定后的值。

示例说明

因为 UE 中提供了 FInstancedPropertyBag,理论上来说我们细节面板上的变量,可以通过较为通用的方案绑定到 FInstancedPropertyBag 变量中的某个成员,如果实现后,对于使用了 FInstancedPropertyBag 的位置都可以较为轻易的再添加绑定的逻辑,对一些编辑器相关逻辑的开发很有帮助,因此考虑实现一个这一功能的简单 Sample。

经过上面 UMG 的分析,其实很明确,系统中提供的可以通用的部分基本只有细节面板自定义的 UI,后续的绑定逻辑不太具有通用性,因此笔者这里不太考虑使用 UMG 模块中的相关类。

方案

那么实现这一功能,可以考虑如下两种方案:

  1. 被绑定者属性变更时,同步到绑定方
    FInstancedPropertyBag 并没有属性变更的通知,按此方式可能要考虑轮询同步、修改源码、或用一个提供操作接口的自定义类包起来供使用,以提供相关时机,体感都不太优雅,笔者也不倾向于在被绑定方完成额外的逻辑。
  2. 绑定方获取时,从被绑定者处获取
    UMG 就是此方案,其额外多定义了一个 Delagate 的成员,然后基于 TAttribute 来完成的数据访问。理论上最优的方案肯定是对用户无感,但没有较好的办法完成这一点,因此笔者也考虑额外添加一个成员来完成这一功能(这种方案其实也依赖 InstancedPropertyBag,例如绑定到的属性变更了怎么办,这里没有回调就没有很好的办法处理,只能容错 O.o)。
    UPROPERTY(EditAnywhere)
    FVector TestVectorValue;
    UPROPERTY()
    FSampleBindingData TestVectorValueBindingData;

笔者这里定义了一个类 FSampleBindingData,承担类似 UMG 里的 XxxxxDelegate 成员的作用,因为 UPROPERTY 宏不能嵌套,所以也没有办法再精简,需要额外定义一个 Binding 类的成员,笔者也考虑过将此成员的放到外部管理,但这样无疑后续的访问都依赖于管理器,反而不方便,因此还是考虑定义到需要支持绑定功能的类里。

最终达到的效果就是直接访问 TestBoolValueBindingData 来获取变量值。

案例说明

笔者这里定义了一个 Standalone 的插件,名为 PropertyBindingSampleEdtor,可以在菜单栏 Window 中找到,作为案例,没再区分运行时模块,自行拓展时将 SampleBindingObject 与 SampleBindingData 在运行时模块完成即可。

TSharedRef<SDockTab> FPropertyBindingSampleEditorModule::OnSpawnPluginTab(const FSpawnTabArgs& SpawnTabArgs)
{
    SampleBindingObject = NewObject<USampleBindingObject>();
    SampleBindingObject->AddToRoot();

    // Details View
    FDetailsViewArgs Args;
    Args.bHideSelectionTip = true;
    Args.bShowPropertyMatrixButton = false;
    Args.DefaultsOnlyVisibility = EEditDefaultsOnlyNodeVisibility::Hide;

    FPropertyEditorModule& PropertyModule = FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");
    DetailsView = PropertyModule.CreateDetailView(Args);
    DetailsView->SetObject(SampleBindingObject);
    DetailsView->SetExtensionHandler(MakeShared<FSampleBindingExtension>());

    return SNew(SDockTab)
        .OnTabClosed_Raw(this, &FPropertyBindingSampleEditorModule::OnTabClosed)
        .TabRole(ETabRole::NomadTab)
        [
            // Put your tab content here!
            SNew(SBox)
            .HAlign(HAlign_Center)
            .VAlign(VAlign_Center)
            [
                DetailsView.ToSharedRef()
            ]
        ];
}

这里在插件界面打开时,初始化了一个细节面板,并指定了 ExtensionHandler 使用我们定义的 FSampleBindingExtension。
然后创建了一个 USampleBindingObject 类型的对象,并 Set 到该细节面板。

USampleBindingObject 类大致如下:

UCLASS()
class PROPERTYBINDINGSAMPLEEDITOR_API USampleBindingObject : public UObject
{
    GENERATED_BODY()

    USampleBindingObject()
        : TestFloatValue(0.114)
        , TestInt32Value(514)
        , TestBoolValue(true)
        , TestVectorValue(FVector(11.22, 22.33, 33.44))
    {
        Parameters.AddProperty(TEXT("TestFloat"), EPropertyBagPropertyType::Double);
        Parameters.SetValueFloat(TEXT("TestFloat"), 123.123);

        Parameters.AddProperty(TEXT("TestInt32"), EPropertyBagPropertyType::Int32);
        Parameters.SetValueInt32(TEXT("TestInt32"), 123123);

        Parameters.AddProperty(TEXT("TestBool"), EPropertyBagPropertyType::Bool);
        Parameters.SetValueBool(TEXT("TestBool"), true);

        Parameters.AddProperty(TEXT("TestVector"), EPropertyBagPropertyType::Struct, TBaseStructure<FVector>::Get());
        Parameters.SetValueStruct(TEXT("TestVector"), FVector(1.11, 22.2, 3.33));
    }

public:
    UPROPERTY(EditAnywhere, Category = TestParameters)
    FInstancedPropertyBag Parameters;

private:
    UPROPERTY(EditAnywhere)
    double TestFloatValue;
    UPROPERTY()
    FSampleBindingData TestFloatValueBindingData;

    UPROPERTY(EditAnywhere)
    int32 TestInt32Value;
    UPROPERTY()
    FSampleBindingData TestInt32ValueBindingData;

    UPROPERTY(EditAnywhere)
    bool TestBoolValue;
    UPROPERTY()
    FSampleBindingData TestBoolValueBindingData;

    UPROPERTY(EditAnywhere)
    FVector TestVectorValue;
    UPROPERTY()
    FSampleBindingData TestVectorValueBindingData;
};

这里将 FInstancedPropertyBag 一并定义在了这个类里,最终实现的效果就是可以支持下方的变量,绑定到上方的 Parameters 中的同类型参数中。实际使用时根据具体业务来获取 FInstancedPropertyBag 对象即可。

绑定说明

可以看到 SampleBindingObject 里,成员的定义与 UMG 中的 delegate 类似,额外定义了一个原变量名加 BindingData 后缀的成员。
在 FSampleBindingExtension::ExtendWidgetRow 方法中:

void FSampleBindingExtension::ExtendWidgetRow(FDetailWidgetRow& InWidgetRow, const IDetailLayoutBuilder& InDetailBuilder, const UClass* InObjectClass, TSharedPtr<IPropertyHandle> PropertyHandle)
{
...
    if (!PropertyHandle->GetProperty())
    {
        return;
    }
    const FProperty* BindingDataProperty = FindFProperty<FProperty>(InObjectClass, FName(*(PropertyHandle->GetProperty()->GetName() + TEXT("BindingData"))));
    if (BindingDataProperty == nullptr)
    {
        return;
    }
    void* BindingDataAddress = BindingDataProperty->ContainerPtrToValuePtr<void>(SampleBindingObject);
    if (BindingDataAddress == nullptr)
    {
        return;
    }
    if (CastField<FStructProperty>(BindingDataProperty) == nullptr)
    {
        return;
    }
    FSampleBindingData* SampleBindingData = static_cast<FSampleBindingData*>(BindingDataAddress);
    if (SampleBindingData == nullptr)
    {
        return;
    }

会对每一行 Property 来检查是否存在同名带 BindingData 后缀的成员,如果存在,则视为可支持绑定的类,进而执行后续的逻辑。

FSampleBindingData 这个类定义了一些参数,来存储绑定信息,并执行变量访问的逻辑:

USTRUCT()
struct FSampleBindingData
{
    GENERATED_BODY()

public:
    static constexpr int32 FirstValidSegmentIndex = 0;
    static constexpr int32 FirstNestSegmentIndex = 1;

    const FInstancedPropertyBag* PropertyBag = nullptr;
    FName PropertyDescName;
    EPropertyBagPropertyType PropertyDescValueType = EPropertyBagPropertyType::None;
    FCachedPropertyPath SourcePath;
    FLinearColor Color = FLinearColor::White;
    const FSlateBrush* Image = nullptr;
...

对于原始细节面板的 ContentWidget,也会根据这个类的变量绑定状态来确定是否启用:

    TAttribute<bool> bOriginValueContentWidgetEnabled = TAttribute<bool>::Create(TAttribute<bool>::FGetter::CreateLambda([SampleBindingData]() -> bool
    {
        if (SampleBindingData != nullptr)
        {
            return !SampleBindingData->HasBinding();
        }
        return true;
    }));
    InWidgetRow.ValueContent().Widget->SetEnabled(bOriginValueContentWidgetEnabled);

参数构建

TArray\<FBindingContextStruct>

首个结构数组参数参考的 FSmartObjectDefinitionBindingExtension::ExtendWidgetRow 中的逻辑,测试如下逻辑,在 OnCanBindPropertyWithBindingChain 回调中,会将 PropertyBag 中的所有 Property 依次传入,满足我们的需求:

    const FText DisplayText = FText::FromString(TEXT("Parameters"));
    const FString CategoryStr = TEXT("TestCategory");
    const FText SectionText = FText::FromString(TEXT("Global"));
    TArray<FBindingContextStruct> BindingContextStructs;
    FBindingContextStruct& ContextStruct = BindingContextStructs.AddDefaulted_GetRef();
    ContextStruct.DisplayText = DisplayText;
    const UStruct* Struct = PropertyBag.GetPropertyBagStruct();
    ContextStruct.Struct = const_cast<UStruct*>(Struct);
    ContextStruct.Section = SectionText;
    ContextStruct.Color = FLinearColor::MakeRandomColor();

OnCanBindPropertyWithBindingChain

这个方法用来筛选是否是我们支持绑定的类型,这里检测的匹配类型使用的符合 EPropertyAccessCompatibility::Compatible,系统中有一些位置使用的不等于 EPropertyAccessCompatibility::Incompatible 时支持绑定,也可以如此,实现访问逻辑时注意支持隐式转换即可。

    Args.OnCanBindPropertyWithBindingChain = FOnCanBindPropertyWithBindingChain::CreateLambda([PropertyHandle](FProperty* InProperty, TConstArrayView<FBindingChainElement> InBindingChain)
    {
        if (InProperty == nullptr || InBindingChain.IsEmpty())
        {
            return true;
        }
        IPropertyAccessEditor& PropertyAccessEditor = IModularFeatures::Get().GetModularFeature<IPropertyAccessEditor>("PropertyAccessEditor");
        const FProperty* BindingProperty = PropertyHandle->GetProperty();
        const bool Result = BindingProperty && PropertyAccessEditor.GetPropertyCompatibility(InProperty, BindingProperty) == EPropertyAccessCompatibility::Compatible;
        return Result;
    });

OnAddBinding

添加绑定时,核心逻辑就是构造了 FSampleBindingData 需要的几个参数,这里因为有嵌套的情况存在,即例如 double 变量绑定到 PropertyBag 中的 FVector 的 Y 值,所以也是需要存储 InBindingChain 携带的相关信息的:

    Args.OnAddBinding = FOnAddBinding::CreateLambda([SampleBindingData, PropertyBag, PropertyHandle](const FName& InPropertyName, const TArray<FBindingChainElement>& InBindingChain)
    {
        TArray<FFieldVariant> FieldChain;
        Algo::Transform(InBindingChain, FieldChain, [](const FBindingChainElement& InElement) {
            return InElement.Field;
        });
        FCachedPropertyPath CachedPropertyPath;
        TArray<FString> PropertyChain;
        for (FFieldVariant Field : FieldChain)
        {
            if (const FProperty* CurProperty = Field.Get<FProperty>())
            {
                FName MemberName = CurProperty->GetFName();
                PropertyChain.Add(MemberName.ToString());
            }
        }
        if (PropertyChain.IsEmpty())
        {
            UE_LOG(LogTemp, Error, TEXT("%s:%d Binding chain is empty."), *FString(__FUNCTION__), __LINE__);
            return;
        }
        SampleBindingData->SourcePath = FCachedPropertyPath(PropertyChain);
        SampleBindingData->PropertyBag = &PropertyBag;
        const FPropertyBagPropertyDesc* PropertyDesc = PropertyBag.FindPropertyDescByName(
            SampleBindingData->SourcePath.GetSegment(FSampleBindingData::FirstValidSegmentIndex).GetName());
        SampleBindingData->PropertyDescName = PropertyDesc->Name;
        SampleBindingData->PropertyDescValueType = PropertyDesc->ValueType;

        // Image
        static FName PropertyIcon(TEXT("Kismet.Tabs.Variables"));
        SampleBindingData->Image = FAppStyle::GetBrush(PropertyIcon);

        // Color
        const UEdGraphSchema_K2* Schema = GetDefault<UEdGraphSchema_K2>();
        check(Schema);
        FEdGraphPinType PinType;
        Schema->ConvertPropertyToPinType(PropertyHandle->GetProperty(), PinType);
        SampleBindingData->Color = Schema->GetPinTypeColor(PinType);
    });

这里获取 Color 的方法从系统中抄过来的,一些绑定逻辑均是如此获取的。

Other

其他的一些方法均利用构造的 FSampleBindingData 中的参数完成即可,不再赘述:

    Args.OnRemoveBinding = FOnRemoveBinding::CreateLambda([SampleBindingData](FName InPropertyName)
    {
        SampleBindingData->RemoveBinding();
    });
    Args.OnHasAnyBindings = FOnHasAnyBindings::CreateLambda([SampleBindingData]()
    {
        return SampleBindingData->HasBinding();
    });
    Args.CurrentBindingText = MakeAttributeLambda([SampleBindingData]()
    {
        return SampleBindingData->GetBindingText();
    });
    Args.CurrentBindingToolTipText = MakeAttributeLambda([SampleBindingData]()
    {
        return SampleBindingData->GetBindingText();
    });
    Args.CurrentBindingImage = MakeAttributeLambda([SampleBindingData]() -> const FSlateBrush*
    {
        return SampleBindingData->Image;
    });
    Args.CurrentBindingColor = MakeAttributeLambda([SampleBindingData]() -> FLinearColor
    {
        return SampleBindingData->Color;
    });

变量访问

在变量访问时,笔者期望能如原生变量一般访问,因此重载了类型转换运算符,具体变量访问时就是转调了 PropertyBag 中的获取值的方法:

    template <typename T>
    operator T() const
    {
        TValueOrError<T, EPropertyBagResult> Result = GetValue<T>();
        if (Result.HasError())
        {
            UE_LOG(LogTemp, Error, TEXT("Type mismatch when reading property: %s"), *PropertyDescName.ToString());
            return T();
        }
        return Result.GetValue();
    }

这里只有一点不同,对于嵌套的绑定额外进行了处理,转调了 GetNestedPropertyValue 方法:

    template <typename T>
    TValueOrError<T, EPropertyBagResult> GetValue() const
    {
        if (!HasBinding())
        {
            return MakeError(EPropertyBagResult::TypeMismatch);
        }

        const FName PropertyName = PropertyDescName;
        switch (PropertyDescValueType)
        {
            case EPropertyBagPropertyType::Bool:
            {
                if constexpr (std::is_same_v<T, bool>)
                {
                    return PropertyBag->GetValueBool(PropertyName);
                }
                break;
            }
...
        {
            case EPropertyBagPropertyType::Struct:
            {
                return GetNestedPropertyValue<T>();
            }
            default:
            {
                break;
            }
        }

        return MakeError(EPropertyBagResult::TypeMismatch);
    }

GetNestedPropertyValue 方法中,基于从 BindingChain 组织的 SourcePath,逐层完成了嵌套类型变量的访问:

    template <typename T>
    TValueOrError<T, EPropertyBagResult> GetNestedPropertyValue() const
    {
        if (!PropertyBag || SourcePath.GetNumSegments() == 0)
        {
            return MakeError(EPropertyBagResult::TypeMismatch);
        }

        const FPropertyBagPropertyDesc* RootDesc = PropertyBag->FindPropertyDescByName(SourcePath.GetSegment(FirstValidSegmentIndex).GetName());
        if (!RootDesc)
        {
            return MakeError(EPropertyBagResult::PropertyNotFound);
        }

        TValueOrError<FStructView, EPropertyBagResult> RootValueResult = PropertyBag->GetValueStruct(RootDesc->Name);
        if (!RootValueResult.IsValid())
        {
            return MakeError(RootValueResult.GetError());
        }
        FStructView CurrentView = RootValueResult.GetValue();

        for (int32 i = FirstNestSegmentIndex; i < SourcePath.GetNumSegments(); ++i)
        {
            const FName SubPropertyName = SourcePath.GetSegment(i).GetName();
            const FProperty* SubProperty = CurrentView.GetScriptStruct()->FindPropertyByName(SubPropertyName);
            if (!SubProperty)
            {
                return MakeError(EPropertyBagResult::PropertyNotFound);
            }

            if (const FStructProperty* StructProp = CastField<FStructProperty>(SubProperty))
            {
                CurrentView = FStructView(StructProp->Struct, static_cast<uint8*>(StructProp->ContainerPtrToValuePtr<void>(CurrentView.GetMemory())));
            }
            else
            {
                void* Value = SubProperty->ContainerPtrToValuePtr<void>(CurrentView.GetMemory());
                return MakeValue(*(static_cast<const T*>(Value)));
            }
        }

        void* CurrentViewMemory = CurrentView.GetMemory();
        return MakeValue(*(static_cast<T*>(CurrentViewMemory)));
    }

这样,主体的逻辑就完成了。

P.S. 最终实现的变量访问虽说可以提供如原生变量一般的访问,但实则很不安全,类型不匹配时运行时才会暴露出问题,实际开发中还是更推荐如 UMG 中的拓展一般,继承多个子类来各自完成变量的访问逻辑。

GitHub: PropertyBindingSampleEditor