Skip to content

About

Unity 6 (uGUI) UI 스택 관리 시스템 — 레이어 캔버스, 정렬 순서 자동 배정, 씬 소유권 기반 수명 관리

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

19 Commits

Folders and files

Repository files navigation

Unity UI Stack System

Validate Unity 6000.3+ version 0.3.1 license

Unity 6(uGUI)용 UI 스택 관리 시스템. UI별 독립 루트 Canvas, 정렬 번호 자동 배정, 씬 소유권 기반 수명 관리를 제공한다. 게임 코드에서는 UIManager.Instance.OpenAsync<T>()로 UI를 열 수 있다. 대량 목록을 위한 재사용 스크롤(RecyclableScrollView)을 함께 제공한다. 같은 컴포넌트의 Loop 를 켜면 목록을 끝없이 반복하는 순환 스크롤이 된다.

  • 패키지 com.frenil.uisystem · 어셈블리 UISystem.Runtime · 네임스페이스 UISystem
  • 요구사항 Unity 6000.3 이상. 런타임 의존은 com.unity.ugui 2.0.0 하나뿐이다
  • 전체 포트폴리오 frenil-portfolio

v0.3.1 주요 기능

  • UI별 독립 루트 Canvas를 사용하고, 스택 순서에 따라 정렬 번호를 자동 배정한다.
  • 고정 크기 리스트·그리드와 순환 목록을 지원하는 재사용 스크롤, 데이터 소스·셀 바인딩 API를 제공한다.
  • Canvas 설정 동기화와 UIElement 기본 설정 인스펙터를 제공한다. 스크롤은 빈 UI 오브젝트에서 기본 설정을 적용해 구성할 수 있다.
  • 스택·스크롤·에디터 설정 테스트와 리스트·그리드·순환 스크롤 샘플을 제공한다.

v0.3.1 주요 변경 — 자세한 변경 사항은 CHANGELOG에 정리했다.

  • 재사용 스크롤에 Loop 옵션을 추가한다. 같은 컴포넌트가 유한 목록과 순환 목록을 모두 처리한다.
  • 표시 구간 API VisibleDisplayRange · ToDataIndex(displayIndex) 를 추가한다.
  • 샘플을 가로 화면(1920x1080) 기준으로 다시 배치하고 순환 스크롤 팝업을 더한다.

빠른 시작 · 설치 · 설정 · 사용법 · 전체 구조 · 동작 방식 · 재사용 스크롤 · 에디터 도구 · 확장 포인트 · 규약 · 샘플 · 저장소 구조와 개발 · 다음에 할 것


실제 사용처

DefenceGame이 이 패키지를 git URL로 설치해 쓴다. 기존 UI 매니저를 이 패키지로 교체했다. 문자열 ID로 팝업을 열던 코드가 OpenAsync<T>()로 바뀌었고, 팝업별 반투명 배경은 공유 UIDim 하나로 대체됐다. 씬의 주 화면이 UIScreen, HUD 부품이 UIElement, 팝업 3종이 UIPopup이다.

실제 적용 과정에서 API도 개선했다. 인스펙터의 UnityEvent에 연결할 수 있는 매개변수 없는 닫기 메서드와, UIScreen을 유지하는 CloseAllAsync()를 추가했다.

프리팹 로딩과 루트 배치는 인터페이스로 분리했다. 연출과 초기화 방식도 변경할 수 있으며, 구체적인 방법은 확장 포인트에 정리했다.


빠른 시작

샘플을 실행하려면 저장소를 클론하고 Assets/Samples/Scene/SampleLobby.unity를 열어 Play를 누른다. 별도 설정 없이 화면 전환·팝업·재사용 스크롤을 확인할 수 있다.

다른 프로젝트에 적용하려면 아래 순서로 진행한다.

  1. 설치에 따라 패키지를 추가한다.
  2. 설정의 레이어 설정·프리팹 표·UIRoot·부팅 설정을 만든다.
  3. 뷰 만들기의 ConfirmPopup을 프리팹으로 만들고 버튼 참조를 연결한 뒤 프리팹 표에 등록한다.
  4. 입력을 처리할 EventSystem이 씬에 있는지 확인하고, 아래 코드를 메서드 안에서 실행한다.
var popup = await UIManager.Instance.OpenAsync<ConfirmPopup>();
if (popup == null)
    return;

var result = await popup.WaitForCloseAsync();
if (result.IsConfirmed)
    Debug.Log("확인 선택");

위 코드는 초기화에 성공한 뒤 async 메서드 안에서 실행하는 예시다. 재사용 스크롤은 빈 UI 오브젝트에서 기본 설정을 적용해 구성할 수 있다.


설치

프로젝트의 Packages/manifest.json에서 dependencies에 다음 항목을 추가한다.

"com.frenil.uisystem": "https://github.com/Frenil-client/unity-ui-system.git?path=/Assets/UISystem#v0.3.1"

에디터에서는 Window ▸ Package Manager ▸ + ▸ Add package from git URL에 같은 URL을 넣어도 된다. ?path=/Assets/UISystem이 패키지 루트를 가리키므로 이 부분을 빼면 저장소 전체를 받으려다 실패한다.

#v0.3.1은 설치할 릴리스 태그를 지정한다. Unity가 해석한 커밋 해시는 Packages/packages-lock.json에 기록된다. manifest.json과 packages-lock.json을 함께 커밋해 사용 중인 버전을 기록한다.

이전 릴리스가 필요하면 URL 끝을 #v0.2.0으로 바꾼다. v0.2.0은 레이어별 Canvas를 사용하는 이전 구조이며, 이 문서의 재사용 스크롤과 에디터 도구는 포함하지 않는다.

