UTKProperyItem 수정 중

This commit is contained in:
logonkhi
2026-02-10 20:48:49 +09:00
parent 97bbb789ed
commit df6d3e3b5a
112 changed files with 2898 additions and 443 deletions

View File

@@ -38,6 +38,7 @@ namespace UVC.UIToolkit
#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
@@ -53,6 +54,8 @@ namespace UVC.UIToolkit
private int _nextTreeViewId = 1;
private string _searchText = string.Empty;
private static VisualTreeAsset? _groupHeaderUxmlCache;
#endregion
#region Events
@@ -172,10 +175,19 @@ namespace UVC.UIToolkit
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<DetachFromPanelEvent>(_ =>
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
});
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
RegisterCallback<DetachFromPanelEvent>(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)
@@ -234,12 +246,20 @@ namespace UVC.UIToolkit
#endregion
#region Public Methods - Group Management
/// <summary>
/// 그룹을 추가하고 TreeView를 갱신합니다.
/// </summary>
/// <param name="group">추가할 속성 그룹.</param>
public void AddGroup(IUTKPropertyGroup group)
{
AddGroupInternal(group);
RefreshTreeView();
}
/// <summary>
/// 지정한 ID의 그룹과 그룹 내 모든 아이템을 제거합니다.
/// </summary>
/// <param name="groupId">제거할 그룹 ID.</param>
public void RemoveGroup(string groupId)
{
if (_groupIndex.TryGetValue(groupId, out var group))
@@ -259,11 +279,21 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 지정한 ID의 그룹을 반환합니다.
/// </summary>
/// <param name="groupId">조회할 그룹 ID.</param>
/// <returns>그룹 인스턴스 또는 null.</returns>
public IUTKPropertyGroup? GetGroup(string groupId)
{
return _groupIndex.TryGetValue(groupId, out var group) ? group : null;
}
/// <summary>
/// 그룹의 펼침/접힘 상태를 설정합니다.
/// </summary>
/// <param name="groupId">대상 그룹 ID.</param>
/// <param name="expanded">true면 펼침, false면 접힘.</param>
public void SetGroupExpanded(string groupId, bool expanded)
{
if (_groupIndex.TryGetValue(groupId, out var group))
@@ -280,6 +310,10 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 그룹의 펼침/접힘 상태를 토글합니다.
/// </summary>
/// <param name="groupId">대상 그룹 ID.</param>
public void ToggleGroupExpanded(string groupId)
{
if (_groupIndex.TryGetValue(groupId, out var group))
@@ -290,12 +324,21 @@ namespace UVC.UIToolkit
#endregion
#region Public Methods - Property Management
/// <summary>
/// 최상위 속성 아이템을 추가하고 TreeView를 갱신합니다.
/// </summary>
/// <param name="item">추가할 속성 아이템.</param>
public void AddProperty(IUTKPropertyItem item)
{
AddPropertyInternal(item);
RefreshTreeView();
}
/// <summary>
/// 지정한 그룹에 속성 아이템을 추가하고 TreeView를 갱신합니다.
/// </summary>
/// <param name="groupId">대상 그룹 ID.</param>
/// <param name="item">추가할 속성 아이템.</param>
public void AddPropertyToGroup(string groupId, IUTKPropertyItem item)
{
if (_groupIndex.TryGetValue(groupId, out var group))
@@ -307,6 +350,11 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 지정한 ID의 속성 아이템을 제거하고 TreeView를 갱신합니다.
/// 그룹에 속한 아이템이면 그룹에서도 제거됩니다.
/// </summary>
/// <param name="itemId">제거할 아이템 ID.</param>
public void RemoveProperty(string itemId)
{
if (_itemIndex.TryGetValue(itemId, out var item))
@@ -328,6 +376,11 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 지정한 ID의 속성 아이템을 반환합니다.
/// </summary>
/// <param name="itemId">조회할 아이템 ID.</param>
/// <returns>아이템 인스턴스 또는 null.</returns>
public IUTKPropertyItem? GetProperty(string itemId)
{
return _itemIndex.TryGetValue(itemId, out var item) ? item : null;
@@ -335,37 +388,85 @@ namespace UVC.UIToolkit
#endregion
#region Public Methods - Value Management
public void UpdatePropertyValue(string propertyId, object newValue)
/// <summary>
/// 속성 값을 변경합니다. 바인딩된 View에 OnTypedValueChanged 이벤트로 자동 반영됩니다.
/// </summary>
/// <param name="propertyId">대상 속성 ID.</param>
/// <param name="newValue">새 값 (타입 변환 자동 시도).</param>
/// <param name="notify">true면 값 변경 알림 이벤트 발생.</param>
public void UpdatePropertyValue(string propertyId, object newValue, bool notify = false)
{
if (_itemIndex.TryGetValue(propertyId, out var item))
{
item.SetValue(newValue);
item.SetValue(newValue, notify);
}
}
public void SetPropertyValue(string propertyId, object value)
/// <summary>
/// 속성 값을 변경합니다. <see cref="UpdatePropertyValue"/>의 별칭입니다.
/// </summary>
/// <param name="propertyId">대상 속성 ID.</param>
/// <param name="value">새 값.</param>
/// <param name="notify">true면 값 변경 알림 이벤트 발생.</param>
public void SetPropertyValue(string propertyId, object value, bool notify = false)
{
UpdatePropertyValue(propertyId, value);
UpdatePropertyValue(propertyId, value, notify);
}
#endregion
#region Public Methods - Visibility & ReadOnly
/// <summary>
/// 속성 아이템의 가시성을 변경합니다. TreeView 항목이 추가/제거되므로 Rebuild가 발생합니다.
/// </summary>
/// <param name="propertyId">대상 속성 ID.</param>
/// <param name="visible">true면 표시, false면 숨김.</param>
public void SetPropertyVisibility(string propertyId, bool visible)
{
if (_itemIndex.TryGetValue(propertyId, out var item))
{
if (item.IsVisible == visible) return;
item.IsVisible = visible;
RefreshTreeViewLight();
}
}
/// <summary>여러 속성의 가시성을 일괄 변경합니다. TreeView는 마지막에 한 번만 갱신됩니다.</summary>
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;
}
}
if (changed) RefreshTreeViewLight();
}
/// <summary>
/// 그룹의 가시성을 변경합니다. TreeView 항목이 추가/제거되므로 Rebuild가 발생합니다.
/// </summary>
/// <param name="groupId">대상 그룹 ID.</param>
/// <param name="visible">true면 표시, false면 숨김.</param>
public void SetGroupVisibility(string groupId, bool visible)
{
if (_groupIndex.TryGetValue(groupId, out var group))
{
if (group.IsVisible == visible) return;
group.IsVisible = visible;
RefreshTreeViewLight();
}
}
/// <summary>
/// 속성 아이템의 읽기 전용 상태를 변경합니다.
/// OnStateChanged 이벤트를 통해 바인딩된 View에 자동 반영됩니다.
/// </summary>
/// <param name="propertyId">대상 속성 ID.</param>
/// <param name="isReadOnly">true면 읽기 전용.</param>
public void SetPropertyReadOnly(string propertyId, bool isReadOnly)
{
if (_itemIndex.TryGetValue(propertyId, out var item))
@@ -374,6 +475,12 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 그룹 내 모든 아이템의 읽기 전용 상태를 일괄 변경합니다.
/// OnStateChanged 이벤트를 통해 바인딩된 View에 자동 반영됩니다.
/// </summary>
/// <param name="groupId">대상 그룹 ID.</param>
/// <param name="isReadOnly">true면 읽기 전용.</param>
public void SetGroupReadOnly(string groupId, bool isReadOnly)
{
if (_groupIndex.TryGetValue(groupId, out var group))
@@ -381,25 +488,48 @@ namespace UVC.UIToolkit
group.SetAllItemsReadOnly(isReadOnly);
}
}
/// <summary>
/// 속성 아이템의 라벨 표시 여부를 변경합니다.
/// OnStateChanged 이벤트를 통해 바인딩된 View에 자동 반영됩니다.
/// </summary>
/// <param name="propertyId">대상 속성 ID.</param>
/// <param name="showLabel">true면 라벨 표시, false면 값이 전체 너비 사용.</param>
public void SetPropertyShowLabel(string propertyId, bool showLabel)
{
if (_itemIndex.TryGetValue(propertyId, out var item))
{
item.ShowLabel = showLabel;
}
}
#endregion
#region Public Methods - Utilities
public void Clear()
/// <summary>
/// 모든 엔트리(그룹 + 아이템)를 제거하고 TreeView를 초기화합니다.
/// VisualElement.Clear()를 숨깁니다(new).
/// </summary>
public new void Clear()
{
ClearInternal();
RefreshTreeView();
}
/// <summary>
/// 현재 데이터를 기반으로 TreeView를 다시 빌드합니다.
/// </summary>
public void Refresh()
{
RefreshTreeView();
}
/// <summary>리스트를 표시합니다.</summary>
public void Show()
{
style.display = DisplayStyle.Flex;
}
/// <summary>리스트를 숨깁니다.</summary>
public void Hide()
{
style.display = DisplayStyle.None;
@@ -448,7 +578,10 @@ namespace UVC.UIToolkit
private void ClearInternal()
{
// 이벤트 구독 해제
// TreeView 내 바인딩된 View의 이벤트 콜백 정리 및 Dispose
CleanupAllTreeViewContainers();
// 데이터 이벤트 구독 해제
foreach (var group in _groupIndex.Values)
{
UnsubscribeGroup(group);
@@ -466,6 +599,19 @@ namespace UVC.UIToolkit
_nextTreeViewId = 1;
}
/// <summary>TreeView 내 모든 컨테이너의 콜백과 View를 정리합니다.</summary>
private void CleanupAllTreeViewContainers()
{
if (_treeView == null) return;
_treeView.Query<VisualElement>(className: "utk-property-item-container")
.ForEach(container =>
{
CleanupContainerCallbacks(container);
DisposeChildren(container);
});
}
private void RefreshTreeView()
{
if (_treeView == null) return;
@@ -474,7 +620,29 @@ namespace UVC.UIToolkit
_treeView.SetRootItems(treeItems);
_treeView.Rebuild();
// 펼침 상태 복원
RestoreExpandedStates();
}
/// <summary>
/// 경량 TreeView 갱신. 아이템 구조(Visibility 변경 등)만 바뀔 때 사용합니다.
/// Rebuild() 대신 RefreshItems()를 사용하여 기존 컨테이너를 재활용합니다.
/// </summary>
private void RefreshTreeViewLight()
{
if (_treeView == null) return;
var treeItems = BuildTreeItems();
_treeView.SetRootItems(treeItems);
_treeView.RefreshItems();
RestoreExpandedStates();
}
/// <summary>그룹 펼침 상태를 복원합니다.</summary>
private void RestoreExpandedStates()
{
if (_treeView == null) return;
foreach (var group in _groupIndex.Values)
{
if (group.IsExpanded)
@@ -545,6 +713,19 @@ namespace UVC.UIToolkit
#endregion
#region TreeView Callbacks
/// <summary>
/// 컨테이너에 등록된 이벤트 콜백 정보를 보관합니다.
/// View 재사용 시 이전 이벤트를 정리하기 위해 사용합니다.
/// </summary>
private sealed class ContainerCallbackInfo
{
public VisualElement? ItemView;
public EventCallback<ClickEvent>? ClickCallback;
public Action<string>? ButtonClickHandler;
public Action<string>? ActionButtonClickHandler;
}
private VisualElement MakeItem()
{
var container = new VisualElement();
@@ -557,6 +738,15 @@ namespace UVC.UIToolkit
var itemData = _treeView?.GetItemDataForIndex<IUTKPropertyEntry>(index);
if (itemData == null) return;
// 기존 자식 View 재사용 시도
if (itemData is IUTKPropertyItem item && TryRebindExistingView(element, item))
{
return;
}
// 재사용 불가 시 기존 View 정리 후 새로 생성
CleanupContainerCallbacks(element);
DisposeChildren(element);
element.Clear();
switch (itemData)
@@ -564,55 +754,146 @@ namespace UVC.UIToolkit
case IUTKPropertyGroup group:
BindGroupItem(element, group);
break;
case IUTKPropertyItem item:
BindPropertyItem(element, item);
case IUTKPropertyItem newItem:
BindPropertyItem(element, newItem);
break;
}
}
private void UnbindItem(VisualElement element, int index)
{
// View에서 직접 Unbind 처리 (IUTKPropertyItemView 구현체인 경우)
// Unbind만 수행 (View 인스턴스는 유지하여 재사용 가능)
foreach (var child in element.Children())
{
if (child is IUTKPropertyItemView view)
{
view.Unbind();
}
}
}
// IDisposable 구현체인 경우 Dispose 호출
/// <summary>
/// 기존 자식 View가 동일 PropertyType이면 Unbind → Bind로 재사용합니다.
/// 이전 이벤트 콜백을 정리하고 새 item으로 재등록합니다.
/// </summary>
/// <returns>재사용 성공 시 true</returns>
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;
}
/// <summary>
/// 컨테이너에 저장된 이벤트 콜백을 해제합니다.
/// </summary>
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;
}
/// <summary>자식 View를 Dispose합니다.</summary>
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();
}
}
element.Clear();
}
private void BindGroupItem(VisualElement container, IUTKPropertyGroup group)
{
var groupElement = new VisualElement();
groupElement.AddToClassList("utk-property-group__header");
// UXML 로드 (캐시)
if (_groupHeaderUxmlCache == null)
{
_groupHeaderUxmlCache = Resources.Load<VisualTreeAsset>(GROUP_HEADER_UXML_PATH);
}
var expandIcon = new UTKLabel(group.IsExpanded ? UTKMaterialIcons.ExpandMore : UTKMaterialIcons.ChevronRight, 16);
expandIcon.AddToClassList("utk-property-group__expand-icon");
VisualElement groupElement;
UTKLabel expandIcon;
UTKLabel title;
// UTKLabel count;
var title = new UTKLabel(group.GroupName, UTKLabel.LabelSize.Label1);
if (_groupHeaderUxmlCache != null)
{
var root = _groupHeaderUxmlCache.Instantiate();
groupElement = root.Q<VisualElement>("group-header");
expandIcon = root.Q<UTKLabel>("expand-icon");
title = root.Q<UTKLabel>("group-title");
// count = root.Q<UTKLabel>("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;
title.AddToClassList("utk-property-group__title");
// count.Text = $"({group.ItemCount})";
// count.Variant = UTKLabel.LabelVariant.Secondary;
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);
// 그룹 클릭 이벤트 - DetachFromPanelEvent에서 자동 정리됨
EventCallback<ClickEvent> clickCallback = null!;
clickCallback = _ =>
// 그룹 클릭 이벤트 - ContainerCallbackInfo로 관리
EventCallback<ClickEvent> clickCallback = _ =>
{
ToggleGroupExpanded(group.GroupId);
expandIcon.SetMaterialIcon(group.IsExpanded ? UTKMaterialIcons.ExpandMore : UTKMaterialIcons.ChevronRight, 16);
@@ -621,63 +902,51 @@ namespace UVC.UIToolkit
groupElement.RegisterCallback(clickCallback);
// DetachFromPanelEvent에서 이벤트 해제
groupElement.RegisterCallback<DetachFromPanelEvent>(evt =>
// 컨테이너에 콜백 정보 저장 (CleanupContainerCallbacks에서 정리)
container.userData = new ContainerCallbackInfo
{
groupElement.UnregisterCallback(clickCallback);
});
container.Add(groupElement);
ItemView = groupElement,
ClickCallback = clickCallback
};
}
private void BindPropertyItem(VisualElement container, IUTKPropertyItem item)
{
// View Factory를 사용하여 View 생성 및 바인딩
var itemView = UTKPropertyItemViewFactory.CreateView(item);
container.Add(itemView);
// 클릭 이벤트 등록 - DetachFromPanelEvent에서 자동 정리됨
EventCallback<ClickEvent> clickCallback = _ => OnPropertyClicked?.Invoke(item);
itemView.RegisterCallback(clickCallback);
// 이벤트 콜백 등록 및 컨테이너에 저장
RegisterPropertyCallbacks(container, itemView, item);
}
/// <summary>
/// PropertyItem View에 이벤트 콜백을 등록하고 컨테이너에 정보를 저장합니다.
/// </summary>
private void RegisterPropertyCallbacks(VisualElement container, VisualElement itemView, IUTKPropertyItem item)
{
var info = new ContainerCallbackInfo { ItemView = itemView };
// 클릭 이벤트 등록
info.ClickCallback = _ => OnPropertyClicked?.Invoke(item);
itemView.RegisterCallback(info.ClickCallback);
// 버튼 아이템인 경우 버튼 클릭 이벤트 구독
Action<string>? buttonClickHandler = null;
if (itemView is UTKButtonItemView buttonView)
{
buttonClickHandler = (actionName) =>
{
OnPropertyButtonClicked?.Invoke(item.Id, actionName);
};
buttonView.OnButtonClicked += buttonClickHandler;
info.ButtonClickHandler = actionName => OnPropertyButtonClicked?.Invoke(item.Id, actionName);
buttonView.OnButtonClicked += info.ButtonClickHandler;
}
// String 아이템에 ActionButton이 있는 경우 이벤트 구독
Action<string>? actionButtonClickHandler = null;
if (itemView is UTKStringPropertyItemView stringView)
{
actionButtonClickHandler = (actionName) =>
{
OnPropertyButtonClicked?.Invoke(item.Id, actionName);
};
stringView.OnActionButtonClicked += actionButtonClickHandler;
info.ActionButtonClickHandler = actionName => OnPropertyButtonClicked?.Invoke(item.Id, actionName);
stringView.OnActionButtonClicked += info.ActionButtonClickHandler;
}
// DetachFromPanelEvent에서 이벤트 해제
itemView.RegisterCallback<DetachFromPanelEvent>(evt =>
{
itemView.UnregisterCallback(clickCallback);
if (itemView is UTKButtonItemView btnView && buttonClickHandler != null)
{
btnView.OnButtonClicked -= buttonClickHandler;
}
if (itemView is UTKStringPropertyItemView strView && actionButtonClickHandler != null)
{
strView.OnActionButtonClicked -= actionButtonClickHandler;
}
});
container.Add(itemView);
// 컨테이너에 콜백 정보 저장 (재사용/정리 시 참조)
container.userData = info;
}
#endregion
@@ -751,8 +1020,13 @@ namespace UVC.UIToolkit
// 테마 변경 이벤트 해제
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanel);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
// TreeView 콜백 정리
// 데이터 이벤트 해제 + TreeView View 콜백/Dispose 정리
ClearInternal();
// TreeView 콜백 해제
if (_treeView != null)
{
_treeView.makeItem = null;
@@ -772,9 +1046,6 @@ namespace UVC.UIToolkit
_clearButton.Dispose();
}
// 이벤트 정리
ClearInternal();
// 이벤트 핸들러 정리
OnPropertyValueChanged = null;
OnGroupExpandedChanged = null;