Files
XRLib/Assets/Scripts/UVC/UI/Window/PropertyWindow/PropertyWindow.cs
2025-12-12 18:58:44 +09:00

537 lines
18 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.EventSystems;
using UVC.Core;
using UVC.Factory;
using UVC.Factory.Cameras;
namespace UVC.UI.Window.PropertyWindow
{
/// <summary>
/// 속성 데이터를 관리하고, 데이터 변경 시 이벤트를 발생시키는 컨트롤러 클래스입니다.
/// Model과 View 사이의 중재자 역할을 합니다.
/// 그룹과 개별 아이템을 혼용하여 사용할 수 있습니다.
/// </summary>
public class PropertyWindow : SingletonScene<PropertyWindow>, IPointerEnterHandler, IPointerExitHandler
{
[SerializeField]
private PropertyView _view;
#region Internal Data Structures
/// <summary>
/// 통합 엔트리 목록 (그룹과 개별 아이템 혼합 저장)
/// </summary>
private readonly List<IPropertyEntry> _entries = new List<IPropertyEntry>();
/// <summary>
/// 빠른 그룹 조회를 위한 인덱스
/// </summary>
private readonly Dictionary<string, IPropertyGroup> _groupIndex = new Dictionary<string, IPropertyGroup>();
/// <summary>
/// 빠른 아이템 조회를 위한 인덱스
/// </summary>
private readonly Dictionary<string, IPropertyItem> _itemIndex = new Dictionary<string, IPropertyItem>();
#endregion
#region Public Properties
/// <summary>
/// 현재 컨트롤러가 관리하는 모든 속성 항목의 목록입니다.
/// 하위 호환성을 위해 유지됩니다. 그룹에 속한 아이템도 포함됩니다.
/// </summary>
public List<IPropertyItem> Properties
{
get
{
var allItems = new List<IPropertyItem>();
foreach (var entry in _entries)
{
if (entry is IPropertyItem item && item.GroupId == null)
{
allItems.Add(item);
}
else if (entry is IPropertyGroup group)
{
allItems.AddRange(group.Items);
}
}
return allItems;
}
}
/// <summary>
/// 정렬된 엔트리 목록을 반환합니다.
/// </summary>
public IReadOnlyList<IPropertyEntry> Entries => _entries.OrderBy(e => e.Order).ToList().AsReadOnly();
/// <summary>
/// 모든 그룹의 읽기 전용 목록을 반환합니다.
/// </summary>
public IReadOnlyList<IPropertyGroup> Groups => _groupIndex.Values.ToList().AsReadOnly();
#endregion
#region Events
/// <summary>
/// 속성 값이 변경될 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler<PropertyValueChangedEventArgs>? PropertyValueChanged;
/// <summary>
/// 그룹이 추가되었을 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler<PropertyGroupEventArgs>? GroupAdded;
/// <summary>
/// 그룹이 제거되었을 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler<PropertyGroupEventArgs>? GroupRemoved;
/// <summary>
/// 그룹의 펼침/접힘 상태가 변경되었을 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler<PropertyGroupExpandedEventArgs>? GroupExpandedChanged;
/// <summary>
/// 엔트리가 추가되었을 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler<PropertyEntryEventArgs>? EntryAdded;
/// <summary>
/// 엔트리가 제거되었을 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler<PropertyEntryEventArgs>? EntryRemoved;
/// <summary>
/// 모든 엔트리가 제거되었을 때 발생하는 이벤트입니다.
/// </summary>
public event EventHandler? EntriesCleared;
#endregion
#region Load Methods ( + + )
/// <summary>
/// [기존 방식] 그룹 없이 속성 목록을 로드합니다.
/// 모든 아이템이 flat하게 표시됩니다.
/// </summary>
/// <param name="items">표시할 속성 항목들의 목록</param>
public void LoadProperties(List<IPropertyItem> items)
{
Clear();
if (items != null)
{
foreach (var item in items)
{
item.GroupId = null; // 그룹 없음 명시
AddEntryInternal(item);
}
}
Refresh();
}
/// <summary>
/// [그룹 방식] 그룹화된 속성 목록을 로드합니다.
/// </summary>
/// <param name="groups">표시할 속성 그룹들의 목록</param>
public void LoadGroupedProperties(List<IPropertyGroup> groups)
{
Clear();
if (groups != null)
{
foreach (var group in groups)
{
AddEntryInternal(group);
}
}
Refresh();
}
/// <summary>
/// [혼용 방식] 그룹과 개별 아이템을 함께 로드합니다.
/// </summary>
/// <param name="entries">표시할 엔트리들의 목록 (그룹 또는 아이템)</param>
public void LoadMixedProperties(List<IPropertyEntry> entries)
{
Clear();
if (entries != null)
{
foreach (var entry in entries)
{
AddEntryInternal(entry);
}
}
Refresh();
}
#endregion
#region Group Management
/// <summary>
/// 그룹을 추가합니다.
/// </summary>
/// <param name="group">추가할 그룹</param>
public void AddGroup(IPropertyGroup group)
{
if (group == null)
throw new ArgumentNullException(nameof(group));
if (_groupIndex.ContainsKey(group.GroupId))
{
Debug.LogWarning($"[PropertyWindow] 이미 존재하는 그룹 ID입니다: {group.GroupId}");
return;
}
AddEntryInternal(group);
GroupAdded?.Invoke(this, new PropertyGroupEventArgs(group));
Refresh();
}
/// <summary>
/// 그룹을 제거합니다.
/// </summary>
/// <param name="groupId">제거할 그룹의 ID</param>
public void RemoveGroup(string groupId)
{
if (_groupIndex.TryGetValue(groupId, out var group))
{
// 그룹 내 모든 아이템의 GroupId 초기화 및 인덱스에서 제거
foreach (var item in group.Items)
{
item.GroupId = null;
_itemIndex.Remove(item.Id);
}
group.Clear();
_groupIndex.Remove(groupId);
_entries.Remove(group);
GroupRemoved?.Invoke(this, new PropertyGroupEventArgs(group));
Refresh();
}
}
/// <summary>
/// 특정 ID의 그룹을 가져옵니다.
/// </summary>
/// <param name="groupId">그룹 ID</param>
/// <returns>그룹 또는 null</returns>
public IPropertyGroup? GetGroup(string groupId)
{
_groupIndex.TryGetValue(groupId, out var group);
return group;
}
/// <summary>
/// 그룹의 펼침/접힘 상태를 변경합니다.
/// </summary>
/// <param name="groupId">그룹 ID</param>
/// <param name="isExpanded">펼침 상태</param>
public void SetGroupExpanded(string groupId, bool isExpanded)
{
if (_groupIndex.TryGetValue(groupId, out var group))
{
bool wasExpanded = group.IsExpanded;
if (wasExpanded != isExpanded)
{
group.IsExpanded = isExpanded;
// 이벤트를 통해 PropertyView.OnGroupExpandedChanged()가 호출되어
// 부분적으로 UI를 업데이트합니다. 전체 Refresh()는 스크롤 위치를 초기화하므로 호출하지 않습니다.
GroupExpandedChanged?.Invoke(this, new PropertyGroupExpandedEventArgs(group, wasExpanded, isExpanded));
}
}
}
/// <summary>
/// 그룹의 펼침/접힘 상태를 토글합니다.
/// </summary>
/// <param name="groupId">그룹 ID</param>
public void ToggleGroupExpanded(string groupId)
{
if (_groupIndex.TryGetValue(groupId, out var group))
{
SetGroupExpanded(groupId, !group.IsExpanded);
}
}
#endregion
#region Property Management
/// <summary>
/// 개별 속성 아이템을 추가합니다 (그룹 없이).
/// </summary>
/// <param name="item">추가할 속성 아이템</param>
public void AddProperty(IPropertyItem item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
item.GroupId = null;
AddEntryInternal(item);
EntryAdded?.Invoke(this, new PropertyEntryEventArgs(item));
Refresh();
}
/// <summary>
/// 특정 그룹에 속성 아이템을 추가합니다.
/// 그룹이 없으면 새로 생성합니다.
/// </summary>
/// <param name="groupId">그룹 ID</param>
/// <param name="item">추가할 속성 아이템</param>
/// <param name="groupNameIfNew">그룹이 새로 생성될 경우 사용할 이름 (null이면 groupId 사용)</param>
public void AddPropertyToGroup(string groupId, IPropertyItem item, string? groupNameIfNew = null)
{
if (string.IsNullOrEmpty(groupId))
throw new ArgumentNullException(nameof(groupId));
if (item == null)
throw new ArgumentNullException(nameof(item));
if (!_groupIndex.TryGetValue(groupId, out var group))
{
// 그룹이 없으면 새로 생성
group = new PropertyGroup(groupId, groupNameIfNew ?? groupId);
AddEntryInternal(group);
GroupAdded?.Invoke(this, new PropertyGroupEventArgs(group));
}
group.AddItem(item);
_itemIndex[item.Id] = item;
Refresh();
}
/// <summary>
/// 여러 속성 아이템을 한번에 그룹에 추가합니다.
/// </summary>
/// <param name="groupId">그룹 ID</param>
/// <param name="items">추가할 속성 아이템들</param>
/// <param name="groupNameIfNew">그룹이 새로 생성될 경우 사용할 이름</param>
public void AddPropertiesToGroup(string groupId, IEnumerable<IPropertyItem> items, string? groupNameIfNew = null)
{
if (string.IsNullOrEmpty(groupId))
throw new ArgumentNullException(nameof(groupId));
if (items == null)
throw new ArgumentNullException(nameof(items));
if (!_groupIndex.TryGetValue(groupId, out var group))
{
group = new PropertyGroup(groupId, groupNameIfNew ?? groupId);
AddEntryInternal(group);
GroupAdded?.Invoke(this, new PropertyGroupEventArgs(group));
}
foreach (var item in items)
{
group.AddItem(item);
_itemIndex[item.Id] = item;
}
Refresh();
}
/// <summary>
/// 속성 아이템을 그룹에서 제거하고 독립 아이템으로 변경합니다.
/// </summary>
/// <param name="itemId">아이템 ID</param>
public void UngroupProperty(string itemId)
{
if (_itemIndex.TryGetValue(itemId, out var item) && item.GroupId != null)
{
if (_groupIndex.TryGetValue(item.GroupId, out var group))
{
group.RemoveItem(itemId);
}
item.GroupId = null;
AddEntryInternal(item); // 독립 엔트리로 추가
Refresh();
}
}
/// <summary>
/// 속성 아이템을 제거합니다.
/// </summary>
/// <param name="itemId">제거할 아이템 ID</param>
public void RemoveProperty(string itemId)
{
if (_itemIndex.TryGetValue(itemId, out var item))
{
if (item.GroupId != null && _groupIndex.TryGetValue(item.GroupId, out var group))
{
group.RemoveItem(itemId);
}
else
{
_entries.Remove(item);
}
_itemIndex.Remove(itemId);
EntryRemoved?.Invoke(this, new PropertyEntryEventArgs(item));
Refresh();
}
}
/// <summary>
/// 특정 ID의 속성 아이템을 가져옵니다.
/// </summary>
/// <param name="itemId">아이템 ID</param>
/// <returns>속성 아이템 또는 null</returns>
public IPropertyItem? GetProperty(string itemId)
{
_itemIndex.TryGetValue(itemId, out var item);
return item;
}
#endregion
#region Clear and Refresh
/// <summary>
/// 모든 엔트리를 제거합니다.
/// </summary>
public void Clear()
{
foreach (var group in _groupIndex.Values)
{
group.Clear();
}
_entries.Clear();
_groupIndex.Clear();
_itemIndex.Clear();
EntriesCleared?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// View를 갱신합니다.
/// </summary>
public void Refresh()
{
if (_view != null)
{
_view.Initialize(this);
}
}
#endregion
#region Value Update
/// <summary>
/// 특정 ID를 가진 속성의 값을 업데이트합니다.
/// 이 메서드는 주로 View에서 사용자 입력이 발생했을 때 호출됩니다.
/// </summary>
/// <param name="propertyId">값을 변경할 속성의 고유 ID</param>
/// <param name="propertyType">속성의 타입</param>
/// <param name="newValue">새로운 값</param>
public void UpdatePropertyValue(string propertyId, PropertyType propertyType, object newValue)
{
if (!_itemIndex.TryGetValue(propertyId, out var propertyItem))
{
Debug.LogError($"[PropertyWindow] ID '{propertyId}'에 해당하는 속성을 찾을 수 없습니다.");
return;
}
object? oldValue = propertyItem.GetValue();
// 값 타입일 때 새 값과 이전 값이 같은지 확인합니다.
if (oldValue != null && oldValue.GetType().IsValueType && Equals(oldValue, newValue))
{
return;
}
propertyItem.SetValue(newValue);
OnPropertyValueChanged(propertyId, propertyType, oldValue!, newValue);
}
/// <summary>
/// PropertyValueChanged 이벤트를 안전하게 발생시키는 보호된 가상 메서드입니다.
/// </summary>
protected virtual void OnPropertyValueChanged(string propertyId, PropertyType propertyType, object oldValue, object newValue)
{
PropertyValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(propertyId, propertyType, oldValue, newValue));
}
#endregion
#region Internal Helpers
/// <summary>
/// 엔트리를 내부 컬렉션에 추가합니다.
/// </summary>
private void AddEntryInternal(IPropertyEntry entry)
{
if (entry is IPropertyGroup group)
{
if (!_groupIndex.ContainsKey(group.GroupId))
{
_groupIndex[group.GroupId] = group;
_entries.Add(group);
// 그룹 내 아이템들도 인덱스에 추가
foreach (var item in group.Items)
{
_itemIndex[item.Id] = item;
}
}
}
else if (entry is IPropertyItem item)
{
if (!_itemIndex.ContainsKey(item.Id))
{
_itemIndex[item.Id] = item;
if (item.GroupId == null)
{
_entries.Add(item);
}
}
}
}
#endregion
#region Visibility
public bool IsVisible => gameObject.activeSelf;
public void Show()
{
gameObject.SetActive(true);
}
public void Hide()
{
gameObject.SetActive(false);
FactoryCameraController.Instance.Enable = true;
}
#endregion
#region Pointer Events
/// <summary>
/// 마우스 포인터가 이 UI 요소의 영역 안으로 들어왔을 때 호출됩니다.
/// </summary>
public virtual void OnPointerEnter(PointerEventData eventData)
{
FactoryCameraController.Instance.Enable = false;
}
/// <summary>
/// 마우스 포인터가 이 UI 요소의 영역 밖으로 나갔을 때 호출됩니다.
/// </summary>
public virtual void OnPointerExit(PointerEventData eventData)
{
FactoryCameraController.Instance.Enable = true;
}
#endregion
}
}