v0.2.0에서 이전할 때 UIRoot와 레이어 그룹의 Canvas를 제거하고, 각 UI 프리팹이 독립 루트 Canvas를 갖도록 구성한다. 이후 UI System ▸ Canvas 설정 동기화로 필요한 컴포넌트와 공통 배율을 적용한다. 기존 SortingOrderAllocator를 받는 매니저 생성자는 사용 중단 예정이며, UILayerSettings를 받는 생성자를 사용한다.

이 저장소의 샘플 프로젝트는 URP와 Input System 위에서 돌지만, 런타임이 참조하는 것은 UnityEngine과 UnityEngine.UI뿐이므로, 패키지를 설치하는 프로젝트에 URP나 Input System이 필수인 것은 아니다.


설정

패키지에는 런타임, 에디터 도구, 스크롤 가이드가 들어 있다. UI를 실행하려면 다음 네 가지 에셋을 준비한다. 이 저장소를 클론한 경우 Assets/Samples/와 Assets/Resources/UIBootstrap.asset이 이미 있으므로 SampleLobby 씬을 열고 바로 Play 하면 되고, 아래는 자기 프로젝트에 붙일 때의 절차다.

  1. 레이어 설정 — Create ▸ UI System ▸ UI Layer Settings 기본값이 Screen / Window / Popup / Overlay / Toast 다섯 개로 채워져 있다. 배열 순서가 UILayerId의 인덱스(0~4)와 반드시 일치해야 한다. 모든 루트 Canvas가 쓰는 공통 배율(기준 해상도, match)도 여기서 정한다.

  2. 프리팹 표 — Create ▸ UI System ▸ UI Prefab Table TypeName에 뷰 타입의 FullName(예: Game.UI.ConfirmPopup), Prefab에 해당 프리팹을 넣는다.

  3. UIRoot 프리팹

    • 빈 GameObject에 RectTransform + UIRoot + UIRootProvider를 붙인다.
    • UIRootProvider._settings에 1번을, _container에 자기 RectTransform을 연결한다.
    • UIRoot._rootProvider에 같은 오브젝트의 UIRootProvider를 연결한다.
    • 자식으로 Image(반투명) + UIDim 오브젝트를 하나 만들어 UIRoot._dim에 연결한다. Canvas · GraphicRaycaster · CanvasScaler는 UIDim이 요구해 따라 붙는다.
    • UIRoot와 레이어 그룹에는 Canvas를 붙이지 않는다. 그룹은 UIRootProvider가 실행 시 Canvas 없이 만든다. 그룹에 Canvas가 남아 있으면 오류를 내고 그 레이어의 UI를 열지 않는다.
  4. 부팅 설정 — Create ▸ UI System ▸ UI Bootstrap Settings Assets/Resources/UIBootstrap.asset으로 저장하고 RootPrefab에는 3번, Layers에는 1번, PrefabProvider에는 2번을 연결한다. (Addressables가 준비되기 전에 읽혀야 해서 Resources에 둔다.) 여기와 UIRootProvider가 서로 다른 레이어 설정을 가리키면 시작할 때 경고한다.

기존 UI 프리팹을 옮겨 올 때는 마지막으로 UI System ▸ Canvas 설정 동기화를 한 번 실행한다. 빠진 CanvasScaler 등을 붙이고 배율을 공통 설정에 맞춰 프리팹에 저장한다.


사용법

접근점은 UIManager.Instance 하나다. 아래에서 manager는 그것을 받아 둔 지역 변수다.

열고 닫기

// 프리팹 표에서 조회해 Popup 레이어에 등록한다.
var popup = await manager.OpenAsync<ConfirmPopup>();
if (popup == null)
    return;

// 닫힘 결과를 기다린다
var result = await popup.WaitForCloseAsync();
if (result.IsConfirmed)
    Purchase();

닫기와 조회 API는 다음과 같다. 아래 호출들은 각 기능을 보여 주는 개별 예시다.

popup.Close(UICloseReason.Confirmed);      // 뷰가 스스로
popup.Close();                             // 확인·취소 선택 없이 닫기(Dismissed)
await manager.CloseAsync(popup);           // 서비스가
await manager.CloseAllAsync<UIPopup>();    // 타입 단위로
await manager.CloseAllAsync();             // UIScreen 을 뺀 전부

// 최상단부터 닫을 수 있는 것을 찾아 하나 닫는다.
// 못 닫는 모달(BlockClose + Dim)을 만나면 거기서 소비하고 아래로 전파하지 않는다.
if (!manager.OnBackPressed())
    ShowQuitConfirm();

manager.IsOpen<InventoryWindow>();
manager.Find<InventoryWindow>();
manager.TryGetTop(out var top);
var openCount = manager.OpenCount;

UICloseReason은 넷이다 — Confirmed / Cancelled / Dismissed(Dim 클릭·뒤로가기) / ClosedByService(CloseAll·씬 전환).

뷰 만들기

using UISystem;
using UnityEngine;
using UnityEngine.UI;

public sealed class ConfirmPopup : UIPopup
{
    [SerializeField] private Button _ok;
    [SerializeField] private Button _cancel;

    // 영구 버튼 연결은 한 번만 한다. 풀에서 다시 열 때 리스너가 누적되지 않는다.
    private void Awake()
    {
        _ok.onClick.AddListener(CloseConfirmed);
        _cancel.onClick.AddListener(CloseCancelled);
    }

    // 코드 연결 대신 버튼의 OnClick에 CloseConfirmed·CloseCancelled를 지정해도 된다.
    // 두 방식을 동시에 사용하지 않는다.

    protected override void OnOpened() { } // 열 때마다 표시할 데이터 갱신
    protected override void OnClosing(UIResult result) { }

    // 위에 다른 UI가 덮이거나 걷혔을 때. 가려진 동안 갱신을 멈추는 용도.
    protected override void OnCoveredChanged(bool covered) { }
}

연출이 필요하면 PlayOpenAsync와 PlayCloseAsync를 재정의한다. 기본 구현은 null을 반환해 즉시 진행하며, 공용 연출은 아직 제공하지 않는다.

