내일배움캠프 Unity 31일차 TIL - UI Binding

Wooooo·2023년 12월 11일
0

내일배움캠프Unity

목록 보기
33/94

오늘의 키워드

UI 작업 시, 인스펙터 창에서의 작업을 최소로 줄이고 C# Script 단에서 UI에 필요한 요소들을 자동으로 바인딩 해주는 UI Binding 시스템을 공부하고, 개인 과제에 적용해봤다.


UI Binding

최근 포스트에서 계속 언급했지만, 기존에 대다수가 UI를 작업할 때, 새로운 UI를 만들면 그 UI를 관리하는 스크립트의 인스펙터 창에 Prefab이나 Hierarchy 창의 인스턴스를 집어넣는 방법으로 작업을 하곤 한다.
하지만 이 방법은 혼자서 개발할 때는 꽤나 간편하지만 협업에서는 불편함이 있다고 한다.
UI를 따로 작업하시는 분이 있다고 치면, 새로운 UI가 생길 때마다 프로그래머도 스크립트를 수정해야한다.

하지만 UI 바인딩을 이용하면, 새로운 UI를 만들거나 기존의 UI를 삭제해도 스크립트에서의 변경 사항이 없거나 최소로 줄어든다.

동작 방식

간단하게 말하면, UI 내부의 요소들과 Event들을 초기화 단계에서 자동으로 Dictionary에 카테고리별로 바인딩하는 것이다.

C#은 열거형의 원소들의 이름을 Enum.GetNames()로 받아올 수 있다.
이것을 이용해서 UI의 내부 요소들을 열거형으로 선언하고, 초기화 시점에서 자동으로 바인딩한다.


UI_Base Class

UI의 가장 기본이 될 클래스다.
모든 UI는 이 클래스를 상속받는 UI_Scene 클래스와 UI_PopUp 클래스를 상속받아서 구현한다.

UI_Scene 클래스는 씬에서 계속 보여지는 UI이다.
UI_PopUp 클래스는 특정 상황에서 팝업되는 UI이다.

Code

public class UI_Base : MonoBehaviour
{
    private Dictionary<Type, UnityEngine.Object[]> objects = new();
    private bool isInitialized = false;

    private void Start()
    {
        Initialize();
    }

    public virtual bool Initialize()
    {
        if (isInitialized) return false;
        isInitialized = true;
        return true;
    }

    void Bind<T>(Type type) where T : UnityEngine.Object
    {
        string[] names = Enum.GetNames(type);
        var objs = new UnityEngine.Object[names.Length];
        objects.Add(typeof(T), objs);

        for (int i = 0; i < names.Length; i++)
        {
            if (typeof(T) == typeof(GameObject))
                objs[i] = Utilities.FindChild(gameObject, names[i], true);
            else
                objs[i] = Utilities.FindChild<T>(gameObject, names[i], true);
            if (objs[i] == null)
                Debug.LogError($"[UI Base: {name}] Failed to bind: {names[i]}");

        }
    }

    protected void BindObject(Type type) => Bind<GameObject>(type);
    protected void BindImage(Type type) => Bind<Image>(type);
    protected void BindText(Type type) => Bind<TextMeshProUGUI>(type);
    protected void BindButton(Type type) => Bind<Button>(type);

    private T Get<T>(int idx) where T : UnityEngine.Object
    {
        if (!objects.TryGetValue(typeof(T), out var objs))
            return null;
        return objs[idx] as T;
    }

    protected GameObject GetObject(int idx) => Get<GameObject>(idx);
    protected Image GetImage(int idx) => Get<Image>(idx);
    protected TextMeshProUGUI GetText(int idx) => Get<TextMeshProUGUI>(idx);
    protected Button GetButton(int idx) => Get<Button>(idx);

