회사의 현재 프로젝트에서 UI 작업자와의 협업 환경 조성을 위해 MVVM 기반 UI 구조를 적극적으로 활용하고 있다. MVVM 시스템을 통해 View와 ViewModel 간의 데이터 바인딩을 관리하고 있었지만, 실제 작업 과정에서 Editor 사용성 측면에서 아쉬운 점이 몇가지 존재했다.
특히 Widget Hierarchy와 MVVM Binding Panel 사이의 연동이 부족해 다음과 같은 불편함이 발생했다.
Widget Hierarchy에서 특정 위젯을 확인했을 때
→ 해당 위젯의 MVVM Binding 정보를 바로 파악하기 어려움
MVVM Binding Panel에서 바인딩을 확인할 때
→ 어떤 위젯에 대한 바인딩인지 Hierarchy와 직관적으로 연결되지 않음
즉, Hierarchy와 Binding Panel 사이의 탐색 흐름이 끊어져 있었고,
이로 인해 특정 위젯의 바인딩을 확인하기 위해서는 패널을 직접 탐색해야 하는 비효율적인 작업 과정이 발생했다.
- Widget Hierarchy에서 특정 위젯의을 클릭하면 MVVM Binding Panel에서 해당 위젯의 Group Row가 자동으로 Highlight 되도록 하는 기능
- MVVM Binding Panel의 Row를 클릭하면 Widget Hierarchy가 Highlight 되도록 하는 기능
이를 구현하기 위해서는 단순한 UI 수정이 아니라 다음과 같은 내부 구조를 이해할 필요가 있었다.
따라서 실제 구현에 앞서 Unreal Engine MVVM Editor 구조 분석과 R&D를 먼저 진행했다.

STableRow의 virtual FReply OnMouseButtonDown
virtual FReply OnMouseButtonDown( const FGeometry& MyGeometry, const FPointerEvent& MouseEvent ) override
{
TSharedRef< ITypedTableView<ItemType> > OwnerTable = OwnerTablePtr.Pin().ToSharedRef();
bChangedSelectionOnMouseDown = false;
bDragWasDetected = false;
if ( MouseEvent.GetEffectingButton() == EKeys::LeftMouseButton )
{
const ESelectionMode::Type SelectionMode = GetSelectionMode();
if (SelectionMode != ESelectionMode::None)
{
if (const TObjectPtrWrapTypeOf<ItemType>* MyItemPtr = GetItemForThis(OwnerTable))
{
...
return FReply::Handled()
.DetectDrag(SharedThis(this), EKeys::LeftMouseButton)
.SetUserFocus(OwnerTable->AsWidget(), EFocusCause::Mouse)
.CaptureMouse(SharedThis(this));
}
}
}
return FReply::Unhandled();
}