프리팹 루트에 스크립트를 붙이면 Canvas · CanvasScaler · CanvasGroup · GraphicRaycaster가 따라 붙는다. 배율, 렌더 모드, 정렬 번호는 뷰마다 설정하지 않는다. 프리팹은 UIPrefabTable에 타입 FullName ↔ 프리팹으로 등록한다. 타입 이름을 키로 직접 참조된 프리팹을 조회하므로, 프리팹 경로를 입력하거나 문자열을 타입으로 변환할 필요가 없다.


전체 구조

UI Stack System 런타임 구조

UIRoot (DontDestroyOnLoad · Canvas 없음)
├─ Window / Popup / Overlay / Toast   ← 레이어 그룹. Canvas 없는 정리용 부모
│  └─ ShopWindow, ConfirmPopup …     ← UI별 루트 Canvas + CanvasScaler + CanvasGroup (+ GraphicRaycaster)
└─ Dim                              ← 독립 루트 Canvas 하나. 정렬 번호만 바뀐다
[UIInactivePool] (DontDestroyOnLoad · 꺼짐)  ← 프리팹 복제와 풀 대기 영역

현재 씬
└─ LobbyScreen                      ← UIScreen. 씬에 남는 루트 Canvas

UIManager는 앱에 하나이고 씬보다 오래 산다. UI의 정렬 번호는 스택의 (레이어, 열린 순서)를 기준으로 각 루트 Canvas에 적용한다. 레이어 그룹은 계층 정리용이며, UIScreen은 해당 씬에 유지한다.


동작 방식

1. 레이어와 뷰 타입

레이어는 UILayerSettings에서 정의하고, 뷰 타입이 자기 레이어를 고정한다. sealed override로 고정하므로 프리팹 인스펙터에서 역할과 다른 레이어를 지정할 수 없다. 인스턴스마다 갈리는 값은 UIViewOptions의 BlockClose, Pooled 둘뿐이다.

타입 레이어 Dim 아래 가림 포인터 입력 용도
UIScreen Screen (0) ✗ ✗ ✓ 씬의 주 화면. 로비, 전투 HUD
UIWindow Window (1) ✗ ✓ ✓ 전체 화면 콘텐츠. 인벤토리, 상점
UIPopup Popup (2) ✓ ✗ ✓ 모달. 확인창, 결과창
UIOverlay Overlay (3) ✓ ✗ ✓ 시스템 층. 로딩, 튜토리얼 마스크
UIToast Toast (4) ✗ ✗ ✗ 최상단 알림. 입력을 통과시킨다
UIElement — — — — 스택에 참여하지 않는 부품(슬롯, 게이지, 스크롤). 유일하게 UIBase를 상속하지 않는다

UIScreen만 특별하다. UIRoot로 옮기지 않고 씬에 놓인 채로 추적만 한다. 씬이 소유권을 갖고, 씬 뷰에서 설정한 모습이 그대로 실행돼야 하기 때문이다. 다른 UI와 같은 루트 Canvas 모델이지만, 씬이 정한 CanvasScaler 값은 시스템이 덮어쓰지 않는다.

2. UI별 루트 Canvas

UIRoot와 레이어 그룹에는 Canvas가 없으므로 각 UI의 Canvas가 루트 Canvas로 동작한다.

  • 필요한 컴포넌트는 역할 클래스가 요구한다. 스크립트를 붙이면 Canvas · CanvasScaler · CanvasGroup(모든 UI)과 GraphicRaycaster(Toast 제외)가 함께 추가된다. 에디터에서 공통 설정을 찾으면 배율을 적용한다. 기존 UIScreen의 배율은 유지한다.
  • 열 때 시스템은 값만 맞춘다. 렌더 모드(ScreenSpaceOverlay), CanvasScaler 값, 정렬 번호, 표시(Canvas.enabled), 포인터 입력(CanvasGroup.blocksRaycasts). 같으면 쓰지 않는다. 빠진 컴포넌트는 안전장치로 붙이고 한 번 경고한다.
  • 하위 Canvas의 설정은 유지한다. 정렬과 활성 상태를 변경하지 않으며, overrideSorting 사용 여부만 검사해 경고한다.
  • 공통 배율은 UILayerSettings에서 관리한다. 값을 바꾸면 열린 UI와 Dim에 다시 적용되고, 풀에서 다시 여는 UI도 따라간다. UIScreen은 씬에 설정된 배율을 유지하며, 공통 값과 다르면 경고한다.

UI별 Canvas는 갱신 범위를 분리하고 편집·실행 환경의 설정을 일치시키기 위한 구조다.

얻는 것 설명
배칭 갱신 범위 분리 한 UI의 변경으로 다른 UI의 Canvas 배칭까지 다시 계산하지 않도록 분리한다
가림 처리 비용 절감 가려진 UI의 루트 Canvas를 끄고 포인터 입력을 차단한다. 규약을 따르는 중첩 Canvas도 함께 숨겨진다
편집·실행 환경의 설정 일치 프리팹의 Canvas가 실행 중에도 루트로 유지되며, 편집 시 공통 렌더 설정을 적용할 수 있다

스택에 참여하는 각 UI는 루트 Canvas 하나를 사용하며, Dim은 별도 Canvas를 사용한다. 스크롤 등에는 서브 Canvas를 추가할 수 있고, 풀에 보관된 UI도 Canvas를 유지한다. 레이어 그룹에는 Canvas가 없다. UI별 CanvasScaler는 공통 설정을 따르되, UIScreen의 씬 설정은 유지한다.

3. 정렬 번호는 손으로 매기지 않는다

레이어마다 BaseSortingOrder부터 열린 UI 하나가 두 칸을 쓴다. 앞 칸은 Dim 자리, 뒤 칸이 UI 루트다.

레이어 Base                         Popup 레이어에 ConfirmPopup, 그 위에 RewardPopup

