UTKTopMenu 기능 추가

This commit is contained in:
logonkhi
2026-02-19 10:43:52 +09:00
parent 200a7faa6b
commit 0333b83b57
9 changed files with 457 additions and 50 deletions

View File

@@ -32,6 +32,15 @@ UIToolkit 기반의 Top Menu 시스템입니다. Unity 6의 최신 UIToolkit 기
- 이벤트 구독/해제 대칭 (RegisterCallback/UnregisterCallback)
- Dictionary 캐싱으로 검색 최적화 (O(1))
**레이아웃 설정**
- 메뉴 아이템 간격 조절 (ItemSpacing)
- 가로/세로 정렬 전환 (Orientation: Horizontal / Vertical)
- UXML 어트리뷰트 지원 (`item-spacing`, `orientation`)
**프로그래밍 방식 제어**
- ItemId로 Command 직접 실행 (`ExecuteCommand`)
- ItemId로 메뉴 데이터 조회 (`TryGetMenuItemData`)
**고성능 최적화**
- **Lazy Loading**: 서브메뉴는 첫 클릭 시에만 생성 (메모리 절약)
- **리소스 캐싱**: UXML/USS 리소스 1회만 로드 (반복 로드 방지)
@@ -49,7 +58,7 @@ Assets/Scripts/UVC/UIToolkit/Menu/
├── UTKMenuItemData.cs # 메뉴 데이터 (텍스트 메뉴)
├── UTKMenuImageItemData.cs # 이미지 메뉴 데이터 (Material Icons 지원)
├── UTKTopMenuModel.cs # 데이터 모델 (Dictionary 캐싱)
├── UTKTopMenuView.cs # View (VisualElement 기반, Lazy Loading)
├── UTKTopMenu.cs # View (VisualElement 기반, Lazy Loading)
├── UTKMenuItemBase.cs # 메뉴 아이템 베이스 클래스
├── UTKTopMenuItem.cs # 텍스트 메뉴 아이템 컴포넌트
├── UTKTopMenuImageItem.cs # 이미지 메뉴 아이템 컴포넌트
@@ -77,8 +86,8 @@ Assets/Resources/UIToolkit/Menu/
var menuObject = new GameObject("TopMenu");
var uiDocument = menuObject.AddComponent<UIDocument>();
// 2. UTKTopMenuView를 UIDocument의 루트에 추가
var menuView = new UTKTopMenuView();
// 2. UTKTopMenu를 UIDocument의 루트에 추가
var menuView = new UTKTopMenu();
uiDocument.rootVisualElement.Add(menuView);
```
@@ -142,7 +151,7 @@ using UnityEngine.UIElements;
public class MenuSetup : MonoBehaviour
{
private UTKTopMenuView? _menuView;
private UTKTopMenu? _menuView;
private UTKTopMenuModel? _model;
void Start()
@@ -178,7 +187,7 @@ public class MenuSetup : MonoBehaviour
_model.AddMenuItem(helpMenu);
// View 생성 및 연결
_menuView = new UTKTopMenuView();
_menuView = new UTKTopMenu();
uiDocument.rootVisualElement.Add(_menuView);
_menuView.CreateMenuItems(_model.GetMenuItems(), _menuView.MenuContainer, 0);
@@ -285,6 +294,55 @@ if (_menuView != null)
}
```
### 메뉴 아이템 간격 조절
```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
<!-- 가로 메뉴 (기본값) -->
<utk:UTKTopMenu item-spacing="8" orientation="Horizontal" />
<!-- 세로 메뉴 -->
<utk:UTKTopMenu item-spacing="4" orientation="Vertical" />
```
### 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 문서
@@ -301,15 +359,17 @@ if (_menuView != null)
| `FindMenuItem(itemId)` | ID로 메뉴 아이템 검색 (O(1)) | string | UTKMenuItemData? |
| `Dispose()` | 리소스 정리 | - | void |
### UTKTopMenuView
### UTKTopMenu
#### Public Properties
| 속성 | 타입 | 설명 |
|------|------|------|
| `MenuContainer` | VisualElement? | 메뉴 아이템이 배치될 컨테이너 |
| `SubMenuOffsetX` | float | 최상위 메뉴의 서브메뉴 X축 offset (픽셀) |
| `SubMenuOffsetY` | float | 최상위 메뉴의 서브메뉴 Y축 offset (픽셀) |
| 속성 | 타입 | UXML 어트리뷰트 | 설명 |
|------|------|----------------|------|
| `MenuContainer` | VisualElement? | - | 메뉴 아이템이 배치될 컨테이너 |
| `SubMenuOffsetX` | float | - | 최상위 메뉴의 서브메뉴 X축 offset (픽셀) |
| `SubMenuOffsetY` | float | - | 최상위 메뉴의 서브메뉴 Y축 offset (픽셀) |
| `ItemSpacing` | float | `item-spacing` | 최상위 메뉴 아이템 간 간격 (픽셀, 기본값: 0) |
| `Orientation` | UTKMenuOrientation | `orientation` | 정렬 방향 (Horizontal / Vertical, 기본값: Horizontal) |
#### Public Methods
@@ -318,6 +378,8 @@ if (_menuView != null)
| `CreateMenuItems(items, container, depth)` | 메뉴 아이템 생성 (Lazy Loading) |
| `ClearMenuItems()` | 모든 메뉴 아이템 제거 |
| `CloseAllOpenSubMenus()` | 모든 열린 서브메뉴 닫기 |
| `TryGetMenuItemData(itemId, out itemData)` | ItemId로 메뉴 아이템 데이터 조회 (O(1)) |
| `ExecuteCommand(itemId)` | ItemId로 Command 실행 (비활성화/Command 없으면 false) |
| `Dispose()` | 리소스 정리 |
#### Public Events
@@ -472,7 +534,7 @@ void OnDisable()
1. Unity Profiler 열기 (Window > Analysis > Profiler)
2. Memory 섹션 선택
3. 씬 전환 10회 후 메모리 증가 확인
4. Detailed View에서 UTKMenuItemData, UTKTopMenuView 검색
4. Detailed View에서 UTKMenuItemData, UTKTopMenu 검색
---
@@ -481,7 +543,7 @@ void OnDisable()
### Lazy Loading (서브메뉴 지연 생성)
```csharp
// UTKTopMenuView는 서브메뉴를 첫 클릭 시에만 생성합니다.
// UTKTopMenu는 서브메뉴를 첫 클릭 시에만 생성합니다.
// 초기 메모리 사용량을 대폭 감소시킵니다.
// Before (모든 서브메뉴 사전 생성):
@@ -497,10 +559,10 @@ void OnDisable()
### 리소스 캐싱
```csharp
// UTKTopMenuView는 UXML/USS 리소스를 클래스 레벨에서 캐싱합니다.
// UTKTopMenu는 UXML/USS 리소스를 클래스 레벨에서 캐싱합니다.
// 서브메뉴 아이템을 여러 개 생성해도 Resources.Load는 1회만 호출됩니다.
// 내부 구현 (UTKTopMenuView.cs):
// 내부 구현 (UTKTopMenu.cs):
// private VisualTreeAsset? _cachedSubMenuItemAsset;
// private StyleSheet? _cachedSubMenuItemUss;
//
@@ -622,7 +684,7 @@ subMenuContainer.style.display = DisplayStyle.Flex; // 표시
```csharp
if (view == null)
{
Debug.LogError("UTKTopMenuView를 찾을 수 없습니다.");
Debug.LogError("UTKTopMenu를 찾을 수 없습니다.");
}
```

