571 lines
19 KiB
C#
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
|
|
}
|
|
}
|