본문 바로가기

C#/게임 제작 + TIL

UI - 팝업 시스템

프로젝트에서 UI의 전반적인 관리를 맡아서 팝업 형식의 UI를 제작하던 중, 팝업 형식의 UI는 경우 여러 시스템, 씬에서 사용될 것으로 생각해 팝업식 UI의 호출 방식을 조금 더 확장성 있게 구현하고 싶었다.

 

때문에 시스템의 구조를 UI 매니저를 통해서 싱글톤으로 원하는 팝업의 정보만 준다면 어디서든지 팝업을 생성하고, 한번 생성된 팝업은 다시 열 때도 계속 사용할 수 있도록 유지되게 만들었다.

 

 


 

아래는 혹시 구조를 다시 참고할 일이 있을지 몰라 기록 용으로 전문을 첨부한다.

public class UIManager : MonoSingleton<UIManager>
{
    // 확장성을 고려한 string을 통한 제네릭 메서드 호출 + Awake 에서 런타임에 Resources 파일을 통해 자동으로 등록.
    // ShowPopupByName() 에서 string 값을 입력하면 Type을 가져올 수 있게함.
    private Dictionary<string, System.Type> popupTypeMap = new();

    // 현재 씬 내 팝업 인스턴스 저장
    private Dictionary<string, BasePopupUI> popupInstances = new Dictionary<string, BasePopupUI>();
    private const string popupPath = "UI/Popup/";
    private Transform popupRoot;

    // PopUp 형식의 UI Stack
    public Stack<BasePopupUI> popupStack = new Stack<BasePopupUI>();

    protected override void Awake()
    {
        RegisterAllPopupsInResources();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Escape) && popupStack.Count > 0)
        {
            BasePopupUI popup = popupStack.Peek();// Close() 메서드 내부에 Pop() 존재.
            popup.Close();
        }
    }
    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }
    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        popupInstances.Clear();// 씬이 바뀔 때 팝업 인스턴스 초기화
        popupStack.Clear();// 팝업 스택 초기화

        GameObject popupRootObj = GameObject.Find("Canvas/PopupUI");
        if (popupRootObj == null)
        {
            Debug.Log("PopupUI 의 캔버스가 없습니다.");
            popupRoot = null;
        }
        else
        {
            popupRoot = popupRootObj.transform;         
        }
    }

    public void ShowPopupByName(string popupName)
    {
        if (!popupTypeMap.TryGetValue(popupName, out var popupType))
        {
            Debug.LogError($"[UIManager] '{popupName}'에 해당하는 팝업 타입이 없습니다.");
            return;
        }

        MethodInfo showPopupGeneric = typeof(UIManager)
            .GetMethod(nameof(ShowPopup), BindingFlags.Public | BindingFlags.Instance);

        MethodInfo showPopupTyped = showPopupGeneric.MakeGenericMethod(popupType);
        showPopupTyped.Invoke(this, null);
    }

    /// <summary>
    /// 팝업 Open 매서드(컴포넌트 기준)
    /// </summary>
    public T ShowPopup<T>() where T : BasePopupUI
    {
        string popupName = typeof(T).Name;

        // 이미 인스턴스가 있다면 재사용
        if (popupInstances.TryGetValue(popupName, out BasePopupUI existingPoupup))
        {
            existingPoupup.Open();
            existingPoupup.gameObject.transform.SetAsLastSibling(); // 팝업이 열릴 때 가장 위로 오도록 설정
            return existingPoupup as T;
        }

        // Resources에서 프리팹 캐싱
        GameObject prefab = Resources.Load<GameObject>($"{popupPath}{popupName}");
        if (prefab == null)
        {
            Debug.LogError($"Resources에 '{popupName}'을 찾을 수 없습니다.");
            return null;
        }

        // 캐싱된 프리팹 생성
        GameObject popupObject = Instantiate(prefab, popupRoot);
        T popup = popupObject.GetComponent<T>();
        if (popup == null)
        {
            Debug.LogError($"프리팹에 {typeof(T).Name} 컴포넌트가 없습니다.");
            Destroy(popupObject);
            return null;
        }


        popupInstances[popupName] = popup;
        popup.Open();
        popup.gameObject.transform.SetAsLastSibling(); // 팝업이 열릴 때 가장 위로 오도록 설정
        return popup;
    }

    /// <summary>
    /// 팝업 Close 매서드
    /// </summary>
    public void ClosePopup<T>() where T : BasePopupUI
    {
        string popupName = typeof(T).Name;

        if (popupInstances.TryGetValue(popupName, out BasePopupUI popup))
        {
            popup.Close();
        }
    }

    /// <summary>
    /// Resources 폴더에서 모든 팝업 프리팹의 Type을 등록.
    /// </summary>
    private void RegisterAllPopupsInResources()
    {
        GameObject[] popupPrefabs = Resources.LoadAll<GameObject>(popupPath);

        foreach (GameObject prefab in popupPrefabs)
        {
            if (prefab.TryGetComponent<BasePopupUI>(out var popup))
            {
                string popupName = prefab.name;
                System.Type popupType = popup.GetType();

                if (!popupTypeMap.ContainsKey(popupName))
                {
                    popupTypeMap.Add(popupName, popupType);
                    // Debug.Log($"[UIManager] 등록됨: {popupName} → {popupType}");
                }
            }
            else
            {
                Debug.LogWarning($"[UIManager] {prefab.name} 프리팹에 BasePopupUI가 없음 → 등록되지 않음");
            }
        }
    }
}

 

 

 

 

여기서 팝업 UI를 호출하는 방식은, Awake() 단계에서 Resources 파일에 있는 팝업 오브젝트를 보관한 파일에서 팝업 오브젝트의 정보를 가져와 오브젝트의 이름 + 타입으로 딕셔너리에 저장하고

 

private Dictionary<string, System.Type> popupTypeMap = new();

 

이렇게 저장한 딕셔너리의 정보를 통해, 외부에서 팝업을 버튼식 또는 미리 입력받은 text 값으로 원하는 팝업의 타입을 매개변수에 입력해 사용해 주면 

 

ShowPopupByName(string popupName)

 

이 매서드를 통해 입력한 타입의 오브젝트를 

1. 생성 + 생성한 오브젝트 딕셔너리에 저장 을 하거나

2. 이미 생성된 오브젝트를 활성화

하는 방식을 통해 불러오며, 이렇게 불러올 때 효율적으로 팝업 닫기를 하기 위해

 

public Stack <BasePopupUI> Stack <BasePopupUI> popupStack = new Stack <BasePopupUI>();

 

if (Input.GetKeyDown(KeyCode.Escape) && popupStack.Count > 0)
{
    BasePopupUI popup = popupStack.Peek();// Close() 메서드 내부에 Pop() 존재.
    popup.Close();
}

 

스택을 통해 ESC 키 또는 직접 Pop을 해서 비활성화를 해 줄 수 있게 만들었다.

 

 


 

 

버튼의 매개변수에 대응되는 팝업 string 값 입력

 

 

이렇게 해 주면, 특정 버튼에 string 값을 부여한 상태에서 누를 시 동일한 타입의 팝업을 호출할 수 있고, ESC로 닫을 수 있다.