UE4에서 제공하는 Tooltip 예시 이미지. 출처: Ryan Laley 유튜브

"언리얼에서 툴팁을 제공하는데 이걸 왜 따로 만들지?"라고 생각하실 수 있습니다.

언리얼 툴팁은 몇가지 특징이 있습니다.

 

1. 툴팁 UI가 적당히 잘 배치됩니다.

"적당히 잘"은 언뜻 들었을 때에는 썩 괜찮은 단어입니다.

하지만 시각적인 부분이 중요한 UI에서 "적당히 잘"은 최소한의 기능 제공만 의미하기도 합니다.

실제로 언리얼이 제공하는 툴팁은 보통 클릭한 커서를 중심으로 위젯이 호출됩니다.

이는 몇가지 누락된 기능이 있는데, 가장 아쉬운 점은 "화면 밖으로 나가는 것을 막지 못한다"는 점입니다.

800*600 화면에서 700위치에 150*50 크기의 툴팁을 생성하면 1/3은 화면 밖에 나가게 되는 것이죠.

 

2. 원하는 위치에 배치할 수 없다.

예를 들어 인벤토리에서 아이템 융합 UI를 생성한다고 합시다.

이 UI는 현재 융합 대기중인 아이템 뿐만 아니라 남은 아이템 목록도 한 눈에 들어와야 하기 때문에 인벤토리와 겹치면 안됩니다.

이럴 때 이 UI를 툴팁으로 만들어 버린다면 원하는 스펙을 맞출 수 없겠죠.

 

위에서 얘기한 것을 바탕으로, 새로 만들 툴팁은 최소 2가지 기능을 갖추어야 합니다.

1) 내가 원하는 위치에 정확히 배치를 해야 한다.

2) 화면 밖으로 나가지 않아야 한다.

 

1. UI의 위치 적용

1. 툴팁 위젯을 대상 위젯과 중심을 일치시키는 작업

언리얼상에서 디스플레이의 원점은 좌상단입니다.(사실 보통 그래픽스는 대체로 그렇습니다.)

그리고 UI의 기본 중심 또한 좌상단으로 잡혀 있죠.

하지만 툴팁은 기준 UI의 무게중심(개념상 일치하니 이렇게 지칭하겠습니다.)을 기준으로 그 외각에 붙어 있어야 하죠.

그렇기 때문에 1차적으로 각 UI의 중심지를 좌상단에서 무게중심으로 이동시키고,

툴팁 UI의 중심과 기준 UI의 중심을 좌표상 같은 곳에 일치하도록 하는 작업을 선행해야 합니다.

 

이는 여러분들이 고등학교(근래는 대학교)에서 배운 기하와 벡터만으로 충분합니다.

기준 UI(이하 A)의 좌상단 좌표가 (a1, a2)이고, 툴팁 UI(이하 B)의 좌상단이 (b1, b2)입니다.

여기서 A의 중심 좌표가 A`(a`1, a`2)이고, B의 것은 B`(b`1, b`2)입니다.

이 때 B와 A의 중심 좌표를 일치시키기 위해서는 디스플레이서부터 A`까지의 벡터와 디스플레이서부터 B`까지의 벡터를 감산 해야 합니다.

그렇게 되면 AB(a`1 - b`1, a`2 - b`2)라는 벡터가 나오게 됩니다.

2. 툴팁을 중심 UI와 겹치지 않게 하는 작업

A와 B가 겹쳐지면 이제 B를 A의 테두리 바깥에 붙도록 위치를 옮겨야 합니다.

이는 1보다는 훨씬 간단한 작업입니다.

B를 옮길 방향에 맞게 A와 B의 해당 크기 값을 연산해주면 됩니다.

여기서는 B를 A의 상단에 붙일겁니다.

이 때 A의 크기는 Ax * Ay이고, B의 크기는 Bx * By입니다.

그럼 B의 좌표(A`1, A`2)에 A와 B의 y크기의 절반만큼을 감산을 합니다.

감산을 하는 이유는 원점이 좌상단이기 때문에 윗 방향은 -Y축이기 때문이구요.

그럼 B의 중심 좌표가 이제 (A`1, A`2 - (Ay + By) / 2)가 됩니다.

 

3. 화면 밖으로 나간 툴팁을 다시 안으로 밀어줘야 한다.

일반적으로는 2번 과정까지만 하면 큰 문제가 없습니다.

하지만 우리는 한가지 더 고려를 해줘야 합니다.

B가 화면 밖으로 삐져나가는 경우에 다시 안쪽으로 밀어줘야 합니다.

 

이 또한 간단한 산수작업입니다.

B의 상단서부터 A의 중심까지의 Y값은 By + Ay / 2입니다.

그리고 실제 A의 중심의 Y좌표값은 A'y입니다.