Toast    4000                      2003  RewardPopup    ← 최상단 모달
Overlay  3000                      2002  Dim            ← 주인 바로 아래 칸
Popup    2000                      2001  ConfirmPopup   ← Dim 아래로 가려진다
Window   1000                      2000  (ConfirmPopup 의 Dim 자리)
Screen      0

스택이 바뀔 때마다 레이어별로 앞에서부터 다시 매기므로 닫힌 자리에 구멍이 남지 않는다. 바뀐 값만 Canvas에 쓴다. 열린 UI × 2가 레이어 용량을 넘으면 스택을 바꾸기 전에 열기를 거절한다. 레이어 구간이 서로 겹치거나 Canvas 정렬 값 범위(-32768~32767)를 벗어나면 매니저를 만들 때 알린다.

4. 스택과 가림 · 입력

열린 UI는 전부 하나의 평평한 스택에 (레이어, 열린 순서) 오름차순으로 들어간다. 씬에 배치된 UI와 프리팹에서 생성된 UI를 같은 기준으로 관리한다.

_stack (아래 → 위)                sortingOrder   표시   입력   비고

  [4] SystemToast   (Toast)          4001         ✓      ✗    입력 통과
  [3] ConfirmPopup  (Popup)          2001         ✓      ✓    ← Dim 소유
       └ UIDim                       2000         ✓           여기서 입력이 막힌다
  [2] ShopWindow    (Window)         1001         ✓      ✗    아래를 숨긴다
  [1] LobbyScreen   (Screen, 씬)        1         ✗      ✗    루트 Canvas 꺼짐

UI가 열리거나 닫힐 때마다 스택을 위에서부터 한 번 훑어 상태를 다시 계산한다. 계산은 UIStackState로 떼어 내 Unity 오브젝트 없이 테스트한다.

여기서 입력 차단은 마우스·터치 등 포인터 입력을 뜻한다. 키보드·패드의 선택 포커스 이동과 복원은 아직 지원하지 않는다.

  • HideBelow(Window) 아래는 루트 Canvas를 끈다 — 렌더링과 포인터 입력을 차단한다. 오브젝트는 켜진 채라 UI의 상태와 스크롤 바인딩이 유지된다.
  • UseDim(Popup/Overlay) 아래는 보이게 두고 포인터 입력만 막은 뒤 OnCoveredChanged(true)로 통지한다.
  • 열기·닫기 연출 중인 UI는 해당 UI의 포인터 입력만 막는다. 닫히는 모달은 연출이 끝날 때까지 Dim과 아래 차단을 유지한다.
  • Toast는 아래 UI를 차단하지 않고 자체 포인터 입력도 받지 않는다.
  • Dim은 보이는 것 중 최상단 모달 하나에만 붙는다. 앱에 하나뿐이라 반투명이 겹쳐 짙어지지 않는다.

뒤로가기도 이 스택을 그대로 쓴다. 위에서부터 닫을 수 있는 것을 찾아 하나 닫고, 닫히면 안 되는 모달(BlockClose + Dim)을 만나면 거기서 소비하고 아래로 전파하지 않는다.

5. 생성과 풀

  • 프리팹은 꺼진 대기 영역에서 복제된다. Awake는 레이어 그룹으로 옮겨 켤 때 돌고, 정렬·표시·포인터 입력은 켜기 전에 정해진다.
  • Pooled 인 UI는 닫힐 때 먼저 꺼진 뒤 대기 영역으로 옮겨진다. 꺼지는 도중에 계층을 바꾸지 않는다. 다시 열면 같은 인스턴스를 쓴다.
  • 열기 연출이 취소되거나 실패하면 열린 적 없던 상태로 되돌린 뒤 예외를 다시 던진다. 닫기 연출이 실패해도 스택에서는 빠진다.
  • 해제(Dispose)된 매니저로는 열 수 없고, 프리팹을 불러오는 도중 해제되면 결과를 스택에 붙이지 않는다.

6. 씬 전환과 소유권

UIManager는 씬보다 오래 유지되므로, 씬이 언로드되면 해당 씬의 UI도 정리해야 한다. 이를 위해 UI별 소유 씬을 기록하고 SceneManager 이벤트에서 등록과 정리를 처리한다.

씬 전환 시 스택 처리

소유 씬은 이렇게 정해진다. 씬에서 자동 등록된 UI는 원래 배치된 씬을 소유 씬으로 기록한다. 프리팹에서 연 UI는 프리팹 로딩이 완료되어 스택에 등록하는 시점의 활성 씬을 기록한다. 로딩 중 활성 씬이 바뀌면 호출 시점과 다른 씬에 등록될 수 있다. 호출 시점의 씬을 기록하는 개선은 향후 계획에 포함돼 있다.

  • sceneLoaded — 그 씬에 배치된 UIBase를 전부 찾아 자동으로 스택에 편입한다. 씬마다 별도의 등록용 컴포넌트를 배치할 필요가 없다. 디자이너가 설정한 Canvas.sortingOrder 순서를 유지한 채 아래에서부터 넣는다.
  • sceneUnloaded — 그 씬이 소유하던 뷰를 스택에서 빼고, 남은 UI의 정렬 번호를 다시 매긴다. 이 이벤트에서 씬 소유 UI를 정리해 파괴된 객체의 참조가 스택에 남지 않도록 한다. 이 시점에 Unity가 이미 파괴해 fake-null이 된 참조도 함께 걷어낸다.

UI의 정리 방식은 생성 방식과 풀링 설정에 따라 달라진다.

생성 방식 닫을 때 소유 씬이 언로드될 때
씬에서 자동 등록됨 (IsSceneOwned) 비활성화만 한다 소유 씬과 함께 정리한다
프리팹에서 열림 · Pooled 대기 영역의 풀로 반납 풀로 반납
프리팹에서 열림 · 그 외 파괴 파괴

