#nullable enable using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.UIElements; namespace UVC.UIToolkit { /// /// 다중 선택 드롭다운 컴포넌트. /// 체크박스를 통해 여러 항목을 동시에 선택할 수 있는 드롭다운입니다. /// /// /// 다중 선택 드롭다운이란? /// /// 일반 드롭다운과 달리 여러 옵션을 동시에 선택할 수 있는 UI 컴포넌트입니다. /// 각 옵션에 체크박스가 있어 다중 선택을 직관적으로 표현합니다. /// 선택된 항목들은 메인 필드에 쉼표로 구분되어 표시됩니다. /// /// /// 주요 속성: /// /// Choices - 선택 가능한 옵션 목록 (List) /// SelectedIndices - 선택된 항목들의 인덱스 목록 /// SelectedValues - 선택된 항목들의 값 목록 /// Placeholder - 아무것도 선택되지 않았을 때 표시할 텍스트 /// /// /// 주요 메서드: /// /// SetOptions(List) - 전체 옵션 목록 교체 /// SetSelectedIndices(List) - 인덱스로 선택 설정 /// SetSelectedValues(List) - 값으로 선택 설정 /// SelectAll() - 모든 항목 선택 /// ClearSelection() - 모든 선택 해제 /// /// /// 실제 활용 예시: /// /// 필터 선택 - 여러 카테고리 동시 필터링 /// 태그 선택 - 여러 태그 동시 적용 /// 권한 설정 - 여러 권한 동시 부여 /// 레이어 선택 - 여러 레이어 동시 표시/숨김 /// /// /// /// C# 코드에서 사용: /// /// // 기본 사용 /// var categoryDropdown = new UTKMultiSelectDropdown(); /// categoryDropdown.Label = "카테고리 선택"; /// categoryDropdown.SetOptions(new List { "과일", "채소", "육류", "유제품" }); /// /// // 선택 변경 이벤트 /// categoryDropdown.OnSelectionChanged += (indices, values) => { /// Debug.Log($"선택된 개수: {values.Count}"); /// Debug.Log($"선택 항목: {string.Join(", ", values)}"); /// }; /// /// // 생성자로 한 번에 설정 /// var tagDropdown = new UTKMultiSelectDropdown( /// "태그", /// new List { "중요", "긴급", "검토 필요", "완료" } /// ); /// /// // 기본값 설정 (인덱스로) /// tagDropdown.SetSelectedIndices(new List { 0, 1 }); /// /// // 기본값 설정 (값으로) /// tagDropdown.SetSelectedValues(new List { "중요", "긴급" }); /// /// // 전체 선택/해제 /// tagDropdown.SelectAll(); /// tagDropdown.ClearSelection(); /// /// UXML에서 사용: /// /// /// /// /// /// /// /// /// /// /// /// [UxmlElement] public partial class UTKMultiSelectDropdown : VisualElement, IDisposable { #region Constants private const string USS_PATH = "UIToolkit/Dropdown/UTKMultiSelectDropdownUss"; private const string DEFAULT_PLACEHOLDER = "Select"; #endregion #region Fields private bool _disposed; private Label? _labelElement; private VisualElement? _inputContainer; private Label? _displayLabel; private VisualElement? _dropdownIconContainer; private UTKLabel? _dropdownIcon; private VisualElement? _popupContainer; private ScrollView? _optionsScrollView; private string _label = ""; private List _choices = new(); private HashSet _selectedIndices = new(); private string _placeholder = DEFAULT_PLACEHOLDER; private bool _isEnabled = true; private bool _isPopupOpen; private StyleSheet? _loadedUss; // 메모리 누수 방지: 생성된 체크박스와 핸들러 추적 private readonly List<(UTKCheckBox checkBox, Action handler)> _checkBoxHandlers = new(); #endregion #region Events /// 선택 변경 이벤트 (선택된 인덱스 목록, 선택된 값 목록) public event Action, List>? OnSelectionChanged; #endregion #region Properties /// 라벨 텍스트 [UxmlAttribute("label")] public string Label { get => _label; set { _label = value; if (_labelElement != null) { _labelElement.text = value; _labelElement.style.display = string.IsNullOrEmpty(value) ? DisplayStyle.None : DisplayStyle.Flex; } } } /// 선택 가능한 옵션 목록 [UxmlAttribute("choices")] public List Choices { get => _choices; set { _choices = value ?? new List(); _selectedIndices.Clear(); RebuildOptions(); UpdateDisplayText(); } } /// 선택된 인덱스 목록 public List SelectedIndices { get => _selectedIndices.OrderBy(x => x).ToList(); } /// 선택된 값 목록 public List SelectedValues { get => _selectedIndices .OrderBy(x => x) .Where(i => i >= 0 && i < _choices.Count) .Select(i => _choices[i]) .ToList(); } /// 아무것도 선택되지 않았을 때 표시할 텍스트 [UxmlAttribute("placeholder")] public string Placeholder { get => _placeholder; set { _placeholder = value; UpdateDisplayText(); } } /// 활성화 상태 [UxmlAttribute("is-enabled")] public bool IsEnabled { get => _isEnabled; set { _isEnabled = value; SetEnabled(value); EnableInClassList("utk-multiselect-dropdown--disabled", !value); } } #endregion #region Constructor public UTKMultiSelectDropdown() : base() { UTKThemeManager.Instance.ApplyThemeToElement(this); _loadedUss = Resources.Load(USS_PATH); if (_loadedUss != null) { styleSheets.Add(_loadedUss); } CreateUI(); SetupEvents(); SubscribeToThemeChanges(); } public UTKMultiSelectDropdown(string label, List? choices = null) : this() { Label = label; if (choices != null) { Choices = choices; } } #endregion #region Setup private void CreateUI() { AddToClassList("utk-multiselect-dropdown"); // 라벨 _labelElement = new Label(_label); _labelElement.AddToClassList("utk-multiselect-dropdown__label"); _labelElement.style.display = string.IsNullOrEmpty(_label) ? DisplayStyle.None : DisplayStyle.Flex; Add(_labelElement); // 입력 컨테이너 _inputContainer = new VisualElement(); _inputContainer.AddToClassList("utk-multiselect-dropdown__input"); Add(_inputContainer); // 표시 라벨 _displayLabel = new Label(_placeholder); _displayLabel.AddToClassList("utk-multiselect-dropdown__display"); _inputContainer.Add(_displayLabel); // 드롭다운 아이콘 컨테이너 (회전 시 위치 고정용) _dropdownIconContainer = new VisualElement(); _dropdownIconContainer.AddToClassList("utk-multiselect-dropdown__icon-container"); _inputContainer.Add(_dropdownIconContainer); // 드롭다운 아이콘 (Material Icons 사용) _dropdownIcon = new UTKLabel(UTKMaterialIcons.ArrowDropDown, 24); _dropdownIcon.AddToClassList("utk-multiselect-dropdown__icon"); _dropdownIconContainer.Add(_dropdownIcon); // 팝업 컨테이너 _popupContainer = new VisualElement(); _popupContainer.AddToClassList("utk-multiselect-dropdown__popup"); _popupContainer.style.display = DisplayStyle.None; Add(_popupContainer); // 옵션 스크롤뷰 _optionsScrollView = new ScrollView(ScrollViewMode.Vertical); _optionsScrollView.AddToClassList("utk-multiselect-dropdown__options"); _popupContainer.Add(_optionsScrollView); } private void SetupEvents() { // 입력 컨테이너 클릭 시 팝업 토글 _inputContainer?.RegisterCallback(OnInputClicked); // 외부 클릭 감지를 위한 루트 패널 이벤트 RegisterCallback(OnAttachedToPanel); RegisterCallback(OnDetachedFromPanel); } private void SubscribeToThemeChanges() { UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged; RegisterCallback(OnAttachToPanelForTheme); RegisterCallback(OnDetachFromPanelForTheme); } 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); } #endregion #region Event Handlers private void OnInputClicked(ClickEvent evt) { if (!_isEnabled) return; _isPopupOpen = !_isPopupOpen; if (_popupContainer != null) { if (_isPopupOpen) { OpenPopup(); } else { ClosePopup(); } } EnableInClassList("utk-multiselect-dropdown--open", _isPopupOpen); evt.StopPropagation(); } private void OpenPopup() { if (_popupContainer == null || _inputContainer == null || panel == null) return; // 아이콘 변경 (회전 대신 다른 아이콘 사용) if (_dropdownIcon is UTKLabel iconLabel) { iconLabel.SetMaterialIcon(UTKMaterialIcons.ArrowDropUp); } // 팝업을 루트로 이동 if (_popupContainer.parent != panel.visualTree) { _popupContainer.RemoveFromHierarchy(); panel.visualTree.Add(_popupContainer); UTKThemeManager.Instance.ApplyThemeToElement(_popupContainer); if (_loadedUss != null) { _popupContainer.styleSheets.Add(_loadedUss); } foreach (var (checkBox, handler) in _checkBoxHandlers) { UTKThemeManager.Instance.ApplyThemeToElement(checkBox); } } // 드롭다운의 월드 위치 계산 var inputBounds = _inputContainer.worldBound; _popupContainer.style.position = Position.Absolute; _popupContainer.style.left = inputBounds.x; _popupContainer.style.top = inputBounds.yMax + 2; // 2px 간격 _popupContainer.style.width = inputBounds.width; _popupContainer.style.display = DisplayStyle.Flex; } private void OnAttachedToPanel(AttachToPanelEvent evt) { if (panel != null) { panel.visualTree.RegisterCallback(OnPanelPointerDown, TrickleDown.TrickleDown); } } private void OnDetachedFromPanel(DetachFromPanelEvent evt) { if (panel != null) { panel.visualTree.UnregisterCallback(OnPanelPointerDown, TrickleDown.TrickleDown); } } private void OnPanelPointerDown(PointerDownEvent evt) { if (!_isPopupOpen) return; var target = evt.target as VisualElement; if (target == null) return; // 입력 컨테이너 클릭 확인 if (_inputContainer != null && target.FindCommonAncestor(_inputContainer) == _inputContainer) { return; // 입력 영역 클릭이면 무시 } // 팝업 컨테이너 클릭 확인 (팝업이 루트로 이동했으므로 직접 확인) if (_popupContainer != null && target.FindCommonAncestor(_popupContainer) == _popupContainer) { return; // 팝업 내부 클릭이면 무시 } // 외부 클릭이면 팝업 닫기 ClosePopup(); } private void OnOptionToggled(int index, bool isChecked) { if (isChecked) { _selectedIndices.Add(index); } else { _selectedIndices.Remove(index); } UpdateDisplayText(); OnSelectionChanged?.Invoke(SelectedIndices, SelectedValues); } #endregion #region Methods /// /// 옵션 목록 설정 /// public void SetOptions(List options) { Choices = options; } /// /// 인덱스로 선택 설정 (알림 발생) /// public void SetSelectedIndices(List indices, bool notify = true) { _selectedIndices.Clear(); foreach (var index in indices) { if (index >= 0 && index < _choices.Count) { _selectedIndices.Add(index); } } RebuildOptions(); UpdateDisplayText(); if (notify) { OnSelectionChanged?.Invoke(SelectedIndices, SelectedValues); } } /// /// 값으로 선택 설정 (알림 발생) /// public void SetSelectedValues(List values, bool notify = true) { var indices = new List(); foreach (var value in values) { var index = _choices.IndexOf(value); if (index >= 0) { indices.Add(index); } } SetSelectedIndices(indices, notify); } /// /// 모든 항목 선택 /// public void SelectAll() { var allIndices = Enumerable.Range(0, _choices.Count).ToList(); SetSelectedIndices(allIndices, notify: true); } /// /// 모든 선택 해제 /// public void ClearSelection() { SetSelectedIndices(new List(), notify: true); } private void ClosePopup() { _isPopupOpen = false; // 아이콘 복원 if (_dropdownIcon is UTKLabel iconLabel) { iconLabel.SetMaterialIcon(UTKMaterialIcons.ArrowDropDown); } if (_popupContainer != null) { _popupContainer.style.display = DisplayStyle.None; // 팝업을 원래 위치(드롭다운 내부)로 되돌림 if (_popupContainer.parent != this) { _popupContainer.RemoveFromHierarchy(); Add(_popupContainer); // 원래 스타일 복원 _popupContainer.style.position = Position.Absolute; _popupContainer.style.left = 0; _popupContainer.style.right = 0; _popupContainer.style.top = Length.Percent(100); _popupContainer.style.width = StyleKeyword.Auto; } } EnableInClassList("utk-multiselect-dropdown--open", false); } private void RebuildOptions() { if (_optionsScrollView == null) return; // 기존 체크박스 이벤트 정리 및 Dispose ClearCheckBoxes(); _optionsScrollView.Clear(); for (int i = 0; i < _choices.Count; i++) { var index = i; // 클로저 캡처 방지 var optionContainer = new VisualElement(); optionContainer.AddToClassList("utk-multiselect-dropdown__option"); var checkBox = new UTKCheckBox(_choices[i], _selectedIndices.Contains(i)); checkBox.AddToClassList("utk-multiselect-dropdown__toggle"); // 핸들러 생성 및 등록 Action handler = (isOn) => { OnOptionToggled(index, isOn); }; checkBox.OnValueChanged += handler; // 체크박스와 핸들러를 함께 추적 _checkBoxHandlers.Add((checkBox, handler)); // 마우스 호버 이벤트 optionContainer.RegisterCallback(_ => { optionContainer.AddToClassList("utk-multiselect-dropdown__option--hover"); }); optionContainer.RegisterCallback(_ => { optionContainer.RemoveFromClassList("utk-multiselect-dropdown__option--hover"); }); optionContainer.Add(checkBox); _optionsScrollView.Add(optionContainer); } } private void ClearCheckBoxes() { foreach (var (checkBox, handler) in _checkBoxHandlers) { checkBox.OnValueChanged -= handler; checkBox.Dispose(); } _checkBoxHandlers.Clear(); } private void UpdateDisplayText() { if (_displayLabel == null) return; if (_selectedIndices.Count == 0) { _displayLabel.text = _placeholder; _displayLabel.AddToClassList("utk-multiselect-dropdown__display--placeholder"); } else { var selectedValues = SelectedValues; _displayLabel.text = string.Join(", ", selectedValues); _displayLabel.RemoveFromClassList("utk-multiselect-dropdown__display--placeholder"); } } #endregion #region IDisposable public void Dispose() { if (_disposed) return; _disposed = true; // 체크박스 이벤트 정리 ClearCheckBoxes(); UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged; UnregisterCallback(OnAttachToPanelForTheme); UnregisterCallback(OnDetachFromPanelForTheme); OnSelectionChanged = null; _inputContainer?.UnregisterCallback(OnInputClicked); if (panel != null) { panel.visualTree.UnregisterCallback(OnPanelPointerDown); } } #endregion } }