UTKToolBar 개발 완료

This commit is contained in:
logonkhi
2026-02-19 18:40:37 +09:00
parent 0333b83b57
commit 739a62eb9b
112 changed files with 7496 additions and 46 deletions

View File

@@ -0,0 +1,13 @@
#nullable enable
namespace UVC.UIToolkit
{
/// <summary>
/// 툴바에 추가될 수 있는 모든 항목(버튼, 구분선 등)의 기본 인터페이스입니다.
/// </summary>
public interface IUTKToolBarItem
{
/// <summary>아이템 고유 식별자</summary>
string ItemId { get; }
}
}

View File

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

View File

@@ -0,0 +1,198 @@
#nullable enable
using System;
using UVC.UI.Commands;
namespace UVC.UIToolkit
{
/// <summary>
/// 모든 툴바 버튼의 공통 데이터를 정의하는 추상 클래스입니다.
/// Text, Icon, Enabled, Tooltip, Command 등의 공통 속성과 상태 변경 이벤트를 제공합니다.
/// </summary>
public abstract class UTKToolBarButtonData : IUTKToolBarItem, IDisposable
{
#region Fields
private string _text = "";
private string? _iconPath;
private bool _isEnabled = true;
private bool _disposed;
#endregion
#region Properties
/// <summary>아이템 고유 식별자</summary>
public string ItemId { get; private set; }
/// <summary>
/// 버튼 텍스트 (다국어 키). 변경 시 OnStateChanged 이벤트 발생.
/// </summary>
public string Text
{
get => _text;
set
{
if (_text != value)
{
_text = value;
OnStateChanged?.Invoke();
}
}
}
/// <summary>
/// 아이콘 경로. Material Icon 유니코드 또는 Resources 경로.
/// 변경 시 OnStateChanged 이벤트 발생.
/// </summary>
public string? IconPath
{
get => _iconPath;
set
{
if (_iconPath != value)
{
_iconPath = value;
OnStateChanged?.Invoke();
}
}
}
/// <summary>Material Icon 사용 여부 (true: 폰트 아이콘, false: 이미지)</summary>
public bool UseMaterialIcon { get; set; } = true;
/// <summary>
/// 활성화 상태. 변경 시 OnStateChanged 이벤트 발생.
/// </summary>
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnStateChanged?.Invoke();
}
}
}
/// <summary>툴팁 텍스트 (다국어 키)</summary>
public string? Tooltip { get; set; }
/// <summary>실행할 명령</summary>
public ICommand? ClickCommand { get; set; }
#endregion
#region Events
/// <summary>Text, Icon, Enabled 등 시각적 상태 변경 시 발생</summary>
public event Action? OnStateChanged;
/// <summary>버튼 클릭 시 발생</summary>
public event Action? OnClicked;
#endregion
#region Constructor
/// <summary>
/// UTKToolBarButtonData의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="itemId">아이템 고유 식별자. null이면 GUID 자동 생성.</param>
protected UTKToolBarButtonData(string? itemId = null)
{
ItemId = itemId ?? Guid.NewGuid().ToString();
}
#endregion
#region Methods
/// <summary>
/// 클릭 실행. Command 실행 + OnClicked 이벤트 발생.
/// IUndoableCommand인 경우 UndoRedoManager를 통해 실행합니다.
/// </summary>
/// <param name="parameter">ClickCommand에 전달할 파라미터</param>
public virtual void ExecuteClick(object? parameter = null)
{
if (!IsEnabled) return;
if (ClickCommand != null)
{
if (ClickCommand is IUndoableCommand undoableCommand)
{
var undoRedoManager = UnityEngine.Object.FindAnyObjectByType<UVC.Studio.Manager.UndoRedoManager>();
if (undoRedoManager != null)
{
undoRedoManager.ExecuteCommand(undoableCommand, parameter);
}
else
{
ClickCommand.Execute(parameter);
}
}
else
{
ClickCommand.Execute(parameter);
}
}
OnClicked?.Invoke();
}
/// <summary>
/// OnStateChanged 이벤트를 수동으로 발생시킵니다.
/// 여러 속성을 변경 후 한 번에 UI 업데이트를 트리거할 때 사용합니다.
/// </summary>
public void NotifyStateChanged()
{
OnStateChanged?.Invoke();
}
/// <summary>
/// 모든 이벤트 핸들러를 해제합니다.
/// </summary>
public virtual void ClearEventHandlers()
{
OnStateChanged = null;
OnClicked = null;
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 정리합니다. Command가 IDisposable이면 함께 정리합니다.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// 리소스 정리 구현.
/// </summary>
/// <param name="disposing">관리 리소스 정리 여부</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
_disposed = true;
if (disposing)
{
ClearEventHandlers();
if (ClickCommand is IDisposable disposableCommand)
{
disposableCommand.Dispose();
}
ClickCommand = null;
}
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,144 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace UVC.UIToolkit
{
/// <summary>
/// 서브 버튼 목록을 가진 확장 가능한 버튼 데이터입니다.
/// 클릭 시 서브 메뉴를 표시하고, 서브 버튼 선택 시 메인 아이콘을 업데이트할 수 있습니다.
/// </summary>
public class UTKToolBarExpandableButtonData : UTKToolBarButtonData
{
#region Fields
private UTKToolBarButtonData? _selectedSubButton;
private string _originalText = "";
#endregion
#region Properties
/// <summary>서브 버튼 목록</summary>
public List<UTKToolBarButtonData> SubButtons { get; private set; } = new();
/// <summary>서브 버튼 선택 시 메인 아이콘 업데이트 여부</summary>
public bool UpdateIconOnSelection { get; set; }
/// <summary>현재 선택된 서브 버튼</summary>
public UTKToolBarButtonData? SelectedSubButton => _selectedSubButton;
/// <summary>원본 텍스트 (서브 버튼 선택 시 변경 전 저장용)</summary>
public string OriginalText => _originalText;
/// <summary>서브 버튼 선택 콜백</summary>
public Action<UTKToolBarButtonData>? OnSubButtonSelected { get; set; }
#endregion
#region Events
/// <summary>서브 버튼 선택 변경 이벤트 (mainText, selectedSubText)</summary>
public event Action<string, string>? OnSubButtonSelectionChanged;
#endregion
#region Constructor
/// <summary>
/// UTKToolBarExpandableButtonData의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="itemId">아이템 고유 식별자. null이면 GUID 자동 생성.</param>
public UTKToolBarExpandableButtonData(string? itemId = null) : base(itemId)
{
}
#endregion
#region Methods
/// <summary>
/// 원본 텍스트를 설정합니다. AddExpandableButton에서 호출됩니다.
/// </summary>
/// <param name="text">원본 텍스트</param>
public void SetOriginalText(string text)
{
_originalText = text;
}
/// <summary>
/// 서브 버튼을 선택합니다. UpdateIconOnSelection이 true이면 메인 아이콘도 업데이트합니다.
/// </summary>
/// <param name="selectedSubButton">선택할 서브 버튼</param>
public void SelectSubButton(UTKToolBarButtonData selectedSubButton)
{
if (selectedSubButton == null || !selectedSubButton.IsEnabled) return;
// 동일 버튼 재선택 시 무시
if (_selectedSubButton == selectedSubButton) return;
_selectedSubButton = selectedSubButton;
if (UpdateIconOnSelection)
{
if (Text != selectedSubButton.Text)
{
Text = selectedSubButton.Text;
}
if (IconPath != selectedSubButton.IconPath)
{
IconPath = selectedSubButton.IconPath;
}
}
OnSubButtonSelected?.Invoke(selectedSubButton);
OnSubButtonSelectionChanged?.Invoke(_originalText, selectedSubButton.Text);
}
/// <summary>
/// 클릭 실행. 기본 Command를 실행합니다.
/// 서브 메뉴 표시/숨김은 View에서 처리합니다.
/// </summary>
/// <param name="parameter">ClickCommand에 전달할 파라미터</param>
public override void ExecuteClick(object? parameter = null)
{
if (!IsEnabled) return;
base.ExecuteClick(parameter);
}
/// <summary>
/// 모든 이벤트 핸들러를 해제합니다. 서브 버튼의 핸들러도 정리합니다.
/// </summary>
public override void ClearEventHandlers()
{
base.ClearEventHandlers();
OnSubButtonSelected = null;
OnSubButtonSelectionChanged = null;
foreach (var subButton in SubButtons)
{
subButton.ClearEventHandlers();
}
}
/// <summary>
/// 리소스 정리. 서브 버튼도 재귀적으로 정리합니다.
/// </summary>
/// <param name="disposing">관리 리소스 정리 여부</param>
protected override void Dispose(bool disposing)
{
if (disposing)
{
foreach (var subButton in SubButtons)
{
subButton.Dispose();
}
SubButtons.Clear();
}
base.Dispose(disposing);
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,79 @@
#nullable enable
using System;
namespace UVC.UIToolkit
{
/// <summary>
/// 그룹 내 상호 배타적 선택을 지원하는 라디오 버튼 데이터입니다.
/// 동일한 GroupName을 가진 라디오 버튼들 중 하나만 선택됩니다.
/// </summary>
public class UTKToolBarRadioButtonData : UTKToolBarToggleButtonData
{
#region Properties
/// <summary>소속 라디오 그룹 이름</summary>
public string GroupName { get; private set; }
/// <summary>라디오 그룹 참조 (모델에서 설정)</summary>
internal UTKToolBarRadioButtonGroup? RadioGroup { get; set; }
#endregion
#region Constructor
/// <summary>
/// UTKToolBarRadioButtonData의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="groupName">소속 라디오 그룹 이름. null이거나 비어있을 수 없습니다.</param>
/// <param name="itemId">아이템 고유 식별자. null이면 GUID 자동 생성.</param>
/// <exception cref="ArgumentNullException">groupName이 null이거나 빈 문자열일 경우</exception>
public UTKToolBarRadioButtonData(string groupName, string? itemId = null) : base(itemId)
{
if (string.IsNullOrEmpty(groupName))
{
throw new ArgumentNullException(nameof(groupName), "라디오 버튼은 반드시 GroupName을 가져야 합니다.");
}
GroupName = groupName;
}
#endregion
#region Methods
/// <summary>
/// 클릭 시 그룹 내 다른 버튼은 해제하고 이 버튼만 선택합니다.
/// 선택된 상태에서만 ClickCommand가 실행됩니다.
/// </summary>
/// <param name="parameter">ClickCommand에 전달할 파라미터</param>
public override void ExecuteClick(object? parameter = null)
{
if (!IsEnabled) return;
if (RadioGroup != null)
{
RadioGroup.SetSelected(this);
}
else
{
UnityEngine.Debug.LogWarning($"UTKToolBarRadioButtonData '{Text}' (그룹: {GroupName})에 RadioGroup이 할당되지 않았습니다.");
}
// 선택된 상태에서만 Command 실행
if (IsSelected && ClickCommand != null)
{
ClickCommand.Execute(parameter ?? this);
}
}
/// <summary>
/// 모든 이벤트 핸들러를 해제합니다.
/// </summary>
public override void ClearEventHandlers()
{
base.ClearEventHandlers();
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18507fdc9b7c65649b9f5604b45532d8

View File

@@ -0,0 +1,165 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace UVC.UIToolkit
{
/// <summary>
/// 라디오 버튼 그룹을 관리합니다. 하나의 버튼만 선택 상태를 유지합니다.
/// </summary>
public class UTKToolBarRadioButtonGroup : IDisposable
{
#region Fields
private readonly List<UTKToolBarRadioButtonData> _buttons = new();
private bool _disposed;
#endregion
#region Properties
/// <summary>그룹 이름</summary>
public string GroupName { get; private set; }
/// <summary>현재 선택된 버튼</summary>
public UTKToolBarRadioButtonData? SelectedButton { get; private set; }
#endregion
#region Constructor
/// <summary>
/// UTKToolBarRadioButtonGroup의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="groupName">그룹 이름</param>
public UTKToolBarRadioButtonGroup(string groupName)
{
GroupName = groupName;
}
#endregion
#region Methods
/// <summary>
/// 버튼을 그룹에 등록합니다.
/// </summary>
/// <param name="button">등록할 라디오 버튼</param>
public void RegisterButton(UTKToolBarRadioButtonData button)
{
if (!_buttons.Contains(button))
{
_buttons.Add(button);
}
}
/// <summary>
/// 특정 버튼을 선택합니다. 나머지 버튼은 해제됩니다.
/// </summary>
/// <param name="buttonToSelect">선택할 버튼</param>
/// <param name="raiseEvent">이벤트 발생 여부</param>
public void SetSelected(UTKToolBarRadioButtonData? buttonToSelect, bool raiseEvent = true)
{
if (buttonToSelect != null && !_buttons.Contains(buttonToSelect))
{
UnityEngine.Debug.LogWarning($"SetSelected: 버튼 '{buttonToSelect.Text}'은 그룹 '{GroupName}'에 등록되어 있지 않습니다.");
return;
}
// 이미 선택된 버튼을 다시 클릭한 경우 무시
if (SelectedButton == buttonToSelect && buttonToSelect != null && buttonToSelect.IsSelected)
{
return;
}
SelectedButton = buttonToSelect;
foreach (var button in _buttons)
{
bool shouldBeSelected = (button == buttonToSelect);
button.SetSelected(shouldBeSelected, raiseEvent);
}
}
/// <summary>
/// 모든 선택을 해제합니다.
/// </summary>
/// <param name="raiseEvent">이벤트 발생 여부</param>
public void ClearSelection(bool raiseEvent = true)
{
SelectedButton = null;
foreach (var button in _buttons)
{
button.SetSelected(false, raiseEvent);
}
}
/// <summary>
/// 그룹 내 버튼 목록을 반환합니다.
/// </summary>
/// <returns>버튼 목록 (읽기 전용)</returns>
public IReadOnlyList<UTKToolBarRadioButtonData> GetButtons()
{
return _buttons.AsReadOnly();
}
/// <summary>
/// 텍스트로 버튼을 검색합니다.
/// </summary>
/// <param name="text">검색할 텍스트</param>
/// <returns>일치하는 버튼 또는 null</returns>
public UTKToolBarRadioButtonData? FindButtonByText(string text)
{
foreach (var button in _buttons)
{
if (string.Equals(button.Text, text, StringComparison.Ordinal))
{
return button;
}
}
return null;
}
/// <summary>
/// 초기 선택 상태를 적용합니다.
/// IsSelected가 true인 버튼이 있으면 해당 버튼을 선택합니다.
/// </summary>
internal void InitializeSelection()
{
if (_buttons.Count == 0) return;
UTKToolBarRadioButtonData? initialButton = null;
foreach (var button in _buttons)
{
if (button.IsSelected)
{
initialButton = button;
break;
}
}
if (initialButton != null)
{
SetSelected(initialButton);
}
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_buttons.Clear();
SelectedButton = null;
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,24 @@
#nullable enable
using System;
namespace UVC.UIToolkit
{
/// <summary>
/// 툴바 내 시각적 구분선 데이터입니다.
/// </summary>
public class UTKToolBarSeparatorData : IUTKToolBarItem
{
/// <summary>아이템 고유 식별자</summary>
public string ItemId { get; private set; }
/// <summary>
/// UTKToolBarSeparatorData의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="itemId">아이템 고유 식별자. null이면 GUID 자동 생성.</param>
public UTKToolBarSeparatorData(string? itemId = null)
{
ItemId = itemId ?? Guid.NewGuid().ToString();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96caf0e0ca9659b4eb7f8c1b56139813

View File

@@ -0,0 +1,18 @@
#nullable enable
namespace UVC.UIToolkit
{
/// <summary>
/// 단순 클릭 동작의 일반 버튼 데이터입니다.
/// </summary>
public class UTKToolBarStandardButtonData : UTKToolBarButtonData
{
/// <summary>
/// UTKToolBarStandardButtonData의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="itemId">아이템 고유 식별자. null이면 GUID 자동 생성.</param>
public UTKToolBarStandardButtonData(string? itemId = null) : base(itemId)
{
}
}
}

View File

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

View File

@@ -0,0 +1,135 @@
#nullable enable
using System;
namespace UVC.UIToolkit
{
/// <summary>
/// On/Off 상태를 가지는 토글 버튼 데이터입니다.
/// 클릭 시 IsSelected 상태가 반전되고, OnToggleStateChanged 이벤트가 발생합니다.
/// </summary>
public class UTKToolBarToggleButtonData : UTKToolBarButtonData
{
#region Fields
private bool _isSelected;
private string? _offIconPath;
#endregion
#region Properties
/// <summary>
/// 현재 선택(On) 상태. 변경 시 OnToggleStateChanged, OnStateChanged 이벤트 발생.
/// </summary>
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
OnToggle?.Invoke(_isSelected);
OnToggleStateChanged?.Invoke(_isSelected);
NotifyStateChanged();
}
}
}
/// <summary>Off 상태 아이콘 경로</summary>
public string? OffIconPath
{
get => _offIconPath;
set
{
if (_offIconPath != value)
{
_offIconPath = value;
NotifyStateChanged();
}
}
}
/// <summary>토글 상태 변경 시 콜백</summary>
public Action<bool>? OnToggle { get; set; }
#endregion
#region Events
/// <summary>IsSelected 상태 변경 시 발생</summary>
public event Action<bool>? OnToggleStateChanged;
#endregion
#region Constructor
/// <summary>
/// UTKToolBarToggleButtonData의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="itemId">아이템 고유 식별자. null이면 GUID 자동 생성.</param>
public UTKToolBarToggleButtonData(string? itemId = null) : base(itemId)
{
}
#endregion
#region Methods
/// <summary>
/// 이벤트 발생 여부를 선택하여 선택 상태를 설정합니다.
/// </summary>
/// <param name="isSelected">새로운 선택 상태</param>
/// <param name="raiseEvent">true이면 OnToggle 콜백을 호출, false이면 UI 이벤트만 발생</param>
public void SetSelected(bool isSelected, bool raiseEvent = true)
{
if (_isSelected != isSelected)
{
_isSelected = isSelected;
if (raiseEvent)
{
OnToggle?.Invoke(_isSelected);
}
OnToggleStateChanged?.Invoke(_isSelected);
NotifyStateChanged();
}
}
/// <summary>
/// 클릭 시 상태를 반전시키고 Command를 실행합니다.
/// </summary>
/// <param name="parameter">전달된 파라미터. bool이면 직접 상태 설정, 아니면 토글.</param>
public override void ExecuteClick(object? parameter = null)
{
if (!IsEnabled) return;
if (parameter is bool newState)
{
IsSelected = newState;
}
else
{
IsSelected = !IsSelected;
}
// Command 실행 (현재 IsSelected 상태를 파라미터로 전달)
if (ClickCommand != null)
{
ClickCommand.Execute(IsSelected);
}
}
/// <summary>
/// 모든 이벤트 핸들러를 해제합니다.
/// </summary>
public override void ClearEventHandlers()
{
base.ClearEventHandlers();
OnToggleStateChanged = null;
OnToggle = null;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 368ce741cc208274989e0f6e37be2d87