Unity UI

선비Sunbei·2023년 1월 23일
0

Unity

목록 보기
11/18
post-thumbnail
post-custom-banner

모든 UI 요소는 Canvas 안에 위치해야 한다. 캔버스는 Canvas 컴포넌트가 있는 게임 오브젝트이며 모든 UI 요소는 반드시 어떤 캔버스의 자식이어야 한다.
Canvas는 여러개 존재할 수 있다.

Canvas는 메시징 시스템을 돕기 위해 EventSystem 오브젝트를 사용한다.

우크릭 후 UI(버튼) 생성 시 자동으로 Canvas와 Eventsystem이 생성된다.

캔버스에 있는 UI 요소는 계층 구조에 나타나는 것을 순서대로 그린다. 두 UI가 겹치면 나중에 그려지는 것이 위에 나타나게 된다.

Canvas 컴포넌트에서는 렌더 모드를 선택할 수 있다.
1. 스크린 공간 -오버레이 : 화면에서 씬의 위에 렌더링 된다. 스크린 크기가 조절되거나 해상도가 변경되면 캔버스는 여기에 맞춰 자동으로 스키기를 변경한다.
2. 스크린 공간 - 카메라 : 카메라 바로 앞에 지정된 거리만큼에 위치하게 되면서 위와 다르게 원근법이 적용된다.
3. 월드 공간 : 캔버스가 씬에 있는 다른 오브젝트처럼 작동한다.

모든 UI는 Transform 컴포넌트가 아닌 RectTransform 컴포넌트를 갖고 있다.
UI는 privot과 Center 방식 중 하나, Local과 Global 방식 중 하나를 선택해서 트랜스폼이 가능하다.

유의깊게 봐야할 것 중 하나가 Rect Transform에서의 Anchors이다. 이는 UI의 크기를 상위 UI의 변경에 따라서 어떻게 변경할 것인지에 대한 선택을 할 수 있다.

사진의 정 가운데를 보면 삼각형 4개가 보일 것이다. 이것은 핀 포인트로, 각 Button의 점과 매핑되어있다.
핀 포인트는 부모의 RectTransform의 비율에 따라서 동일한 비율의 위치로 변경된다. 따라서 핀셋의 위치는 유동적이다. 하지만, 핀 포인트와 매핑된 점들과의 거리는 그대로 유지된다.

예제로는 미리 만들어놓은 앵케 프리셋을 참고하면 될 것 같다.

SHIFT 키를 누르고 선택 시 앵커와 피봇을 같이 이동시키게끔 할 수 있다. ALT키를 누르고 선택 시 RectTransform도 같이 늘어나거나 줄어든다.
(SHIFT+ALT도 가능하다.)

버튼의 경우 하단에 OnClick()을 통해서 게임오브젝트를 연결하면 해당 게임오브젝트에 붙은 컴포넌트들의 함수를 호출할 수 있다. 이는 매우 간단한 게임에서는 좋겠지만 다음과 같이 관리하면 나중에는 크러쉬 오류가 마구 발생할 것이다.
이를 해결하기 위해서 UI Manager를 만들어 관리할 것이다.

참고로 UI가 눌렸는지 안눌렸는지는 다음과 같은 메서드를 통해서 확인할 수 있다.

using UnityEngine.EventSystems;
EventSystem.current.IsPointerOverGameObject()

외에도 버튼을 누르면 Animation을 Transition을 변경하게끔도 할 수 있다. Auto Generate Animator를 선택해서 Animator controller를 생성하면 된다.

버튼외에 다른 UI들은 해당 공식문서를 참조하기를 바란다.

https://docs.unity3d.com/kr/530/Manual/UIInteractionComponents.html

이제부터 UI Manager를 만들어볼 것이다. UI Manager의 설계는 다음과 같다.
1. OnClick과 같은 이벤트를 코드 상에서 추가할 수 있게 하자.
2. UI를 2가지 분류로 나눠서 처리할 것이다. i) PopUp창과 같이 순서대로 띄어지고 순서대로 나가지게끔 ii) Screen형태로 띄어고 나가고 하게끔
3. UI BASE 스크립트 클래스르 설계해서 자신의 자식 게임오브젝트들을(컴포넌트를) 쉽게 갖고올 수 있게끔 하자.

