[Unreal Engine] Custom Slate Widget 도입 사례
Custom Slate Widget
Phil in the Mirror에서는 언리얼 엔진 5에서 기본적으로 제공하지 않는 UI 요소 몇몇가지가 필요했는데요. 그러한 요소들을 어떻게 만들어서 해결했는지에 대해서 기술해볼까 합니다. 단순히 유저 위젯 블루프린트를 이용한 에디터 수준에서 해결할 수도 있었지만 코드 수준에서 이들을 적절히 구현해서, 유연함과 성능적 이점 및 사용성을 챙긴 사례를 공유합니다.
1) Redact Decorator
Phil in the Mirror에서 플레이어는 다양한 문서들을 읽고 사건들의 관계를 파악하여 숨겨진 날짜들을 추리해야 합니다. 따라서 중요한 정보들은 가려놓고, 이들을 추리할 수 있게 해야 했습니다. 다만 여기서 고려되어야 할 사항이 있었는데요. 바로 로컬라이제이션입니다. 텍스트 상에서 가려질 부분들은 어떤 언어이느냐에 따라서 위치가 달라지고, 길이도 달라지게 됩니다. 이를 자연스럽게 해결할 필요가 있었습니다.
| 텍스트의 일부가 검정색으로 가려져 있습니다. |
단순히 U+2588(█) 문자를 이용할 수도 있었지만, 다음과 같은 문제가 있었습니다: 1) 언어마다 글자의 개수가 상이한데, 정말로 문맥 상의 단어가 자연스럽게 가려지는 느낌을 연출하기 어려웠습니다. 가령 "손가락 절단 사고"와 "finger amputation accident"는 글자수의 차이가 커서, 어느 한쪽 언어에서 어색하게 보이는 측면이 있습니다. 2) 사이에 공백이 없이 이어지는 느낌이 필요했습니다. (███처럼 이어서 써보면 사이에 애매한 공백이 보입니다)
따라서 텍스트가 렌더링될 때, 해당 영역 위에 검정색 박스를 그려낼 필요가 있었습니다. 이는 RichTextBlock의 기능을 확장하여 구현할 수 있었습니다. 정확히는, Decorator를 만드는 것이죠. RichTextBlock은 Decorator를 추가할 수 있는데, 이러한 Decorator들은 RichTextBlock의 내용에 접근하여 본인이 꾸밀 수 있는 것들을 꾸미게 됩니다. 따라서 저는 "<redact>content</>" 따위로 감싸져있는 내용을 RedactDecorator가 접근하여 검정색 박스를 그려내는 형태로 구현하였습니다.
| 위젯을 이용한 가리기 |
두 가지 방법을 고려할 수 있었는데요. 첫 번째는 해당 위치에 SBox 위젯을 적절히 생성해서 가리는 것입니다. 이것이 제가 처음으로 접근한 방법이었습니다. 코드는 다음과 같습니다:
// Slate의 내부 구현을 위해 필요한 클래스입니다. // <redact.[stylename]>content</> 형태로 사용할 수 있습니다. class FRedactDecorator : public ITextDecorator public: FRedactDecorator(URichTextBlock* InOwner) : Owner(InOwner) {} virtual bool Supports(const FTextRunParseResults& RunParseResult, const FString& Text) const override { return RunParseResult.Name.StartsWith("redact"); } virtual TSharedRef<ISlateRun> Create(const TSharedRef<class FTextLayout>& TextLayout, const FTextRunParseResults& RunParseResult, const FString& OriginalText, const TSharedRef< FString >& InOutModelText, const ISlateStyle* Style) override { const int32 TagContentLength = RunParseResult.ContentRange.EndIndex - RunParseResult.ContentRange.BeginIndex; const FString TagContent = OriginalText.Mid(RunParseResult.ContentRange.BeginIndex, TagContentLength); TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService(); // RichTextBlock에서 기본 텍스트 스타일을 가져온다. FTextBlockStyle TextStyle; if (Owner) { TextStyle = Owner->GetDefaultTextStyle(); } else { TextStyle = FCoreStyle::Get().GetWidgetStyle<FTextBlockStyle>("NormalText"); } // 메타데이터에 style 속성이 있으면 해당 스타일을 가져온다. FName StyleName = NAME_None; if (StyleName.IsNone()) { // OriginalText에서 .을 기준으로 TextStyle을 가져온다. static const FRegexPattern TagPattern(TEXT("<\\s*redact\\.([^>\\s]+)[^>]*>")); FRegexMatcher Matcher(TagPattern, OriginalText); if (Matcher.FindNext()) { StyleName = FName(*Matcher.GetCaptureGroup(1)); } } // 스타일 이름을 데이터 테이블에서 조회한다. if (StyleName != NAME_None && Owner && Owner->GetTextStyleSet()) { // DataTable에서 FRichTextStyleRow를 찾는다. const FRichTextStyleRow* StyleRow = Owner->GetTextStyleSet()->FindRow<FRichTextStyleRow>(StyleName, TEXT("RedactDecorator"), false); if (StyleRow) { TextStyle = StyleRow->TextStyle; } } // 텍스트를 투명하게 만들어 크기 측정용으로만 사용하고, 화면에는 보이지 않게 한다. FTextBlockStyle TransparentTextStyle = TextStyle; TransparentTextStyle.ColorAndOpacity = FSlateColor(FLinearColor::Black); const FVector2D TextSize = FontMeasure->Measure(TagContent, TextStyle.Font); const int16 Baseline = FontMeasure->GetBaseline(TextStyle.Font); TSharedRef<SWidget> RedactionWidget = SNew(SBox) .WidthOverride(TextSize.X) .HeightOverride(TextSize.Y) [ SNew(SBorder) .BorderImage(FCoreStyle::Get().GetBrush("BlackBrush")) .Padding(0) ]; // FTextRunParseResults에서 필요한 정보를 추출한다. FRunInfo RunInfo(RunParseResult.Name); for (const TPair<FString, FTextRange>& Pair : RunParseResult.MetaData) { const int32 MetaDataLength = Pair.Value.EndIndex - Pair.Value.BeginIndex; RunInfo.MetaData.Add(Pair.Key, OriginalText.Mid(Pair.Value.BeginIndex, MetaDataLength)); } InOutModelText->AppendChar(TCHAR(0xFFFC)); // U+FFFC Object Replacement Character const FTextRange ModelRange(InOutModelText->Len() - 1, InOutModelText->Len()); const FSlateWidgetRun::FWidgetRunInfo WidgetRunInfo(RedactionWidget, Baseline); return FSlateWidgetRun::Create(TextLayout, RunInfo, InOutModelText, WidgetRunInfo, ModelRange); } private: URichTextBlock* Owner; }; TSharedPtr<ITextDecorator> URedactDecorator::CreateDecorator(URichTextBlock* InOwner) { return MakeShareable(new FRedactDecorator(InOwner)); }
RichTextBlock에서 텍스트를 처리할 때, Run이라는 단위로 나누어서 텍스트를 처리하게 됩니다. 텍스트의 길이가 길다거나 해서 줄바꿈 등이 일어날 수 있기 때문에, 이러한 것들로부터 자유로운 최소한의 렌더 단위를 갖는 것이죠(예를 들면, 단어 하나). Supports()는 해당 Decorator가 어떤 텍스트를 처리할지를 결정합니다. Create()에서는 Supports()에서 true를 반환한 Run이 인자로 넘어오게 되고, Decorator 클래스들은 주어진 Run들에 대해서 추가적인 행위를 할 수 있습니다.
Run 정보를 이용해서, 어떤 TextStyle을 적용해야 할지 결정하고, FontMeasure를 이용해서 해당 Run이 어느정도의 크기를 갖고 어느 위치에 렌더링될지 계산할 수 있습니다. 이를 이용해서 검정색의 Border를 자식으로 갖는 SBox 위젯을 생성하여 해당 텍스트를 가리는 것입니다.
하지만 이는 어쨌거나 새로운 위젯을 생성하는 것이기 때문에, 약간의 오버헤드가 있습니다. 애초에 FSlateTextRun에서 '텍스트를 렌더링'하는 과정을 거치므로, FSlateTextRun을 확장하여 '텍스트를 렌더링하지 않고 검정색 박스를 렌더링'하게끔 하면 더 간결한 구현이 될 것입니다.
기존의 RedactDecorator는 가려져야 할 텍스트의 Run에 대해 FSlateWidgetRun을 반환하고, 해당 위젯에 텍스트와 박스를 넣었습니다. 대신에, FRedactTextRun을 만들어서 반환하도록 하고, FRedactTextRun은 글자와 관계없이 그 크기만큼 단순히 검정색 상자를 렌더링하도록 개선하면, 텍스트를 렌더링하는것과 복합 위젯을 렌더링하는 오버헤드 등을 없앨 수 있을 것입니다:
class FRedactTextRun : public FSlateTextRun { public: // 생성자: 부모 클래스(FSlateTextRun)의 생성자를 호출하여 텍스트 측정(Measure) 기능을 그대로 물려받습니다. static TSharedRef<FRedactTextRun> Create( const FRunInfo& InRunInfo, const TSharedRef<const FString>& InText, const FTextBlockStyle& Style, const FTextRange& InRange) { return MakeShareable(new FRedactTextRun(InRunInfo, InText, Style, InRange)); } protected: // private 생성자 FRedactTextRun( const FRunInfo& InRunInfo, const TSharedRef<const FString>& InText, const FTextBlockStyle& InStyle, const FTextRange& InRange) : FSlateTextRun(InRunInfo, InText, InStyle, InRange) { } public: virtual int32 OnPaint( const FPaintArgs& PaintArgs, const FTextArgs& TextArgs, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override { const TSharedRef<ILayoutBlock>& Block = TextArgs.Block; const FSlateColor BoxColor = FSlateColor(FLinearColor::Black); // 현재 위젯의 누적 스케일(Zoom/DPI) 가져오기 // 0으로 나누는 것을 방지하기 위해 최소값을 보장합니다. const float Scale = FMath::Max(AllottedGeometry.GetAccumulatedLayoutTransform().GetScale(), 1.0f / 1024.0f); // 좌표 계산 (Layout Space -> Local Space 변환) // TextArgs의 좌표들은 이미 Scale이 곱해진 "Pixel" 단위입니다. // ToPaintGeometry는 "Local Unit" 단위를 받아 다시 Scale을 곱하므로, // 여기서 미리 Scale로 나누어 주어야 정확한 위치가 나옵니다. // 위치 계산: Block Offset / Scale FVector2D Location = (/*TextArgs.Line.Offset + */Block->GetLocationOffset()) / Scale; // 크기 계산: Block Size / Scale // 높이를 TextArgs.Line.Size.Y(줄 전체 높이)로 맞춤 FVector2D Size = FVector2D(Block->GetSize().X, TextArgs.Line.Size.Y) / Scale; // Y축 강제 정렬 (줄 높이 꽉 채우기용) // 위에서 계산한 Location.Y 대신 줄의 시작점(Line Offset)을 사용 Location.Y = TextArgs.Line.Offset.Y / Scale; // PaintGeometry 생성 // 이제 Location과 Size는 "Scale이 적용되지 않은 순수 로컬 좌표"입니다. // ToPaintGeometry가 이를 받아 현재 스케일(Zoom)을 적용해 화면에 그립니다. FPaintGeometry BoxGeometry = AllottedGeometry.ToPaintGeometry( Size, FSlateLayoutTransform(Location) ); FSlateDrawElement::MakeBox( OutDrawElements, LayerId + 1, BoxGeometry, FCoreStyle::Get().GetBrush("BlackBrush"), ESlateDrawEffect::None, BoxColor.GetColor(InWidgetStyle) ); return LayerId + 1; } };
RedactDecorator는 다음과 같아질 것입니다:
class FRedactTextRun : public FSlateTextRun { public: // 생성자: 부모 클래스(FSlateTextRun)의 생성자를 호출하여 텍스트 측정(Measure) 기능을 그대로 물려받습니다. static TSharedRef<FRedactTextRun> Create( const FRunInfo& InRunInfo, const TSharedRef<const FString>& InText, const FTextBlockStyle& Style, const FTextRange& InRange) { return MakeShareable(new FRedactTextRun(InRunInfo, InText, Style, InRange)); } protected: // private 생성자 FRedactTextRun( const FRunInfo& InRunInfo, const TSharedRef<const FString>& InText, const FTextBlockStyle& InStyle, const FTextRange& InRange) : FSlateTextRun(InRunInfo, InText, InStyle, InRange) { } public: virtual int32 OnPaint( const FPaintArgs& PaintArgs, const FTextArgs& TextArgs, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override { const TSharedRef<ILayoutBlock>& Block = TextArgs.Block; const FSlateColor BoxColor = FSlateColor(FLinearColor::Black); // 현재 위젯의 누적 스케일(Zoom/DPI) 가져오기 // 0으로 나누는 것을 방지하기 위해 최소값을 보장합니다. const float Scale = FMath::Max(AllottedGeometry.GetAccumulatedLayoutTransform().GetScale(), 1.0f / 1024.0f); // 좌표 계산 (Layout Space -> Local Space 변환) // TextArgs의 좌표들은 이미 Scale이 곱해진 "Pixel" 단위입니다. // ToPaintGeometry는 "Local Unit" 단위를 받아 다시 Scale을 곱하므로, // 여기서 미리 Scale로 나누어 주어야 정확한 위치가 나옵니다. // 위치 계산: Block Offset / Scale FVector2D Location = (/*TextArgs.Line.Offset + */Block->GetLocationOffset()) / Scale; // 크기 계산: Block Size / Scale // 높이를 TextArgs.Line.Size.Y(줄 전체 높이)로 맞춤 FVector2D Size = FVector2D(Block->GetSize().X, TextArgs.Line.Size.Y) / Scale; // Y축 강제 정렬 (줄 높이 꽉 채우기용) // 위에서 계산한 Location.Y 대신 줄의 시작점(Line Offset)을 사용 Location.Y = TextArgs.Line.Offset.Y / Scale; // PaintGeometry 생성 // 이제 Location과 Size는 "Scale이 적용되지 않은 순수 로컬 좌표"입니다. // ToPaintGeometry가 이를 받아 현재 스케일(Zoom)을 적용해 화면에 그립니다. FPaintGeometry BoxGeometry = AllottedGeometry.ToPaintGeometry( Size, FSlateLayoutTransform(Location) ); FSlateDrawElement::MakeBox( OutDrawElements, LayerId + 1, BoxGeometry, FCoreStyle::Get().GetBrush("BlackBrush"), ESlateDrawEffect::None, BoxColor.GetColor(InWidgetStyle) ); return LayerId + 1; } };
이런 식의 구현은 "<redact>아주 긴 문장으로 이루어진 텍스트</>" 따위처럼 구성된 상황에서도 이점이 있었습니다. 왜냐하면, SBox를 직접 구성하는 경우에는 어쨌거나 하나의 SlateWidgetRun으로 구성되기 때문에 여러 단어로 구성되어 있음에도 불구하고 줄바꿈이 이루어지지 않았었기 때문입니다. 하지만 FRedactTextRun은 FSlateTextRun을 상속받기 때문에, 내부적으로 단어 별 wrapping이 이루어지고 이에 대한 이점을 얻을 수 있었습니다. 다음처럼 자연스럽게 줄바꿈이 이루어지는 것을 확인하세요:
| 아주 긴 검열 상자의 경우 자연스럽게 줄바꿈이 이루어집니다. |
2) Fixed Size Thumb Scroll
Phil in the Mirror에서는 문서를 스크롤하면서 읽을 수 있어야 했는데요. 저는 스크롤의 위치를 표시하는 막대(thumb)가 아주 작고 고정된 크기였으면 했습니다. 그러나 언리얼 엔진에서 제공하는 스크롤 위젯에서는 막대의 크기를 조절할 수 있는 옵션이 없었습니다. 따라서 이러한 UI를 별도로 만들어줄 필요가 있었습니다.
| 우측 인디케이터에서 하얀색 상자같이 작은 스크롤 막대를 원했습니다. |
우선 커스텀 슬레이트 위젯을 사용하지 않고 고려할 수 있는 가장 간단한 방법은 다음과 같습니다: 1) 언리얼 엔진의 기본 스크롤 바를 숨긴 뒤 2) 제가 원하는 형태의 스크롤 바 형태의 위젯들을 잘 조합해서 배치하고 3) 스크롤의 변화를 받는 리스너를 만들어 해당 위치에 따라 막대 위치를 직접 조절해주는 것입니다. 물론 이렇게 해도 할 수 있지만, 스크롤 위젯과 인디케이터를 위젯 블루프린트를 이용해 허술하게 묶는 것이 약간은 걱정이 되었습니다. 저는 언리얼 엔진 경험도 부족했고, 차후 어떻게 변경될 지 몰랐기 때문입니다.
따라서 스크롤 위젯과 동일하지만 고정된 막대 길이를 갖는 커스텀 슬레이트 위젯을 만들어 해결하기로 하였습니다. 우선, FixedThumbScrollBox라는 위젯을 만들어야 했습니다. 이는 별다른 작업은 아니고, 그냥 위젯 블루프린트를 이용하던 것을 C++ 수준으로 옮겨놓은 것에 가깝습니다. UFixedThumbScrollBox는 단순히 안쪽에 SScrollBox를 만들고, 옆에 각종 버튼과 FixedThumbScrollBar가 배치되는 복합 위젯입니다.
| 대략적인 위젯의 구성. 최상위에는 HorizontalBox가, 그 하위에는 ScrollBox와 VerticalBox, VerticalBox 하위에는 UpButton과 FixedThumbScrollBar, DownButton이 존재합니다. |
코드는 다음과 같습니다(cpp):
TSharedRef<SWidget> UFixedThumbScrollBox::RebuildWidget()
{
// SScrollBox에 넘겨주기 위해, 우리가 만든 커스텀 스크롤바를 먼저 생성합니다.
TSharedRef<SScrollBar> FixedScrollBar = SNew(SFixedThumbScrollBar)
.Style(&GetWidgetBarStyle())
.Orientation(GetOrientation())
.AlwaysShowScrollbar(IsAlwaysShowScrollbar())
.AlwaysShowScrollbarTrack(IsAlwaysShowScrollbarTrack())
.FixedThumbSize(FixedThumbSize)
.Thickness(GetScrollbarThickness())
.Padding(GetScrollbarPadding());
MyScrollBox = SNew(SScrollBox)
.Style(&GetWidgetStyle())
.ScrollBarStyle(&GetWidgetBarStyle())
.ExternalScrollbar(FixedScrollBar) // SetExternalScrollbar() 대신 생성 시 인자로 전달
.Orientation(GetOrientation())
.ConsumeMouseWheel(GetConsumeMouseWheel())
.NavigationDestination(GetNavigationDestination())
.NavigationScrollPadding(GetNavigationScrollPadding())
.ScrollWhenFocusChanges(GetScrollWhenFocusChanges())
.BackPadScrolling(IsBackPadScrolling())
.FrontPadScrolling(IsFrontPadScrolling())
.AnimateWheelScrolling(IsAnimateWheelScrolling())
.ScrollAnimationInterpSpeed(GetScrollAnimationInterpolationSpeed())
.WheelScrollMultiplier(GetWheelScrollMultiplier())
.EnableTouchScrolling(GetIsTouchScrollingEnabled())
.OnUserScrolled(BIND_UOBJECT_DELEGATE(FOnUserScrolled, SlateHandleUserScrolled))
.OnScrollBarVisibilityChanged(BIND_UOBJECT_DELEGATE(FOnScrollBarVisibilityChanged, SlateHandleScrollBarVisibilityChanged));
// 이제 SScrollBox를 생성하면서, 위에서 만든 커스텀 스크롤바를 .ExternalScrollbar 인자로 전달합니다.
MyScrollBoxVerticalLayout =
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.f)
[
MyScrollBox.ToSharedRef()
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(BarPadding)
[
SNew(SVerticalBox)
// Up Button
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SButton)
.ButtonStyle(&UpButtonStyle)
.OnClicked(FOnClicked::CreateUObject(this, &UFixedThumbScrollBox::OnScrollUpClicked))
]
// ScrollBar (가운데)
+ SVerticalBox::Slot()
.FillHeight(1.f)
[
FixedScrollBar // 위에서 생성한 스크롤바 인스턴스를 여기에 배치합니다.
]
// Down Button
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SButton)
.ButtonStyle(&DownButtonStyle)
.OnClicked(FOnClicked::CreateUObject(this, &UFixedThumbScrollBox::OnScrollDownClicked))
]
];
for (UPanelSlot* PanelSlot : Slots)
{
if (UScrollBoxSlot* TypedSlot = Cast<UScrollBoxSlot>(PanelSlot))
{
TypedSlot->Parent = this;
TypedSlot->BuildSlot(MyScrollBox.ToSharedRef());
}
}
return MyScrollBoxVerticalLayout.ToSharedRef();
}
void UFixedThumbScrollBox::ReleaseSlateResources(bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
MyScrollBoxVerticalLayout.Reset();
}
FReply UFixedThumbScrollBox::OnScrollUpClicked()
{
if (MyScrollBox.IsValid())
{
const float CurrentOffset = MyScrollBox->GetScrollOffset();
const float NewOffset = FMath::Max(0.0f, CurrentOffset - ButtonScrollAmount);
MyScrollBox->SetScrollOffset(NewOffset);
}
return FReply::Handled();
}
FReply UFixedThumbScrollBox::OnScrollDownClicked()
{
if (MyScrollBox.IsValid())
{
const float CurrentOffset = MyScrollBox->GetScrollOffset();
MyScrollBox->SetScrollOffset(CurrentOffset + ButtonScrollAmount);
}
return FReply::Handled();
}
여기서 핵심은 SScrollBox를 배치한 뒤, SScrollBox의 변화를 추적하는 커스텀 ScrollBar를 구현해야 한다는 것입니다. 이는 SFixedThumbScrollBar로 구현되었으며, SScrollBar를 상속받습니다. 다행히, SScrollBox에는 ExternalScrollBar라는 인수가 있어 이를 이용하여 외장 스크롤 막대를 손쉽게 구현할 수 있었습니다.
SFixedThumbScrollBar에서는 큰 구현의 변화가 없었습니다. 단순히 그리는 방식만 변경하면 되었기 때문이죠. 따라서 1) FixedThumbSize를 제외한 모든 멤버들은 부모의 초기화를 따라가고, 2) OnPaint에서 MakeBox를 이용해 적절한 위치에 막대를 그려주기만 하면 되었습니다. 다음 코드를 참고하세요:
void SFixedThumbScrollBar::Construct(const FArguments& InArgs) { FixedThumbSize = InArgs._FixedThumbSize; SScrollBar::Construct( SScrollBar::FArguments() .Style(InArgs._Style) .OnUserScrolled(InArgs._OnUserScrolled) .AlwaysShowScrollbar(InArgs._AlwaysShowScrollbar) .AlwaysShowScrollbarTrack(InArgs._AlwaysShowScrollbarTrack) .Orientation(InArgs._Orientation) .Thickness(InArgs._Thickness) .Padding(InArgs._Padding) ); } int32 SFixedThumbScrollBar::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const { // 부모로부터 상속받은 개별 브러시 멤버 변수를 직접 사용합니다. const FSlateBrush* ThumbBrush = GetDragThumbImage(); // 호버/드래그 상태를 고려해주는 헬퍼 함수 사용 const FSlateBrush* TrackBrush = this->BackgroundBrush; // 트랙 브러시는 BackgroundBrush 입니다. const FLinearColor FinalColorAndOpacity(InWidgetStyle.GetColorAndOpacityTint() * ThumbBrush->GetTint(InWidgetStyle)); const ESlateDrawEffect DrawEffects = ShouldBeEnabled(bParentEnabled) ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect; if (bAlwaysShowScrollbarTrack || IsNeeded()) { FSlateDrawElement::MakeBox( OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(), TrackBrush, DrawEffects, TrackBrush->GetTint(InWidgetStyle) * InWidgetStyle.GetColorAndOpacityTint() ); } if (IsNeeded()) { // 현재 스크롤 위치를 가져옵니다. const float ThumbOffsetFraction = this->DistanceFromTop(); const float ThumbSize = this->ThumbSizeFraction(); // 썸 크기를 알아야 정확한 위치 계산이 가능합니다. // 전체 트랙에서 썸이 움직일 수 있는 실제 공간 크기 const float MovableTrackSizeRatio = 1.0f - ThumbSize; // 썸의 실제 위치 (MovableTrackSizeRatio가 0일 때의 0으로 나누기 방지) const float CorrectedThumbOffset = (MovableTrackSizeRatio > 0.f) ? ThumbOffsetFraction / MovableTrackSizeRatio : 0.f; if (Orientation == Orient_Vertical) { const float TrackSize = AllottedGeometry.GetLocalSize().Y; const float ClampedFixedThumbSize = FMath::Min(FixedThumbSize, TrackSize); const float ThumbTop = CorrectedThumbOffset * (TrackSize - ClampedFixedThumbSize); // 수정된 오프셋 사용 FSlateRect ThumbRect(FVector2D(0, ThumbTop), FVector2D(AllottedGeometry.GetLocalSize().X, ThumbTop + ClampedFixedThumbSize)); FSlateDrawElement::MakeBox( OutDrawElements, ++LayerId, AllottedGeometry.ToPaintGeometry(ThumbRect.GetSize(), FSlateLayoutTransform(ThumbRect.GetTopLeft())), ThumbBrush, DrawEffects, FinalColorAndOpacity ); } else { const float TrackSize = AllottedGeometry.GetLocalSize().X; const float ClampedFixedThumbSize = FMath::Min(FixedThumbSize, TrackSize); const float ThumbLeft = CorrectedThumbOffset * (TrackSize - ClampedFixedThumbSize); // 수정된 오프셋 사용 FSlateRect ThumbRect(FVector2D(ThumbLeft, 0), FVector2D(ThumbLeft + ClampedFixedThumbSize, AllottedGeometry.GetLocalSize().Y)); FSlateDrawElement::MakeBox( OutDrawElements, ++LayerId, AllottedGeometry.ToPaintGeometry(ThumbRect.GetSize(), FSlateLayoutTransform(ThumbRect.GetTopLeft())), ThumbBrush, DrawEffects, FinalColorAndOpacity ); } } return LayerId; }
저는 Vertical로만 제대로 동작하면 되었기 때문에, Horizontal 방향에 대해서 별도의 테스트를 거치지는 않았습니다. 하여튼, 막대의 위치는 스크롤의 상대적인 위치(0이면 맨 위, 1이면 맨 아래)를 기준으로 중앙점을 잡으면 될 것입니다. 코드에서는 ThumbTop을 계산해야 했으므로, 막대의 전체 길이에서 FixedThumbSize만큼을 뺀 뒤에 상대적인 위치를 적용하였음에 유의하세요.
결과는 다음과 같습니다. 고정된 길이의 작은 막대가 스크롤의 위치에 따라 적절히 변하는 것을 확인할 수 있습니다:
3) Grid Table Decorator
언리얼 엔진에서는 격자의 형태로 레이아웃을 구성할 수 있는 GridPanel을 제공하는데요. 저는 문서들을 연출해야 했기 때문에, 이를 이용해서 표 등을 쉽게 그려내고 싶었습니다. 표 레이아웃 디자인을 별도로 만든 뒤 이미지를 삽입하고, 각 위치에 TextBlock을 배치하는 것은 유연하지 않다고 생각했습니다. 예를 들어 한 행을 더 추가해야 한다고 할 때, 표 레이아웃 이미지를 다시 그려야 하기 때문이죠. 또한, 로컬라이제이션을 고려해 TextBlock의 사이즈에도 많은 신경을 써야했을 것입니다. 절차적인 디자인이 필요한 순간이었죠.
특히, GridPanel을 사용하면서도 몇몇가지 디자인 요구사항을 해결할 수 있어야 했습니다. 각 표 항목을 구분하는 경계선을 그릴 수 있어야 했고, 특정 행 다음에는 구분선을 그릴 수도 있어야 했습니다. 또한 외곽선은 그리지 않고 내부선만 그릴 수도 있어야 했습니다. 다음 그림을 참고하세요:
| 헤더 다음의 행은 굵은 선으로 그려져있고, 표의 외곽선은 그려져있지 않습니다. |
절차적인 디자인을 사용하지 않는 간단한 방법은, GridPanel 안에 다시 Border를 만들고 Border 안에 TextBlock을 삽입한 뒤 Border를 이용해서 각 선의 디자인을 해결하는 것입니다. 하지만 이는 표의 모든 칸에다가 수행해야 하는 작업들이고, 구분선을 그린다거나 외곽선을 그리지 않는 등의 디자인을 하기 위해서는 조금 더 복잡한 작업이 요구됩니다. 따라서 이런 방법 대신, GridTableDecorator라는 위젯을 만들어 요구사항에 맞게 표를 꾸며주도록 했습니다.
| GridTableDecorator 하위의 GridTable은 GridTableDecorator에 의해서 꾸며지게 됩니다. |
기본적인 전략은 다음과 같습니다: GridTableDecorator의 자식으로 GridTable을 놓으면, GridTableDecorator는 해당 GridTable을 자신의 속성에 따라 적절히 꾸밉니다. 이를 위해서는 GridPanel의 속성을 기반으로 경계선이 어디에 위치해야 하는지 계산할 수 있어야 합니다. 다만 저는 Span 관련 속성을 사용하지 않았으므로, 이와 관한 고려는 정확한 테스트를 거치지 않았습니다. 다음 코드를 참고하세요:
void SGridTableDecorator::ComputeRowColBounds(const UGridPanel* Grid, TArray<float>& OutColXs, TArray<float>& OutRowYs) const { OutColXs.Reset(); OutRowYs.Reset(); if (!Grid) { return; } // 1) 차수 파악 int32 NumCols = 0, NumRows = 0; const int32 ChildCount = Grid->GetChildrenCount(); for (int32 i = 0; i < ChildCount; ++i) { if (const UWidget* Child = Grid->GetChildAt(i)) { if (!Child->IsVisible()) { continue; } if (const UGridSlot* GS = Cast<UGridSlot>(Child->Slot)) { NumCols = FMath::Max(NumCols, GS->GetColumn() + FMath::Max(1, GS->GetColumnSpan())); NumRows = FMath::Max(NumRows, GS->GetRow() + FMath::Max(1, GS->GetRowSpan())); } } } if (NumCols <= 0 || NumRows <= 0) { return; } // 2) Min 크기(Span==1에서만) TArray<float> ColMin, RowMin; ColMin.Init(0.f, NumCols); RowMin.Init(0.f, NumRows); TArray<FVector2D> ChildNeed; ChildNeed.SetNum(ChildCount); for (int32 i = 0; i < ChildCount; ++i) { if (const UWidget* Child = Grid->GetChildAt(i)) { if (!Child->IsVisible()) { continue; } if (const UGridSlot* GS = Cast<UGridSlot>(Child->Slot)) { const FMargin Pad = GS->GetPadding(); const FVector2D DS = Child->GetDesiredSize(); const float NeedW = DS.X + (Pad.Left + Pad.Right); const float NeedH = DS.Y + (Pad.Top + Pad.Bottom); ChildNeed[i] = FVector2D(NeedW, NeedH); const int32 C = GS->GetColumn(); const int32 R = GS->GetRow(); const int32 CS = FMath::Max(1, GS->GetColumnSpan()); const int32 RS = FMath::Max(1, GS->GetRowSpan()); if (CS == 1) { ColMin[C] = FMath::Max(ColMin[C], NeedW); } if (RS == 1) { RowMin[R] = FMath::Max(RowMin[R], NeedH); } } } } // 3) Fill 읽기 TArray<float> ColFill, RowFill; ColFill.Init(0.f, NumCols); RowFill.Init(0.f, NumRows); // UGridPanelEx 등으로 Getter 제공 for (int32 c = 0; c < FMath::Min(ColFill.Num(), Grid->ColumnFill.Num()); ++c) { ColFill[c] = Grid->ColumnFill[c]; } for (int32 r = 0; r < FMath::Min(RowFill.Num(), Grid->RowFill.Num()); ++r) { RowFill[r] = Grid->RowFill[r]; } // 4) 초기 할당 TArray<float> ColW, RowH; ColW = ColMin; RowH = RowMin; const FVector2D GridSize = Grid->GetPaintSpaceGeometry().GetLocalSize(); // 가로 { float AutoW = 0.f, SumFill = 0.f; for (int32 c = 0; c < NumCols; ++c) { if (ColFill[c] <= 0.f) { AutoW += ColW[c]; } else { SumFill += ColFill[c]; } } float Remain = FMath::Max(0.f, GridSize.X - AutoW); if (SumFill > KINDA_SMALL_NUMBER) { for (int32 c = 0; c < NumCols; ++c) { if (ColFill[c] > 0.f) { ColW[c] = Remain * (ColFill[c] / SumFill); } } } // else: Fill 열이 없으면 Auto 합이 GridSize를 넘어가든 모자라든, 나중 Span 보정에서 처리 } // 세로 { float AutoH = 0.f, SumFill = 0.f; for (int32 r = 0; r < NumRows; ++r) { if (RowFill[r] <= 0.f) { AutoH += RowH[r]; } else { SumFill += RowFill[r]; } } float Remain = FMath::Max(0.f, GridSize.Y - AutoH); if (SumFill > KINDA_SMALL_NUMBER) { for (int32 r = 0; r < NumRows; ++r) { if (RowFill[r] > 0.f) { RowH[r] = Remain * (RowFill[r] / SumFill); } } } } // 5) 누적합 → 경계 OutColXs.Add(0.f); for (int32 c = 0; c < NumCols; ++c) { OutColXs.Add(OutColXs.Last() + ColW[c]); } OutRowYs.Add(0.f); for (int32 r = 0; r < NumRows; ++r) { OutRowYs.Add(OutRowYs.Last() + RowH[r]); } }
이렇게 찾아진 행과 열의 경계를 이용하여 선을 그려주기만 하면 됩니다. 다음 코드를 참고하세요:
void SGridTableDecorator::Construct(const FArguments& InArgs) { LineThickness = InArgs._LineThickness; LineColor = InArgs._LineColor; bDrawOuterBorder = InArgs._bDrawOuterBorder.Get(); bDrawInnerLines = InArgs._bDrawInnerLines.Get(); TargetGrid = InArgs._TargetGrid; SeparatorStyle = InArgs._SeparatorStyle; SeparatorRowIndex = InArgs._SeparatorRowIndex; SeparatorLineColor = InArgs._SeparatorLineColor; SeparatorLineThickness = InArgs._SeparatorLineThickness; SeparatorDoubleLineStyle = InArgs._SeparatorDoubleLineStyle; ChildSlot [ InArgs._Content.Widget // 감쌀 실제 자식 ]; } // Geo에 걸린 전체 스케일(레이아웃×렌더×앱 DPI)을 구해, 화면 픽셀 기준 두께로 보정 float ComputePixelPerfectThickness(const FGeometry& Geo, float DesiredPixelThickness) { return DesiredPixelThickness * Geo.GetAccumulatedLayoutTransform().GetScale(); } static float ToPixel(float v, const FGeometry& Geo) { return v * Geo.GetAccumulatedLayoutTransform().GetScale(); } // 얇은 라인의 선명도 개선: 홀수px 두께일 때 half-pixel, 짝수px는 정수 스냅 static float SnapForThickness(float center, float thicknessPx) { const bool bOdd = (FMath::RoundToInt(thicknessPx) % 2) != 0; return bOdd ? FMath::RoundToFloat(center) + 0.5f : FMath::RoundToFloat(center); } // [x0, x1] 구간에 수평 더블라인을 yCenter 기준으로 그림 static void DrawDoubleHLine(FSlateWindowElementList& Out, int32 Layer, const FGeometry& GridGeo, float x0, float x1, float yCenter, const FDoubleLineStyle& S) { const float tA = FMath::Max(0.f, ToPixel(S.ThicknessA, GridGeo)); const float tB = FMath::Max(0.f, ToPixel(S.ThicknessB, GridGeo)); const float gap = FMath::Max(0.f, ToPixel(S.Gap, GridGeo)); const float inset = FMath::Max(0.f, ToPixel(S.Inset, GridGeo)); x0 += inset; x1 -= inset; const float yA = SnapForThickness(yCenter - gap * 0.5f, tA); const float yB = SnapForThickness(yCenter + gap * 0.5f, tB); const FPaintGeometry PG = GridGeo.ToPaintGeometry(); auto HLine = [&](float y, float thickness, const FLinearColor& col) { TArray<FVector2D> P; P.Reserve(2); P.Add(FVector2D(x0, y)); P.Add(FVector2D(x1, y)); FSlateDrawElement::MakeLines( Out, Layer, PG, P, ESlateDrawEffect::None, col, /*bAntialias*/ true, thickness ); }; HLine(yA, tA, S.ColorA); HLine(yB, tB, S.ColorB); } int32 SGridTableDecorator::OnPaint(const FPaintArgs& Args, const FGeometry& Allotted, const FSlateRect& Culling, FSlateWindowElementList& Out, int32 LayerId, const FWidgetStyle& Style, bool bParentEnabled) const { // 먼저 자식 그리기 const int32 Base = SCompoundWidget::OnPaint(Args, Allotted, Culling, Out, LayerId, Style, bParentEnabled); UGridPanel* Grid = TargetGrid.Get(); if (!Grid) return Base; // Grid의 경계 계산 TArray<float> ColXs, RowYs; ComputeRowColBounds(Grid, ColXs, RowYs); if (ColXs.Num() < 2 || RowYs.Num() < 2) return Base; const FGeometry GridGeo = Grid->GetPaintSpaceGeometry(); // 일반선/구분선 스타일 준비 const FLinearColor RegularColor = LineColor.Get(); const float RegularThickness = ComputePixelPerfectThickness(GridGeo, LineThickness.Get()); const int32 SepRowIdx = SeparatorRowIndex.Get(); const FLinearColor SepColor = SeparatorLineColor.Get(); const float SepThickness = ComputePixelPerfectThickness(GridGeo, SeparatorLineThickness.Get()); // 픽셀 스냅(얇은 라인 품질 개선) — 선택사항 auto Snap = [](float v) { return FMath::RoundToFloat(v) + 0.5f; }; auto MakeV = [&](float x, float y) { return FVector2D(Snap(x), Snap(y)); }; // Grid 좌표계로 직접 그리기 (좌표=Grid 로컬, PaintGeometry=GridGeo) const FPaintGeometry PG = GridGeo.ToPaintGeometry(); // 내부 수직선 if (bDrawInnerLines) { for (int32 c = 1; c < ColXs.Num() - 1; ++c) { TArray<FVector2D> Pts; Pts.Add(MakeV(ColXs[c], RowYs[0])); Pts.Add(MakeV(ColXs[c], RowYs.Last())); FSlateDrawElement::MakeLines( Out, Base + 1, PG, Pts, ESlateDrawEffect::None, RegularColor, /*bAntialias*/ true, RegularThickness ); } // 내부 수평선 for (int32 r = 1; r < RowYs.Num() - 1; ++r) { const bool bIsSeparatorRow = (SepRowIdx >= 0 && r == (SepRowIdx + 1)); // 기존 인덱싱 규칙 유지 // 이 행이 구분선인지 확인 if (bIsSeparatorRow) { if (SeparatorStyle.Get() == ESeparatorStyle::Single) { TArray<FVector2D> Pts; Pts.Add(MakeV(ColXs[0], RowYs[r])); Pts.Add(MakeV(ColXs.Last(), RowYs[r])); FSlateDrawElement::MakeLines( Out, Base + 1, PG, Pts, ESlateDrawEffect::None, SepColor, true, SepThickness ); } else if (SeparatorStyle.Get() == ESeparatorStyle::Double) { DrawDoubleHLine( Out, Base + 1, /*GridGeo*/ GridGeo, /*x0*/ ColXs[0], /*x1*/ ColXs.Last(), /*yCenter*/ RowYs[r], /*Style*/ SeparatorDoubleLineStyle.Get() // FDoubleLineStyle ); } } else { TArray<FVector2D> Pts; Pts.Add(MakeV(ColXs[0], RowYs[r])); Pts.Add(MakeV(ColXs.Last(), RowYs[r])); FSlateDrawElement::MakeLines( Out, Base + 1, PG, Pts, ESlateDrawEffect::None, RegularColor, true, RegularThickness ); } } } // 외곽선 if (bDrawOuterBorder) { { TArray<FVector2D> P = { MakeV(ColXs[0], RowYs[0]), MakeV(ColXs.Last(), RowYs[0]) }; FSlateDrawElement::MakeLines(Out, Base + 1, PG, P, ESlateDrawEffect::None, RegularColor, true, RegularThickness); } { TArray<FVector2D> P = { MakeV(ColXs[0], RowYs.Last()), MakeV(ColXs.Last(), RowYs.Last()) }; FSlateDrawElement::MakeLines(Out, Base + 1, PG, P, ESlateDrawEffect::None, RegularColor, true, RegularThickness); } { TArray<FVector2D> P = { MakeV(ColXs[0], RowYs[0]), MakeV(ColXs[0], RowYs.Last()) }; FSlateDrawElement::MakeLines(Out, Base + 1, PG, P, ESlateDrawEffect::None, RegularColor, true, RegularThickness); } { TArray<FVector2D> P = { MakeV(ColXs.Last(), RowYs[0]), MakeV(ColXs.Last(), RowYs.Last()) }; FSlateDrawElement::MakeLines(Out, Base + 1, PG, P, ESlateDrawEffect::None, RegularColor, true, RegularThickness); } } return Base + 2; }
선을 그리는 것은 단순히 MakeLines를 이용해서 쉽게 해결할 수 있었고, 다만 선이 그려지는 위치를 계산하는 과정에서 언리얼 엔진이 UI 트랜스폼을 어떻게 다루는지에 대한 이해가 부족해 애를 먹었던 것 같습니다. 결론적으로는 GridTableDecorator를 이용해서 손쉽게 GridTable을 꾸미고 유연한 표 디자인을 만들 수 있게 되었습니다.
끝.
댓글
댓글 쓰기