View File

@@ -162,7 +162,7 @@ namespace UVC.UIToolkit
ItemId = data.ItemId;
DisplayName = data.DisplayName;
IsEnabled = data.IsEnabled;
Shortcut = data.Shortcut;
Shortcut = data.Shortcut ?? "";
UpdateUI();
}

View File

@@ -8,6 +8,17 @@ using UVC.Locale;
namespace UVC.UIToolkit
{
/// <summary>
/// 메뉴의 정렬 방향을 나타냅니다.
/// </summary>
public enum UTKMenuOrientation
{
/// <summary>가로 정렬 (기본값)</summary>
Horizontal,
/// <summary>세로 정렬</summary>
Vertical
}
/// <summary>
/// UIToolkit 기반 Top Menu의 View 레이어입니다.
/// 메뉴 아이템의 시각적 표현과 사용자 상호작용을 처리합니다.
@@ -20,6 +31,10 @@ namespace UVC.UIToolkit
/// <item>테마 변경 지원 (UTKThemeManager 연동)</item>
/// <item>외부 클릭으로 서브메뉴 자동 닫기</item>
/// <item>서브메뉴 위치 조정 (SubMenuOffsetX/Y)</item>
/// <item>메뉴 아이템 간격 조절 (ItemSpacing)</item>
/// <item>가로/세로 정렬 전환 (Orientation)</item>
/// <item>ItemId로 Command 실행 (ExecuteCommand)</item>
/// <item>ItemId로 메뉴 데이터 조회 (TryGetMenuItemData)</item>
/// <item>성능 최적화 (리소스 캐싱, Dictionary 추적)</item>
/// </list>
/// </summary>
@@ -28,7 +43,7 @@ namespace UVC.UIToolkit
/// // === 기본 사용법 ===
///
/// // 1. View 생성 및 UIDocument에 추가
/// var menuView = new UTKTopMenuView();
/// var menuView = new UTKTopMenu();
/// uiDocument.rootVisualElement.Add(menuView);
///
/// // 2. 메뉴 데이터 생성
@@ -78,6 +93,21 @@ namespace UVC.UIToolkit
/// menuView.SubMenuOffsetX = 10; // 오른쪽으로 10px 이동
/// menuView.SubMenuOffsetY = 5; // 아래쪽으로 5px 이동
///
/// // 8. 메뉴 아이템 간격 조절 (선택적)
/// menuView.ItemSpacing = 8; // 아이템 간 8px 간격
///
/// // 9. 세로 정렬 (선택적)
/// menuView.Orientation = UTKMenuOrientation.Vertical; // 세로 메뉴로 전환
///
/// // 10. ItemId로 Command 직접 실행 (선택적)
/// bool executed = menuView.ExecuteCommand("file_save"); // true: 실행됨, false: 비활성화/Command 없음
///
/// // 11. ItemId로 메뉴 데이터 조회 (선택적)
/// if (menuView.TryGetMenuItemData("file_save", out var data))
/// {
/// Debug.Log($"Enabled: {data?.IsEnabled}, Shortcut: {data?.Shortcut}");
/// }
///
///
/// // === 이벤트 처리 ===
///
@@ -150,6 +180,14 @@ namespace UVC.UIToolkit
/// <item>Dispose 시 모든 리소스 및 참조 정리</item>
/// </list>
///
/// <para><strong>레이아웃 설정:</strong></para>
/// <list type="bullet">
/// <item><strong>ItemSpacing:</strong> 최상위 메뉴 아이템 간 간격 (픽셀). 기본값 0</item>
/// <item><strong>Orientation:</strong> 가로(Horizontal, 기본값) 또는 세로(Vertical) 정렬 전환</item>
/// <item>세로 정렬 시 서브메뉴는 아이템 오른쪽에 표시됨</item>
/// <item>UXML에서 item-spacing, orientation 어트리뷰트로 설정 가능</item>
/// </list>
///
/// <para><strong>주의사항:</strong></para>
/// <list type="bullet">
/// <item>CreateMenuItems 호출 전에 MenuContainer가 null이 아닌지 확인</item>
@@ -158,7 +196,7 @@ namespace UVC.UIToolkit
/// </list>
/// </remarks>
[UxmlElement]
public partial class UTKTopMenuView : VisualElement, IDisposable
public partial class UTKTopMenu : VisualElement, IDisposable
{
#region Constants
@@ -192,6 +230,10 @@ namespace UVC.UIToolkit
private float _subMenuOffsetX = 0f;
private float _subMenuOffsetY = 0f;
// 메뉴 아이템 간격 및 정렬 방향
private float _itemSpacing = 0f;
private UTKMenuOrientation _orientation = UTKMenuOrientation.Horizontal;
// 리소스 캐싱 (성능 개선)
private VisualTreeAsset? _cachedSubMenuItemAsset;
private StyleSheet? _cachedSubMenuItemUss;
@@ -223,6 +265,36 @@ namespace UVC.UIToolkit
set => _subMenuOffsetY = value;
}
/// <summary>
/// 최상위 메뉴 아이템 간 간격 (픽셀 단위).
/// 0 이상의 값을 지정하면 각 아이템 사이에 margin이 적용됩니다.
/// </summary>
[UxmlAttribute("item-spacing")]
public float ItemSpacing
{
get => _itemSpacing;
set
{
_itemSpacing = value;
ApplyItemSpacing();
}
}
/// <summary>
/// 최상위 메뉴의 정렬 방향입니다.
/// Horizontal(기본값)은 가로 정렬, Vertical은 세로 정렬입니다.
/// </summary>
[UxmlAttribute("orientation")]
public UTKMenuOrientation Orientation
{
get => _orientation;
set
{
_orientation = value;
ApplyOrientation();
}
}
#endregion
#region Events
@@ -235,9 +307,9 @@ namespace UVC.UIToolkit
#region Constructor
/// <summary>
/// UTKTopMenuView의 새 인스턴스를 초기화합니다.
/// UTKTopMenu의 새 인스턴스를 초기화합니다.
/// </summary>
public UTKTopMenuView() : base()
public UTKTopMenu() : base()
{
// 1. 테마 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
@@ -540,6 +612,12 @@ namespace UVC.UIToolkit
}
}
}
// 최상위 메뉴인 경우 간격 적용
if (depth == 0)
{
ApplyItemSpacing();
}
}
/// <summary>
@@ -628,6 +706,37 @@ namespace UVC.UIToolkit
return _menuItemElements.TryGetValue(itemId, out menuItemElement);
}
/// <summary>
/// ItemId로 메뉴 아이템 데이터를 가져옵니다.
/// </summary>
/// <param name="itemId">메뉴 아이템 ID</param>
/// <param name="itemData">찾은 메뉴 아이템 데이터</param>
/// <returns>찾았으면 true, 그렇지 않으면 false</returns>
public bool TryGetMenuItemData(string itemId, out UTKMenuItemData? itemData)
{
return _menuItemDataMap.TryGetValue(itemId, out itemData);
}
/// <summary>
/// ItemId로 메뉴 아이템의 Command를 실행합니다.
/// Command가 없거나 비활성화된 아이템이면 false를 반환합니다.
/// </summary>
/// <param name="itemId">실행할 메뉴 아이템 ID</param>
/// <returns>Command가 실행되었으면 true, 그렇지 않으면 false</returns>
public bool ExecuteCommand(string itemId)
{
if (!_menuItemDataMap.TryGetValue(itemId, out var itemData))
return false;
if (!itemData.IsEnabled || itemData.Command == null)
return false;
itemData.Command.Execute(itemData.CommandParameter);
OnMenuItemClicked?.Invoke(itemData);
CloseAllOpenSubMenus();
return true;
}
/// <summary>
/// 모든 열린 하위 메뉴를 닫습니다.
/// </summary>
@@ -658,6 +767,83 @@ namespace UVC.UIToolkit
#endregion
#region Private Methods -
/// <summary>
/// 메뉴 아이템 간 간격을 적용합니다.
/// </summary>
private void ApplyItemSpacing()
{
if (_menuContainer == null) return;
for (int i = 0; i < _menuContainer.childCount; i++)
{
var child = _menuContainer[i];
if (_orientation == UTKMenuOrientation.Horizontal)
{
child.style.marginLeft = i > 0 ? _itemSpacing : 0;
child.style.marginTop = 0;
}
else
{
child.style.marginTop = i > 0 ? _itemSpacing : 0;
child.style.marginLeft = 0;
}
}
}
/// <summary>
/// 메뉴 컨테이너의 정렬 방향을 적용합니다.
/// </summary>
private void ApplyOrientation()
{
if (_menuContainer == null) return;
if (_orientation == UTKMenuOrientation.Horizontal)
{
_menuContainer.style.flexDirection = FlexDirection.Row;
_menuContainer.RemoveFromClassList("top-menu__items--vertical");
_menuContainer.AddToClassList("top-menu__items");
}
else
{
_menuContainer.style.flexDirection = FlexDirection.Column;
_menuContainer.RemoveFromClassList("top-menu__items");
_menuContainer.AddToClassList("top-menu__items--vertical");
}
// 간격도 방향에 맞게 재적용
ApplyItemSpacing();
// 최상위 메뉴 아이템의 화살표 표시 갱신
ApplyTopMenuArrows();
}
/// <summary>
/// 최상위 메뉴 아이템의 화살표 표시를 정렬 방향에 맞게 갱신합니다.
/// </summary>
private void ApplyTopMenuArrows()
{
if (_menuContainer == null) return;
foreach (var kvp in _menuItemDataMap)
{
var itemData = kvp.Value;
// 최상위 메뉴만 대상 (Depth == 0)
if (itemData.Depth != 0) continue;
if (_menuItemElements.TryGetValue(kvp.Key, out var element) && element is UTKMenuItemBase menuItem)
{
bool showArrow = _orientation == UTKMenuOrientation.Vertical
&& itemData.SubMenuItems != null
&& itemData.SubMenuItems.Count > 0;
menuItem.ShowArrow(showArrow);
}
}
}
#endregion
#region Private Methods -
/// <summary>
@@ -678,7 +864,12 @@ namespace UVC.UIToolkit
}
menuItem.SetData(itemData);
menuItem.ShowArrow(false); // 최상위 메뉴는 화살표 표시하지 않음
// 세로 정렬 시 서브메뉴가 있으면 화살표 표시
bool showArrow = _orientation == UTKMenuOrientation.Vertical
&& itemData.SubMenuItems != null
&& itemData.SubMenuItems.Count > 0;
menuItem.ShowArrow(showArrow);
// 클릭 이벤트 등록
menuItem.OnClicked += (data) =>
@@ -960,9 +1151,18 @@ namespace UVC.UIToolkit
if (depth == 0)
{
// 1차 메뉴: 아래쪽에 표시 (offset 적용)
subMenuContainer.style.left = menuItemRect.x + _subMenuOffsetX;
subMenuContainer.style.top = menuItemRect.yMax + _subMenuOffsetY;
if (_orientation == UTKMenuOrientation.Horizontal)
{
// 가로 정렬: 아래쪽에 표시 (offset 적용)
subMenuContainer.style.left = menuItemRect.x + _subMenuOffsetX;
subMenuContainer.style.top = menuItemRect.yMax + _subMenuOffsetY;
}
else
{
// 세로 정렬: 오른쪽에 표시 (offset 적용)
subMenuContainer.style.left = menuItemRect.xMax + _subMenuOffsetX;
subMenuContainer.style.top = menuItemRect.y + _subMenuOffsetY;
}
}
else
{

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2be8c935a6302b648a5c4ce5e6168a98