먼저 UI BASE 스크립트를 작성하겠다.
1. Dictionary 객체를 갖고 있다.
2. UI Base를 상속하는 스크립트는 Enum으로 하위 GameObject 정보를 기록하고 UI Base 함수의 Bind로 Dictionary에 등록한다.
3. UI Base의 Bind 함수는 하위 GameObject(컴포넌트)를 찾아준다.
4. UI Base의 Get 함수로 Dictionary에다가 클래스 Type을 넘겨주면 해당 리스트를 리턴한다.

// Util.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Util
{
    // 게임오브젝트에 만약 컴포넌트가 있으면 그것을 리턴, 없으면 생성해서 추가 후 리턴.
    public static T GetOrAddComponent<T>(GameObject go) where T : UnityEngine.Component
    {
        T comp =  go.GetComponent<T>();
        if(comp == null)
            comp = go.AddComponent<T>();
        return comp;
    }

    // GameObject의 자식들의 컴포넌트들을 모두 갖고와서 GameObject의 이름과 비교해서 같으면 리턴.
    // 만약 이름이 없으면 가장 먼저 만나는 컴포넌트 리턴
    public static T FindChild<T>(GameObject go, string name = null, bool recursive = true) where T : UnityEngine.Object
    {
        if (go == null)
            return null;
        if(recursive == false)
        {
            for(int i=0; i < go.transform.childCount; i++) // 차일드 수는 gameobject가 아닌 transform에 있음
            {
                Transform transform = go.transform.GetChild(i); // 이하 동
                if(string.IsNullOrEmpty(name) || transform.name == name)
                {
                    T comp = transform.GetComponent<T>();
                    if (comp != null)
                        return comp;
                }
                    
            }
        }
        else {
            foreach (T comp in go.GetComponentsInChildren<T>())
                if(string.IsNullOrEmpty(name) || name == comp.name)
                    return comp;
        }
        return null;
    }

    // 위 함수와 동일 단, 게임 오브젝트로 리턴
    public static GameObject FindChild(GameObject go, string name = null, bool recursive = true)
    {
        return FindChild<Transform>(go,name,recursive)?.gameObject;
    }
}

먼저 자주 사용할만한 함수들을 Util 클래스에 만들어서 관리하겠다.

// UIBase.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class UIBase : MonoBehaviour
{
    Dictionary<Type, UnityEngine.Object[]> _objects = new Dictionary<Type, UnityEngine.Object[]>();
    
    // Enum Type을 받아서 이름을 모두 갖고옴(리플렉션)
    // object[]을 Dictionary에 넣는다. 
    // object[]를 채워넣는다.
    protected void Bind<T>(Type type ) where T : UnityEngine.Object
    {
        string[] names = type.GetEnumNames(); // == Enum.GetNames(type);
        UnityEngine.Object[] objects = new UnityEngine.Object[names.Length];
        _objects.Add(typeof(T), objects);

        for (int i = 0; i < names.Length; i++) {
            if (typeof(T) == typeof(GameObject))
                objects[i] = Util.FindChild(this.gameObject, names[i], true);
            else
                objects[i] = Util.FindChild<T>(this.gameObject, names[i], true);

            if (objects[i] == null)
                Debug.Log($"Not Found : {names[i]}");        
        }
    }

    // index는 enum에서 int형으로 캐스팅하면 된다.
    protected T Get<T>(int index) where T : UnityEngine.Object
    {
        UnityEngine.Object[] objects = null;
        if (!_objects.TryGetValue(typeof(T), out objects))
            return null;
        return objects[index] as T;
    }

    protected abstract void Init();

}

정리하면 Bind는 typeof(Enum)을 받아서 리플렉션으로 이름 목록을 모두 가져온다. 이때 이름은 하이러키창의 UI 이름과 같아야 한다.
Get은 enum.value를 int로 변환해서 받는 것이 index이고 이를 Dictionary에서 갖고와서 반환한다.

다음과 같이 하이러키창을 세팅해보겠다.
그리고 UI BASE를 상속한 예를 보여주겠다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Canvas : UIBase
{

    private static int score = 0;

    enum BUTTON
    {
        ScoreUpBTN
    }
    enum TEXT
    {
        ScoreUpTXT,
        Score
    }
    enum GAMEOBJECTS
    {
        ScoreUpTXT,
        Score,
        ScoreUpBTN
    }
    void Start()
    {
        Init();    
    }
    protected override void Init()
    {
        Bind<Button>(typeof(BUTTON));
        Bind<Text>(typeof(TEXT));
        Bind<GameObject>(typeof(GAMEOBJECTS));

        Text text = Get<Text>((int)TEXT.Score);
        if (text != null)
            text.text = $"Score : {score}";
    }
}