LoadSceneMode.Additive로 겹쳐 올린 씬을 내릴 때도 판단 기준은 같다. 그 씬이 소유한 것만 빠지고 아래 씬의 UI는 건드리지 않는다. 정리가 끝나면 상태를 다시 계산하므로, 위가 걷힌 자리의 UI가 알아서 다시 보인다.

7. 별도 부트스트랩 씬 없이 초기화한다

별도의 부트스트랩 씬이 필요하지 않다. 설정이 유효하고 초기화에 성공하면, 작업 중인 씬에서 Play를 눌러도 영속 오브젝트가 생성되고 UIManager.Instance가 준비된다. 초기화 씬으로 이동하지 않고 현재 씬에서 UI를 확인할 수 있다.

SubsystemRegistration  ─→  Instance 되돌리기 (도메인 리로드를 꺼도 안전하게)
BeforeSceneLoad        ─→  UIBootstrap.Create()  →  UIManager.Instance
───────────────────────────────────────────────────────────────────────
첫 씬의 Awake                 ← 초기화 성공 시 Instance 사용 가능

UIBootstrap은 Resources/UIBootstrap.asset을 읽어 UIRoot를 DontDestroyOnLoad로 올리고 UIManager를 생성한다. 조립 전담이라 internal이고, 게임 코드에는 보이지 않는다. 조립은 이 훅 한 곳에서만 일어나며, 실패하면 사유가 로그로 남고 Instance는 null로 남는다.

이 구조에서 UIManager의 정적 멤버는 Instance 하나이며, 필요한 의존성은 생성자로 전달한다. 그래서 테스트에서 new UIManager(rootProvider, prefabProvider, layers, dim)이 되고, DI 컨테이너를 쓰는 프로젝트는 UIBootstrap을 버리고 컨테이너에 등록만 하면 된다. SortingOrderAllocator를 받던 이전 생성자는 [Obsolete]로 남아 새 생성자로 넘긴다.

BeforeSceneLoad 훅끼리의 순서는 Unity가 보장하지 않는다. 다른 초기화 훅에서 UI를 건드려야 한다면 AfterSceneLoad로 미루는 편이 안전하다.


재사용 스크롤

RecyclableScrollView는 데이터가 몇 개든 화면에 보이는 만큼만 셀을 만들어 돌려 쓰는 스크롤 뷰다. 입력·드래그·관성은 같은 오브젝트의 uGUI ScrollRect가 맡고, Content 크기와 셀 배치·재사용은 이 컴포넌트가 맡는다.

// 셀: ScrollItem<T> 를 상속한 구체 클래스를 셀 프리팹 루트에 붙인다.
public sealed class InventorySlot : ScrollItem<ItemData>
{
    [SerializeField] private Text _name;

    protected override void OnBind(ItemData data, ScrollItemContext context) => _name.text = data.Name;
}

// 연결과 제어는 스크롤 뷰로만 한다.
_scroll.SetDataSource(new ScrollDataSource<ItemData, InventorySlot>(items));
_scroll.ScrollToIndex(50, ScrollAlignment.Center);
_scroll.VisibleRangeChanged += () => Debug.Log(_scroll.VisibleRange);
  • 1만 개를 넣어도 화면에 필요한 셀은 뷰포트와 앞뒤 여유 줄로 제한된다. 워밍업 이후 필요한 셀 수가 유지되는 일반적인 스크롤에서는 화면 밖으로 나간 셀을 새 인덱스로 직접 재사용한다. 필요한 수가 늘면 셀을 추가로 생성하거나 풀에서 활성화하고, 줄면 남는 셀을 비활성화한다.
  • 워밍업 이후 일정한 목록·뷰포트 조건에서 스크롤 내부 갱신 경로의 GC 무할당을 검사하는 테스트가 있다. 문자열 생성이나 이미지 로딩 등 셀 바인딩 코드의 할당은 별도다.
  • 수직 또는 수평 한 축, 리스트와 열(행) 수가 고정된 그리드를 지원한다.
  • 같은 프레임에 소스를 여러 번 바꿔도 마지막 상태로 한 번만 바인딩한다. 드래그 중 위치 이동은 손을 뗀 뒤 적용한다.
  • 셀은 풀링되므로 선택 같은 상태는 데이터 쪽에 둔다. 늦게 도착한 비동기 결과는 IsCurrentBinding으로 버린다.
  • 인스펙터의 기본 설정 적용 버튼이 Viewport·Content·마스크·입력 영역을 구성한다.
  • 아직 없는 것: 가변 크기 셀, 여러 종류의 셀 프리팹, 추가 로딩, 순환 그리드.

에디터에서 만드는 순서

  1. UIBase 아래에 빈 UI 오브젝트를 만든다.
  2. RectTransform으로 스크롤 영역을 지정한다.
  3. RecyclableScrollView를 추가한다. ScrollRect도 함께 추가된다.
  4. 인스펙터에서 기본 설정 적용을 누른다.
  5. 셀 프리팹과 셀 크기를 지정하고 데이터 소스를 연결한다.

기본 설정은 루트 영역을 유지하면서 Viewport / Content, 마스크, 투명 입력 영역, 서브 Canvas와 GraphicRaycaster를 구성한다. 서브 Canvas는 overrideSorting = false로 부모 UI의 정렬을 따르고, 별도 CanvasScaler는 추가하지 않는다. 기존 연결과 콘텐츠는 재사용하며, 보조 컴포넌트는 접어서 표시한다. 반복 적용과 Undo/Redo를 지원한다.

순환 스크롤 (Loop)

인스펙터의 Loop 를 켜면 같은 컴포넌트가 목록을 양방향으로 끝없이 반복한다. 셀·데이터 소스·풀링·적용 시점은 유한 목록과 같다.

