Files
XRLib/Assets/Scripts/UVC/UIToolkit/ToolBar/UTKToolBar.cs
2026-02-19 18:40:37 +09:00

571 lines
19 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
/// <summary>
/// UIToolkit 기반 툴바 메인 컴포넌트입니다.
/// 가로/세로 배치를 지원하며, 4가지 버튼 타입과 구분선을 렌더링합니다.
///
/// <para><strong>주요 기능:</strong></para>
/// <list type="bullet">
/// <item>가로/세로 배치 전환 (Orientation)</item>
/// <item>Standard, Toggle, Radio, Expandable 버튼 지원</item>
/// <item>구분선 지원</item>
/// <item>서브 메뉴 외부 클릭 감지 자동 닫기</item>
/// <item>테마 변경 지원 (UTKThemeManager 연동)</item>
/// <item>성능 최적화 (리소스 캐싱, Dictionary 추적)</item>
/// </list>
/// </summary>
/// <example>
/// <code>
/// // 1. 모델 생성
/// var model = new UTKToolBarModel();
/// model.AddStandardButton("Save", UTKMaterialIcons.Save);
/// model.AddSeparator();
/// model.AddToggleButton("Grid", false, UTKMaterialIcons.GridOn, UTKMaterialIcons.GridOff);
///
/// // 2. View 생성 및 추가
/// var toolbar = new UTKToolBar();
/// toolbar.Orientation = UTKToolBarOrientation.Horizontal;
/// uiDocument.rootVisualElement.Add(toolbar);
///
/// // 3. 툴바 빌드
/// toolbar.BuildToolBar(model);
///
/// // 4. 이벤트 구독
/// toolbar.OnAction += args => Debug.Log($"{args.Text}: {args.ActionType}");
///
/// // 5. 배치 방향 변경
/// toolbar.SetOrientation(UTKToolBarOrientation.Vertical);
/// </code>
/// </example>
[UxmlElement]
public partial class UTKToolBar : VisualElement, IDisposable
{
#region Constants
private const string UXML_PATH = "UIToolkit/ToolBar/UTKToolBar";
private const string USS_PATH = "UIToolkit/ToolBar/UTKToolBarUss";
#endregion
#region Fields
private UTKToolBarOrientation _orientation = UTKToolBarOrientation.Horizontal;
private VisualElement? _itemContainer;
private readonly List<VisualElement> _itemElements = new();
private readonly Dictionary<string, UTKToolBarButtonBase> _buttonMap = new();
private readonly List<UTKToolBarExpandableButton> _expandableButtons = new();
private bool _disposed;
#endregion
#region UXML Attributes
/// <summary>
/// 툴바 배치 방향. 변경 시 즉시 레이아웃이 전환됩니다.
/// </summary>
[UxmlAttribute("orientation")]
public UTKToolBarOrientation Orientation
{
get => _orientation;
set
{
if (_orientation != value)
{
_orientation = value;
ApplyOrientation();
}
}
}
/// <summary>아이템 간 간격 (px)</summary>
[UxmlAttribute("item-spacing")]
public float ItemSpacing { get; set; } = 2f;
/// <summary>툴바 크기 (가로 시 높이, 세로 시 너비)</summary>
[UxmlAttribute("toolbar-size")]
public float ToolBarSize { get; set; } = 40f;
#endregion
#region Events
/// <summary>버튼 액션 이벤트</summary>
public event Action<UTKToolBarActionEventArgs>? OnAction;
#endregion
#region Constructor
/// <summary>
/// UTKToolBar의 새 인스턴스를 초기화합니다.
/// </summary>
public UTKToolBar()
{
// 1. 테마 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 2. USS 로드
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
{
styleSheets.Add(uss);
}
// 3. UI 생성
CreateUI();
// 4. 배치 방향 적용
ApplyOrientation();
// 5. 테마 구독
SubscribeToThemeChanges();
}
#endregion
#region Setup
/// <summary>
/// UXML/USS 로드 및 UI 구성.
/// </summary>
private void CreateUI()
{
var asset = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (asset != null)
{
var root = asset.Instantiate();
root.style.flexGrow = 1;
_itemContainer = root.Q<VisualElement>("toolbar-container");
Add(root);
}
else
{
// Fallback
AddToClassList("utk-toolbar");
_itemContainer = new VisualElement();
_itemContainer.AddToClassList("utk-toolbar__container");
Add(_itemContainer);
}
}
#endregion
#region Public Methods
/// <summary>
/// 모델 데이터로 툴바를 생성합니다.
/// </summary>
/// <param name="model">툴바 데이터 모델</param>
public void BuildToolBar(UTKToolBarModel model)
{
ClearToolBar();
if (_itemContainer == null || model == null) return;
foreach (var item in model.Items)
{
var element = CreateItemElement(item);
if (element != null)
{
_itemContainer.Add(element);
_itemElements.Add(element);
}
}
// 아이템 간격 적용
ApplyItemSpacing();
}
/// <summary>
/// 툴바의 모든 아이템을 제거합니다.
/// </summary>
public void ClearToolBar()
{
// 서브 메뉴 닫기
CloseAllSubMenus();
// 버튼 정리
foreach (var kvp in _buttonMap)
{
kvp.Value.OnButtonClicked -= OnItemClicked;
kvp.Value.Dispose();
}
_buttonMap.Clear();
// 확장 버튼 리스트 정리
_expandableButtons.Clear();
// 구분선 정리
foreach (var element in _itemElements)
{
if (element is UTKToolBarSeparator separator)
{
separator.Dispose();
}
}
_itemElements.Clear();
_itemContainer?.Clear();
}
/// <summary>
/// 특정 버튼의 활성화 상태를 변경합니다.
/// </summary>
/// <param name="itemId">아이템 ID</param>
/// <param name="isEnabled">활성화 여부</param>
public void SetButtonEnabled(string itemId, bool isEnabled)
{
if (_buttonMap.TryGetValue(itemId, out var button))
{
button.SetDataEnabled(isEnabled);
}
}
/// <summary>
/// 배치 방향을 변경합니다.
/// </summary>
/// <param name="orientation">새 배치 방향</param>
public void SetOrientation(UTKToolBarOrientation orientation)
{
Orientation = orientation;
}
/// <summary>
/// 특정 버튼 요소를 가져옵니다.
/// </summary>
/// <param name="itemId">아이템 ID</param>
/// <param name="button">찾은 버튼 (out)</param>
/// <returns>존재 여부</returns>
public bool TryGetButtonElement(string itemId, out UTKToolBarButtonBase? button)
{
return _buttonMap.TryGetValue(itemId, out button);
}
#endregion
#region Private Methods
/// <summary>
/// 배치 방향을 적용합니다. CSS 클래스를 전환합니다.
/// </summary>
private void ApplyOrientation()
{
RemoveFromClassList("utk-toolbar--horizontal");
RemoveFromClassList("utk-toolbar--vertical");
// 루트 요소에도 클래스 적용 (자손 선택자 동작을 위해)
var root = this.Q<VisualElement>("toolbar-root");
root?.RemoveFromClassList("utk-toolbar--horizontal");
root?.RemoveFromClassList("utk-toolbar--vertical");
if (_orientation == UTKToolBarOrientation.Horizontal)
{
AddToClassList("utk-toolbar--horizontal");
root?.AddToClassList("utk-toolbar--horizontal");
}
else
{
AddToClassList("utk-toolbar--vertical");
root?.AddToClassList("utk-toolbar--vertical");
}
// 확장 버튼의 방향 업데이트
foreach (var expandable in _expandableButtons)
{
expandable.CurrentOrientation = _orientation;
}
// 아이템 간격 재적용 (방향에 따라 margin 축이 변경됨)
ApplyItemSpacing();
// 열린 서브 메뉴 닫기 (위치 재계산 필요)
CloseAllSubMenus();
}
/// <summary>
/// 아이템 간격을 적용합니다. 첫 번째 아이템을 제외하고 방향에 맞는 margin을 설정합니다.
/// </summary>
private void ApplyItemSpacing()
{
bool isHorizontal = _orientation == UTKToolBarOrientation.Horizontal;
for (int i = 0; i < _itemElements.Count; i++)
{
var element = _itemElements[i];
if (i == 0)
{
element.style.marginLeft = 0;
element.style.marginTop = 0;
}
else
{
if (isHorizontal)
{
element.style.marginLeft = ItemSpacing;
element.style.marginTop = 0;
}
else
{
element.style.marginTop = ItemSpacing;
element.style.marginLeft = 0;
}
}
}
}
/// <summary>
/// 모델 아이템으로 개별 버튼/구분선을 생성합니다.
/// </summary>
/// <param name="item">아이템 데이터</param>
/// <returns>생성된 VisualElement</returns>
private VisualElement? CreateItemElement(IUTKToolBarItem item)
{
switch (item)
{
case UTKToolBarRadioButtonData radioData:
{
var button = new UTKToolBarRadioButton();
button.BindData(radioData);
button.OnButtonClicked += OnItemClicked;
_buttonMap[radioData.ItemId] = button;
// 라디오 버튼 선택 변경 → OnAction 이벤트
radioData.OnToggleStateChanged += (isSelected) =>
{
if (isSelected)
{
RaiseOnAction(radioData.GroupName, UTKToolBarActionType.Radio, radioData.Text);
}
};
return button;
}
case UTKToolBarToggleButtonData toggleData:
{
var button = new UTKToolBarToggleButton();
button.BindData(toggleData);
button.OnButtonClicked += OnItemClicked;
_buttonMap[toggleData.ItemId] = button;
// 토글 상태 변경 → OnAction 이벤트
toggleData.OnToggleStateChanged += (isSelected) =>
{
RaiseOnAction(toggleData.Text, UTKToolBarActionType.Toggle, isSelected);
};
return button;
}
case UTKToolBarExpandableButtonData expandableData:
{
var button = new UTKToolBarExpandableButton();
button.CurrentOrientation = _orientation;
button.BindData(expandableData);
button.OnButtonClicked += OnItemClicked;
_buttonMap[expandableData.ItemId] = button;
_expandableButtons.Add(button);
// 서브 버튼 선택 변경 → OnAction 이벤트
expandableData.OnSubButtonSelectionChanged += (mainText, subText) =>
{
RaiseOnAction(mainText, UTKToolBarActionType.Expandable, subText);
};
return button;
}
case UTKToolBarStandardButtonData standardData:
{
var button = new UTKToolBarStandardButton();
button.BindData(standardData);
button.OnButtonClicked += OnItemClicked;
_buttonMap[standardData.ItemId] = button;
// 클릭 → OnAction 이벤트
standardData.OnClicked += () =>
{
RaiseOnAction(standardData.Text, UTKToolBarActionType.Standard, null);
};
return button;
}
case UTKToolBarSeparatorData:
{
var separator = new UTKToolBarSeparator();
return separator;
}
default:
return null;
}
}
/// <summary>
/// 아이템 클릭 핸들러 (공통).
/// </summary>
/// <param name="data">클릭된 버튼 데이터</param>
private void OnItemClicked(UTKToolBarButtonData data)
{
if (data is UTKToolBarExpandableButtonData)
{
// 다른 Expandable 버튼의 서브 메뉴 닫기 (클릭한 것만 남김)
foreach (var expandable in _expandableButtons)
{
if (expandable.IsSubMenuOpen && expandable.BoundData != data)
{
expandable.CloseSubMenu();
}
}
}
else
{
// 일반 버튼 클릭 시, 열린 서브 메뉴 모두 닫기
CloseAllSubMenus();
}
}
/// <summary>
/// 모든 열린 서브 메뉴를 닫습니다.
/// </summary>
private void CloseAllSubMenus()
{
foreach (var expandable in _expandableButtons)
{
if (expandable.IsSubMenuOpen)
{
expandable.CloseSubMenu();
}
}
}
/// <summary>
/// OnAction 이벤트를 발생시킵니다.
/// </summary>
/// <param name="text">버튼 텍스트</param>
/// <param name="actionType">액션 타입</param>
/// <param name="value">액션 값</param>
private void RaiseOnAction(string text, UTKToolBarActionType actionType, object? value = null)
{
OnAction?.Invoke(new UTKToolBarActionEventArgs
{
Text = text,
ActionType = actionType,
Value = value
});
}
/// <summary>
/// 외부 클릭 감지 (서브 메뉴 닫기용).
/// Panel의 PointerDownEvent를 캡처 단계에서 감지합니다.
/// </summary>
/// <param name="evt">포인터 다운 이벤트</param>
private void OnPanelPointerDown(PointerDownEvent evt)
{
// 열린 서브 메뉴가 없으면 무시
bool hasOpenSubMenu = false;
foreach (var expandable in _expandableButtons)
{
if (expandable.IsSubMenuOpen)
{
hasOpenSubMenu = true;
break;
}
}
if (!hasOpenSubMenu) return;
var target = evt.target as VisualElement;
if (target != null)
{
// 이 UTKToolBar 내부 클릭이면 무시 (개별 버튼이 처리)
var ancestor = target;
while (ancestor != null)
{
if (ancestor == this) return;
ancestor = ancestor.parent;
}
// panel.visualTree에 추가된 서브 메뉴 내부 클릭이면 무시
foreach (var expandable in _expandableButtons)
{
if (expandable.IsSubMenuOpen && expandable.IsInsideSubMenu(target))
{
return;
}
}
}
// 외부 클릭 → 모든 서브 메뉴 닫기
CloseAllSubMenus();
}
#endregion
#region Theme
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 패널 레벨 외부 클릭 감지 등록
if (panel != null)
{
panel.visualTree.RegisterCallback<PointerDownEvent>(OnPanelPointerDown, TrickleDown.TrickleDown);
}
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
// 패널 레벨 외부 클릭 감지 해제
if (evt.originPanel?.visualTree != null)
{
evt.originPanel.visualTree.UnregisterCallback<PointerDownEvent>(OnPanelPointerDown, TrickleDown.TrickleDown);
}
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
ClearToolBar();
// 테마 구독 해제
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
OnAction = null;
}
#endregion
}
}