Score Text의 font 크기나 위치는 미리 잡아주었다.

이제 UI BASE 클래스를 만들었기 때문에, 소스코드 상에서 OnClick을 할 수 있게 만들어주겠다.
다시 설계를 해보자면 다음과 같다.
1. EventHandler 스크립트를 만들어서 delegate event를 이용하여 콜백함수를 등록할 수 있게 한다.
2. EventSystem에게 메시지를 받은 delegate에 있는 함수들에게 콜백을 해준다.
3. delegate event에 콜백함수를 등록하는 것은 UI BASE를 상속하면 할 수 있다. 즉, 등록하는 것을 UI BASE 클래스에 만든다.

먼저 EventSystem에 대해서 콜백 받는 것을 보겠다.
Unity Docs에서 메시징 시스템에 대한 설명은 다음과 같다.

시스템은 컴포넌트가 메시징 시스템에서 콜백을 수신할 수 있다는 것을 나타내기 위해 MonoBehaviour에 구현할 수 있는 커스텀 인터페이스를 사용하여 작동합니다. 호출로 타겟 게임 오브젝트가 지정되면 특정한 인터페이스를 구현한 게임 오브젝트의 모든 컴포넌트로 호출이 이뤄집니다. 메시징 시스템은 커스텀 사용자 데이터를 전달하게 허용하고 이벤트가 전파해야 하는 게임 오브젝트 계층 구조를 통해서도 전달됩니다. 이것은 지정된 게임 오브젝트에서 그냥 실행돼야 하거나 자식과 부모 오브젝트를 대상으로도 실행돼야 합니다. 이 외에도 메시징 프레임워크는 주어진 메시징 인터페이스를 구현하는 게임 오브젝트를 검색하여 찾는 헬퍼 함수를 제공합니다.
메시징 시스템은 일반용으로 UI 시스템뿐만 아니라 일반적인 게임 코드에도 사용할 수 있게 설계되어 있습니다. 커스텀 메시징 이벤트를 추가하는 것은 비교적 간단한 일이며 UI 시스템은 모든 이벤트 처리를 위해 사용하는 것과 같은 프레임워크를 통해 이벤트를 추가합니다.

정리하면 다음과 가다.
1. MonoBehavoir을 상속하고 있어야 하며, 특정 인터페이스를 상속해야 호출된다.
2. 게임오브젝트 계층 구조를 통해 이벤트가 전달되며, 한쪽에서 받아서 누군가에게 전달하지 않는 이상 받은 쪽에서 끝난다.

전달되는 순서의 우선순위는 다음과 같다.
1. 부모 게임 오브젝트
2. 계층 구조 중 아래있을 수록 위쪽에 있는 것보다 우선순위가 높다.

특정 인터페이스는 다음 사이트에서 확인할 수 있다.

https://docs.unity3d.com/kr/2020.3/Manual/SupportedEvents.html

// EventHandler.cs
using System;
using UnityEngine;
using UnityEngine.EventSystems;

public class EventHandler : MonoBehaviour, IPointerClickHandler, IDragHandler
{
   public event Action<PointerEventData> dragEvent;
   public event Action<PointerEventData> clickEvent;


   public void OnDrag(PointerEventData eventData)
   {
       if (dragEvent != null)
           dragEvent.Invoke(eventData);
   }

   public void OnPointerClick(PointerEventData eventData)
   {
       if (clickEvent != null)
           clickEvent.Invoke(eventData);
   }
}

EventHandler Script를 들고 있으면 이벤트 시스템 메시지를 수신할 수 있게끔 하려고 한다.
이벤트 인터페이스는 일단 Click과 Drag만 만들겠다.
나중에 추가하고 싶을 때 추가하면 될 것 같다.

// Define.cs
public class Define 
{
    enum UIEventList
    {
        Click,
        Drag
    }
}

그리고 Event 목록을 관리하기 쉽게하기 위해서 다음과 같이 Define.cs를 만들겠다.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;

public abstract class UIBase : MonoBehaviour
{
    Dictionary<Type, UnityEngine.Object[]> _objects = new Dictionary<Type, UnityEngine.Object[]>();
    