_carousel.Loop = true;
_carousel.ScrollToIndex(3, ScrollAlignment.Center);   // 가장 가까운 반복 위치로
  • 한 줄 리스트만 지원한다. 열 수가 2 이상이면 설정 오류로 멈추고, 스크롤 방향 패딩은 쓰지 않는다.
  • 셀은 원점 기준 상대 위치에 놓인다. Content 좌표가 멀어지면 셀 간격 단위로 원점을 옮겨 좌표를 작게 유지한다. 이 보정은 드래그 중이 아닐 때만 하며 화면 위치·바인딩을 바꾸지 않고, ScrollRect 가 기록한 이전 위치까지 맞춰 관성이 튀지 않는다.
  • 보이는 구간은 VisibleDisplayRange 로 읽는다. 반복 경계에서 항목 구간이 끊겨 VisibleRange 를 읽으면 예외가 난다. 표시 인덱스의 항목 인덱스는 ToDataIndex 로 구한다.
  • RefreshItem 은 같은 항목을 표시하는 모든 칸을 갱신하고, Reload(true) 는 뷰포트 시작 항목을 유지한다.
  • 켜 두는 동안 ScrollRect 의 이동 방식은 Unrestricted, 위치 스크롤바는 해제로 동작한다. 끄면 켜기 전 값으로 되돌린다.
  • 실행 중 켜고 꺼도 뷰포트 시작 항목을 유지한 채 전환한다. 드래그 중이면 손을 뗀 뒤에 적용된다.

셀 수명, 데이터가 바뀔 때 부를 API, 팝업 안에서 쓰는 법, 문제 해결까지는 **재사용 스크롤 가이드**에 정리했다. 가이드는 패키지에 함께 들어간다.


에디터 도구

편집 도구는 UISystem.Editor 어셈블리에 분리되어 플레이어 빌드에 포함되지 않는다. 컴포넌트를 추가할 때 적용하는 기본값은 런타임 타입의 에디터 전용 코드에서도 처리한다.

도구 하는 일
UI 스크립트를 붙일 때 요구 컴포넌트가 따라 붙고 공통 배율이 들어간다. 원본은 Resources/UIBootstrap의 레이어 설정이고, 없으면 프로젝트에 하나뿐인 UILayerSettings다
UI System ▸ Canvas 설정 동기화 프로젝트의 UI·Dim 프리팹과 열린 씬의 루트 Canvas 구성을 공통 설정에 맞춘다. 바뀐 프리팹만 저장한다. 씬의 UIScreen 배율은 덮어쓰지 않고 다르면 알린다
UIElement 인스펙터의 기본 설정 적용 공통 설정은 RectTransform을 확인하고, 타입별 에디터가 필요한 구성을 추가한다. RecyclableScrollView는 Viewport·Content·마스크·입력 영역·서브 Canvas·Raycaster와 연결을 구성한다. 기존 연결과 콘텐츠를 재사용하며 실행 취소를 지원한다
스크롤 인스펙터의 Loop 검사 Loop 를 켜면 한 줄 리스트가 아니거나 스크롤 방향 패딩이 남아 있을 때 인스펙터에서 바로 알린다

실행 중 UI 루트에 필요한 CanvasScaler·CanvasGroup·GraphicRaycaster가 없으면 매니저가 추가하고 경고한다. 스크롤의 Viewport·Content 자동 생성은 편집 시 기본 설정 적용으로 처리한다.


확장 포인트

프로젝트 요구에 따라 다음 기능을 교체하거나 확장할 수 있다.

확장 대상 방법
프리팹 로딩 (Addressables / 번들) IUIPrefabProvider를 구현한 ScriptableObject를 만들어 UIBootstrapSettings.PrefabProvider에 연결한다. Addressables 구현은 ReleaseAll()에서 로드 핸들을 해제해야 한다
레이어 구성 UILayerSettings에서 이름·BaseSortingOrder·용량·그룹 생성 여부와 공통 배율을 정의한다
루트 배치 IUIRootProvider를 직접 구현한다. 돌려주는 그룹과 그 부모에는 Canvas가 없어야 한다
연출 뷰에서 PlayOpenAsync / PlayCloseAsync를 재정의한다
조립 방식 UIManager는 생성자 주입만 쓴다. DI 컨테이너에 직접 등록해도 된다
스크롤 데이터 목록이 아닌 저장소나 선택 모델을 함께 넘길 때 IScrollDataSource를 구현한다
Element 기본 설정 UIElementEditor.ApplyDefaults를 확장하면 다른 Element도 같은 버튼을 쓴다

규약

  • UIScreen은 씬마다 하나다. 겹친 씬이 각자 Screen을 들고 오면 경고가 뜬다 — 그런 화면은 UIWindow가 맞다.
  • UI 스크립트는 프리팹 루트에 붙이고 그 Canvas를 루트로 쓴다. 위에 다른 Canvas가 있으면 열지 않는다.
  • UIRoot와 레이어 그룹에는 Canvas를 두지 않는다.
  • 배율·렌더 모드·정렬 번호는 UI마다 설정하지 않는다. 원본은 UILayerSettings 하나다. 씬의 UIScreen만 설정한 CanvasScaler를 존중하고, 공통 설정과 다르면 경고한다.
  • 하위 Canvas에 overrideSorting을 쓰지 않는다. UI 사이 정렬과 상위 입력 차단 밖에 놓인다(열 때 경고한다). 갱신이 잦은 영역을 떼어 낼 때는 overrideSorting 없는 Canvas를 둔다.
  • UI 루트의 CanvasGroup.blocksRaycasts는 시스템이 쓴다. 페이드 같은 연출용 CanvasGroup은 콘텐츠 아래에 둔다.
  • UIToast는 포인터 입력을 받지 않는다. GraphicRaycaster가 있어도 입력 그룹이 막는다.
  • UseDim인 UI를 쓰려면 UIRoot 프리팹에 UIDim이 있어야 한다. 없으면 반투명 배경과 배경 클릭 차단이 제공되지 않으므로 오류를 기록한다.
  • Reset을 재정의하면 base.Reset()을 호출한다. 그래야 새로 붙인 UI에 공통 배율이 들어간다.
  • 재사용 스크롤의 셀 프리팹에는 Canvas를 두지 않는다. 그 밖의 셀 규칙은 가이드에 있다.

