#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"; private const string GROUP_HEADER_UXML_PATH = "UIToolkit/List/UTKPropertyGroupHeader"; #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; private static VisualTreeAsset? _groupHeaderUxmlCache; #endregion #region Events /// 속성 값 변경 이벤트 public event Action? OnPropertyValueChanged; /// 그룹 펼침/접힘 변경 이벤트 public event Action? OnGroupExpandedChanged; /// 속성 클릭 이벤트 public event Action? OnPropertyClicked; /// 버튼 클릭 이벤트 (액션 이름 전달) public event Action? OnPropertyButtonClicked; #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(OnAttachToPanel); RegisterCallback(OnDetachFromPanel); } private void OnAttachToPanel(AttachToPanelEvent evt) { UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged; UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged; } private void OnDetachFromPanel(DetachFromPanelEvent evt) { 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 /// /// 그룹을 추가하고 TreeView를 갱신합니다. /// /// 추가할 속성 그룹. public void AddGroup(IUTKPropertyGroup group) { AddGroupInternal(group); RefreshTreeView(); } /// /// 지정한 ID의 그룹과 그룹 내 모든 아이템을 제거합니다. /// /// 제거할 그룹 ID. 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(); } } /// /// 지정한 ID의 그룹을 반환합니다. /// /// 조회할 그룹 ID. /// 그룹 인스턴스 또는 null. public IUTKPropertyGroup? GetGroup(string groupId) { return _groupIndex.TryGetValue(groupId, out var group) ? group : null; } /// /// 그룹의 펼침/접힘 상태를 설정합니다. /// /// 대상 그룹 ID. /// true면 펼침, false면 접힘. 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); } } } /// /// 그룹의 펼침/접힘 상태를 토글합니다. /// /// 대상 그룹 ID. public void ToggleGroupExpanded(string groupId) { if (_groupIndex.TryGetValue(groupId, out var group)) { SetGroupExpanded(groupId, !group.IsExpanded); } } #endregion #region Public Methods - Property Management /// /// 최상위 속성 아이템을 추가하고 TreeView를 갱신합니다. /// /// 추가할 속성 아이템. public void AddProperty(IUTKPropertyItem item) { AddPropertyInternal(item); RefreshTreeView(); } /// /// 지정한 그룹에 속성 아이템을 추가하고 TreeView를 갱신합니다. /// /// 대상 그룹 ID. /// 추가할 속성 아이템. public void AddPropertyToGroup(string groupId, IUTKPropertyItem item) { if (_groupIndex.TryGetValue(groupId, out var group)) { group.AddItem(item); _itemIndex[item.Id] = item; SubscribeItem(item); RefreshTreeView(); } } /// /// 지정한 ID의 속성 아이템을 제거하고 TreeView를 갱신합니다. /// 그룹에 속한 아이템이면 그룹에서도 제거됩니다. /// /// 제거할 아이템 ID. 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(); } } /// /// 지정한 ID의 속성 아이템을 반환합니다. /// /// 조회할 아이템 ID. /// 아이템 인스턴스 또는 null. public IUTKPropertyItem? GetProperty(string itemId) { return _itemIndex.TryGetValue(itemId, out var item) ? item : null; } #endregion #region Public Methods - Value Management /// /// 속성 값을 변경합니다. 바인딩된 View에 OnTypedValueChanged 이벤트로 자동 반영됩니다. /// /// 대상 속성 ID. /// 새 값 (타입 변환 자동 시도). /// true면 값 변경 알림 이벤트 발생. public void UpdatePropertyValue(string propertyId, object newValue, bool notify = false) { if (_itemIndex.TryGetValue(propertyId, out var item)) { item.SetValue(newValue, notify); } } /// /// 속성 값을 변경합니다. 의 별칭입니다. /// /// 대상 속성 ID. /// 새 값. /// true면 값 변경 알림 이벤트 발생. public void SetPropertyValue(string propertyId, object value, bool notify = false) { UpdatePropertyValue(propertyId, value, notify); } #endregion #region Public Methods - Visibility & ReadOnly /// /// 속성 아이템의 가시성을 변경합니다. TreeView 항목이 추가/제거되므로 Rebuild가 발생합니다. /// /// 대상 속성 ID. /// true면 표시, false면 숨김. public void SetPropertyVisibility(string propertyId, bool visible) { if (_itemIndex.TryGetValue(propertyId, out var item)) { if (item.IsVisible == visible) return; item.IsVisible = visible; RefreshTreeViewLight(); } } /// 여러 속성의 가시성을 일괄 변경합니다. TreeView는 마지막에 한 번만 갱신됩니다. public void SetPropertyVisibilityBatch(IEnumerable<(string propertyId, bool visible)> changes) { bool changed = false; foreach (var (propertyId, visible) in changes) { if (_itemIndex.TryGetValue(propertyId, out var item) && item.IsVisible != visible) { item.IsVisible = visible; changed = true; } } Debug.Log($"SetPropertyVisibilityBatch applied {changes.Count()} changes, any visibility changed: {changed}"); if (changed) RefreshTreeViewLight(); } /// /// 그룹의 가시성을 변경합니다. TreeView 항목이 추가/제거되므로 Rebuild가 발생합니다. /// /// 대상 그룹 ID. /// true면 표시, false면 숨김. public void SetGroupVisibility(string groupId, bool visible) { if (_groupIndex.TryGetValue(groupId, out var group)) { if (group.IsVisible == visible) return; group.IsVisible = visible; RefreshTreeViewLight(); } } /// /// 속성 아이템의 읽기 전용 상태를 변경합니다. /// OnStateChanged 이벤트를 통해 바인딩된 View에 자동 반영됩니다. /// /// 대상 속성 ID. /// true면 읽기 전용. public void SetPropertyReadOnly(string propertyId, bool isReadOnly) { if (_itemIndex.TryGetValue(propertyId, out var item)) { item.IsReadOnly = isReadOnly; } } /// /// 그룹 내 모든 아이템의 읽기 전용 상태를 일괄 변경합니다. /// OnStateChanged 이벤트를 통해 바인딩된 View에 자동 반영됩니다. /// /// 대상 그룹 ID. /// true면 읽기 전용. public void SetGroupReadOnly(string groupId, bool isReadOnly) { if (_groupIndex.TryGetValue(groupId, out var group)) { group.SetAllItemsReadOnly(isReadOnly); } } /// /// 속성 아이템의 라벨 표시 여부를 변경합니다. /// OnStateChanged 이벤트를 통해 바인딩된 View에 자동 반영됩니다. /// /// 대상 속성 ID. /// true면 라벨 표시, false면 값이 전체 너비 사용. public void SetPropertyShowLabel(string propertyId, bool showLabel) { if (_itemIndex.TryGetValue(propertyId, out var item)) { item.ShowLabel = showLabel; } } #endregion #region Public Methods - Utilities /// /// 모든 엔트리(그룹 + 아이템)를 제거하고 TreeView를 초기화합니다. /// VisualElement.Clear()를 숨깁니다(new). /// public new void Clear() { ClearInternal(); RefreshTreeView(); } /// /// 현재 데이터를 기반으로 TreeView를 다시 빌드합니다. /// 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() { // TreeView 내 바인딩된 View의 이벤트 콜백 정리 및 Dispose CleanupAllTreeViewContainers(); // 데이터 이벤트 구독 해제 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; } /// TreeView 내 모든 컨테이너의 콜백과 View를 정리합니다. private void CleanupAllTreeViewContainers() { if (_treeView == null) return; _treeView.Query(className: "utk-property-item-container") .ForEach(container => { CleanupContainerCallbacks(container); DisposeChildren(container); }); } private void RefreshTreeView() { if (_treeView == null) return; var treeItems = BuildTreeItems(); _treeView.SetRootItems(treeItems); _treeView.Rebuild(); RestoreExpandedStates(); } /// /// 경량 TreeView 갱신. 아이템 구조(Visibility 변경 등)만 바뀔 때 사용합니다. /// Rebuild() 대신 RefreshItems()를 사용하여 기존 컨테이너를 재활용합니다. /// private void RefreshTreeViewLight() { if (_treeView == null) return; var treeItems = BuildTreeItems(); _treeView.SetRootItems(treeItems); _treeView.RefreshItems(); RestoreExpandedStates(); } /// 그룹 펼침 상태를 복원합니다. private void RestoreExpandedStates() { if (_treeView == null) return; 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 /// /// 컨테이너에 등록된 이벤트 콜백 정보를 보관합니다. /// View 재사용 시 이전 이벤트를 정리하기 위해 사용합니다. /// private sealed class ContainerCallbackInfo { public VisualElement? ItemView; public EventCallback? ClickCallback; public Action? ButtonClickHandler; public Action? ActionButtonClickHandler; } 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; // 기존 자식 View 재사용 시도 if (itemData is IUTKPropertyItem item && TryRebindExistingView(element, item)) { return; } // 재사용 불가 시 기존 View 정리 후 새로 생성 CleanupContainerCallbacks(element); DisposeChildren(element); element.Clear(); switch (itemData) { case IUTKPropertyGroup group: BindGroupItem(element, group); break; case IUTKPropertyItem newItem: BindPropertyItem(element, newItem); break; } } private void UnbindItem(VisualElement element, int index) { // Unbind만 수행 (View 인스턴스는 유지하여 재사용 가능) foreach (var child in element.Children()) { if (child is IUTKPropertyItemView view) { view.Unbind(); } } } /// /// 기존 자식 View가 동일 PropertyType이면 Unbind → Bind로 재사용합니다. /// 이전 이벤트 콜백을 정리하고 새 item으로 재등록합니다. /// /// 재사용 성공 시 true private bool TryRebindExistingView(VisualElement container, IUTKPropertyItem item) { if (container.childCount != 1) return false; var existingChild = container[0]; if (existingChild is not IUTKPropertyItemView existingView) return false; // 같은 PropertyType인지 확인 var expectedViewType = UTKPropertyItemViewFactory.GetViewType(item); if (existingChild.GetType() != expectedViewType) return false; // 이전 이벤트 콜백 정리 CleanupContainerCallbacks(container); // Unbind → Bind (View 인스턴스 재사용) existingView.Unbind(); existingView.Bind(item); // 새 item에 대한 이벤트 콜백 재등록 RegisterPropertyCallbacks(container, existingChild, item); return true; } /// /// 컨테이너에 저장된 이벤트 콜백을 해제합니다. /// private static void CleanupContainerCallbacks(VisualElement container) { if (container.userData is not ContainerCallbackInfo info) return; if (info.ItemView != null && info.ClickCallback != null) { info.ItemView.UnregisterCallback(info.ClickCallback); } if (info.ButtonClickHandler != null && info.ItemView is UTKButtonItemView btnView) { btnView.OnButtonClicked -= info.ButtonClickHandler; } if (info.ActionButtonClickHandler != null && info.ItemView is UTKStringPropertyItemView strView) { strView.OnActionButtonClicked -= info.ActionButtonClickHandler; } container.userData = null; } /// 자식 View를 Dispose합니다. private static void DisposeChildren(VisualElement element) { foreach (var child in element.Children()) { if (child is IUTKPropertyItemView view) { view.Unbind(); } if (child is IDisposable disposable) { disposable.Dispose(); } } } private void BindGroupItem(VisualElement container, IUTKPropertyGroup group) { // UXML 로드 (캐시) if (_groupHeaderUxmlCache == null) { _groupHeaderUxmlCache = Resources.Load(GROUP_HEADER_UXML_PATH); } VisualElement groupElement; UTKLabel expandIcon; UTKLabel title; // UTKLabel count; if (_groupHeaderUxmlCache != null) { var root = _groupHeaderUxmlCache.Instantiate(); groupElement = root.Q("group-header"); expandIcon = root.Q("expand-icon"); title = root.Q("group-title"); // count = root.Q("group-count"); container.Add(root); } else { // Fallback: UXML 로드 실패 시 코드로 생성 groupElement = new VisualElement(); groupElement.AddToClassList("utk-property-group__header"); expandIcon = new UTKLabel(); expandIcon.AddToClassList("utk-property-group__expand-icon"); title = new UTKLabel(); title.AddToClassList("utk-property-group__title"); // count = new UTKLabel(); // count.AddToClassList("utk-property-group__count"); groupElement.Add(expandIcon); groupElement.Add(title); // groupElement.Add(count); container.Add(groupElement); } // 데이터 바인딩 expandIcon.SetMaterialIcon(group.IsExpanded ? UTKMaterialIcons.ExpandMore : UTKMaterialIcons.ChevronRight, 16); title.Text = group.GroupName; title.Size = UTKLabel.LabelSize.Label1; title.IsBold = true; // count.Text = $"({group.ItemCount})"; // count.Variant = UTKLabel.LabelVariant.Secondary; // 그룹 클릭 이벤트 - ContainerCallbackInfo로 관리 EventCallback clickCallback = _ => { ToggleGroupExpanded(group.GroupId); expandIcon.SetMaterialIcon(group.IsExpanded ? UTKMaterialIcons.ExpandMore : UTKMaterialIcons.ChevronRight, 16); OnGroupExpandedChanged?.Invoke(group, group.IsExpanded); }; groupElement.RegisterCallback(clickCallback); // 컨테이너에 콜백 정보 저장 (CleanupContainerCallbacks에서 정리) container.userData = new ContainerCallbackInfo { ItemView = groupElement, ClickCallback = clickCallback }; } private void BindPropertyItem(VisualElement container, IUTKPropertyItem item) { // View Factory를 사용하여 View 생성 및 바인딩 var itemView = UTKPropertyItemViewFactory.CreateView(item); container.Add(itemView); // 이벤트 콜백 등록 및 컨테이너에 저장 RegisterPropertyCallbacks(container, itemView, item); } /// /// PropertyItem View에 이벤트 콜백을 등록하고 컨테이너에 정보를 저장합니다. /// private void RegisterPropertyCallbacks(VisualElement container, VisualElement itemView, IUTKPropertyItem item) { var info = new ContainerCallbackInfo { ItemView = itemView }; // 클릭 이벤트 등록 info.ClickCallback = _ => OnPropertyClicked?.Invoke(item); itemView.RegisterCallback(info.ClickCallback); // 버튼 아이템인 경우 버튼 클릭 이벤트 구독 if (itemView is UTKButtonItemView buttonView) { info.ButtonClickHandler = actionName => OnPropertyButtonClicked?.Invoke(item.Id, actionName); buttonView.OnButtonClicked += info.ButtonClickHandler; } // String 아이템에 ActionButton이 있는 경우 이벤트 구독 if (itemView is UTKStringPropertyItemView stringView) { info.ActionButtonClickHandler = actionName => OnPropertyButtonClicked?.Invoke(item.Id, actionName); stringView.OnActionButtonClicked += info.ActionButtonClickHandler; } // 컨테이너에 콜백 정보 저장 (재사용/정리 시 참조) container.userData = info; } #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, bool notify) { if (notify) { 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; UnregisterCallback(OnAttachToPanel); UnregisterCallback(OnDetachFromPanel); // 데이터 이벤트 해제 + TreeView View 콜백/Dispose 정리 ClearInternal(); // 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(); } // 이벤트 핸들러 정리 OnPropertyValueChanged = null; OnGroupExpandedChanged = null; OnPropertyClicked = null; OnPropertyButtonClicked = null; // UI 참조 정리 _treeView = null; _searchField = null; _clearButton = null; } #endregion } }