UTKPropertyItem 개선

This commit is contained in:
logonkhi
2026-02-04 20:31:52 +09:00
parent 8181eae4c6
commit c9af0d2d6f
202 changed files with 8337 additions and 3878 deletions

View File

@@ -0,0 +1,731 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
/// <summary>
/// UTKPropertyListWindow의 핵심 리스트 컴포넌트입니다.
/// TreeView 기반으로 그룹화된 속성들을 표시합니다.
///
/// <para><b>개요:</b></para>
/// <para>
/// UTKPropertyList는 UXML/USS 기반의 속성 리스트 컴포넌트입니다.
/// TreeView를 사용하여 가상화(Virtualization)를 지원하며, 대량의 속성을 효율적으로 렌더링합니다.
/// 그룹별 펼침/접힘, 검색 필터링 기능을 제공합니다.
/// </para>
///
/// <para><b>주요 기능:</b></para>
/// <list type="bullet">
/// <item>TreeView 기반 가상화 (Virtualization) - 대량 속성 성능 최적화</item>
/// <item>그룹별 펼침/접힘 기능</item>
/// <item>실시간 검색 필터링</item>
/// <item>다양한 속성 타입 지원 (Text, Number, Boolean, Dropdown 등)</item>
/// </list>
///
/// <para><b>관련 리소스:</b></para>
/// <list type="bullet">
/// <item>Resources/UIToolkit/List/UTKPropertyList.uxml - 메인 레이아웃</item>
/// <item>Resources/UIToolkit/List/UTKPropertyListUss.uss - 스타일</item>
/// </list>
/// </summary>
[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<IUTKPropertyEntry> _entries = new();
private readonly Dictionary<string, IUTKPropertyGroup> _groupIndex = new();
private readonly Dictionary<string, IUTKPropertyItem> _itemIndex = new();
private readonly Dictionary<int, IUTKPropertyEntry> _treeViewIdToEntry = new();
private int _nextTreeViewId = 1;
private string _searchText = string.Empty;
#endregion
#region Events
/// <summary>속성 값 변경 이벤트</summary>
public event Action<UTKPropertyValueChangedEventArgs>? OnPropertyValueChanged;
/// <summary>그룹 펼침/접힘 변경 이벤트</summary>
public event Action<IUTKPropertyGroup, bool>? OnGroupExpandedChanged;
/// <summary>속성 클릭 이벤트</summary>
public event Action<IUTKPropertyItem>? OnPropertyClicked;
#endregion
#region Constructor
public UTKPropertyList()
{
// 메인 UXML 로드
var visualTree = Resources.Load<VisualTreeAsset>(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<StyleSheet>(USS_PATH);
if (uss != null)
{
styleSheets.Add(uss);
}
// TreeView 초기화
InitializeTreeView();
}
#endregion
#region UI Creation
/// <summary>UXML에서 UI 요소 참조를 획득합니다.</summary>
private void CreateUIFromUxml()
{
_searchField = this.Q<UTKInputField>("search-field");
_treeView = this.Q<TreeView>("property-tree-view");
_clearButton = this.Q<UTKButton>("clear-btn");
// 검색 필드 이벤트 연결
if (_searchField != null)
{
_searchField.OnSubmit += OnSearch;
}
// 검색어 지우기 버튼
if (_clearButton != null)
{
_clearButton.OnClicked += OnClearButtonClicked;
// 초기에는 숨김
_clearButton.style.display = DisplayStyle.None;
}
}
/// <summary>UXML 로드 실패 시 코드로 UI를 생성합니다.</summary>
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);
}
/// <summary>TreeView 콜백을 초기화합니다.</summary>
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<DetachFromPanelEvent>(_ =>
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
});
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
#endregion
#region Public Methods - Data Loading
/// <summary>평면 속성 목록 로드 (그룹 없이)</summary>
public void LoadProperties(List<IUTKPropertyItem> items)
{
ClearInternal();
foreach (var item in items)
{
AddPropertyInternal(item);
}
RefreshTreeView();
}
/// <summary>그룹화된 속성 목록 로드</summary>
public void LoadGroupedProperties(List<IUTKPropertyGroup> groups)
{
ClearInternal();
foreach (var group in groups)
{
AddGroupInternal(group);
}
RefreshTreeView();
}
/// <summary>혼합 엔트리 목록 로드 (그룹 + 속성)</summary>
public void LoadMixedProperties(List<IUTKPropertyEntry> 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<TreeViewItemData<IUTKPropertyEntry>> BuildTreeItems()
{
var result = new List<TreeViewItemData<IUTKPropertyEntry>>();
_treeViewIdToEntry.Clear();
foreach (var entry in _entries)
{
if (!MatchesSearch(entry)) continue;
if (entry is IUTKPropertyGroup group)
{
var children = new List<TreeViewItemData<IUTKPropertyEntry>>();
foreach (var item in group.Items)
{
if (item.IsVisible && MatchesSearch(item))
{
_treeViewIdToEntry[item.TreeViewId] = item;
children.Add(new TreeViewItemData<IUTKPropertyEntry>(item.TreeViewId, item));
}
}
if (children.Count > 0 || string.IsNullOrEmpty(_searchText))
{
_treeViewIdToEntry[group.TreeViewId] = group;
result.Add(new TreeViewItemData<IUTKPropertyEntry>(group.TreeViewId, group, children));
}
}
else if (entry is IUTKPropertyItem item && item.IsVisible)
{
_treeViewIdToEntry[item.TreeViewId] = item;
result.Add(new TreeViewItemData<IUTKPropertyEntry>(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<IUTKPropertyEntry>(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<ClickEvent>(_ =>
{
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<ClickEvent>(_ => 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
}
}