샘플

SampleLobby 씬을 열고 Play 한다. 로비·전투 화면(SampleNavigationScreen 상속) 왼쪽 위에 상태 줄과 버튼이 있다.

버튼 확인하는 것
상대 씬으로 전환 (Single) 씬 소유 UI 정리와 새 씬 화면의 자동 편입
상대 씬 겹치기 (Additive) · 겹친 씬 내리기 겹친 씬 소유만 빠지고 아래 씬 UI는 남는다
확인 팝업 열기 프리팹 표 로딩, Dim과 정렬 번호
스크롤 리스트 팝업 열기 1,000개 리스트. 선택 모델과 RefreshItem, 섞은 뒤 Reload, 추가 후 ScrollToIndex, 직접 구현한 데이터 소스
스크롤 그리드 팝업 열기 1만 개 8열 그리드와 수평 리스트. VisibleRangeChanged로 보이는 범위와 셀 오브젝트 수 표시
순환 스크롤 팝업 열기 12개(개수 바꾸기로 3개)를 수평 캐러셀과 수직 리스트로 반복. 같은 항목의 모든 칸 선택 표시, 가장 가까운 반복 위치로 이동
최상단 닫기 (뒤로가기) OnBackPressed 전파 규칙

샘플 화면은 EventSystem이 없으면 하나 생성해 씬 전환 후에도 유지한다. 샘플 스크롤 팝업은 풀링된다. 샘플은 공통 배율 1920x1080(match 0) 기준이다. 팝업 패널은 1288x1000이고, 리스트 셀 폭 1184와 8열 그리드가 뷰포트 1208에 딱 맞는다.


저장소 구조와 개발

패키지 경계

Assets/UISystem/이 UPM 패키지 루트다. 사용 프로젝트가 받는 것은 이 폴더뿐이다.

저장소 패키지
Assets/UISystem/ — 런타임 · 에디터 도구 · 스크롤 가이드 · package.json · CHANGELOG.md ✓ ✓
Assets/Samples/ — 샘플 씬 · 프리팹 · 샘플 스크립트 ✓ ✗
Assets/Tests/ — EditMode · PlayMode 테스트 ✓ ✗
Assets/Resources/UIBootstrap.asset — 이 프로젝트의 부팅 설정 ✓ ✗
docs/ — 구조도 ✓ ✗

git URL 패키지는 ?path= 폴더를 통째로 복사하는 방식이라 파일 단위 제외 수단이 없다. 그래서 전달하지 않을 것은 경계 밖에 둔다. 샘플과 테스트가 Assets/UISystem/ 밖에 있는 이유다. 클론하면 샘플과 부팅 설정이 함께 포함되므로 SampleLobby 씬을 열고 바로 Play 할 수 있다.

폴더 구조

Assets/
├─ UISystem/                      ← 패키지 루트. 여기까지가 사용 프로젝트로 나간다
│  ├─ package.json
│  ├─ CHANGELOG.md
│  ├─ Documentation/
│  │  └─ RecyclableScroll.md         # 재사용 스크롤 가이드
│  ├─ Editor/                     · asmdef: UISystem.Editor (에디터 전용)
│  │  ├─ UICanvasSetupSync.cs        # Canvas 설정 동기화 메뉴
│  │  ├─ UIElementEditor.cs          # Element 공통 인스펙터 · 기본 설정 적용
│  │  └─ RecyclableScrollViewEditor.cs  # 스크롤 계층 기본 설정
│  └─ Runtime/                    · asmdef: UISystem.Runtime
│     ├─ Core/
│     │  ├─ UIBootstrap.cs           # (internal) 조립 전담. 어느 씬에서 Play 해도 영속 오브젝트를 생성한다
│     │  ├─ UIBootstrapSettings.cs   # RootPrefab / Layers / PrefabProvider 참조
│     │  ├─ UIManager.cs             # 스택, 열기/닫기, 정렬 번호, 씬 훅, 풀링. 접근점 UIManager.Instance
│     │  ├─ UIStackState.cs          # (internal) 표시·입력·가림·Dim 소유 UI 계산
│     │  ├─ UICanvasController.cs    # (internal) 루트 Canvas 구성·정렬·표시·입력, 공통 배율 적용
│     │  ├─ UILayerSettings.cs       # 레이어 정의 + 공통 배율
│     │  ├─ UIRoot.cs / UIRootProvider.cs  # 영속 루트, Canvas 없는 레이어 그룹
│     │  ├─ UIPrefabTable.cs         # 기본 프리팹 공급원 (타입 ↔ 프리팹 직접 참조)
│     │  ├─ IUIPrefabProvider.cs / IUIRootProvider.cs
│     │  ├─ SortingOrderAllocator.cs # 이전 구조의 정렬 구간 할당기. 매니저는 쓰지 않는다
│     │  └─ UITypes.cs               # UILayerId, UIViewOptions, UICloseReason, UIResult
│     ├─ Views/
│     │  ├─ UIBase.cs                # 스택에 참여하는 모든 UI의 베이스
│     │  ├─ UIScreen / UIWindow / UIPopup / UIOverlay / UIToast / UIElement
│     │  └─ UIDim.cs                 # 공유 반투명 판
│     └─ Components/Scrolling/       # RecyclableScrollView, ScrollItem, 데이터 소스, internal 레이아웃(유한·순환)·풀·가상화
├─ Samples/                       ← 패키지 밖. 저장소에만 있다
│  ├─ Runtime/                    · asmdef: UISystem.Samples → UISystem.Runtime 참조
│  │  ├─ SampleNavigationScreen.cs   # 로비·전투 화면 공통 버튼
│  │  └─ Scroll/                     # 스크롤 샘플 데이터·셀·팝업
│  ├─ Scene/                      # SampleLobby, SampleBattle
│  └─ Generated/                  # UIRoot, 레이어 설정, 프리팹 표, 확인·스크롤 팝업, 셀 프리팹
├─ Tests/                         ← 패키지 밖
│  ├─ EditMode/                   · asmdef: UISystem.Tests.EditMode
│  └─ PlayMode/                   · asmdef: UISystem.Tests.PlayMode
└─ Resources/
   └─ UIBootstrap.asset           # 이 프로젝트의 부팅 설정. 패키지에 포함되지 않는다

