#nullable enable using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.UIElements; namespace UVC.UIToolkit { /// /// UTKPropertyListWindow의 핵심 리스트 컴포넌트입니다. /// TreeView 기반으로 그룹화된 속성들을 표시합니다. /// /// 개요: /// /// UTKPropertyList는 UXML/USS 기반의 속성 리스트 컴포넌트입니다. /// TreeView를 사용하여 가상화(Virtualization)를 지원하며, 대량의 속성을 효율적으로 렌더링합니다. /// 그룹별 펼침/접힘, 검색 필터링 기능을 제공합니다. /// /// /// 주요 기능: /// /// TreeView 기반 가상화 (Virtualization) - 대량 속성 성능 최적화 /// 그룹별 펼침/접힘 기능 /// 실시간 검색 필터링 /// 다양한 속성 타입 지원 (Text, Number, Boolean, Dropdown 등) /// /// /// 관련 리소스: /// /// Resources/UIToolkit/List/UTKPropertyList.uxml - 메인 레이아웃 /// Resources/UIToolkit/List/UTKPropertyListUss.uss - 스타일 /// /// [UxmlElement] public partial class UTKPropertyList : VisualElement, IDisposable { #region Constants private const string UXML_PATH = "UIToolkit/List/UTKPropertyList"; private const string USS_PATH = "UIToolkit/List/UTKPropertyListUss"; #endregion #region Fields private bool _disposed; private TreeView? _treeView; private UTKInputField? _searchField; private UTKButton? _clearButton; private readonly List _entries = new(); private readonly Dictionary _groupIndex = new(); private readonly Dictionary _itemIndex = new(); private readonly Dictionary _treeViewIdToEntry = new(); private int _nextTreeViewId = 1; private string _searchText = string.Empty; #endregion #region Events /// 속성 값 변경 이벤트 public event Action? OnPropertyValueChanged; /// 그룹 펼침/접힘 변경 이벤트 public event Action? OnGroupExpandedChanged; /// 속성 클릭 이벤트 public event Action? OnPropertyClicked; #endregion #region Constructor public UTKPropertyList() { // 메인 UXML 로드 var visualTree = Resources.Load(UXML_PATH); if (visualTree != null) { visualTree.CloneTree(this); CreateUIFromUxml(); } else { Debug.LogWarning($"[UTKPropertyList] UXML not found at: {UXML_PATH}, using fallback"); CreateUIFallback(); } // 테마 적용 및 변경 구독 UTKThemeManager.Instance.ApplyThemeToElement(this); SubscribeToThemeChanges(); // USS 로드 (테마 변수 스타일시트 이후에 로드되어야 변수가 해석됨) var uss = Resources.Load(USS_PATH); if (uss != null) { styleSheets.Add(uss); } // TreeView 초기화 InitializeTreeView(); } #endregion #region UI Creation /// UXML에서 UI 요소 참조를 획득합니다. private void CreateUIFromUxml() { _searchField = this.Q("search-field"); _treeView = this.Q("property-tree-view"); _clearButton = this.Q("clear-btn"); // 검색 필드 이벤트 연결 if (_searchField != null) { _searchField.OnSubmit += OnSearch; } // 검색어 지우기 버튼 if (_clearButton != null) { _clearButton.OnClicked += OnClearButtonClicked; // 초기에는 숨김 _clearButton.style.display = DisplayStyle.None; } } /// UXML 로드 실패 시 코드로 UI를 생성합니다. private void CreateUIFallback() { // USS 클래스 기반 스타일링 - 인라인 스타일 최소화 AddToClassList("utk-property-list"); // 검색 필드 var searchContainer = new VisualElement { name = "search-container" }; searchContainer.AddToClassList("utk-property-list__search-container"); _searchField = new UTKInputField { name = "search-field" }; _searchField.Placeholder = "Search..."; _searchField.AddToClassList("utk-property-list__search-field"); _searchField.OnSubmit += OnSearch; searchContainer.Add(_searchField); Add(searchContainer); _clearButton = new UTKButton { name = "clear-btn", Variant = UTKButton.ButtonVariant.Text, Icon = "Close", IconSize = 12, IconOnly = true }; _clearButton.AddToClassList("utk-property-list__search-field-clear-button"); _clearButton.OnClicked += OnClearButtonClicked; searchContainer.Add(_clearButton); // TreeView _treeView = new TreeView { name = "property-tree-view" }; _treeView.AddToClassList("utk-property-list__tree-view"); _treeView.selectionType = SelectionType.None; _treeView.virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight; _treeView.showAlternatingRowBackgrounds = AlternatingRowBackground.None; Add(_treeView); } /// TreeView 콜백을 초기화합니다. private void InitializeTreeView() { if (_treeView == null) return; _treeView.makeItem = MakeItem; _treeView.bindItem = BindItem; _treeView.unbindItem = UnbindItem; } #endregion #region Theme private void SubscribeToThemeChanges() { UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged; RegisterCallback(_ => { UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged; }); } private void OnThemeChanged(UTKTheme theme) { UTKThemeManager.Instance.ApplyThemeToElement(this); } #endregion #region Public Methods - Data Loading /// 평면 속성 목록 로드 (그룹 없이) public void LoadProperties(List items) { ClearInternal(); foreach (var item in items) { AddPropertyInternal(item); } RefreshTreeView(); } /// 그룹화된 속성 목록 로드 public void LoadGroupedProperties(List groups) { ClearInternal(); foreach (var group in groups) { AddGroupInternal(group); } RefreshTreeView(); } /// 혼합 엔트리 목록 로드 (그룹 + 속성) public void LoadMixedProperties(List entries) { ClearInternal(); foreach (var entry in entries) { switch (entry) { case IUTKPropertyGroup group: AddGroupInternal(group); break; case IUTKPropertyItem item: AddPropertyInternal(item); break; } } RefreshTreeView(); } #endregion #region Public Methods - Group Management public void AddGroup(IUTKPropertyGroup group) { AddGroupInternal(group); RefreshTreeView(); } public void RemoveGroup(string groupId) { if (_groupIndex.TryGetValue(groupId, out var group)) { // 그룹 내 아이템 제거 foreach (var item in group.Items.ToList()) { UnsubscribeItem(item); _itemIndex.Remove(item.Id); } UnsubscribeGroup(group); _groupIndex.Remove(groupId); _entries.Remove(group); RefreshTreeView(); } } public IUTKPropertyGroup? GetGroup(string groupId) { return _groupIndex.TryGetValue(groupId, out var group) ? group : null; } public void SetGroupExpanded(string groupId, bool expanded) { if (_groupIndex.TryGetValue(groupId, out var group)) { group.IsExpanded = expanded; if (_treeView != null) { if (expanded) _treeView.ExpandItem(group.TreeViewId); else _treeView.CollapseItem(group.TreeViewId); } } } public void ToggleGroupExpanded(string groupId) { if (_groupIndex.TryGetValue(groupId, out var group)) { SetGroupExpanded(groupId, !group.IsExpanded); } } #endregion #region Public Methods - Property Management public void AddProperty(IUTKPropertyItem item) { AddPropertyInternal(item); RefreshTreeView(); } public void AddPropertyToGroup(string groupId, IUTKPropertyItem item) { if (_groupIndex.TryGetValue(groupId, out var group)) { group.AddItem(item); _itemIndex[item.Id] = item; SubscribeItem(item); RefreshTreeView(); } } public void RemoveProperty(string itemId) { if (_itemIndex.TryGetValue(itemId, out var item)) { UnsubscribeItem(item); _itemIndex.Remove(itemId); // 그룹에서 제거 if (!string.IsNullOrEmpty(item.GroupId) && _groupIndex.TryGetValue(item.GroupId, out var group)) { group.RemoveItem(itemId); } else { _entries.Remove(item); } RefreshTreeView(); } } public IUTKPropertyItem? GetProperty(string itemId) { return _itemIndex.TryGetValue(itemId, out var item) ? item : null; } #endregion #region Public Methods - Value Management public void UpdatePropertyValue(string propertyId, object newValue) { if (_itemIndex.TryGetValue(propertyId, out var item)) { item.SetValue(newValue); } } public void SetPropertyValue(string propertyId, object value) { UpdatePropertyValue(propertyId, value); } #endregion #region Public Methods - Visibility & ReadOnly public void SetPropertyVisibility(string propertyId, bool visible) { if (_itemIndex.TryGetValue(propertyId, out var item)) { item.IsVisible = visible; } } public void SetGroupVisibility(string groupId, bool visible) { if (_groupIndex.TryGetValue(groupId, out var group)) { group.IsVisible = visible; } } public void SetPropertyReadOnly(string propertyId, bool isReadOnly) { if (_itemIndex.TryGetValue(propertyId, out var item)) { item.IsReadOnly = isReadOnly; } } public void SetGroupReadOnly(string groupId, bool isReadOnly) { if (_groupIndex.TryGetValue(groupId, out var group)) { group.SetAllItemsReadOnly(isReadOnly); } } #endregion #region Public Methods - Utilities public void Clear() { ClearInternal(); RefreshTreeView(); } public void Refresh() { RefreshTreeView(); } public void Show() { style.display = DisplayStyle.Flex; } public void Hide() { style.display = DisplayStyle.None; } #endregion #region Private Methods - Internal private void AddGroupInternal(IUTKPropertyGroup group) { if (_groupIndex.ContainsKey(group.GroupId)) return; group.TreeViewId = _nextTreeViewId++; _groupIndex[group.GroupId] = group; _entries.Add(group); SubscribeGroup(group); // 그룹 내 아이템 등록 foreach (var item in group.Items) { item.TreeViewId = _nextTreeViewId++; _itemIndex[item.Id] = item; SubscribeItem(item); } } private void AddPropertyInternal(IUTKPropertyItem item) { if (_itemIndex.ContainsKey(item.Id)) return; item.TreeViewId = _nextTreeViewId++; _itemIndex[item.Id] = item; // 그룹에 속하는지 확인 if (!string.IsNullOrEmpty(item.GroupId) && _groupIndex.TryGetValue(item.GroupId, out var group)) { group.AddItem(item); } else { _entries.Add(item); } SubscribeItem(item); } private void ClearInternal() { // 이벤트 구독 해제 foreach (var group in _groupIndex.Values) { UnsubscribeGroup(group); } foreach (var item in _itemIndex.Values) { UnsubscribeItem(item); } _entries.Clear(); _groupIndex.Clear(); _itemIndex.Clear(); _treeViewIdToEntry.Clear(); _nextTreeViewId = 1; } private void RefreshTreeView() { if (_treeView == null) return; var treeItems = BuildTreeItems(); _treeView.SetRootItems(treeItems); _treeView.Rebuild(); // 펼침 상태 복원 foreach (var group in _groupIndex.Values) { if (group.IsExpanded) { _treeView.ExpandItem(group.TreeViewId); } } } private IList> BuildTreeItems() { var result = new List>(); _treeViewIdToEntry.Clear(); foreach (var entry in _entries) { if (!MatchesSearch(entry)) continue; if (entry is IUTKPropertyGroup group) { var children = new List>(); foreach (var item in group.Items) { if (item.IsVisible && MatchesSearch(item)) { _treeViewIdToEntry[item.TreeViewId] = item; children.Add(new TreeViewItemData(item.TreeViewId, item)); } } if (children.Count > 0 || string.IsNullOrEmpty(_searchText)) { _treeViewIdToEntry[group.TreeViewId] = group; result.Add(new TreeViewItemData(group.TreeViewId, group, children)); } } else if (entry is IUTKPropertyItem item && item.IsVisible) { _treeViewIdToEntry[item.TreeViewId] = item; result.Add(new TreeViewItemData(item.TreeViewId, item)); } } return result; } private bool MatchesSearch(IUTKPropertyEntry entry) { if (string.IsNullOrEmpty(_searchText)) return true; if (entry is IUTKPropertyGroup group) { // 그룹 이름 검색 또는 자식 아이템 검색 if (group.GroupName.Contains(_searchText, StringComparison.OrdinalIgnoreCase)) return true; return group.Items.Any(item => MatchesSearch(item)); } else if (entry is IUTKPropertyItem item) { return item.Name.Contains(_searchText, StringComparison.OrdinalIgnoreCase) || (item.Description?.Contains(_searchText, StringComparison.OrdinalIgnoreCase) ?? false); } return false; } #endregion #region TreeView Callbacks private VisualElement MakeItem() { var container = new VisualElement(); container.AddToClassList("utk-property-item-container"); return container; } private void BindItem(VisualElement element, int index) { var itemData = _treeView?.GetItemDataForIndex(index); if (itemData == null) return; element.Clear(); switch (itemData) { case IUTKPropertyGroup group: BindGroupItem(element, group); break; case IUTKPropertyItem item: BindPropertyItem(element, item); break; } } private void UnbindItem(VisualElement element, int index) { // View에서 직접 Unbind 처리 (IUTKPropertyItemView 구현체인 경우) foreach (var child in element.Children()) { if (child is IUTKPropertyItemView view) { view.Unbind(); } } element.Clear(); } private void BindGroupItem(VisualElement container, IUTKPropertyGroup group) { var groupElement = new VisualElement(); groupElement.AddToClassList("utk-property-group__header"); var expandIcon = new UTKLabel(group.IsExpanded ? UTKMaterialIcons.ExpandMore : UTKMaterialIcons.ChevronRight, 16); expandIcon.AddToClassList("utk-property-group__expand-icon"); var title = new UTKLabel(group.GroupName, UTKLabel.LabelSize.Label1); title.IsBold = true; title.AddToClassList("utk-property-group__title"); var count = new UTKLabel($"({group.ItemCount})", UTKLabel.LabelSize.Caption); count.Variant = UTKLabel.LabelVariant.Secondary; count.AddToClassList("utk-property-group__count"); groupElement.Add(expandIcon); groupElement.Add(title); groupElement.Add(count); groupElement.RegisterCallback(_ => { ToggleGroupExpanded(group.GroupId); expandIcon.SetMaterialIcon(group.IsExpanded ? UTKMaterialIcons.ExpandMore : UTKMaterialIcons.ChevronRight, 16); OnGroupExpandedChanged?.Invoke(group, group.IsExpanded); }); container.Add(groupElement); } private void BindPropertyItem(VisualElement container, IUTKPropertyItem item) { // View Factory를 사용하여 View 생성 및 바인딩 var itemView = UTKPropertyItemViewFactory.CreateView(item); itemView.RegisterCallback(_ => OnPropertyClicked?.Invoke(item)); container.Add(itemView); } #endregion #region Event Subscriptions private void SubscribeGroup(IUTKPropertyGroup group) { group.OnExpandedChanged += OnGroupExpandedChangedInternal; } private void UnsubscribeGroup(IUTKPropertyGroup group) { group.OnExpandedChanged -= OnGroupExpandedChangedInternal; } private void SubscribeItem(IUTKPropertyItem item) { item.OnValueChanged += OnItemValueChanged; } private void UnsubscribeItem(IUTKPropertyItem item) { item.OnValueChanged -= OnItemValueChanged; } private void OnGroupExpandedChangedInternal(bool expanded) { // TreeView 갱신은 ToggleGroupExpanded에서 처리 } private void OnItemValueChanged(IUTKPropertyItem item, object? oldValue, object? newValue) { var args = new UTKPropertyValueChangedEventArgs(item, oldValue, newValue); OnPropertyValueChanged?.Invoke(args); } private void OnSearch(string newValue) { _searchText = newValue ?? string.Empty; RefreshTreeView(); // 클리어 버튼 가시성 업데이트 if (_clearButton != null) { _clearButton.style.display = string.IsNullOrEmpty(_searchText) ? DisplayStyle.None : DisplayStyle.Flex; } } private void OnClearButtonClicked() { if (_searchField != null && !string.IsNullOrEmpty(_searchField.value)) { _searchField.value = string.Empty; _searchText = string.Empty; RefreshTreeView(); // 클리어 버튼 숨김 if (_clearButton != null) { _clearButton.style.display = DisplayStyle.None; } } } #endregion #region IDisposable public void Dispose() { if (_disposed) return; _disposed = true; // 테마 변경 이벤트 해제 UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged; // TreeView 콜백 정리 if (_treeView != null) { _treeView.makeItem = null; _treeView.bindItem = null; _treeView.unbindItem = null; } // 검색 필드 이벤트 정리 if (_searchField != null) { _searchField.OnSubmit -= OnSearch; } if (_clearButton != null) { _clearButton.OnClicked -= OnClearButtonClicked; _clearButton.Dispose(); } // 이벤트 정리 ClearInternal(); // 이벤트 핸들러 정리 OnPropertyValueChanged = null; OnGroupExpandedChanged = null; OnPropertyClicked = null; // UI 참조 정리 _treeView = null; _searchField = null; _clearButton = null; } #endregion } }