2026-01-08 20:15:57 +09:00
|
|
|
#nullable enable
|
|
|
|
|
using System;
|
2026-02-02 19:33:27 +09:00
|
|
|
using System.Collections.Generic;
|
2026-01-08 20:15:57 +09:00
|
|
|
using System.Threading;
|
|
|
|
|
using Cysharp.Threading.Tasks;
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
using UnityEngine.UIElements;
|
|
|
|
|
|
2026-01-20 20:18:47 +09:00
|
|
|
namespace UVC.UIToolkit
|
2026-01-08 20:15:57 +09:00
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 모든 PropertyItem의 기본 추상 클래스
|
|
|
|
|
/// 공통 기능 및 UIToolkit 바인딩 로직 제공
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <typeparam name="T">속성 값의 타입</typeparam>
|
|
|
|
|
public abstract class UTKPropertyItemBase<T> : IUTKPropertyItem<T>, IDisposable
|
|
|
|
|
{
|
|
|
|
|
#region Constants
|
|
|
|
|
protected const int DEFAULT_DEBOUNCE_MS = 300;
|
|
|
|
|
protected const string USS_CLASS_READONLY = "utk-property-item--readonly";
|
|
|
|
|
protected const string USS_CLASS_HIDDEN = "utk-property-item--hidden";
|
2026-02-02 19:33:27 +09:00
|
|
|
protected const string UXML_BASE_PATH = "UIToolkit/Property/Items/";
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Static Cache
|
|
|
|
|
private static readonly Dictionary<string, VisualTreeAsset> _uxmlCache = new();
|
2026-01-08 20:15:57 +09:00
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Fields
|
|
|
|
|
private T _value;
|
2026-02-03 20:43:36 +09:00
|
|
|
protected bool _isReadOnly;
|
|
|
|
|
protected bool _isVisible = true;
|
2026-01-08 20:15:57 +09:00
|
|
|
private string? _description;
|
|
|
|
|
private string? _tooltip;
|
|
|
|
|
private string? _groupId;
|
|
|
|
|
private bool _disposed;
|
|
|
|
|
|
|
|
|
|
protected VisualElement? _rootElement;
|
|
|
|
|
protected CancellationTokenSource? _debounceCts;
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Properties
|
|
|
|
|
public string Id { get; }
|
|
|
|
|
public string Name { get; }
|
|
|
|
|
public string DisplayName => Name;
|
|
|
|
|
public bool IsGroup => false;
|
|
|
|
|
public int TreeViewId { get; set; }
|
|
|
|
|
public abstract UTKPropertyType PropertyType { get; }
|
|
|
|
|
|
|
|
|
|
public T Value
|
|
|
|
|
{
|
|
|
|
|
get => _value;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
if (!Equals(_value, value))
|
|
|
|
|
{
|
|
|
|
|
var oldValue = _value;
|
|
|
|
|
_value = value;
|
|
|
|
|
NotifyValueChanged(oldValue, value);
|
|
|
|
|
RefreshUI();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string? Description
|
|
|
|
|
{
|
|
|
|
|
get => _description;
|
|
|
|
|
set => _description = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string? Tooltip
|
|
|
|
|
{
|
|
|
|
|
get => _tooltip;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
_tooltip = value;
|
|
|
|
|
UpdateTooltip();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool IsReadOnly
|
|
|
|
|
{
|
|
|
|
|
get => _isReadOnly;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
if (_isReadOnly != value)
|
|
|
|
|
{
|
|
|
|
|
_isReadOnly = value;
|
|
|
|
|
UpdateReadOnlyState();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool IsVisible
|
|
|
|
|
{
|
|
|
|
|
get => _isVisible;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
if (_isVisible != value)
|
|
|
|
|
{
|
|
|
|
|
_isVisible = value;
|
|
|
|
|
UpdateVisibility();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string? GroupId
|
|
|
|
|
{
|
|
|
|
|
get => _groupId;
|
|
|
|
|
set => _groupId = value;
|
|
|
|
|
}
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Events
|
|
|
|
|
public event Action<IUTKPropertyItem, object?, object?>? OnValueChanged;
|
|
|
|
|
public event Action<IUTKPropertyItem<T>, T, T>? OnTypedValueChanged;
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Constructor
|
|
|
|
|
protected UTKPropertyItemBase(string id, string name, T initialValue)
|
|
|
|
|
{
|
|
|
|
|
Id = id ?? throw new ArgumentNullException(nameof(id));
|
|
|
|
|
Name = name ?? throw new ArgumentNullException(nameof(name));
|
|
|
|
|
_value = initialValue;
|
|
|
|
|
}
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Public Methods
|
|
|
|
|
public object? GetValue() => _value;
|
|
|
|
|
|
|
|
|
|
public void SetValue(object? value)
|
|
|
|
|
{
|
|
|
|
|
if (value == null)
|
|
|
|
|
{
|
|
|
|
|
if (default(T) == null)
|
|
|
|
|
{
|
|
|
|
|
Value = default!;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (value is T typedValue)
|
|
|
|
|
{
|
|
|
|
|
Value = typedValue;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Value = (T)Convert.ChangeType(value, typeof(T));
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogWarning($"[UTKPropertyItem] Failed to convert value '{value}' to type {typeof(T)}: {ex.Message}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public abstract VisualElement CreateUI();
|
|
|
|
|
|
|
|
|
|
public virtual void BindUI(VisualElement element)
|
|
|
|
|
{
|
|
|
|
|
_rootElement = element;
|
|
|
|
|
UpdateReadOnlyState();
|
|
|
|
|
UpdateVisibility();
|
|
|
|
|
UpdateTooltip();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public virtual void UnbindUI(VisualElement element)
|
|
|
|
|
{
|
|
|
|
|
CancelDebounce();
|
|
|
|
|
|
|
|
|
|
if (_rootElement != null)
|
|
|
|
|
{
|
|
|
|
|
_rootElement.ClearTooltip();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_rootElement = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public virtual void RefreshUI()
|
|
|
|
|
{
|
|
|
|
|
// 하위 클래스에서 오버라이드하여 UI 갱신
|
|
|
|
|
}
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Protected Methods
|
|
|
|
|
protected void NotifyValueChanged(T oldValue, T newValue)
|
|
|
|
|
{
|
|
|
|
|
OnTypedValueChanged?.Invoke(this, oldValue, newValue);
|
|
|
|
|
OnValueChanged?.Invoke(this, oldValue, newValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected async UniTaskVoid DebounceValueChange(T newValue, int delayMs = DEFAULT_DEBOUNCE_MS)
|
|
|
|
|
{
|
|
|
|
|
CancelDebounce();
|
|
|
|
|
_debounceCts = new CancellationTokenSource();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await UniTask.Delay(delayMs, cancellationToken: _debounceCts.Token);
|
|
|
|
|
Value = newValue;
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
// 디바운스 취소됨 - 정상 동작
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected void CancelDebounce()
|
|
|
|
|
{
|
|
|
|
|
_debounceCts?.Cancel();
|
|
|
|
|
_debounceCts?.Dispose();
|
|
|
|
|
_debounceCts = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected virtual void UpdateReadOnlyState()
|
|
|
|
|
{
|
|
|
|
|
if (_rootElement == null) return;
|
|
|
|
|
|
|
|
|
|
if (_isReadOnly)
|
|
|
|
|
{
|
|
|
|
|
_rootElement.AddToClassList(USS_CLASS_READONLY);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_rootElement.RemoveFromClassList(USS_CLASS_READONLY);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected virtual void UpdateVisibility()
|
|
|
|
|
{
|
|
|
|
|
if (_rootElement == null) return;
|
|
|
|
|
|
|
|
|
|
_rootElement.style.display = _isVisible ? DisplayStyle.Flex : DisplayStyle.None;
|
|
|
|
|
|
|
|
|
|
if (_isVisible)
|
|
|
|
|
{
|
|
|
|
|
_rootElement.RemoveFromClassList(USS_CLASS_HIDDEN);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
_rootElement.AddToClassList(USS_CLASS_HIDDEN);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected virtual void UpdateTooltip()
|
|
|
|
|
{
|
|
|
|
|
if (_rootElement == null || string.IsNullOrEmpty(_tooltip)) return;
|
|
|
|
|
|
|
|
|
|
_rootElement.SetTooltip(_tooltip);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 19:33:27 +09:00
|
|
|
protected UTKLabel CreateNameLabel()
|
2026-01-08 20:15:57 +09:00
|
|
|
{
|
2026-02-02 19:33:27 +09:00
|
|
|
var label = new UTKLabel(Name, UTKLabel.LabelSize.Body2);
|
2026-01-08 20:15:57 +09:00
|
|
|
label.AddToClassList("utk-property-item__label");
|
|
|
|
|
return label;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected VisualElement CreateContainer()
|
|
|
|
|
{
|
|
|
|
|
var container = new VisualElement();
|
|
|
|
|
container.AddToClassList("utk-property-item");
|
|
|
|
|
container.AddToClassList($"utk-property-item--{PropertyType.ToString().ToLower()}");
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(Description))
|
|
|
|
|
{
|
|
|
|
|
container.AddToClassList("utk-property-item--has-description");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return container;
|
|
|
|
|
}
|
2026-02-02 19:33:27 +09:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// UXML 템플릿에서 UI 생성
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="uxmlName">UXML 파일명 (확장자 제외)</param>
|
|
|
|
|
/// <returns>생성된 루트 VisualElement</returns>
|
|
|
|
|
protected VisualElement? CreateUIFromUxml(string uxmlName)
|
|
|
|
|
{
|
|
|
|
|
var asset = LoadUxmlAsset(uxmlName);
|
|
|
|
|
if (asset == null)
|
|
|
|
|
{
|
|
|
|
|
Debug.LogWarning($"[UTKPropertyItem] UXML not found: {uxmlName}");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var root = asset.Instantiate();
|
|
|
|
|
var itemRoot = root.Q<VisualElement>("item-root") ?? root;
|
|
|
|
|
|
|
|
|
|
// name-label에 Name 설정
|
|
|
|
|
var nameLabel = itemRoot.Q<UTKLabel>("name-label");
|
|
|
|
|
if (nameLabel != null)
|
|
|
|
|
{
|
|
|
|
|
nameLabel.Text = Name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return itemRoot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// UXML 에셋 로드 (캐시 사용)
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected static VisualTreeAsset? LoadUxmlAsset(string uxmlName)
|
|
|
|
|
{
|
|
|
|
|
string path = UXML_BASE_PATH + uxmlName;
|
|
|
|
|
|
|
|
|
|
if (_uxmlCache.TryGetValue(path, out var cached))
|
|
|
|
|
{
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var asset = Resources.Load<VisualTreeAsset>(path);
|
|
|
|
|
if (asset != null)
|
|
|
|
|
{
|
|
|
|
|
_uxmlCache[path] = asset;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return asset;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// UXML 캐시 클리어 (에디터 용도)
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static void ClearUxmlCache()
|
|
|
|
|
{
|
|
|
|
|
_uxmlCache.Clear();
|
|
|
|
|
}
|
2026-01-08 20:15:57 +09:00
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region IDisposable
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
Dispose(true);
|
|
|
|
|
GC.SuppressFinalize(this);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected virtual void Dispose(bool disposing)
|
|
|
|
|
{
|
|
|
|
|
if (_disposed) return;
|
|
|
|
|
_disposed = true;
|
|
|
|
|
|
|
|
|
|
if (disposing)
|
|
|
|
|
{
|
|
|
|
|
CancelDebounce();
|
|
|
|
|
|
|
|
|
|
if (_rootElement != null)
|
|
|
|
|
{
|
|
|
|
|
UnbindUI(_rootElement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
OnValueChanged = null;
|
|
|
|
|
OnTypedValueChanged = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endregion
|
|
|
|
|
}
|
|
|
|
|
}
|