    public static void BindEvent(GameObject obj, Action action = null, Action<BaseEventData> dragAction = null, UIEvent type = UIEvent.Click)
    {
        UIEventHandler uIEventHandler = Utilities.GetOrAddComponent<UIEventHandler>(obj);

        switch (type)
        {
            case UIEvent.Click:
                uIEventHandler.onClickHandler -= action;
                uIEventHandler.onClickHandler += action;
                break;
            case UIEvent.Pressed:
                uIEventHandler.onPointerPressedHandler -= action;
                uIEventHandler.onPointerPressedHandler += action;
                break;
            case UIEvent.PounterUp:
                uIEventHandler.onClickHandler -= action;
                uIEventHandler.onClickHandler += action;
                break;
            case UIEvent.PointerDown:
                uIEventHandler.onClickHandler -= action;
                uIEventHandler.onClickHandler += action;
                break;
            case UIEvent.Drag:
                uIEventHandler.onDragHandler -= dragAction;
                uIEventHandler.onDragHandler += dragAction;
                break;
            case UIEvent.BeginDrag:
                uIEventHandler.onBeginDragHandler -= dragAction;
                uIEventHandler.onBeginDragHandler += dragAction;
                break;
            case UIEvent.EndDrag:
                uIEventHandler.onEndDragHandler -= dragAction;
                uIEventHandler.onEndDragHandler += dragAction;
                break;
        }
    }
}

Dictionary<Type, Object[]>

UI에는 다양한 내부 요소들이 있을 수 있다. 그 중에 카테고리를 크게 4가지로 묶으면 다음과 같다.

  • GameObject
    • UI 내부에서 동작하는 또 다른 GameObject (ex. UI 안의 UI 등등..)
  • Text
    • Title text, descript text 등등..
  • Image
    • Icon 등등..
  • Button
    • OK 버튼, Cancel 버튼, Open 버튼 등등..

이 카테고리들을 Key로, 카테고리 별 오브젝트의 배열을 Value로 갖는 Dictionary를 이용하여 하위 contents를 관리한다.

Bind<T>(Type)

자식 오브젝트 중에서 TypeT인 오브젝트를 찾아 Dictionary에 추가한다.

Get<T>(int)

Dictionary에서 T를 key로 갖는 오브젝트의 배열의 int번째 인덱스에 있는 오브젝트를 반환한다.

BindEvent()

매개변수로 받아온 오브젝트에 UIEventHandler 컴포넌트를 추가한다.
EventSystem에서 호출되는 각종 이벤트를 바인딩할 수 있게 해준다.

UIEventHandler 클래스는 EventSystem에서 호출되는 인터페이스를 구현한다.

public class UIEventHandler : MonoBehaviour, IPointerClickHandler, IDragHandler, IPointerDownHandler, IPointerUpHandler, IBeginDragHandler, IEndDragHandler
{
    public Action onClickHandler = null;
    public Action onPointerPressedHandler = null;
    public Action onPointerDownHandler = null;
    public Action onPointerUpHandler = null;
    public Action<BaseEventData> onDragHandler = null;
    public Action<BaseEventData> onBeginDragHandler = null;
    public Action<BaseEventData> onEndDragHandler = null;

    private bool pressed = false;

    private void Update()
    {
        if (pressed)
            onPointerPressedHandler?.Invoke();
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        onClickHandler?.Invoke();
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        pressed = true;
        onPointerDownHandler?.Invoke();
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        pressed = false;
        onPointerUpHandler?.Invoke();
    }

    public void OnDrag(PointerEventData eventData)
    {
        onDragHandler?.Invoke(eventData);
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        onBeginDragHandler?.Invoke(eventData);
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        onEndDragHandler?.Invoke(eventData);
    }
}

UI_Scene Class

UI_Base를 상속받는다.
씬에서 계속해서 보여질 UI이므로 초기화 시, UIManager에게 sortOrder를 0으로 설정하는 SetCanvas()를 호출한다.

Code

public class UI_Scene : UI_Base
{
    public override bool Initialize()
    {
        if (!base.Initialize()) return false;

        UIManager.Instance.SetCanvas(gameObject);

        return true;
    }
}

UI_PopUp Class

UI_Base를 상속받는다.
특정 상황에서 Pop Up되는 UI이므로 초기화 시, UIManager에게 sortOrder를 PopUp Stack에 따라 나중에 열린 PopUp일 수록 위에 그려지게 하는 SetCanvas()를 호출한다.

UIManager에게 현재 PopUp을 Close 요청하는 메서드도 작성한다.

Code

public class UI_PopUp : UI_Base
{
    public override bool Initialize()
    {
        if (!base.Initialize())
            return false;

        UIManager.Instance.SetCanvas(gameObject, null);
        return true;
    }

    public virtual void ClosePopUpUI()
    {
        UIManager.Instance.ClosePopUp(this);
    }
}

UIManager