TSharedRef<ITableRow> SBindingsList::GenerateEntryRow(TSharedPtr<FBindingEntry> Entry, const TSharedRef<STableViewBase>& OwnerTable)
{
TSharedPtr<ITableRow> Row;
if (UMVVMWidgetBlueprintExtension_View* MVVMExtensionPtr = MVVMExtension.Get())
{
switch (Entry->GetRowType())
{
case FBindingEntry::ERowType::Group:
{
Row = SNew(UE::MVVM::BindingEntry::SGroupRow, this, OwnerTable, WeakBlueprintEditor.Pin(), MVVMExtensionPtr->GetWidgetBlueprint(), Entry);
break;
}
case FBindingEntry::ERowType::Binding:
{
Row = SNew(UE::MVVM::BindingEntry::SBindingRow, this, OwnerTable, WeakBlueprintEditor.Pin(), MVVMExtensionPtr->GetWidgetBlueprint(), Entry);
break;
}

void SGroupRow::Construct(const FArguments& Args, SBindingsList* InBindingsList, const TSharedRef<STableViewBase>& OwnerTableView, const TSharedPtr<FWidgetBlueprintEditor>& InBlueprintEditor, UWidgetBlueprint* InBlueprint, const TSharedPtr<FBindingEntry>& InEntry)
{
SBaseRow::Construct(SBaseRow::FArguments(), InBindingsList, OwnerTableView, InBlueprintEditor, InBlueprint, InEntry);
OwnerList = InBindingsList;
WeakEntry = InEntry;
TSharedPtr<SWidget> ChildContent = ChildSlot.DetachWidget();
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("Brushes.Recessed"))
.BorderBackgroundColor(this, &SGroupRow::GetContentBgColor)
[
ChildContent.ToSharedRef()
]
];
}
FSlateColor SGroupRow::GetContentBgColor() const
{
const TSharedPtr<FBindingEntry> Entry = WeakEntry.Pin();
if (!OwnerList || !Entry)
{
return FLinearColor::Transparent;
}
return IsSelected() ? FLinearColor::Red : FLinearColor::Transparent;
}

void SBindingRow::Construct(const FArguments& Args, SBindingsList* InBindingsList, const TSharedRef<STableViewBase>& OwnerTableView, const TSharedPtr<FWidgetBlueprintEditor>& InBlueprintEditor, UWidgetBlueprint* InBlueprint, const TSharedPtr<FBindingEntry>& InEntry)
{
static IConsoleVariable* CVarDefaultExecutionMode = IConsoleManager::Get().FindConsoleVariable(TEXT("MVVM.DefaultExecutionMode"));
ensure(CVarDefaultExecutionMode);
DefaultExecutionMode = CVarDefaultExecutionMode ? (EMVVMExecutionMode)CVarDefaultExecutionMode->GetInt() : EMVVMExecutionMode::DelayedWhenSharedElseImmediate;
SBaseRow::Construct(SBaseRow::FArguments(), InBindingsList, OwnerTableView, InBlueprintEditor, InBlueprint, InEntry);
OwnerList = InBindingsList;
WeakEntry = InEntry;
TSharedPtr<SWidget> ChildContent = ChildSlot.DetachWidget();
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("WhiteBrush"))
.BorderBackgroundColor(this, &SBindingRow::GetContentBgColor)
[
ChildContent.ToSharedRef()
]
];
}
FSlateColor SBindingRow::GetContentBgColor() const
{
const TSharedPtr<FBindingEntry> Entry = WeakEntry.Pin();
if (!OwnerList || !Entry)
{
return FLinearColor::Transparent;
}
return IsSelected() ? FLinearColor::Red : FLinearColor::Transparent;
}
Widget Hierarchy에서 특정 위젯을 클릭했을 때 어떤 위젯이 선택되었는지 확인하고 해당 위젯 정보를 얻어오는 과정을 먼저 확인할 필요가 있었다.
위젯 Hierarchy의 동작은 SHierarchyView 클래스에서 구현되어 있으며, 위젯 선택 이벤트는 다음 함수에서 처리된다.

void SHierarchyView::WidgetHierarchy_OnSelectionChanged(TSharedPtr<FHierarchyModel> SelectedItem, ESelectInfo::Type SelectInfo)
{
if ( SelectInfo != ESelectInfo::Direct )
{
bIsUpdatingSelection = true;
TArray< TSharedPtr<FHierarchyModel> > SelectedItems = WidgetTreeView->GetSelectedItems();
TSet<FWidgetReference> Clear;
BlueprintEditor.Pin()->SelectWidgets(Clear, false);
for ( TSharedPtr<FHierarchyModel>& Item : SelectedItems )
{
Item->OnSelection();
UE_LOG(LogTemp, Log, TEXT("SelectedItemName : %s"), *Item->GetUniqueName().ToString());
}
if ( RootWidgets.Num() > 0 )
{
RootWidgets[0]->RefreshSelection();
}
BlueprintEditor.Pin()->PasteDropLocation = FVector2D(0, 0);
bIsUpdatingSelection = false;
}
}
Widget Hierarchy에서 위젯을 선택하면 WidgetHierarchy_OnSelectionChanged() 함수가 호출되며, 선택된 FHierarchyModel을 기반으로 현재 선택된 위젯 정보를 처리하는 흐름이 진행된다.
여기서 중요한 점은 다음과 같다.
- Hierarchy에서 선택된 위젯은 FHierarchyModel 객체로 관리된다.
- 실제 위젯 정보는 FHierarchyModel을 통해 접근할 수 있다.
- Item->OnSelection() 호출을 통해 선택된 위젯의 Editor 상태가 갱신된다.
특히 Item->GetUniqueName()을 통해 현재 선택된 위젯의 이름을 확인할 수 있다.
cpp UE_LOG(LogTemp, Log, TEXT("SelectedItemName : %s"), *Item->GetUniqueName().ToString());
이를 통해 Widget Hierarchy에서 선택된 위젯의 식별 키(Widget Name) 를 얻을 수 있으며, 이 키를 기반으로 MVVM Binding Panel과 연동하는 기능을 구현할 수 있다.