샘플이 자체 asmdef를 갖고 UISystem.Runtime을 명시적으로 참조한다. Assembly-CSharp의 자동 참조에 의존하지 않으므로, 프로젝트에 패키지를 설치했을 때의 어셈블리 참조 상황이 이 저장소 안에서 그대로 재현된다. 런타임의 internal은 테스트 어셈블리와 패키지 에디터 어셈블리에만 연다(AssemblyInfo.cs의 InternalsVisibleTo).

테스트

Window ▸ General ▸ Test Runner 에서 EditMode와 PlayMode를 각각 돌린다.

어셈블리 보는 것
EditMode 스크롤 줄 배치와 보이는 범위 계산, 스택의 표시·입력·Dim 계산, Canvas 설정 동기화, 스크롤 기본 설정 적용과 실행 취소
PlayMode 스택 — 루트 Canvas와 정렬 번호·Dim 위치, 가림과 중첩 Canvas, 입력 차단과 Toast, 용량 초과·Canvas 부모 거절, 풀 재사용, 닫기 연출 실패 정리, 배율 변경 재적용
스크롤 — 가상화와 셀 직접 인계, 바인딩 수명과 세대, 드래그 중 이동, 축소 Reload, 훅 예외 격리와 재진입 금지, 보이는 범위 이벤트, 워밍업 뒤 할당 없음
순환 — 양방향 반복과 표시 인덱스, 원점 보정과 드래그 연속성, 기준 항목 유지, 모드 전환, 긴 목록의 인덱스 끝값

아래 CI는 Unity를 띄우지 않으므로 테스트는 로컬에서 돌린다. 파일 검사 통과와 테스트 통과는 별개다.

CI

Unity 에디터 실행이나 라이선스 없이 패키지 파일을 검사한다.

워크플로 시점 하는 일
Validate push · PR 패키지 정합성 검사, 사용 프로젝트가 받는 파일 목록을 Job Summary에 기록
Release v* 태그 정합성 검사 → 태그와 package.json 버전 대조 → CHANGELOG에서 노트 추출 → 릴리스 생성

검사 본체는 validate-package.sh 하나이고 로컬에서도 같은 것을 돌린다.

bash .github/scripts/validate-package.sh
  • 패키지 경계 — 경계 안에 씬·프리팹·샘플·Resources가 섞였는지. 위 규칙이 지켜지는지를 기계가 본다
  • meta 정합성 — 모든 에셋·폴더에 .meta가 있는지, 짝 잃은 .meta가 없는지
  • package.json — 필수 필드, name 형식, version이 유의적 버전인지
  • CHANGELOG — 현재 version에 해당하는 항목이 있는지
  • 라이선스 — package.json의 license 필드와 저장소 루트 LICENSE가 둘 다 있는지
  • asmdef — JSON 문법

meta 검사는 클론한 상태를 기준으로 돈다. git이 빈 폴더를 추적하지 않아 로컬에서는 보이지 않고 CI 에서만 드러나는 짝 잃은 .meta 가 실제로 여기서 잡혔다.

릴리스는 태그와 package.json 버전이 어긋나면 만들어지지 않는다. 태그와 패키지 버전이 다른 상태로 배포되는 것을 방지한다.


다음에 할 것

위에서부터 손댈 순서다.

할 일 내용
선택 영역 Canvas (UICanvasScope) 갱신이 잦은 영역을 떼어 낸 Canvas를 UI의 가림·입력 상태에 등록한다. 지금은 루트 Canvas를 끄면 함께 숨는 동작에 기댄다
포커스 제어 모달 아래로 키보드·패드 선택이 새지 않게 EventSystem 선택을 옮기고 복원한다. 지금은 포인터 입력만 막는다
열기·닫기 수명 정리 호출 시점의 소유 씬 기록, 닫힘 결과를 여러 대기자에게 배포, 훅 안에서 온 요청의 순차 처리
스크롤 확장 VisibleRangeChanged를 쓰는 추가 로딩, 가변 크기 셀, 순환 그리드
무결성 검사 툴 위 규약 위반을 에디터에서 한 번에 찾는다. 지금은 실행 시 경고와 Canvas 설정 동기화까지다
UIToast 자동 소멸 지속 시간과 타이머. 지금은 호출부가 직접 Close 한다
기준 해상도 프리셋 패키지 기본값은 1080x1920(세로)이고 샘플 프로젝트는 1920x1080(가로, match 0)으로 바꿔 쓴다. 프리셋으로 고르도록 한다
연출과 화면 전환 PlayOpenAsync와 PlayCloseAsync에서 사용할 공용 연출을 제공한다
Addressables 프로바이더 IUIPrefabProvider 구현체를 추가하고 로딩 방식 교체를 검증한다

진행 상황은 CHANGELOG에 남긴다.


라이선스

MIT. 저작권 표시만 남기면 상업적 사용·수정·재배포 모두 자유롭다.

라이선스 본문은 저장소 루트의 LICENSE 하나에 두고, 패키지에는 package.json의 "license": "MIT" 필드로만 표시한다. UniTask, NaughtyAttributes 등 다른 UPM 저장소와 같은 방식이다.

About

Unity 6 (uGUI) UI 스택 관리 시스템 — 레이어 캔버스, 정렬 순서 자동 배정, 씬 소유권 기반 수명 관리

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages