UI 작업 시, 인스펙터 창에서의 작업을 최소로 줄이고 C# Script 단에서 UI에 필요한 요소들을 자동으로 바인딩 해주는 UI Binding
시스템을 공부하고, 개인 과제에 적용해봤다.
최근 포스트에서 계속 언급했지만, 기존에 대다수가 UI를 작업할 때, 새로운 UI를 만들면 그 UI를 관리하는 스크립트의 인스펙터 창에 Prefab이나 Hierarchy 창의 인스턴스를 집어넣는 방법으로 작업을 하곤 한다.
하지만 이 방법은 혼자서 개발할 때는 꽤나 간편하지만 협업에서는 불편함이 있다고 한다.
UI를 따로 작업하시는 분이 있다고 치면, 새로운 UI가 생길 때마다 프로그래머도 스크립트를 수정해야한다.
하지만 UI 바인딩을 이용하면, 새로운 UI를 만들거나 기존의 UI를 삭제해도 스크립트에서의 변경 사항이 없거나 최소로 줄어든다.
간단하게 말하면, UI 내부의 요소들과 Event들을 초기화 단계에서 자동으로 Dictionary에 카테고리별로 바인딩하는 것이다.
C#은 열거형의 원소들의 이름을 Enum.GetNames()
로 받아올 수 있다.
이것을 이용해서 UI의 내부 요소들을 열거형으로 선언하고, 초기화 시점에서 자동으로 바인딩한다.
UI의 가장 기본이 될 클래스다.
모든 UI는 이 클래스를 상속받는 UI_Scene
클래스와 UI_PopUp
클래스를 상속받아서 구현한다.
UI_Scene
클래스는 씬에서 계속 보여지는 UI이다.
UI_PopUp
클래스는 특정 상황에서 팝업되는 UI이다.
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;
}
}
}
UI에는 다양한 내부 요소들이 있을 수 있다. 그 중에 카테고리를 크게 4가지로 묶으면 다음과 같다.
GameObject
Text
Image
Button
이 카테고리들을 Key로, 카테고리 별 오브젝트의 배열을 Value로 갖는 Dictionary를 이용하여 하위 contents를 관리한다.
Bind<T>(Type)
자식 오브젝트 중에서 Type
이 T
인 오브젝트를 찾아 Dictionary에 추가한다.
Get<T>(int)
Dictionary에서 T
를 key로 갖는 오브젝트의 배열의 int
번째 인덱스에 있는 오브젝트를 반환한다.
매개변수로 받아온 오브젝트에 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_Base
를 상속받는다.
씬에서 계속해서 보여질 UI이므로 초기화 시, UIManager
에게 sortOrder
를 0으로 설정하는 SetCanvas()
를 호출한다.
public class UI_Scene : UI_Base
{
public override bool Initialize()
{
if (!base.Initialize()) return false;
UIManager.Instance.SetCanvas(gameObject);
return true;
}
}
UI_Base
를 상속받는다.
특정 상황에서 Pop Up되는 UI이므로 초기화 시, UIManager
에게 sortOrder
를 PopUp Stack에 따라 나중에 열린 PopUp일 수록 위에 그려지게 하는 SetCanvas()
를 호출한다.
UIManager
에게 현재 PopUp을 Close 요청하는 메서드도 작성한다.
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);
}
}
제네릭 Singleton 클래스를 작성하고 상속받아서, 싱글톤으로 구현했다.
Hierarchy 상에서 관리하기 쉽도록 UI Object를 한 개의 Object 밑에 주렁주렁 달기 위한 Root 객체를 참조할 수 있다.
현재 씬의 UI_Scene을 참조할 수 있도록 SceneUI 프로퍼티를 제공한다.
현재 Pop Up된 UI들을 관리하기 위해 Stack
을 사용한다.
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();
}
}
매개변수로 받은 GameObject
에 Canvas
를 추가하고 sortOrder
를 설정한다.
sortOrder는 int?
형으로, null이라면 PopUp UI로 간주하고 popUpOrder
를 설정한다.
매개변수로 받은 GameObject
에 EventSystem
을 추가한다.
ShowSceneUI<T>()
UI_Scene
을 상속받는 T
UI를 로드하고 Instantiate한다.
ShowPopUpUI<T>()
UI_PopUp
을 상속받는 T
UI를 로드하고 Instantiate한다.
PopUp UI를 Stack에서 제거한 후 Destroy한다.
현재 PopUp이 가장 위의 PopUp UI인지 검사할 수도 있다. (오버로딩)
열린 모든 PopUp UI를 닫는다.
자식 오브젝트에서 특정 Component를 찾거나, 어떤 Object에 원하는 Component가 있는지 없는지 검사 후 없다면 추가하여 반환해주는 등 유틸리티 적인 기능들을 구현하는 클래스이다.
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
클래스를 싱글톤으로 만들어주는 클래스를 작성해봤다.
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()
{
}
}
C#의 문법 기능인 확장 메서드의 구현을 모아놓는 클래스.
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;
}
}
열거형, 구조체 등의 선언을 모아놓은 클래스.
public class Define
{
public enum UIEvent
{
Click,
Pressed,
PointerDown,
PounterUp,
Drag,
BeginDrag,
EndDrag,
}
}
바인딩이 잘 동작하는지 테스트해보기 위해서, UI_Scene
을 상속받는 Test UI와 UI_PopUP
을 상속받는 Test UI를 하나씩 만들어봤다.
현재 씬의 진행 시간을 보여주고, 버튼 클릭 시 UI_PopUp_Test
UI를 PopUp하도록 해봤다.
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}";
}
}
현재 열린 팝업이 UIManager
의 Stack의 몇 번째 PopUp UI인지 보여주게 해봤다.
Button을 누르면 창이 닫히도록 해봤다.
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();
}
}
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
으로 수정하여 해결했다.
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에 대해 적용할 수 있게 오버로딩 돼있다고 한다.
COALESCE 식
은 아직 오버로딩이 되지 않아서 오류가 나는 것 같다.
안녕하세요 Wooooo님.
루키스에듀 교육팀, 클로이입니다.
다름이 아니라, 현재 포스팅에서 작성해주신 내용 관련하여 확인하고 싶은 것이 있어 댓글 남깁니다.
댓글 확인해주시면 번거로우시겠지만 rookisschloe@gmail.com으로 메일 한 통 부탁드리겠습니다.
감사합니다.