Files
EnglewoodLAB/Assets/Scripts/UVC/UIToolkit/Dropdown/UTKButtonDropdown.cs

593 lines
22 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
/// <summary>
/// UTKButton 하나를 트리거로 사용하는 드롭다운 컴포넌트.
/// 기본 표시는 외부에서 주입된 UTKButton 하나만 보이며, 클릭 시 팝업 목록이 열립니다.
/// 선택된 항목은 버튼 텍스트에 반영되고, 팝업 목록에서는 체크 아이콘으로 표시됩니다.
/// </summary>
/// <remarks>
/// <para><b>특징:</b></para>
/// <list type="bullet">
/// <item><description>기본 UI: <c>UTKButton</c> 하나만 표시</description></item>
/// <item><description>트리거 버튼은 생성자 기본값 또는 <c>SetButton()</c>으로 교체 가능</description></item>
/// <item><description>팝업: 루트 패널로 이동하여 다른 UI 위에 렌더링</description></item>
/// <item><description>선택된 항목은 체크 아이콘으로 표시</description></item>
/// <item><description>외부 클릭 시 팝업 자동 닫힘</description></item>
/// </list>
///
/// <para><b>주요 메서드:</b></para>
/// <list type="bullet">
/// <item><description><c>SetButton(UTKButton)</c> - 트리거 버튼 교체</description></item>
/// <item><description><c>SetOptions(List<string>)</c> - 옵션 목록 교체</description></item>
/// <item><description><c>SetSelectedIndex(int, bool)</c> - 인덱스로 선택</description></item>
/// <item><description><c>SetSelectedValue(string, bool)</c> - 값으로 선택</description></item>
/// </list>
///
/// <para><b>주요 이벤트:</b></para>
/// <list type="bullet">
/// <item><description><c>OnSelectionChanged</c> - 선택 변경 시 발생 (인덱스, 값 전달)</description></item>
/// </list>
/// </remarks>
/// <example>
/// <code>
/// // 기본 생성 (Language 아이콘 + Text variant)
/// var dropdown = new UTKButtonDropdown();
/// dropdown.SetOptions(new List<string> { "한국어", "English", "日本語" });
/// dropdown.OnSelectionChanged += (index, value) => Debug.Log($"선택: {value}");
///
/// // 버튼 교체
/// var myBtn = new UTKButton("EN", UTKMaterialIcons.Language, UTKButton.ButtonVariant.Ghost);
/// dropdown.SetButton(myBtn);
///
/// // 트리거 버튼에 직접 접근
/// dropdown.TriggerButton.Size = UTKButton.ButtonSize.Small;
///
/// // UXML
/// // <utk:UTKButtonDropdown choices="한국어,English,日本語" index="0" />
/// </code>
/// </example>
[UxmlElement]
public partial class UTKButtonDropdown : VisualElement, IDisposable
{
#region Constants
private const string USS_PATH = "UIToolkit/Dropdown/UTKButtonDropdownUss";
private const string DEFAULT_PLACEHOLDER = "Select";
#endregion
#region Fields
private bool _disposed;
// 트리거 버튼
private UTKButton? _triggerButton;
// 팝업 관련
// _popupLayer: 화면 전체를 덮는 레이어 (panel.visualTree에 추가).
// pickingMode 기본값(Position) 유지로 이벤트 경로에 포함되어
// UI Toolkit 패널 내 어디를 클릭해도 OnPanelPointerDown이 발화됨.
private VisualElement? _popupLayer;
private VisualElement? _popupContainer;
private ScrollView? _optionsScrollView;
// 상태
private List<string> _choices = new();
private int _selectedIndex = -1;
private string _placeholder = DEFAULT_PLACEHOLDER;
private bool _isEnabled = true;
private bool _isPopupOpen;
// 팝업 USS 재사용 캐시
private readonly StyleSheet? _loadedUss;
// 옵션 항목과 체크 아이콘 목록 (인덱스 순서 유지)
private readonly List<(VisualElement container, UTKLabel checkIcon)> _optionItems = new();
#endregion
#region Events
/// <summary>선택 변경 이벤트 (선택된 인덱스, 선택된 값)</summary>
public event Action<int, string>? OnSelectionChanged;
#endregion
#region Properties
/// <summary>현재 트리거 버튼. <c>SetButton()</c>으로 교체 가능.</summary>
public UTKButton? TriggerButton => _triggerButton;
/// <summary>선택 가능한 옵션 목록 (쉼표 구분 문자열)</summary>
[UxmlAttribute("choices")]
public string Choices
{
get => string.Join(",", _choices);
set
{
_choices = string.IsNullOrEmpty(value)
? new List<string>()
: new List<string>(value.Split(','));
RebuildOptions();
}
}
/// <summary>선택된 인덱스 (0부터, -1은 미선택)</summary>
[UxmlAttribute("index")]
public int SelectedIndex
{
get => _selectedIndex;
set => SetSelectedIndex(value, true);
}
/// <summary>선택된 값 (읽기 전용)</summary>
public string? SelectedValue =>
_selectedIndex >= 0 && _selectedIndex < _choices.Count
? _choices[_selectedIndex]
: null;
/// <summary>미선택 시 버튼에 표시될 텍스트</summary>
[UxmlAttribute("placeholder")]
public string Placeholder
{
get => _placeholder;
set
{
_placeholder = value;
UpdateButtonText();
}
}
/// <summary>활성화 상태</summary>
[UxmlAttribute("is-enabled")]
public bool IsEnabled
{
get => _isEnabled;
set
{
_isEnabled = value;
if(_triggerButton != null) _triggerButton.IsEnabled = value;
EnableInClassList("utk-button-dropdown--disabled", !value);
// _popupLayer는 panel.visualTree로 이동하므로 부모 CSS 선택자가 적용되지 않음
// _popupLayer에 직접 disabled 클래스를 토글하여 옵션 hover 억제
_popupLayer?.EnableInClassList("utk-button-dropdown--disabled", !value);
}
}
#endregion
#region Constructor
public UTKButtonDropdown()
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
_loadedUss = Resources.Load<StyleSheet>(USS_PATH);
if (_loadedUss != null)
{
styleSheets.Add(_loadedUss);
}
CreateUI();
SetupEvents();
SubscribeToThemeChanges();
}
/// <summary>
/// 옵션 목록을 지정하여 생성합니다.
/// </summary>
/// <param name="options">옵션 목록</param>
public UTKButtonDropdown(List<string>? options) : this()
{
if (options != null)
{
SetOptions(options);
}
}
#endregion
#region UI Creation
private void CreateUI()
{
AddToClassList("utk-button-dropdown");
focusable = true;
// 기본 트리거 버튼: Check 아이콘 + Text variant
_triggerButton = new UTKButton(
text: _placeholder,
icon: UTKMaterialIcons.Check,
variant: UTKButton.ButtonVariant.Text
);
_triggerButton.AddToClassList("utk-button-dropdown__trigger");
Add(_triggerButton);
// 팝업 레이어: 화면 전체를 덮는 컨테이너 (OpenPopup 시 panel.visualTree에 추가)
// pickingMode 기본값(Position) 유지 — 이벤트 경로에 포함되어야
// UI Toolkit 패널 내 어디를 클릭해도 OnPanelPointerDown이 발화됨
_popupLayer = new VisualElement();
_popupLayer.name = "utk-button-dropdown-layer";
_popupLayer.style.position = Position.Absolute;
_popupLayer.style.left = 0;
_popupLayer.style.top = 0;
_popupLayer.style.right = 0;
_popupLayer.style.bottom = 0;
_popupLayer.style.display = DisplayStyle.None;
// 팝업 컨테이너 (실제 옵션 목록이 표시되는 영역)
_popupContainer = new VisualElement();
_popupContainer.AddToClassList("utk-button-dropdown__popup");
_popupLayer.Add(_popupContainer);
// 옵션 스크롤뷰
_optionsScrollView = new ScrollView(ScrollViewMode.Vertical);
_optionsScrollView.AddToClassList("utk-button-dropdown__options");
_popupContainer.Add(_optionsScrollView);
}
private void SetupEvents()
{
_triggerButton?.RegisterCallback<ClickEvent>(OnTriggerClicked);
RegisterCallback<AttachToPanelEvent>(OnAttachedToPanel);
RegisterCallback<DetachFromPanelEvent>(OnDetachedFromPanel);
}
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
#endregion
#region Theme Handlers
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
if (_popupLayer != null && _isPopupOpen)
{
UTKThemeManager.Instance.ApplyThemeToElement(_popupLayer);
}
}
#endregion
#region Event Handlers
private void OnTriggerClicked(ClickEvent evt)
{
if (!_isEnabled) return;
if (_isPopupOpen) ClosePopup();
else OpenPopup();
evt.StopPropagation();
}
private void OnAttachedToPanel(AttachToPanelEvent evt)
{
if (panel != null)
{
panel.visualTree.RegisterCallback<PointerDownEvent>(OnPanelPointerDown, TrickleDown.TrickleDown);
}
}
private void OnDetachedFromPanel(DetachFromPanelEvent evt)
{
if (_isPopupOpen) ForceClosePopup();
if (panel != null)
{
panel.visualTree.UnregisterCallback<PointerDownEvent>(OnPanelPointerDown, TrickleDown.TrickleDown);
}
}
private void OnPanelPointerDown(PointerDownEvent evt)
{
if (!_isPopupOpen) return;
var target = evt.target as VisualElement;
if (target == null) return;
// 트리거 버튼 클릭이면 무시 (OnTriggerClicked에서 처리)
if (_triggerButton != null && target.FindCommonAncestor(_triggerButton) == _triggerButton) return;
// 팝업 컨테이너 내부 클릭이면 무시
if (_popupContainer != null && target.FindCommonAncestor(_popupContainer) == _popupContainer) return;
// 팝업 레이어 외부(_popupLayer 자체 포함) 또는 다른 UI 영역 클릭 → 닫기
ClosePopup();
}
private void OnOptionClicked(int index)
{
SetSelectedIndex(index, true);
ClosePopup();
}
/// <summary>userData에서 인덱스를 읽어 선택 처리 — 클로저 미사용으로 GC 최소화</summary>
private void OnOptionItemClicked(ClickEvent evt)
{
if (evt.currentTarget is VisualElement ve && ve.userData is int index)
{
OnOptionClicked(index);
}
evt.StopPropagation();
}
private void OnOptionMouseEnter(MouseEnterEvent evt)
{
(evt.currentTarget as VisualElement)?.AddToClassList("utk-button-dropdown__option--hover");
}
private void OnOptionMouseLeave(MouseLeaveEvent evt)
{
(evt.currentTarget as VisualElement)?.RemoveFromClassList("utk-button-dropdown__option--hover");
}
#endregion
#region Popup
private void OpenPopup()
{
if (_popupLayer == null || _popupContainer == null || _triggerButton == null || panel == null) return;
// _popupLayer를 panel.visualTree에 추가 (다른 UI 위에 렌더링, 한 번만 수행)
if (_popupLayer.parent != panel.visualTree)
{
_popupLayer.RemoveFromHierarchy();
panel.visualTree.Add(_popupLayer);
UTKThemeManager.Instance.ApplyThemeToElement(_popupLayer);
// USS를 _popupLayer에 적용 (루트로 이동하면 부모 StyleSheet 상속 안 됨)
if (_loadedUss != null && !_popupLayer.styleSheets.Contains(_loadedUss))
{
_popupLayer.styleSheets.Add(_loadedUss);
}
}
// 트리거 버튼의 월드 좌표 기준으로 팝업 위치 설정
var bounds = _triggerButton.worldBound;
_popupContainer.style.position = Position.Absolute;
_popupContainer.style.left = bounds.x;
_popupContainer.style.top = bounds.yMax + 2f;
_popupContainer.style.minWidth = bounds.width;
_popupLayer.style.display = DisplayStyle.Flex;
_isPopupOpen = true;
EnableInClassList("utk-button-dropdown--open", true);
}
private void ClosePopup()
{
if (_popupLayer == null) return;
// 레이어를 숨김 처리 (RemoveFromHierarchy 없이 DisplayStyle 토글 — 레이아웃 재계산 최소화)
_popupLayer.style.display = DisplayStyle.None;
_isPopupOpen = false;
EnableInClassList("utk-button-dropdown--open", false);
}
/// <summary>패널 해제 등 강제 종료 시 _popupLayer를 계층에서 제거</summary>
private void ForceClosePopup()
{
if (_popupLayer == null) return;
_popupLayer.style.display = DisplayStyle.None;
_popupLayer.RemoveFromHierarchy();
_isPopupOpen = false;
}
#endregion
#region Public Methods
/// <summary>
/// 트리거 버튼을 외부에서 전달한 UTKButton으로 교체합니다.
/// 기존 버튼의 이벤트를 해제하고 Dispose한 뒤, 새 버튼을 등록합니다.
/// </summary>
/// <param name="button">새로 사용할 UTKButton 인스턴스</param>
public void SetButton(UTKButton button)
{
if (_disposed) return;
// 기존 버튼 정리
if (_triggerButton != null)
{
_triggerButton.UnregisterCallback<ClickEvent>(OnTriggerClicked);
_triggerButton.RemoveFromHierarchy();
_triggerButton.Dispose();
}
// 새 버튼 설정
_triggerButton = button;
_triggerButton.AddToClassList("utk-button-dropdown__trigger");
_triggerButton.SetEnabled(_isEnabled);
// 선택 상태 반영
UpdateButtonText();
// 팝업 컨테이너보다 앞에 삽입 (index 0)
Insert(0, _triggerButton);
_triggerButton.RegisterCallback<ClickEvent>(OnTriggerClicked);
}
/// <summary>
/// 옵션 목록 전체 교체
/// </summary>
/// <param name="options">옵션 목록</param>
public void SetOptions(List<string> options)
{
_choices = options ?? new List<string>();
RebuildOptions();
}
/// <summary>
/// 옵션 하나 추가
/// </summary>
/// <param name="option">추가할 옵션 이름</param>
public void AddOption(string option)
{
_choices.Add(option);
RebuildOptions();
}
/// <summary>
/// 인덱스로 선택
/// </summary>
/// <param name="index">선택할 인덱스 (-1은 미선택)</param>
/// <param name="notify">true면 OnSelectionChanged 발생</param>
public void SetSelectedIndex(int index, bool notify)
{
if (index < -1 || index >= _choices.Count) return;
if (_selectedIndex == index) return;
_selectedIndex = index;
UpdateButtonText();
UpdateCheckIcons();
if (notify && _selectedIndex >= 0)
{
OnSelectionChanged?.Invoke(_selectedIndex, _choices[_selectedIndex]);
}
}
/// <summary>
/// 값으로 선택
/// </summary>
/// <param name="selectedValue">선택할 값 (null이면 미선택)</param>
/// <param name="notify">true면 OnSelectionChanged 발생</param>
public void SetSelectedValue(string? selectedValue, bool notify = true)
{
if (selectedValue == null)
{
SetSelectedIndex(-1, notify);
return;
}
var idx = _choices.IndexOf(selectedValue);
if (idx >= 0)
{
SetSelectedIndex(idx, notify);
}
}
#endregion
#region Private Methods
/// <summary>
/// 옵션 목록을 다시 빌드합니다.
/// 기존 이벤트 핸들러를 명시적으로 해제하여 메모리 누수를 방지합니다.
/// </summary>
private void RebuildOptions()
{
if (_optionsScrollView == null) return;
// 기존 옵션의 이벤트 핸들러 해제
foreach (var (container, _) in _optionItems)
{
container.UnregisterCallback<ClickEvent>(OnOptionItemClicked);
container.UnregisterCallback<MouseEnterEvent>(OnOptionMouseEnter);
container.UnregisterCallback<MouseLeaveEvent>(OnOptionMouseLeave);
}
_optionItems.Clear();
_optionsScrollView.Clear();
for (int i = 0; i < _choices.Count; i++)
{
var optionContainer = new VisualElement();
optionContainer.AddToClassList("utk-button-dropdown__option");
// 인덱스를 userData에 저장 — 클로저 대신 userData 활용으로 GC 부담 감소
optionContainer.userData = i;
// 체크 아이콘
var checkIcon = new UTKLabel(UTKMaterialIcons.Check, 16);
checkIcon.AddToClassList("utk-button-dropdown__check-icon");
checkIcon.style.display = _selectedIndex == i ? DisplayStyle.Flex : DisplayStyle.None;
optionContainer.Add(checkIcon);
// 옵션 라벨 (클릭 이벤트는 container에서만 처리)
var optionLabel = new Label(_choices[i]);
optionLabel.AddToClassList("utk-button-dropdown__option-label");
optionLabel.pickingMode = PickingMode.Ignore;
optionContainer.Add(optionLabel);
// 비클로저 핸들러로 이벤트 등록
optionContainer.RegisterCallback<ClickEvent>(OnOptionItemClicked);
optionContainer.RegisterCallback<MouseEnterEvent>(OnOptionMouseEnter);
optionContainer.RegisterCallback<MouseLeaveEvent>(OnOptionMouseLeave);
_optionItems.Add((optionContainer, checkIcon));
_optionsScrollView.Add(optionContainer);
}
UpdateButtonText();
}
private void UpdateButtonText()
{
if (_triggerButton == null) return;
_triggerButton.Text = _selectedIndex >= 0 && _selectedIndex < _choices.Count
? _choices[_selectedIndex]
: _placeholder;
}
private void UpdateCheckIcons()
{
for (int i = 0; i < _optionItems.Count; i++)
{
var (_, checkIcon) = _optionItems[i];
checkIcon.style.display = _selectedIndex == i ? DisplayStyle.Flex : DisplayStyle.None;
}
}
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 테마 이벤트 해제
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
// 패널 포인터 이벤트 해제
UnregisterCallback<AttachToPanelEvent>(OnAttachedToPanel);
UnregisterCallback<DetachFromPanelEvent>(OnDetachedFromPanel);
if (panel != null)
{
panel.visualTree.UnregisterCallback<PointerDownEvent>(OnPanelPointerDown, TrickleDown.TrickleDown);
}
// 트리거 버튼 이벤트 해제 및 Dispose
if (_triggerButton != null)
{
_triggerButton.UnregisterCallback<ClickEvent>(OnTriggerClicked);
_triggerButton.Dispose();
}
// 옵션 이벤트 해제
foreach (var (container, _) in _optionItems)
{
container.UnregisterCallback<ClickEvent>(OnOptionItemClicked);
container.UnregisterCallback<MouseEnterEvent>(OnOptionMouseEnter);
container.UnregisterCallback<MouseLeaveEvent>(OnOptionMouseLeave);
}
_optionItems.Clear();
// 팝업 정리 (_popupLayer를 계층에서 제거)
if (_isPopupOpen) ForceClosePopup();
else _popupLayer?.RemoveFromHierarchy();
OnSelectionChanged = null;
}
#endregion
}
}