이들의 차인 By + Ay / 2 - A`y 는 B가 화면 밖으로 나간만큼의 Y 수치가 되는 것이죠.

이만큼 Y좌표 값에 가산을 해주면 B가 화면에 맞게 조절이 됩니다.

 

4. 툴팁 완성

 

자세히 보면 B가 A의 위에 살짝 걸쳐진 것을 볼 수 있습니다.

하지만 어쩔 수 없습니다. 여기서는 B의 크기를 고정해놓기 때문에 A와의 겹침보다 화면 안에 들어가는게 더 우선순위가 높거든요.

물론 UI를 줄일 수 있으나 이런 UI들은 특성상 상당히 많은 정보를 제공하기 때문에 쉬이 크기 조절을 할 수 없을겁니다.

장담컨에 디 방식이 보편적인 타협책이라고 봅니다.

 

이런 과정을 거치기 위해서는 우리는 5개의 값이 필요합니다.

1) 화면 전체 크기

2) 기준 UI의 중심 좌표

3) 기준 UI의 크기

4) 툴팁 UI의 중심 좌표

5) 툴팁 UI의 크기

 

이를 3가지로 줄일 수 있는 것이 있는데, 바로 FGeometry입니다.

https://docs.unrealengine.com/4.26/en-US/API/Runtime/SlateCore/Layout/FGeometry/

 

FGeometry

Represents the position, size, and absolute position of a Widget in Slate.

docs.unrealengine.com

 

FGeomtery는 의미 그대로 Geometry. 크기와 좌표 정보를 가지고 있습니다.

이걸 이용하면 위 5가지 정보를 아래 3가지로 압축할 수 있습니다.

1) 화면 전체 크기

2) 기준 UI의 Geometry

3) 툴팁 UI의 Geometry

 

여기서 한가지 편의사항을 알려드리자면, 게임 개발 하면서 화면 전체를 덮는 UI가 적어도 1개는 발생을 하게 됩니다.

그 UI의 Geometry를 구한다면, 화면 전체의 크기를 구할 수 있게 되죠.

그럼 최종적으로 이렇게 됩니다.

1) 화면 전체를 덮는 UI의 Geometry

2) 기준 UI의 Geometry

3) 툴팁 UI의 Geometry

 

Geometry를 이용한 위젯의 위치 변경은 대략 이렇게 표현이 가능할 것 같습니다.

const FVector2D GetMovementVector(const FGeometry& DisplayGeometry, const FGeometry& IconGeometry, const FGeometry& TooltipGeometry, const EAlignVertical:::Type& VertAlign, cont EAlignHorizental::Type& HorAlign)
{
	const FVector2D& DisplaySize = DisplayGeometry.GetAbsoluteSize();

	const FVector2D& IconCenterPosition = IconGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
    const FVeoctor2D& IconCenterVector = DisplayGeometry.AbsoluteToLocal(IconCenterPosition);
    const FVector2D& IconSize = IconGeometry.GetAbsoluteSize();
    
	const FVector2D& TooltipCenterPosition = TooltipGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
    const FVeoctor2D& TooltipCenterVector = DisplayGeometry.AbsoluteToLocal(TooltipCenterPosition);
    const FVector2D& TooltipSize = IconGeometry.GetAbsoluteSize();
    
    FVector2D MoveVector = IconCenterVector = TooltipCenterVector;
    const FVector2D& TotalOutlineVector = IconSize + TooltipSize;
    Switch(VertAlign)
    {
    	case EAlignVertical::Type::Left
        	MoveVector.x -= TotalOutlineVector.x;
        	break;
        case EAlignVertical::Type::Right
        	MoveVector.x += TotalOutlineVector.x;
            break;
    }
    MoveVector.x = FMath::Clamp(MoveVector.x, 0, DisplaySize.x);
    
    swietch(HorAlign)
    {
    	case EAlignHorizental::Type::Up
        	MoveVector.y -= TotalOutlineVector.y;
            break;
        case EAlignHorizental::Type::Down
        	MoveVector.y += TotalOutlineVector.y;
            break;            
    }
    MoveVector.y = FMath::Clamp(MoveVector.y, 0, DisplaySize.x);
    
    return MoveVector;
}

 

수식은 위에서 다 설명 하였고, 몇가지 함수 설명을 첨하면 될 것 같습니다.

 

GetAbsolutePositionAtCoordinates는 NormalizedVector를 통해 중심값에서부터 절대 좌표값을 구하는 수식입니다.

즉, UI의 좌상단을 기준으로 무게중심의 절대 좌표를 구하는 함수입니다.

AbsoluteToLocal은 절대 좌표를 해당 Geometry의 Position을 기준으로 지역 좌표로 변환하는 함수입니다.

위 코드에서는 DisplayGeometry의 Position, 즉 화면의 좌상단을 기준으로 하는 상대 좌표로 변환을 하게 됩니다.

이를 통해 TooltipCenterVector와 IconCenterVector는 Display상에서의 값들로 변환이 되게 됩니다.

 

화면 밖으로 나가는 것은 DisplaySize를 구해놓고 Clamp로 변환을 해주었습니다.

Vector2D Clamp가 따로 있었다면 좋았겠지만 아쉽게도 없더군요.

 

자 이렇게 해서 간단한 수식을 작업해 보았습니다.

이걸로 마무리...를 하면 여러분들은 제대로 된 결과를 보지 못하게 될겁니다.

그리고 절 욕하겠죠.

위 코드는 한가지 맹점이 있습니다. 무엇일까요?

 

바로 Geometry 부분입니다.

정확히는 저 Geometry가 런타임 상에서 Invalid한 값이 들어올 수 있다는 점입니다.

보통 Geometry 값을 받아올 때에는 GetCachedGeometry라는 함수를 사용합니다.

https://docs.unrealengine.com/4.27/en-US/BlueprintAPI/Widget/GetCachedGeometry/

 

Get Cached Geometry

Get Cached Geometry

docs.unrealengine.com

그런데 이 GetCachedGeometry는 구조 상 몇가지 제약이 있습니다.

1) 화면상에 노출되어야 한다.

2) 최상위 레이어에 위치해야 한다.

 

이는 GetCachedGeometry가 호출 될 시 그 Tick에서의 Geometry 정보를 담아와서 반환하기 때문입니다.

사실 UI의 크기가 고정적이라면 아무런 문제가 되지 않습니다.

UI 내부에 USizeBox를 두고 모든 항목을 그 아래로 내린 뒤, OverrideSize를 지정해서 사이즈를 고정해버리면 해당 SizeBox의 OverrideSize로 값을 연산할 수 있을테니까요.

하지만 UI가 내부 데이터에 따라 크기가 변동이 된다면 엄청난 오차가 발생을 하게 될겁니다.

그리고 이런 눈꼴시러운 오차를 없애기 위해 노력을 하다 보면 저 GetCachedGeometry에서 막히게 될겁니다.

 

저 제약이 썩 거지같은 점은 "무조건 한번은 화면 레이어에 올라가야 한다"는 점입니다.

이는 디스플레이에 한번은 찍혀야 하는 의미이고, 이는 곧 잔상을 의미합니다.

이제 우리는 3번째 기능을 고민해야 합니다.

3) 화면상에 잔상이 남지 않고 어느 사이즈더라도 동작하는 Geometry 값을 구한다.

 

문제를 해결하기 위해서는 제약을 잘 살펴봐야 합니다.

1)을 다시 볼까요? 

"노출 되어야 한다"라고 되어 있지 "표시되어야 한다"라고 되어 있지 않습니다.

이것이 의미하는 바는, 어떤 형태로든 화면상에 나오기만 하면 Valid한 Geometry를 구할 수 있다는 점입니다.

그것이 "투명"하더라도 말이죠.

 

네 3번째 기능에 대한 해답은 바로 Opacity에서 alpha값을 0으로 내려버리는 것이었습니다.

이렇게 하면 레이어 상으로는 최상위에 툴팁 UI가 올라가 있지만, 투명하기 때문에 유저들이 육안으로 볼 수는 없죠.

이 때 Geometry를 구해서 위치를 옮겨주고, alpha값을 복구하면 잔상 없는 커스텀 툹팁 기능이 완성이 됩니다.

 

UTooltipWidger : public UWidget
{
	virtual void NativeConstruct() override
    {
    	SetOpacity(0.f, 0.f, 0.f, 0.f);
    }

	virtual void Open(bool bCancelAnim) override
    {
    	Super::Open(bCancelAnim);
    	auto World = GetWorld();
        if(IsValid(World) == false)
        {
        	return;
        }
        
        World->GetTimerManager().AddTimer(PositionHandle, this, &ThisClass::UpdatePosition, World->GetTickDeltaTime());
    }
    
    void UpdatePosition()
    {
    	const FGeometry& DisplayGeometry = GetDisplayGeometry();	// 어떻게 구해 왔다고 가정
        const FGeometry& IconGeometry = GetIconGeometry();			// 어떻게 구해 왔다고 가정
        const FGeometry& TooltipGeometry = GetCachedGeoemtry();
        
        SetPosition(GetMovementVector(DisplayGeometry, IconGeometry, TooltipGeometry, EVerticalAlign::Center, EHorizentalAlign::Up));
    	SetOpacity(0.f, 0.f, 0.f, 1.f);
	}
}

 

물론 이 방식은 약간의 딜레이를 요구합니다.

하지만 그 수치가 Tick 함수가 한번 호출 될 정도의 간극이라는 점을 감안하면 손해라고는 있을 수 없는 로직이라 볼 수도 있습니다.

 

저처럼 개발자(혹은 타 업무자)가 좀 더 디테일하게 커스텀이 가능한 툴팁을 필요로 하는 경우가 얼마나 있을지 잘 모르겠습니다.

특히 이런 블로그 글을 보고 들어온건 개인개발이라는 건데 개인 개발에서 이런 정도의 스펙은 잘 구현 안하잖아요?

하지만 어떻게 해야 할지 조금 막막한 기능이었으나 의외로 쉬운 로직이라는 점을 명시하면서.

이 글이 필요로 하시는 분들에게 도움이 되었으면 좋겠습니다.

+ Recent posts