제네릭 Singleton 클래스를 작성하고 상속받아서, 싱글톤으로 구현했다.

Hierarchy 상에서 관리하기 쉽도록 UI Object를 한 개의 Object 밑에 주렁주렁 달기 위한 Root 객체를 참조할 수 있다.

현재 씬의 UI_Scene을 참조할 수 있도록 SceneUI 프로퍼티를 제공한다.

현재 Pop Up된 UI들을 관리하기 위해 Stack을 사용한다.

Code

public class UIManager : Singleton<UIManager>
{
    private GameObject root;
    public GameObject Root
    {
        get
        {
            root = GameObject.Find("@UI_Root");
            if (root == null)
                root = new("@UI_Root");
            return root;
        }
    }

    private UI_Scene sceneUI;
    public UI_Scene SceneUI => sceneUI;

    private int popUpOrder = 10;
    private Stack<UI_PopUp> popUpStack = new();
    public int PopUpCount => popUpStack.Count;

    public void SetCanvas(GameObject obj, int? sortOrder = 0)
    {
        Canvas canvas = obj.GetOrAddComponent<Canvas>();
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvas.overrideSorting = true;

        CanvasScaler scaler = obj.GetOrAddComponent<CanvasScaler>();
        scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        scaler.referenceResolution = new(1600, 900);

        obj.GetOrAddComponent<GraphicRaycaster>();

        if (!sortOrder.HasValue)
        {
            canvas.sortingOrder = popUpOrder;
            popUpOrder++;
        }
        else
            canvas.sortingOrder = sortOrder.Value;
    }

    public void SetEventSystem(GameObject obj)
    {
        obj.GetOrAddComponent<EventSystem>();
        obj.GetOrAddComponent<InputSystemUIInputModule>();
    }

    protected override void Initialize()
    {
        SetCanvas(Root);
        SetEventSystem(gameObject);

        //Test Code
        ShowSceneUI<UI_Scene_Test>();
    }

    public T ShowSceneUI<T>(string name = null) where T : UI_Scene
    {
        if (string.IsNullOrEmpty(name))
            name = typeof(T).Name;

        // TODO: 나중에 Addressable에서 Load한 프리팹으로 바꿔야함.
        GameObject obj = Resources.Load<GameObject>($"UI/{name}");
        obj = Instantiate(obj, Root.transform);

        //GameObject obj = ResourceManager.Instantiate($"{name}.prefab");
        //obj.transform.SetParent(Root.transform);

        sceneUI = obj.GetOrAddComponent<T>();
        return sceneUI as T;
    }

    public T ShowPopUpUI<T>(string name = null) where T : UI_PopUp
    {
        if (string.IsNullOrEmpty(name))
            name = typeof(T).Name;

        // TODO: 나중에 Addressable에서 Load한 프리팹으로 바꿔야함.
        GameObject obj = Resources.Load<GameObject>($"UI/{name}");
        obj = Instantiate(obj, Root.transform);

        //GameObject obj = ResourceManager.Instantiate($"{name}.prefab");
        //obj.transform.SetParent(Root.transform);

        T popUp = obj.GetOrAddComponent<T>();
        popUpStack.Push(popUp);

        //RefreshTimeScale();

        return popUp;
    }

    public void ClosePopUp(UI_PopUp popUp)
    {
        if (popUpStack.Count == 0) return;
        if (popUpStack.Peek() != popUp)
        {
            Debug.LogError($"[UIManager] ClosePopUp({popUp.name}): Close pop up failed");
            return;
        }
        ClosePopUp();
    }

    public void ClosePopUp()
    {
        if (popUpStack.Count == 0) return;

        UI_PopUp popUp = popUpStack.Pop();
        // TODO: 나중에 ResourceManager에서 Destroy 시켜줘야함
        Destroy(popUp.gameObject);
        //ResourceManager.Destroy(popUp);
        popUpOrder--;
        //RefreshTimeScale();
    }

    public void CloseAllPopUp()
    {
        while (popUpStack.Count > 0)
            ClosePopUp();
    }
}

SetCanvas()

매개변수로 받은 GameObjectCanvas를 추가하고 sortOrder를 설정한다.
sortOrder는 int?형으로, null이라면 PopUp UI로 간주하고 popUpOrder를 설정한다.

SetEventSystem()

매개변수로 받은 GameObjectEventSystem을 추가한다.

ShowSceneUI<T>()

UI_Scene을 상속받는 T UI를 로드하고 Instantiate한다.

ShowPopUpUI<T>()

UI_PopUp을 상속받는 T UI를 로드하고 Instantiate한다.

ClosePopUp()

PopUp UI를 Stack에서 제거한 후 Destroy한다.
현재 PopUp이 가장 위의 PopUp UI인지 검사할 수도 있다. (오버로딩)

CloseAllPopUp()

열린 모든 PopUp UI를 닫는다.


기타 Class


Utilities Class

자식 오브젝트에서 특정 Component를 찾거나, 어떤 Object에 원하는 Component가 있는지 없는지 검사 후 없다면 추가하여 반환해주는 등 유틸리티 적인 기능들을 구현하는 클래스이다.

Code

public class Utilities
{
    public static GameObject FindChild(GameObject obj, string name, bool recursive)
    {
        Transform transform = FindChild<Transform>(obj, name, recursive);
        if (transform != null) return transform.gameObject;
        else return null;
    }

    public static T FindChild<T>(GameObject obj, string name, bool recursive) where T : UnityEngine.Object
    {
        if (obj == null) return null;

        if (!recursive)
        {
            Transform transform = obj.transform.Find(name);
            if (transform != null) return transform.GetComponent<T>();
        }
        else
        {
            foreach (T component in obj.GetComponentsInChildren<T>())
                if (string.IsNullOrEmpty(name) || component.name == name)
                    return component;
        }
        return null;
    }

    public static T GetOrAddComponent<T>(GameObject obj) where T : Component
    {
        T res = obj.GetComponent<T>();
        return res != null ? res : obj.AddComponent<T>();
    }
}

FindChild<T>()

매개변수로 받은 GameObject의 자식 Object 중에 name을 이름으로 가지는 Object의 T 컴포넌트를 찾아서 반환한다.
recursive 매개변수로 재귀적으로 자식을 탐색할 것인지 여부도 설정할 수 있다.

GetOrAddComponent<T>()

매개변수로 받은 GameObject에 T 컴포넌트가 있는지 검사한 후, 없다면 추가하여 반환한다.


Singleton<T>

이 클래스를 상속받는 T 클래스를 싱글톤으로 만들어주는 클래스를 작성해봤다.

Code

public class Singleton<T> : MonoBehaviour where T : Singleton<T>
{
    protected static T instance;
    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new GameObject(nameof(T)).AddComponent<T>();
                instance.Initialize();
            }
            return instance;
        }
    }

    protected virtual void Awake()
    {
        if (instance == null)
        {
            instance = this as T;
            instance.Initialize();
        }
    }

    protected virtual void Start()
    {
        if (instance != this)
            Destroy(gameObject);
    }

    protected virtual void Initialize()
    {

    }
}

static Extension Class

C#의 문법 기능인 확장 메서드의 구현을 모아놓는 클래스.

Code

public static class Extension
{
    public static T GetOrAddComponent<T>(this GameObject obj) where T : Component
    {
        return Utilities.GetOrAddComponent<T>(obj);
    }

    public static void BindEvent(this GameObject obj, Action action = null, Action<BaseEventData> dragAction = null, UIEvent type = UIEvent.Click)
    {
        UI_Base.BindEvent(obj, action, dragAction, type);
    }

    public static bool IsValid(this GameObject obj)
    {
        return obj != null && obj.activeSelf;
    }
}

Define Class

열거형, 구조체 등의 선언을 모아놓은 클래스.

Code

public class Define
{
    public enum UIEvent
    { 
        Click,
        Pressed,
        PointerDown,
        PounterUp,
        Drag,
        BeginDrag,
        EndDrag,
    }
}

Test UI 만들어보기

바인딩이 잘 동작하는지 테스트해보기 위해서, UI_Scene을 상속받는 Test UI와 UI_PopUP을 상속받는 Test UI를 하나씩 만들어봤다.


UI_Scene_Test Class

현재 씬의 진행 시간을 보여주고, 버튼 클릭 시 UI_PopUp_Test UI를 PopUp하도록 해봤다.

Prefab

Code

public class UI_Scene_Test : UI_Scene
{
    enum Objects
    {

    }

    enum Texts
    {
        txtLapseTime,
    }

    enum Images
    {   

    }

    enum Buttons
    {
        btnPopUp,
    }

    private float lapseTime = 0f;
    private TextMeshProUGUI txtLapseTime;

    public override bool Initialize()
    {
        if (!base.Initialize()) return false;

        BindObject(typeof(Objects));
        BindText(typeof(Texts));
        BindImage(typeof(Images));
        BindButton(typeof(Buttons));

        GetButton((int)Buttons.btnPopUp).gameObject.BindEvent(OnPopUpButton);
        txtLapseTime = GetText((int)Texts.txtLapseTime);
        return true;
    }

    private void OnPopUpButton()
    {
        UIManager.Instance.ShowPopUpUI<UI_PopUp_Test>();
    }

    private void Update()
    {
        lapseTime += Time.deltaTime;
        txtLapseTime.text = $"{lapseTime:N2}";
    }
}

UI_PopUp_Test

현재 열린 팝업이 UIManager의 Stack의 몇 번째 PopUp UI인지 보여주게 해봤다.
Button을 누르면 창이 닫히도록 해봤다.

Prefab

Code

public class UI_PopUp_Test : UI_PopUp
{
    enum Objects
    {

    }

    enum Texts
    {
        txtTitle,
        txtCnt,
    }

    enum Images
    {

    }

    enum Buttons
    {
        btnClose,
    }

    public override bool Initialize()
    {
        if (!base.Initialize()) return false;

        BindObject(typeof(Objects));
        BindText(typeof(Texts));
        BindImage(typeof(Images));
        BindButton(typeof(Buttons));

        GetButton((int)Buttons.btnClose).gameObject.BindEvent(() => UIManager.Instance.ClosePopUp(this));

        return true;
    }

    private void OnEnable()
    {
        Initialize();
        GetText((int)Texts.txtCnt).text = UIManager.Instance.PopUpCount.ToString();
    }
}

테스트 화면


트러블 슈팅


nameof() vs typeof().Name

UIManager에서 UI를 Show 해주는 메서드에서, 제네릭으로 명시된 타입명을 name으로 갖는 prefab을 로드하는 코드가 있다. 나는 처음엔 nameof(T)를 사용했는데, prefab이 정상적으로 로드되지 않았다.

if (string.IsNullOrEmpty(name))
	name = nameof(T);			// string "T" 반환
            
if (string.IsNullOrEmpty(name))
	name = typeof(T).Name;		// 제네릭으로 명시된 type명을 string으로 반환

nameof(T)는 문자 그대로 T를 반환하고 있었다.
이 부분은 typeof(T).Name으로 수정하여 해결했다.


COALESCE 식 사용 시 완벽하지 않은 null 처리

Utilities에서 GetOrAddComponent() 시, IDE에서 COALESCE 식으로 null 검사를 단순화 할 수 있다고 해서 사용해봤다.

// 원래 작성한 코드
T res = obj.GetComponent<T>();
return res != null ? res : obj.AddComponent<T>();

// COALESCE 식
return obj.GetComponent<T>() ?? obj.AddComponent<T>();

하지만 COALESCE 식 사용 시, Missing Component 오류가 났다.
몇 번 써봤던 방법이라 로직 상의 문제는 없어 보여서 사용해본 건데 오류가 나서 이유를 찾아봤다.

문제는 유니티에서 null은 두 가지가 있을 수 있다는 것에서 발생한 것이었다.

유니티에서의 null

  • 참조를 잃어버렸을 때의 null
  • 말 그대로 아무것도 할당되지 않은 진짜 null

유니티에서는 !=와 ==연산자를 두 가지 null에 대해 적용할 수 있게 오버로딩 돼있다고 한다.
COALESCE 식은 아직 오버로딩이 되지 않아서 오류가 나는 것 같다.


참고 자료

인프런 루키스님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part3: 유니티 엔진

profile
game developer

1개의 댓글

comment-user-thumbnail
2024년 3월 28일

안녕하세요 Wooooo님.
루키스에듀 교육팀, 클로이입니다.

다름이 아니라, 현재 포스팅에서 작성해주신 내용 관련하여 확인하고 싶은 것이 있어 댓글 남깁니다.
댓글 확인해주시면 번거로우시겠지만 rookisschloe@gmail.com으로 메일 한 통 부탁드리겠습니다.

감사합니다.

답글 달기