# UIToolkit Top Menu UIToolkit 기반의 Top Menu 시스템입니다. Unity 6의 최신 UIToolkit 기능과 성능 최적화를 적용했습니다. ## 📋 목차 - [주요 기능](#주요-기능) - [파일 구조](#파일-구조) - [빠른 시작](#빠른-시작) - [사용 예제](#사용-예제) - [API 문서](#api-문서) - [메모리 관리](#메모리-관리) - [성능 최적화](#성능-최적화) --- ## 주요 기능 ✅ **UIToolkit 네이티브** - Unity 6 방식 ([UxmlElement], partial, [UxmlAttribute]) - UXML/USS 기반 UI 구조 - 테마 시스템 연동 (UTKThemeManager) - 다국어 지원 (LocalizationManager) ✅ **다양한 메뉴 타입** - 텍스트 메뉴 (UTKTopMenuItem) - 이미지 메뉴 (UTKTopMenuImageItem) - Material Icons 지원 (폰트 기반 아이콘) - 무제한 깊이의 서브메뉴 ✅ **완벽한 메모리 관리** - 모든 클래스 IDisposable 구현 - 이벤트 구독/해제 대칭 (RegisterCallback/UnregisterCallback) - Dictionary 캐싱으로 검색 최적화 (O(1)) ✅ **레이아웃 설정** - 메뉴 아이템 간격 조절 (ItemSpacing) - 가로/세로 정렬 전환 (Orientation: Horizontal / Vertical) - UXML 어트리뷰트 지원 (`item-spacing`, `orientation`) ✅ **프로그래밍 방식 제어** - ItemId로 Command 직접 실행 (`ExecuteCommand`) - ItemId로 메뉴 데이터 조회 (`TryGetMenuItemData`) ✅ **고성능 최적화** - **Lazy Loading**: 서브메뉴는 첫 클릭 시에만 생성 (메모리 절약) - **리소스 캐싱**: UXML/USS 리소스 1회만 로드 (반복 로드 방지) - **열린 메뉴 추적**: HashSet으로 O(n) → O(열린 메뉴 수)로 성능 향상 - **DisplayStyle 토글**: 생성/파괴 대신 숨김/표시 (레이아웃 재계산 최소화) - 쿼리 캐싱 (Q() 1회만 호출) - GC Alloc 최소화 (LINQ 미사용) --- ## 파일 구조 ``` Assets/Scripts/UVC/UIToolkit/Menu/ ├── UTKMenuItemData.cs # 메뉴 데이터 (텍스트 메뉴) ├── UTKMenuImageItemData.cs # 이미지 메뉴 데이터 (Material Icons 지원) ├── UTKTopMenuModel.cs # 데이터 모델 (Dictionary 캐싱) ├── UTKTopMenu.cs # View (VisualElement 기반, Lazy Loading) ├── UTKMenuItemBase.cs # 메뉴 아이템 베이스 클래스 ├── UTKTopMenuItem.cs # 텍스트 메뉴 아이템 컴포넌트 ├── UTKTopMenuImageItem.cs # 이미지 메뉴 아이템 컴포넌트 └── README.md Assets/Resources/UIToolkit/Menu/ ├── UTKTopMenu.uxml # 메인 메뉴 구조 ├── UTKTopMenuUss.uss # 메인 메뉴 스타일 ├── UTKMenuItem.uxml # 텍스트 메뉴 아이템 구조 ├── UTKMenuItemUss.uss # 텍스트 메뉴 아이템 스타일 ├── UTKMenuImageItem.uxml # 이미지 메뉴 아이템 구조 ├── UTKMenuImageItemUss.uss # 이미지 메뉴 아이템 스타일 ├── UTKSubMenuItem.uxml # 하위 메뉴 구조 └── UTKSubMenuItemUss.uss # 하위 메뉴 스타일 ``` --- ## 빠른 시작 ### 1. UIDocument에 메뉴 추가 ```csharp // 1. UIDocument 컴포넌트가 있는 GameObject 생성 var menuObject = new GameObject("TopMenu"); var uiDocument = menuObject.AddComponent(); // 2. UTKTopMenu를 UIDocument의 루트에 추가 var menuView = new UTKTopMenu(); uiDocument.rootVisualElement.Add(menuView); ``` ### 2. 메뉴 아이템 추가 ```csharp // Model 생성 var model = new UTKTopMenuModel(); // 파일 메뉴 생성 var fileMenu = new UTKMenuItemData("file", "menu_file"); fileMenu.AddSubMenuItem(new UTKMenuItemData( "file_new", "menu_file_new", new NewFileCommand(), shortcut: "Ctrl+N" )); fileMenu.AddSubMenuItem(new UTKMenuItemData( "file_open", "menu_file_open", new OpenFileCommand(), shortcut: "Ctrl+O" )); fileMenu.AddSubMenuItem(UTKMenuItemData.CreateSeparator()); fileMenu.AddSubMenuItem(new UTKMenuItemData( "file_save", "menu_file_save", new SaveFileCommand(), shortcut: "Ctrl+S" )); // Model에 추가 model.AddMenuItem(fileMenu); ``` ### 3. View와 Model 연결 ```csharp // Model의 메뉴 아이템을 View에 표시 menuView.CreateMenuItems(model.GetMenuItems(), menuView.MenuContainer, 0); // 메뉴 클릭 이벤트 구독 menuView.OnMenuItemClicked += (data) => { Debug.Log($"Menu clicked: {data.ItemId}"); data.Command?.Execute(data.CommandParameter); }; ``` --- ## 사용 예제 ### 전체 메뉴 시스템 구성 ```csharp using UnityEngine; using UVC.UIToolkit.Menu; using UVC.UI.Commands; using UnityEngine.UIElements; public class MenuSetup : MonoBehaviour { private UTKTopMenu? _menuView; private UTKTopMenuModel? _model; void Start() { // UIDocument 가져오기 var uiDocument = GetComponent(); // Model 생성 _model = new UTKTopMenuModel(); // 파일 메뉴 var fileMenu = new UTKMenuItemData("file", "menu_file"); fileMenu.AddSubMenuItem(new UTKMenuItemData("file_new", "menu_file_new", new NewFileCommand(), shortcut: "Ctrl+N")); fileMenu.AddSubMenuItem(new UTKMenuItemData("file_open", "menu_file_open", new OpenFileCommand(), shortcut: "Ctrl+O")); fileMenu.AddSubMenuItem(UTKMenuItemData.CreateSeparator()); fileMenu.AddSubMenuItem(new UTKMenuItemData("file_save", "menu_file_save", new SaveFileCommand(), shortcut: "Ctrl+S")); fileMenu.AddSubMenuItem(new UTKMenuItemData("file_exit", "menu_file_exit", new ExitCommand(), shortcut: "Alt+F4")); _model.AddMenuItem(fileMenu); // 편집 메뉴 var editMenu = new UTKMenuItemData("edit", "menu_edit"); editMenu.AddSubMenuItem(new UTKMenuItemData("edit_undo", "menu_edit_undo", null, shortcut: "Ctrl+Z")); editMenu.AddSubMenuItem(new UTKMenuItemData("edit_redo", "menu_edit_redo", null, shortcut: "Ctrl+Y")); editMenu.AddSubMenuItem(UTKMenuItemData.CreateSeparator()); editMenu.AddSubMenuItem(new UTKMenuItemData("edit_cut", "menu_edit_cut", null, shortcut: "Ctrl+X")); editMenu.AddSubMenuItem(new UTKMenuItemData("edit_copy", "menu_edit_copy", null, shortcut: "Ctrl+C")); editMenu.AddSubMenuItem(new UTKMenuItemData("edit_paste", "menu_edit_paste", null, shortcut: "Ctrl+V")); _model.AddMenuItem(editMenu); // 도움말 메뉴 var helpMenu = new UTKMenuItemData("help", "menu_help"); helpMenu.AddSubMenuItem(new UTKMenuItemData("help_about", "menu_help_about", new ShowAboutCommand())); _model.AddMenuItem(helpMenu); // View 생성 및 연결 _menuView = new UTKTopMenu(); uiDocument.rootVisualElement.Add(_menuView); _menuView.CreateMenuItems(_model.GetMenuItems(), _menuView.MenuContainer, 0); // 이벤트 구독 _menuView.OnMenuItemClicked += OnMenuItemClicked; } void OnMenuItemClicked(UTKMenuItemData data) { Debug.Log($"Menu clicked: {data.ItemId}"); data.Command?.Execute(data.CommandParameter); } void OnDestroy() { if (_menuView != null) { _menuView.OnMenuItemClicked -= OnMenuItemClicked; _menuView.Dispose(); } _model?.Dispose(); } } ``` ### 동적으로 메뉴 아이템 활성화/비활성화 ```csharp // Model에서 메뉴 아이템 찾기 var undoItem = _model.FindMenuItem("edit_undo"); if (undoItem != null) { undoItem.IsEnabled = undoManager.CanUndo; } var redoItem = _model.FindMenuItem("edit_redo"); if (redoItem != null) { redoItem.IsEnabled = undoManager.CanRedo; } // View 갱신 _menuView.ClearMenuItems(); _menuView.CreateMenuItems(_model.GetMenuItems(), _menuView.MenuContainer, 0); ``` ### 단축키 업데이트 ```csharp // Model에서 메뉴 아이템 찾아서 단축키 변경 var saveItem = _model.FindMenuItem("file_save"); if (saveItem != null) { saveItem.Shortcut = "Ctrl+Shift+S"; } // View 갱신 _menuView.ClearMenuItems(); _menuView.CreateMenuItems(_model.GetMenuItems(), _menuView.MenuContainer, 0); ``` ### 이미지 메뉴 아이템 (Material Icons) ```csharp // Material Icon을 사용하는 이미지 메뉴 var settingsMenu = new UTKMenuImageItemData( "settings", UTKMaterialIcons.Settings, // Material Icon Unicode useMaterialIcon: true, imageSize: 24f ); settingsMenu.AddSubMenuItem(new UTKMenuItemData( "settings_preferences", "환경설정", new ShowPreferencesCommand() )); // 일반 이미지를 사용하는 이미지 메뉴 var customMenu = new UTKMenuImageItemData( "custom", "Icons/CustomIcon", // Resources 경로 useMaterialIcon: false, imageSize: 20f, imageColor: Color.white ); // Model에 추가 _model.AddMenuItem(settingsMenu); _model.AddMenuItem(customMenu); // View 갱신 _menuView.CreateMenuItems(_model.GetMenuItems(), _menuView.MenuContainer, 0); ``` ### 서브메뉴 위치 조정 ```csharp // 서브메뉴 위치 조정 if (_menuView != null) { // 오른쪽으로 10px, 아래로 5px 이동 _menuView.SubMenuOffsetX = 10f; _menuView.SubMenuOffsetY = 5f; } ``` ### 메뉴 아이템 간격 조절 ```csharp // 메뉴 아이템 간 간격 설정 if (_menuView != null) { _menuView.ItemSpacing = 8f; // 아이템 간 8px 간격 } ``` ### 세로 메뉴 (Vertical Orientation) ```csharp // 세로 메뉴로 전환 var sideMenu = new UTKTopMenu(); sideMenu.Orientation = UTKMenuOrientation.Vertical; // 세로 정렬 sideMenu.ItemSpacing = 4f; // 아이템 간 4px 간격 // 서브메뉴는 아이템 오른쪽에 표시됨 sideMenu.SubMenuOffsetX = 5f; ``` ### UXML에서 레이아웃 설정 ```xml ``` ### ItemId로 Command 실행 ```csharp // ItemId로 메뉴 아이템의 Command를 직접 실행 bool executed = _menuView.ExecuteCommand("file_save"); if (!executed) { Debug.Log("Command 실행 실패: 비활성화되었거나 Command가 없습니다."); } // 메뉴 데이터 조회 if (_menuView.TryGetMenuItemData("file_save", out var itemData)) { Debug.Log($"Enabled: {itemData?.IsEnabled}, Shortcut: {itemData?.Shortcut}"); } ``` --- ## API 문서 ### UTKTopMenuModel #### Public Methods | 메서드 | 설명 | 파라미터 | 반환 | |--------|------|----------|------| | `AddMenuItem(item)` | 메뉴 아이템 추가 | UTKMenuItemData | void | | `RemoveMenuItem(itemId)` | 메뉴 아이템 제거 | string | void | | `GetMenuItems()` | 모든 최상위 메뉴 아이템 가져오기 | - | List\ | | `FindMenuItem(itemId)` | ID로 메뉴 아이템 검색 (O(1)) | string | UTKMenuItemData? | | `Dispose()` | 리소스 정리 | - | void | ### UTKTopMenu #### Public Properties | 속성 | 타입 | UXML 어트리뷰트 | 설명 | |------|------|----------------|------| | `MenuContainer` | VisualElement? | - | 메뉴 아이템이 배치될 컨테이너 | | `SubMenuOffsetX` | float | - | 최상위 메뉴의 서브메뉴 X축 offset (픽셀) | | `SubMenuOffsetY` | float | - | 최상위 메뉴의 서브메뉴 Y축 offset (픽셀) | | `ItemSpacing` | float | `item-spacing` | 최상위 메뉴 아이템 간 간격 (픽셀, 기본값: 0) | | `Orientation` | UTKMenuOrientation | `orientation` | 정렬 방향 (Horizontal / Vertical, 기본값: Horizontal) | #### Public Methods | 메서드 | 설명 | |--------|------| | `CreateMenuItems(items, container, depth)` | 메뉴 아이템 생성 (Lazy Loading) | | `ClearMenuItems()` | 모든 메뉴 아이템 제거 | | `CloseAllOpenSubMenus()` | 모든 열린 서브메뉴 닫기 | | `TryGetMenuItemData(itemId, out itemData)` | ItemId로 메뉴 아이템 데이터 조회 (O(1)) | | `ExecuteCommand(itemId)` | ItemId로 Command 실행 (비활성화/Command 없으면 false) | | `Dispose()` | 리소스 정리 | #### Public Events | 이벤트 | 타입 | 설명 | |--------|------|------| | `OnMenuItemClicked` | Action\ | 메뉴 아이템 클릭 이벤트 | ### UTKMenuItemData (텍스트 메뉴) #### Constructor ```csharp public UTKMenuItemData( string itemId, string displayName, ICommand? command = null, object? commandParameter = null, List? subMenuItems = null, bool isSeparator = false, bool isEnabled = true, string? shortcut = null ) ``` #### Properties | 속성 | 타입 | 설명 | |------|------|------| | `ItemId` | string | 고유 식별자 | | `DisplayName` | string | 표시 이름 (다국어 키) | | `Command` | ICommand? | 실행할 명령 | | `CommandParameter` | object? | 명령 파라미터 | | `SubMenuItems` | List | 하위 메뉴 리스트 | | `IsSeparator` | bool | 구분선 여부 | | `IsEnabled` | bool | 활성화 상태 | | `Shortcut` | string? | 단축키 문자열 | | `Depth` | int | 메뉴 깊이 (0: 최상위) | | `Parent` | UTKMenuItemData? | 부모 메뉴 | #### Methods | 메서드 | 설명 | |--------|------| | `AddSubMenuItem(subItem)` | 하위 메뉴 추가 | | `CreateSeparator(itemId)` | 구분선 생성 (static) | | `HasSubMenuItem(itemId)` | 하위 메뉴 존재 확인 | | `Dispose()` | 리소스 정리 | ### UTKMenuImageItemData (이미지 메뉴) #### Constructor ```csharp public UTKMenuImageItemData( string itemId, string imagePath, // Material Icon Unicode 또는 이미지 경로 bool useMaterialIcon = true, // Material Icon 사용 여부 float imageSize = 20f, // 아이콘 크기 (픽셀) Color? imageColor = null, // 아이콘 색상 (null이면 기본 색상) ICommand? command = null, object? commandParameter = null, List? subMenuItems = null, bool isEnabled = true ) ``` #### Properties UTKMenuItemData의 모든 속성 포함 + | 속성 | 타입 | 설명 | |------|------|------| | `ImagePath` | string | Material Icon Unicode 또는 Resources 경로 | | `UseMaterialIcon` | bool | Material Icon 사용 여부 | | `ImageSize` | float | 아이콘 크기 (픽셀) | | `ImageColor` | Color? | 아이콘 색상 (null이면 기본 색상) | #### Example ```csharp // Material Icon 사용 var settingsMenu = new UTKMenuImageItemData( "settings", UTKMaterialIcons.Settings, // "\ue8b8" useMaterialIcon: true, imageSize: 24f, imageColor: Color.white ); // 일반 이미지 사용 var customMenu = new UTKMenuImageItemData( "custom", "Icons/CustomIcon", // Resources 경로 useMaterialIcon: false, imageSize: 20f ); ``` --- ## 메모리 관리 ### IDisposable 구현 모든 클래스가 IDisposable을 구현하여 안전한 리소스 정리를 보장합니다: ```csharp public class UTKMenuItemData : IDisposable { public void Dispose() { // 하위 메뉴 재귀적 정리 foreach (var subItem in SubMenuItems) subItem?.Dispose(); // Command가 IDisposable이면 정리 if (Command is IDisposable disposable) disposable.Dispose(); // 참조 정리 Command = null; CommandParameter = null; Parent = null; } } ``` ### 이벤트 구독/해제 모든 이벤트는 대칭적으로 구독/해제됩니다: ```csharp // ✅ 올바른 예 private EventCallback? _onClickCallback; void OnEnable() { _onClickCallback = OnButtonClicked; _button.RegisterCallback(_onClickCallback); } void OnDisable() { _button?.UnregisterCallback(_onClickCallback); } ``` ### Unity Profiler 확인 메모리 누수 점검 방법: 1. Unity Profiler 열기 (Window > Analysis > Profiler) 2. Memory 섹션 선택 3. 씬 전환 10회 후 메모리 증가 확인 4. Detailed View에서 UTKMenuItemData, UTKTopMenu 검색 --- ## 성능 최적화 ### Lazy Loading (서브메뉴 지연 생성) ```csharp // UTKTopMenu는 서브메뉴를 첫 클릭 시에만 생성합니다. // 초기 메모리 사용량을 대폭 감소시킵니다. // Before (모든 서브메뉴 사전 생성): // - 10개 메뉴 × 각 20개 서브메뉴 = 200개 VisualElement (항상 메모리에 상주) // After (Lazy Loading): // - 10개 메뉴만 생성 → 사용자가 클릭한 서브메뉴만 생성 // - 메모리: 10개 + (사용한 서브메뉴 수) // 결과: 초기 메모리 사용량 90% 감소 ``` ### 리소스 캐싱 ```csharp // UTKTopMenu는 UXML/USS 리소스를 클래스 레벨에서 캐싱합니다. // 서브메뉴 아이템을 여러 개 생성해도 Resources.Load는 1회만 호출됩니다. // 내부 구현 (UTKTopMenu.cs): // private VisualTreeAsset? _cachedSubMenuItemAsset; // private StyleSheet? _cachedSubMenuItemUss; // // if (_cachedSubMenuItemAsset == null) // { // _cachedSubMenuItemAsset = Resources.Load(...); // _cachedSubMenuItemUss = Resources.Load(...); // } ``` ### 열린 메뉴 추적 최적화 ```csharp // Before (전체 순회): O(n) // 외부 클릭 시 모든 서브메뉴 컨테이너를 순회 foreach (var subMenuContainer in _subMenuContainers.Values) // 100개 순회 { if (subMenuContainer.style.display == DisplayStyle.Flex) return; // SubMenu 내부 클릭 } // After (열린 메뉴만 추적): O(열린 메뉴 수) // HashSet으로 열린 서브메뉴만 추적 foreach (var menuId in _openSubMenuIds) // 2-3개만 순회 { if (_subMenuContainers.TryGetValue(menuId, out var subMenuContainer)) return; // SubMenu 내부 클릭 } // 결과: 외부 클릭 감지 성능 10-50배 향상 ``` ### 쿼리 캐싱 ```csharp // ❌ 나쁜 예: 매 프레임 쿼리 void Update() { var label = root.Q