    protected void Bind<T>(Type type ) where T : UnityEngine.Object{
    // 생략
    }

    // index는 enum에서 int형으로 캐스팅하면 된다.
    protected T Get<T>(int index) where T : UnityEngine.Object{ 
    // 생략   
    }

    public static void BindEvent(GameObject go, Action<PointerEventData> action , Define.UIEventList uIEvent = Define.UIEventList.Click)
    {
        if (go == null)
            return;

        EventHandler eventHandler = Util.GetOrAddComponent<EventHandler>(go);
        switch (uIEvent)
        {
            case Define.UIEventList.Click:
                eventHandler.clickEvent += action;
                break;
            case Define.UIEventList.Drag:
                eventHandler.dragEvent += action;
                break;
        }
    }

    protected abstract void Init();

}

실제로 작동하는지 테스트해보겠다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class Canvas : UIBase
{
   private static int score = 0;
   enum BUTTON
   {
       ScoreUpBTN
   }
   enum TEXT
   {
       ScoreUpTXT,
       Score
   }
   enum GAMEOBJECTS
   {
       ScoreUpTXT,
       Score,
       ScoreUpBTN
   }
   void Start()
   {
       Init();    
   }
   protected override void Init()
   {
       Bind<Button>(typeof(BUTTON));
       Bind<Text>(typeof(TEXT));
       Bind<GameObject>(typeof(GAMEOBJECTS));

       Text text = Get<Text>((int)TEXT.Score);
       if (text != null)
           text.text = $"Score : {score}";

       BindEvent(Get<GameObject>((int)GAMEOBJECTS.ScoreUpBTN),
           (PointerEventData ped) =>
           {
               score++;
               Get<Text>((int)TEXT.Score).text = $"Score : {score}";
           }
       );
   }
}

실험 결과 문제없이 잘 작동한다.

이제 UI Manager에서 Popup형태와 Screen 형태로 만들어보겠다.

Canvas 컴포넌트에는 Sort Order라는 것이 있다.
이는 우선순위에 관한 내용으로 Sort Order가 높을수록 더 위에 있다.
이것과 Stack을 이용해서 PopUp을 만들 것이고, Screen의 경우 sort=0으로 고정하겠다.

// UIManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UIManager 
{
   private int sortOrder = 0;
   Stack<UIPopUp> popupStack = new Stack<UIPopUp>();

   public GameObject Root
   {
       get
       {
           GameObject root = GameObject.Find("UI_ROOT");
           if (root == null)
               root = new GameObject("UI_ROOT");
           return root;
       }
   }

   // sort == true : PopUp, sort == false : Screen
   public void SetCanvas(GameObject go, bool sort = true)
   {
       Canvas canvas = Util.GetOrAddComponent<Canvas>(go);
       canvas.renderMode = RenderMode.ScreenSpaceOverlay;
       canvas.overrideSorting = true;
       // overrideSorting : Override the sorting of canvas. Allows for nested canvas's to ignore the parent draw order and draw ontop or below.
       // 상위 캔버스 sort 무시

       if (sort)
       {
           canvas.sortingOrder = sortOrder;
           sortOrder++;
       }
       else
       {
           canvas.sortingOrder = 0;
       }
   }

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

       GameObject go = Resources.Load<GameObject>(name);
       if (go == null)
           return null;
       return Util.GetOrAddComponent<T>(Object.Instantiate(go,Root.transform));
   }

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

       GameObject go = Resources.Load<GameObject>(name);
       if (go == null)
           return null;
       popupStack.Push(Util.GetOrAddComponent<T>(Object.Instantiate(go, Root.transform)));
       return popupStack.Peek() as T;
   }

   public void ClosePopUpUI()
   {
       if (popupStack.Count == 0)
           return;
       UIPopUp popup = popupStack.Pop();
       Object.Destroy(popup.gameObject);
       sortOrder--;
   }

}
// UIPopUp.cs
public class UIPopUp : UIBase
{
    protected override void Init()
    {
        Manager.Instance.mUIManager.SetCanvas(this.gameObject, true);
    }
}
// UIScreen.cs
public class UIScreen : UIBase
{
    protected override void Init()
    {
        Manager.Instance.mUIManager.SetCanvas(this.gameObject, false);
    }
}

SetCanvas로 Sort 순위를 정한다.

post-custom-banner

0개의 댓글