1616 lines
64 KiB
C#
1616 lines
64 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using UVC.Locale;
|
|
using static UVC.UIToolkit.UTKStyleGuide;
|
|
|
|
namespace UVC.UIToolkit.List
|
|
{
|
|
/// <summary>
|
|
/// 계층적 트리 구조를 표시하는 커스텀 UI Toolkit 컴포넌트입니다.
|
|
///
|
|
/// <para><b>개요:</b></para>
|
|
/// <para>
|
|
/// UTKComponentList는 Unity UI Toolkit의 TreeView를 래핑하여 검색, 가시성 토글,
|
|
/// 닫기 기능 등을 제공하는 재사용 가능한 컴포넌트입니다.
|
|
/// UXML 파일(UTKComponentList.uxml, UTKComponentListItem.uxml)과 함께 사용됩니다.
|
|
/// </para>
|
|
///
|
|
/// <para><b>주요 기능:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item>계층적 트리 구조 표시 (펼치기/접기 지원)</item>
|
|
/// <item>실시간 검색 필터링</item>
|
|
/// <item>항목별 가시성(눈 아이콘) 토글</item>
|
|
/// <item>선택 이벤트 처리</item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>UXML에서 사용:</b></para>
|
|
/// <code>
|
|
/// <UVC.UIToolkit.Window.UTKComponentList name="tree-list" />
|
|
/// </code>
|
|
///
|
|
/// <para><b>코드에서 사용:</b></para>
|
|
/// <code>
|
|
/// var list = root.Q<UTKComponentList>();
|
|
/// list.OnSelectionChanged += (item) => Debug.Log($"선택: {item.name}");
|
|
/// list.OnVisibilityChanged += (item) => model.SetActive(item.id, item.IsVisible);
|
|
/// list.SetData(treeItems);
|
|
/// </code>
|
|
///
|
|
/// <para><b>관련 리소스:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item>Resources/UIToolkit/Window/UTKComponentList.uxml - 메인 레이아웃</item>
|
|
/// <item>Resources/UIToolkit/List/UTKComponentListItem.uxml - 개별 항목 템플릿</item>
|
|
/// <item>Resources/UIToolkit/List/UTKComponentListGroupItem.uxml - 그룹 항목 템플릿</item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>선택 해제 방지:</b></para>
|
|
/// <para>
|
|
/// 빈 영역 클릭 시 선택이 해제되지 않도록 하려면 EventSystem의 InputSystemUIInputModule 컴포넌트에서
|
|
/// <c>Deselect On Background Click</c> 옵션을 해제해야 합니다.
|
|
/// </para>
|
|
/// </summary>
|
|
[UxmlElement]
|
|
public partial class UTKComponentList : VisualElement, IDisposable
|
|
{
|
|
#region IDisposable
|
|
private bool _disposed = false;
|
|
#endregion
|
|
#region 상수 (Constants)
|
|
/// <summary>메인 UXML 파일 경로 (Resources 폴더 기준)</summary>
|
|
private const string UXML_PATH = "UIToolkit/List/UTKComponentList";
|
|
|
|
/// <summary>일반 항목 UXML 파일 경로 (Resources 폴더 기준)</summary>
|
|
private const string ITEM_UXML_PATH = "UIToolkit/List/UTKComponentListItem";
|
|
|
|
/// <summary>그룹 항목 UXML 파일 경로 (Resources 폴더 기준)</summary>
|
|
private const string GROUP_ITEM_UXML_PATH = "UIToolkit/List/UTKComponentListGroupItem";
|
|
#endregion
|
|
|
|
#region 캐싱된 리소스 (Cached Resources)
|
|
/// <summary>일반 항목 UXML 템플릿</summary>
|
|
private VisualTreeAsset? _itemTemplate;
|
|
|
|
/// <summary>그룹 항목 UXML 템플릿</summary>
|
|
private VisualTreeAsset? _groupItemTemplate;
|
|
#endregion
|
|
|
|
#region UI 컴포넌트 참조 (UI Component References)
|
|
/// <summary>검색어 입력 필드</summary>
|
|
private TextField? _searchField;
|
|
|
|
/// <summary>Unity UI Toolkit의 TreeView 컴포넌트</summary>
|
|
private TreeView? _treeView;
|
|
|
|
/// <summary>검색어 지우기 버튼</summary>
|
|
private Button? _clearButton;
|
|
#endregion
|
|
|
|
#region 내부 데이터 (Internal Data)
|
|
|
|
|
|
/// <summary>
|
|
/// 원본 루트 데이터입니다.
|
|
/// 검색 필터 해제 시 원래 데이터로 복원하는 데 사용됩니다.
|
|
/// </summary>
|
|
private List<UTKComponentListItemDataBase> _originalRoots = new();
|
|
|
|
/// <summary>
|
|
/// TreeView에 바인딩되는 데이터 소스입니다.
|
|
/// TreeViewItemData는 Unity의 TreeView가 요구하는 래퍼 타입입니다.
|
|
/// </summary>
|
|
private List<TreeViewItemData<UTKComponentListItemDataBase>>? _rootData;
|
|
|
|
/// <summary>
|
|
/// 항목 ID 자동 생성을 위한 시드 값입니다.
|
|
/// SetData() 호출 시 id가 0인 항목에 순차적으로 ID를 할당합니다.
|
|
/// </summary>
|
|
private int _idSeed = 1;
|
|
|
|
/// <summary>
|
|
/// 이전에 선택된 항목들입니다.
|
|
/// 선택 해제 이벤트 발송에 사용됩니다.
|
|
/// </summary>
|
|
private List<UTKComponentListItemDataBase> _previouslySelectedItems = new();
|
|
|
|
/// <summary>
|
|
/// 선택 이벤트 발송을 일시적으로 억제하는 플래그입니다.
|
|
/// 프로그래밍 방식으로 선택 시 이벤트를 발송하지 않으려면 true로 설정합니다.
|
|
/// </summary>
|
|
private bool _suppressSelectionEvent = false;
|
|
|
|
/// <summary>
|
|
/// 펼침/접힘 이벤트 처리를 일시적으로 억제하는 플래그입니다.
|
|
/// ExpandByData() 실행 중 이벤트로 인한 데이터 덮어쓰기를 방지합니다.
|
|
/// </summary>
|
|
private bool _suppressExpandEvent = false;
|
|
#endregion
|
|
|
|
#region 공개 속성 (Public Properties)
|
|
/// <summary>
|
|
/// 항목 삭제 기능 활성화 여부입니다.
|
|
/// true일 때만 Delete/Backspace 키로 항목 삭제 이벤트가 발생합니다.
|
|
/// 기본값은 false입니다.
|
|
/// </summary>
|
|
public bool EnabledDeleteItem { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// 현재 검색어를 가져오거나 설정합니다.
|
|
/// 설정 시 검색 필드의 값만 변경하고 검색은 실행하지 않습니다.
|
|
/// </summary>
|
|
public string SearchQuery
|
|
{
|
|
get => _searchField?.value ?? string.Empty;
|
|
set { if (_searchField != null) _searchField.value = value; }
|
|
}
|
|
#endregion
|
|
|
|
#region 외부 이벤트 (Public Events)
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목이 선택될 때 발생합니다.
|
|
/// </summary>
|
|
public Action<List<UTKComponentListItemDataBase>>? OnItemSelected;
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목이 선택 해제될 때 발생합니다.
|
|
/// </summary>
|
|
public Action<List<UTKComponentListItemDataBase>>? OnItemDeselected;
|
|
/// <summary>
|
|
/// 항목의 가시성(눈 아이콘)이 변경될 때 발생합니다.
|
|
/// 3D 모델의 GameObject 활성화/비활성화에 연동합니다.
|
|
/// </summary>
|
|
public event Action<UTKComponentListItemDataBase, bool>? OnItemVisibilityChanged;
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목이 삭제될 때 발생합니다 (Delete 키).
|
|
/// </summary>
|
|
public Action<UTKComponentListItemDataBase>? OnItemDeleted;
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목이 더블클릭될 때 발생합니다.
|
|
/// </summary>
|
|
public Action<UTKComponentListItemDataBase>? OnItemDoubleClicked;
|
|
|
|
/// <summary>
|
|
/// 아이콘을 클릭할 때 발생합니다.
|
|
/// </summary>
|
|
public Action<string, UTKComponentListItemDataBase>? OnItemIconClicked;
|
|
|
|
#endregion
|
|
|
|
#region 생성자 (Constructor)
|
|
/// <summary>
|
|
/// UTKComponentList 컴포넌트를 초기화합니다.
|
|
/// UXML 템플릿을 로드하고 내부 컴포넌트를 설정합니다.
|
|
/// </summary>
|
|
public UTKComponentList()
|
|
{
|
|
// 1. 메인 UXML 로드 및 복제
|
|
// CloneTree(this)로 UXML 내용이 이 클래스의 자식으로 추가됨
|
|
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
|
|
if (visualTree == null)
|
|
{
|
|
Debug.LogError($"[TreeMenu] UXML not found at: {UXML_PATH}");
|
|
return;
|
|
}
|
|
visualTree!.CloneTree(this);
|
|
|
|
// 2. 항목 템플릿 로드
|
|
_itemTemplate = Resources.Load<VisualTreeAsset>(ITEM_UXML_PATH);
|
|
_groupItemTemplate = Resources.Load<VisualTreeAsset>(GROUP_ITEM_UXML_PATH);
|
|
if (_itemTemplate == null)
|
|
Debug.LogError($"[UTKComponentList] Item UXML not found at: {ITEM_UXML_PATH}");
|
|
if (_groupItemTemplate == null)
|
|
Debug.LogError($"[UTKComponentList] Group Item UXML not found at: {GROUP_ITEM_UXML_PATH}");
|
|
|
|
|
|
// 2. 자식 요소 참조 획득 (UXML의 name 속성으로 찾음)
|
|
_searchField = this.Q<TextField>("search-field");
|
|
_treeView = this.Q<TreeView>("main-tree-view");
|
|
_clearButton = this.Q<Button>("clear-btn");
|
|
|
|
// 3. 이벤트 연결 및 로직 초기화
|
|
InitializeLogic();
|
|
}
|
|
#endregion
|
|
|
|
#region 초기화 (Initialization)
|
|
/// <summary>
|
|
/// 내부 로직과 이벤트 핸들러를 초기화합니다.
|
|
/// </summary>
|
|
private void InitializeLogic()
|
|
{
|
|
// 검색창 이벤트: Enter 키를 눌렀을 때 또는 포커스를 잃었을 때 필터링 실행
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.RegisterCallback<KeyDownEvent>(OnSearchFieldKeyDown);
|
|
_searchField.RegisterCallback<FocusOutEvent>(OnSearchFieldFocusOut);
|
|
}
|
|
|
|
// TreeView 설정
|
|
// makeItem: 새 항목 UI 생성 시 호출
|
|
// bindItem: 데이터를 UI에 바인딩할 때 호출
|
|
// selectionChanged: 선택 변경 시 호출
|
|
if (_treeView != null)
|
|
{
|
|
_treeView.makeItem = MakeTreeItem;
|
|
_treeView.bindItem = BindTreeItem;
|
|
_treeView.selectionChanged += OnTreeViewSelectionChanged;
|
|
_treeView.itemsChosen += OnTreeViewItemsChosen;
|
|
_treeView.RegisterCallback<KeyDownEvent>(OnTreeViewKeyDown);
|
|
|
|
// 펼침/접힘 이벤트 처리
|
|
_treeView.itemExpandedChanged += OnTreeViewItemExpandedChanged;
|
|
}
|
|
|
|
// 검색어 지우기 버튼
|
|
if(_clearButton != null)
|
|
{
|
|
_clearButton.style.display = DisplayStyle.None; // 초기에는 숨김
|
|
_clearButton.clicked += () =>
|
|
{
|
|
if (_searchField.value.Length > 0)
|
|
{
|
|
_searchField.value = string.Empty;
|
|
OnSearch(string.Empty);
|
|
}
|
|
_clearButton.style.display = DisplayStyle.None; // 클리어 후 숨김
|
|
};
|
|
}
|
|
|
|
// 스크롤바 hover/active 색상 설정
|
|
SetupScrollerDraggerColors();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 스크롤바 드래거의 hover/active 색상을 설정합니다.
|
|
/// USS로는 드래거에 직접 hover/active 상태를 적용할 수 없어 코드로 구현합니다.
|
|
/// </summary>
|
|
private void SetupScrollerDraggerColors()
|
|
{
|
|
if (_treeView == null) return;
|
|
|
|
// TreeView가 렌더링된 후에 스크롤러 설정
|
|
_treeView.RegisterCallback<GeometryChangedEvent>(OnTreeViewGeometryChanged);
|
|
}
|
|
|
|
// USS 변수 정의 (customStyle에서 읽기 위한 키)
|
|
private static readonly CustomStyleProperty<Color> s_DraggerNormalColor = new("--scroller-dragger-normal");
|
|
private static readonly CustomStyleProperty<Color> s_DraggerHoverColor = new("--scroller-dragger-hover");
|
|
private static readonly CustomStyleProperty<Color> s_DraggerActiveColor = new("--scroller-dragger-active");
|
|
private static readonly CustomStyleProperty<Color> s_TrackerNormalColor = new("--scroller-tracker-normal");
|
|
private static readonly CustomStyleProperty<Color> s_TrackerHoverColor = new("--scroller-tracker-hover");
|
|
|
|
/// <summary>
|
|
/// TreeView 렌더링 완료 후 스크롤러 색상을 설정합니다.
|
|
/// </summary>
|
|
private void OnTreeViewGeometryChanged(GeometryChangedEvent evt)
|
|
{
|
|
if (_treeView == null) return;
|
|
|
|
// 한 번만 실행
|
|
_treeView.UnregisterCallback<GeometryChangedEvent>(OnTreeViewGeometryChanged);
|
|
|
|
// USS 변수에서 색상 읽기 (실패 시 UTKStyleGuide 기본값 사용)
|
|
Color normalColor = ScrollbarDraggerNormal;
|
|
Color hoverColor = ScrollbarDraggerHover;
|
|
Color activeColor = ScrollbarDraggerActive;
|
|
Color trackerNormalColor = ScrollbarTrackerNormal;
|
|
Color trackerHoverColor = ScrollbarTrackerHover;
|
|
|
|
if (_treeView.customStyle.TryGetValue(s_DraggerNormalColor, out Color n)) normalColor = n;
|
|
if (_treeView.customStyle.TryGetValue(s_DraggerHoverColor, out Color h)) hoverColor = h;
|
|
if (_treeView.customStyle.TryGetValue(s_DraggerActiveColor, out Color a)) activeColor = a;
|
|
if (_treeView.customStyle.TryGetValue(s_TrackerNormalColor, out Color tn)) trackerNormalColor = tn;
|
|
if (_treeView.customStyle.TryGetValue(s_TrackerHoverColor, out Color th)) trackerHoverColor = th;
|
|
|
|
// TreeView 내부의 ScrollView 찾기
|
|
var scrollView = _treeView.Q<ScrollView>();
|
|
if (scrollView == null) return;
|
|
|
|
// 수직 스크롤바
|
|
var verticalScroller = scrollView.verticalScroller;
|
|
if (verticalScroller != null)
|
|
{
|
|
SetupDraggerEvents(verticalScroller, normalColor, hoverColor, activeColor, trackerNormalColor, trackerHoverColor);
|
|
}
|
|
|
|
// 수평 스크롤바
|
|
var horizontalScroller = scrollView.horizontalScroller;
|
|
if (horizontalScroller != null)
|
|
{
|
|
SetupDraggerEvents(horizontalScroller, normalColor, hoverColor, activeColor, trackerNormalColor, trackerHoverColor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 개별 스크롤러에 드래거 색상 이벤트를 설정합니다.
|
|
/// </summary>
|
|
private void SetupDraggerEvents(Scroller scroller, Color normalColor, Color hoverColor, Color activeColor, Color trackerNormalColor, Color trackerHoverColor)
|
|
{
|
|
var dragger = scroller.Q(className: "unity-base-slider__dragger");
|
|
var tracker = scroller.Q(className: "unity-base-slider__tracker");
|
|
if (dragger == null) return;
|
|
|
|
// 드래거에 직접 이벤트 등록
|
|
dragger.RegisterCallback<MouseEnterEvent>(evt =>
|
|
{
|
|
dragger.style.backgroundColor = hoverColor;
|
|
});
|
|
|
|
dragger.RegisterCallback<MouseLeaveEvent>(evt =>
|
|
{
|
|
dragger.style.backgroundColor = normalColor;
|
|
});
|
|
|
|
dragger.RegisterCallback<MouseDownEvent>(evt =>
|
|
{
|
|
dragger.style.backgroundColor = activeColor;
|
|
});
|
|
|
|
dragger.RegisterCallback<MouseUpEvent>(evt =>
|
|
{
|
|
dragger.style.backgroundColor = hoverColor;
|
|
});
|
|
|
|
// 트래커(배경) 마우스 오버 시 색상 변경
|
|
if (tracker != null)
|
|
{
|
|
tracker.RegisterCallback<MouseEnterEvent>(evt =>
|
|
{
|
|
tracker.style.backgroundColor = trackerHoverColor;
|
|
});
|
|
|
|
tracker.RegisterCallback<MouseLeaveEvent>(evt =>
|
|
{
|
|
tracker.style.backgroundColor = trackerNormalColor;
|
|
});
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 공개 메서드 (Public Methods)
|
|
/// <summary>
|
|
/// 트리 리스트를 화면에 표시합니다.
|
|
/// </summary>
|
|
public void Show()
|
|
{
|
|
this.style.display = DisplayStyle.Flex;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 검색 필드의 값으로 검색을 실행합니다.
|
|
/// </summary>
|
|
public void ApplySearch()
|
|
{
|
|
OnSearch(_searchField?.value ?? string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정된 검색어로 검색을 실행합니다.
|
|
/// 검색 필드의 값도 함께 업데이트됩니다.
|
|
/// </summary>
|
|
/// <param name="query">검색어</param>
|
|
public void ApplySearch(string query)
|
|
{
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.value = query;
|
|
}
|
|
OnSearch(query);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 트리 데이터를 설정합니다.
|
|
/// UTKComponentListItemDataBase 리스트를 받아 TreeView에 바인딩합니다.
|
|
/// </summary>
|
|
/// <param name="roots">루트 항목들의 리스트 (계층 구조의 최상위 항목들)</param>
|
|
public void SetData(List<UTKComponentListItemDataBase> roots)
|
|
{
|
|
// 원본 데이터 저장 (필터 복원용)
|
|
_originalRoots = roots ?? new List<UTKComponentListItemDataBase>();
|
|
|
|
// ID 시드 초기화
|
|
_idSeed = 1;
|
|
|
|
// 순환 참조 감지를 위한 방문 집합
|
|
var visited = new HashSet<UTKComponentListItemDataBase>();
|
|
|
|
// TreeView 형식으로 데이터 변환
|
|
_rootData = ConvertToTreeViewData(_originalRoots, visited, 0);
|
|
|
|
// TreeView에 데이터 설정
|
|
_treeView!.SetRootItems<UTKComponentListItemDataBase>(_rootData);
|
|
|
|
// UI 갱신 및 데이터의 isExpanded 상태에 따라 펼치기
|
|
_treeView!.Rebuild();
|
|
ExpandByData(_originalRoots);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 데이터의 isExpanded 값에 따라 항목을 펼치거나 접습니다.
|
|
/// </summary>
|
|
/// <param name="items">처리할 항목 리스트</param>
|
|
private void ExpandByData(List<UTKComponentListItemDataBase> items)
|
|
{
|
|
if (_treeView == null || items == null) return;
|
|
|
|
// 이벤트 억제 시작 (재귀 호출 고려하여 최상위에서만 설정)
|
|
bool wasSupressed = _suppressExpandEvent;
|
|
_suppressExpandEvent = true;
|
|
|
|
try
|
|
{
|
|
ExpandByDataInternal(items);
|
|
}
|
|
finally
|
|
{
|
|
_suppressExpandEvent = wasSupressed;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// ExpandByData의 내부 재귀 구현입니다.
|
|
/// </summary>
|
|
private void ExpandByDataInternal(List<UTKComponentListItemDataBase> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
if (item.children != null && item.children.Count > 0)
|
|
{
|
|
if (item.isExpanded)
|
|
{
|
|
_treeView!.ExpandItem(item.id);
|
|
}
|
|
else
|
|
{
|
|
_treeView!.CollapseItem(item.id);
|
|
}
|
|
|
|
// 자식 항목들도 재귀적으로 처리
|
|
ExpandByDataInternal(item.children);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정된 ID의 항목을 프로그래밍 방식으로 선택합니다.
|
|
/// </summary>
|
|
/// <param name="itemId">선택할 항목의 ID</param>
|
|
public void SelectByItemId(int itemId)
|
|
{
|
|
_treeView?.SetSelection(new List<int> { itemId });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정된 이름 목록에 해당하는 항목만 표시하고 나머지는 숨깁니다.
|
|
/// </summary>
|
|
/// <param name="items">표시할 항목들의 이름 목록</param>
|
|
/// <param name="depth">검색 깊이 (1=1뎁스 자식만, 2=2뎁스까지, 0이하=전체)</param>
|
|
public void ShowItems(List<string> items, int depth = 1)
|
|
{
|
|
if (_originalRoots == null || _originalRoots.Count == 0) return;
|
|
|
|
var visibleNames = new HashSet<string>(items ?? new List<string>());
|
|
|
|
foreach (var root in _originalRoots)
|
|
{
|
|
SetVisibilityByNames(root, visibleNames, depth, 0);
|
|
}
|
|
|
|
// UI 갱신
|
|
_treeView?.RefreshItems();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 루트 레벨에 새 항목을 추가합니다.
|
|
/// ID가 0이면 자동으로 할당됩니다.
|
|
/// </summary>
|
|
/// <param name="data">추가할 항목 데이터</param>
|
|
public void AddItem(UTKComponentListItemDataBase data)
|
|
{
|
|
if (data == null) return;
|
|
|
|
// ID가 없으면 자동 할당
|
|
if (data.id == 0) data.id = _idSeed++;
|
|
|
|
// 원본 데이터에 추가
|
|
_originalRoots.Add(data);
|
|
|
|
// _rootData가 null이면 초기화
|
|
_rootData ??= new List<TreeViewItemData<UTKComponentListItemDataBase>>();
|
|
|
|
// TreeViewItemData 래퍼 생성 및 추가
|
|
var childData = data.children != null && data.children.Count > 0
|
|
? ConvertToTreeViewData(data.children, new HashSet<UTKComponentListItemDataBase>(), 1)
|
|
: null;
|
|
var treeItem = new TreeViewItemData<UTKComponentListItemDataBase>(data.id, data, childData);
|
|
_rootData.Add(treeItem);
|
|
|
|
// TreeView 갱신
|
|
_treeView?.SetRootItems(_rootData);
|
|
_treeView?.Rebuild();
|
|
ExpandByData(_originalRoots);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지정된 부모 카테고리의 자식으로 새 항목을 추가합니다.
|
|
/// </summary>
|
|
/// <param name="parent">부모 카테고리 (null이면 루트에 추가)</param>
|
|
/// <param name="data">추가할 항목 데이터</param>
|
|
public void AddItem(UTKComponentListCategoryData? parent, UTKComponentListItemDataBase data)
|
|
{
|
|
if (data == null) return;
|
|
|
|
// 부모가 없으면 루트에 추가
|
|
if (parent == null)
|
|
{
|
|
AddItem(data);
|
|
return;
|
|
}
|
|
|
|
// ID가 없으면 자동 할당
|
|
if (data.id == 0) data.id = _idSeed++;
|
|
|
|
// 부모의 children 리스트에 추가
|
|
parent.Add(data);
|
|
|
|
// TreeView 데이터 재구성 및 갱신
|
|
RefreshTreeViewData();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 트리에서 항목을 완전히 삭제합니다 (데이터 + 뷰 + 선택 상태).
|
|
/// 자식 항목이 있는 경우 함께 삭제됩니다.
|
|
/// </summary>
|
|
/// <param name="data">삭제할 항목 데이터</param>
|
|
public void DeleteItem(UTKComponentListItemDataBase data)
|
|
{
|
|
if (data == null) return;
|
|
|
|
bool removed = false;
|
|
|
|
// 루트 레벨에서 삭제 시도
|
|
if (_originalRoots.Remove(data))
|
|
{
|
|
removed = true;
|
|
}
|
|
else
|
|
{
|
|
// 루트에 없으면 자식에서 재귀적으로 검색하여 삭제
|
|
foreach (var root in _originalRoots)
|
|
{
|
|
if (RemoveFromChildren(root, data))
|
|
{
|
|
removed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (removed)
|
|
{
|
|
// 선택 상태에서 삭제된 항목 및 자식들 제거
|
|
RemoveFromSelection(data);
|
|
|
|
// TreeView 데이터 재구성 및 갱신
|
|
RefreshTreeViewData();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 항목의 이름을 변경하고 UI를 갱신합니다.
|
|
/// </summary>
|
|
/// <param name="data">변경할 항목 데이터</param>
|
|
/// <param name="newName">새 이름</param>
|
|
public void SetItemName(UTKComponentListItemDataBase data, string newName)
|
|
{
|
|
if (data == null || string.IsNullOrEmpty(newName)) return;
|
|
|
|
data.name = newName;
|
|
|
|
// UI 갱신 (해당 항목의 레이블 업데이트)
|
|
_treeView?.RefreshItems();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이름으로 항목을 찾아 선택합니다.
|
|
/// 검색 모드 중이면 검색을 취소하고 원본 데이터에서 선택합니다.
|
|
/// SelectionType에 따라 단일 선택이면 기존 선택을 대체하고, 다중 선택이면 기존 선택에 추가합니다.
|
|
/// </summary>
|
|
/// <param name="name">선택할 항목의 이름</param>
|
|
/// <param name="notify">선택 이벤트 발송 여부 (false면 OnItemSelected/OnItemDeselected 이벤트 억제)</param>
|
|
public void SelectItem(string name, bool notify = true)
|
|
{
|
|
if (string.IsNullOrEmpty(name) || _treeView == null) return;
|
|
|
|
// SelectionType.None인 경우 선택 불가
|
|
if (_treeView.selectionType == SelectionType.None) return;
|
|
|
|
// 검색 중이면 검색 취소
|
|
if (_searchField != null && !string.IsNullOrEmpty(_searchField.value))
|
|
{
|
|
_searchField.value = string.Empty;
|
|
OnSearch(string.Empty);
|
|
}
|
|
|
|
// 이름으로 항목 찾기
|
|
var item = FindItemByName(name);
|
|
if (item == null) return;
|
|
|
|
// 이미 선택되어 있으면 무시
|
|
if (_previouslySelectedItems.Contains(item)) return;
|
|
|
|
List<int> newSelection;
|
|
|
|
// SelectionType에 따라 선택 방식 결정
|
|
if (_treeView.selectionType == SelectionType.Multiple)
|
|
{
|
|
// 다중 선택: 기존 선택에 추가
|
|
newSelection = _previouslySelectedItems.Select(i => i.id).ToList();
|
|
newSelection.Add(item.id);
|
|
}
|
|
else
|
|
{
|
|
// 단일 선택: 기존 선택 대체
|
|
newSelection = new List<int> { item.id };
|
|
}
|
|
|
|
// 이벤트 억제 플래그 설정
|
|
_suppressSelectionEvent = !notify;
|
|
try
|
|
{
|
|
_treeView.SetSelection(newSelection);
|
|
}
|
|
finally
|
|
{
|
|
_suppressSelectionEvent = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이름으로 항목을 찾아 선택을 해제합니다.
|
|
/// 단일 선택 모드에서는 모든 선택이 해제되고, 다중 선택 모드에서는 해당 항목만 제외됩니다.
|
|
/// </summary>
|
|
/// <param name="name">선택 해제할 항목의 이름</param>
|
|
/// <param name="notify">선택 해제 이벤트 발송 여부 (false면 OnItemDeselected 이벤트 억제)</param>
|
|
public void DeselectItem(string name, bool notify = true)
|
|
{
|
|
if (string.IsNullOrEmpty(name) || _treeView == null) return;
|
|
|
|
// SelectionType.None인 경우 선택 해제 불필요
|
|
if (_treeView.selectionType == SelectionType.None) return;
|
|
|
|
var item = FindItemByName(name);
|
|
if (item == null) return;
|
|
|
|
// 현재 선택되어 있지 않으면 무시
|
|
if (!_previouslySelectedItems.Contains(item)) return;
|
|
|
|
List<int> newSelection;
|
|
|
|
// SelectionType에 따라 선택 해제 방식 결정
|
|
if (_treeView.selectionType == SelectionType.Multiple)
|
|
{
|
|
// 다중 선택: 해당 항목만 제외
|
|
newSelection = _previouslySelectedItems
|
|
.Where(i => i.id != item.id)
|
|
.Select(i => i.id)
|
|
.ToList();
|
|
}
|
|
else
|
|
{
|
|
// 단일 선택: 전체 선택 해제
|
|
newSelection = new List<int>();
|
|
}
|
|
|
|
// 이벤트 억제 플래그 설정
|
|
_suppressSelectionEvent = !notify;
|
|
try
|
|
{
|
|
_treeView.SetSelection(newSelection);
|
|
}
|
|
finally
|
|
{
|
|
_suppressSelectionEvent = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 모든 항목의 선택을 해제합니다.
|
|
/// </summary>
|
|
public void ClearSelection()
|
|
{
|
|
_treeView?.ClearSelection();
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// 원본 데이터를 기반으로 TreeView 데이터를 재구성하고 UI를 갱신합니다.
|
|
/// </summary>
|
|
private void RefreshTreeViewData()
|
|
{
|
|
var visited = new HashSet<UTKComponentListItemDataBase>();
|
|
_rootData = ConvertToTreeViewData(_originalRoots, visited, 0);
|
|
_treeView?.SetRootItems(_rootData);
|
|
_treeView?.Rebuild();
|
|
ExpandByData(_originalRoots);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 부모 노드의 자식들에서 대상 항목을 재귀적으로 찾아 삭제합니다.
|
|
/// </summary>
|
|
/// <param name="parent">부모 노드</param>
|
|
/// <param name="target">삭제할 대상 항목</param>
|
|
/// <returns>삭제 성공 여부</returns>
|
|
private bool RemoveFromChildren(UTKComponentListItemDataBase parent, UTKComponentListItemDataBase target)
|
|
{
|
|
if (parent.children == null || parent.children.Count == 0) return false;
|
|
|
|
// 카테고리인 경우에만 자식에서 삭제 가능
|
|
if (parent is UTKComponentListCategoryData category)
|
|
{
|
|
// 직접 자식에서 삭제 시도
|
|
if (category.Remove(target))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 하위 자식들에서 재귀적으로 검색
|
|
foreach (var child in parent.children)
|
|
{
|
|
if (RemoveFromChildren(child, target))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이름으로 항목을 재귀적으로 검색합니다.
|
|
/// </summary>
|
|
/// <param name="name">찾을 항목의 이름</param>
|
|
/// <returns>찾은 항목 또는 null</returns>
|
|
private UTKComponentListItemDataBase? FindItemByName(string name)
|
|
{
|
|
foreach (var root in _originalRoots)
|
|
{
|
|
var found = FindItemByNameRecursive(root, name);
|
|
if (found != null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 삭제된 항목과 그 자식들을 선택 상태에서 제거합니다.
|
|
/// _previouslySelectedItems와 TreeView의 선택 상태 모두에서 제거합니다.
|
|
/// </summary>
|
|
/// <param name="data">삭제된 항목</param>
|
|
private void RemoveFromSelection(UTKComponentListItemDataBase data)
|
|
{
|
|
// 삭제할 항목과 모든 자식들의 ID 수집
|
|
var idsToRemove = new HashSet<int>();
|
|
CollectItemIds(data, idsToRemove);
|
|
|
|
// _previouslySelectedItems에서 제거
|
|
_previouslySelectedItems.RemoveAll(item => idsToRemove.Contains(item.id));
|
|
|
|
// TreeView 선택 상태에서 제거
|
|
if (_treeView != null)
|
|
{
|
|
var currentSelection = _treeView.selectedIndices.ToList();
|
|
var newSelection = new List<int>();
|
|
|
|
foreach (var index in currentSelection)
|
|
{
|
|
var selectedItem = _treeView.GetItemDataForIndex<UTKComponentListItemDataBase>(index);
|
|
if (selectedItem != null && !idsToRemove.Contains(selectedItem.id))
|
|
{
|
|
newSelection.Add(selectedItem.id);
|
|
}
|
|
}
|
|
|
|
if (currentSelection.Count != newSelection.Count)
|
|
{
|
|
_treeView.SetSelection(newSelection);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 항목과 모든 자식들의 ID를 재귀적으로 수집합니다.
|
|
/// </summary>
|
|
/// <param name="node">수집 시작 노드</param>
|
|
/// <param name="ids">ID를 저장할 HashSet</param>
|
|
private void CollectItemIds(UTKComponentListItemDataBase node, HashSet<int> ids)
|
|
{
|
|
ids.Add(node.id);
|
|
|
|
if (node.children != null)
|
|
{
|
|
foreach (var child in node.children)
|
|
{
|
|
CollectItemIds(child, ids);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 노드와 그 자식들에서 이름으로 항목을 재귀적으로 검색합니다.
|
|
/// </summary>
|
|
/// <param name="node">검색 시작 노드</param>
|
|
/// <param name="name">찾을 항목의 이름</param>
|
|
/// <returns>찾은 항목 또는 null</returns>
|
|
private UTKComponentListItemDataBase? FindItemByNameRecursive(UTKComponentListItemDataBase node, string name)
|
|
{
|
|
if (node.name == name) return node;
|
|
|
|
if (node.children != null)
|
|
{
|
|
foreach (var child in node.children)
|
|
{
|
|
var found = FindItemByNameRecursive(child, name);
|
|
if (found != null) return found;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 재귀적으로 항목의 가시성을 이름 목록에 따라 설정합니다.
|
|
/// </summary>
|
|
/// <param name="node">현재 노드</param>
|
|
/// <param name="visibleNames">표시할 이름 목록</param>
|
|
/// <param name="maxDepth">최대 검색 깊이 (0이하=무제한)</param>
|
|
/// <param name="currentDepth">현재 깊이</param>
|
|
private void SetVisibilityByNames(UTKComponentListItemDataBase node, HashSet<string> visibleNames, int maxDepth, int currentDepth)
|
|
{
|
|
if (node == null) return;
|
|
|
|
// maxDepth <= 0 이면 전체 검색, 아니면 깊이 제한 체크
|
|
bool isWithinDepth = maxDepth <= 0 || currentDepth < maxDepth;
|
|
|
|
if (isWithinDepth)
|
|
{
|
|
// 이름이 목록에 있으면 visible, 없으면 hidden
|
|
node.IsVisible = visibleNames.Contains(node.name);
|
|
|
|
// 가시성 변경 이벤트 발송
|
|
OnItemVisibilityChanged?.Invoke(node, node.IsVisible);
|
|
}
|
|
|
|
// 자식들도 재귀적으로 처리
|
|
if (node.children != null)
|
|
{
|
|
foreach (var child in node.children)
|
|
{
|
|
SetVisibilityByNames(child, visibleNames, maxDepth, currentDepth + 1);
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 선택 처리 (Selection Handling)
|
|
/// <summary>
|
|
/// TreeView의 선택 변경 이벤트를 처리합니다.
|
|
/// SelectionType에 따라 TreeView가 자동으로 선택 항목 수를 제한합니다:
|
|
/// - None: 선택 불가 (selectedItems 항상 비어있음)
|
|
/// - Single: 최대 1개 선택
|
|
/// - Multiple: 여러 개 선택 가능
|
|
/// </summary>
|
|
/// <param name="selectedItems">선택된 항목들</param>
|
|
private void OnTreeViewSelectionChanged(IEnumerable<object> selectedItems)
|
|
{
|
|
// SelectionType.None인 경우 선택 변경 무시
|
|
if (_treeView?.selectionType == SelectionType.None) return;
|
|
|
|
// 카테고리가 선택되었는지 확인
|
|
var allSelectedItems = selectedItems.OfType<UTKComponentListItemDataBase>().ToList();
|
|
var categorySelected = allSelectedItems.Any(item => item.IsCategory);
|
|
|
|
// 카테고리가 선택된 경우: 기존 선택 상태 유지
|
|
if (categorySelected)
|
|
{
|
|
// 기존 선택 상태로 복원
|
|
_suppressSelectionEvent = true;
|
|
try
|
|
{
|
|
var previousIds = _previouslySelectedItems.Select(item => item.id).ToList();
|
|
_treeView?.SetSelection(previousIds);
|
|
}
|
|
finally
|
|
{
|
|
_suppressSelectionEvent = false;
|
|
}
|
|
return; // 이벤트 발송하지 않고 종료
|
|
}
|
|
|
|
// 현재 선택된 항목들 (카테고리 제외)
|
|
var currentItems = allSelectedItems
|
|
.Where(item => !item.IsCategory)
|
|
.ToList();
|
|
|
|
// 선택 해제된 항목들 찾기 (이전에는 있었지만 현재는 없는 항목)
|
|
var deselectedItems = _previouslySelectedItems
|
|
.Where(prev => !currentItems.Contains(prev))
|
|
.ToList();
|
|
|
|
// 새로 선택된 항목들 찾기 (이전에는 없었지만 현재는 있는 항목)
|
|
var newlySelectedItems = currentItems
|
|
.Where(curr => !_previouslySelectedItems.Contains(curr))
|
|
.ToList();
|
|
|
|
// 선택 해제 상태 업데이트
|
|
foreach (var item in deselectedItems)
|
|
{
|
|
item.isSelected = false;
|
|
}
|
|
|
|
// 선택 상태 업데이트
|
|
foreach (var item in newlySelectedItems)
|
|
{
|
|
item.isSelected = true;
|
|
}
|
|
|
|
// 이벤트 억제 플래그가 false일 때만 이벤트 발송
|
|
if (!_suppressSelectionEvent)
|
|
{
|
|
if (deselectedItems.Count > 0)
|
|
{
|
|
OnItemDeselected?.Invoke(deselectedItems);
|
|
}
|
|
|
|
if (newlySelectedItems.Count > 0)
|
|
{
|
|
OnItemSelected?.Invoke(newlySelectedItems);
|
|
}
|
|
}
|
|
|
|
// 현재 선택 상태 저장
|
|
_previouslySelectedItems = currentItems;
|
|
}
|
|
|
|
/// <summary>
|
|
/// TreeView에서 항목이 더블클릭되거나 Enter 키로 선택될 때 호출됩니다.
|
|
/// </summary>
|
|
/// <param name="chosenItems">선택된 항목들</param>
|
|
private void OnTreeViewItemsChosen(IEnumerable<object> chosenItems)
|
|
{
|
|
var item = chosenItems.FirstOrDefault() as UTKComponentListItemDataBase;
|
|
if (item != null)
|
|
{
|
|
OnItemDoubleClicked?.Invoke(item);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// TreeView에서 키보드 입력 이벤트를 처리합니다.
|
|
/// EnabledDeleteItem이 true이고 Delete 키 입력 시 선택된 항목 삭제 이벤트를 발송합니다.
|
|
/// </summary>
|
|
/// <param name="evt">키 입력 이벤트</param>
|
|
private void OnTreeViewKeyDown(KeyDownEvent evt)
|
|
{
|
|
if (evt.keyCode == KeyCode.Delete || evt.keyCode == KeyCode.Backspace)
|
|
{
|
|
// EnabledDeleteItem이 false이면 삭제 무시
|
|
if (!EnabledDeleteItem) return;
|
|
|
|
if (_previouslySelectedItems.Count > 0)
|
|
{
|
|
// 선택된 항목들 각각에 대해 삭제 이벤트 발송
|
|
foreach (var item in _previouslySelectedItems)
|
|
{
|
|
OnItemDeleted?.Invoke(item);
|
|
}
|
|
evt.StopPropagation();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// TreeView 항목의 펼침/접힘 상태가 변경될 때 호출됩니다.
|
|
/// 데이터의 isExpanded 값을 동기화합니다.
|
|
/// </summary>
|
|
/// <param name="evt">펼침/접힘 변경 이벤트</param>
|
|
private void OnTreeViewItemExpandedChanged(TreeViewExpansionChangedArgs evt)
|
|
{
|
|
// ExpandByData() 실행 중에는 이벤트 무시 (데이터 덮어쓰기 방지)
|
|
if (_suppressExpandEvent) return;
|
|
|
|
var item = _treeView?.GetItemDataForId<UTKComponentListItemDataBase>(evt.id);
|
|
if (item != null)
|
|
{
|
|
item.isExpanded = evt.isExpanded;
|
|
}
|
|
|
|
// 해당 항목의 화살표 회전 업데이트
|
|
UpdateToggleRotation(evt.id, evt.isExpanded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 특정 항목의 토글 화살표 회전을 업데이트합니다.
|
|
/// </summary>
|
|
/// <param name="itemId">항목 ID</param>
|
|
/// <param name="isExpanded">확장 상태</param>
|
|
private void UpdateToggleRotation(int itemId, bool isExpanded)
|
|
{
|
|
if (_treeView == null) return;
|
|
|
|
// TreeView에서 해당 항목의 인덱스 찾기
|
|
int index = _treeView.viewController.GetIndexForId(itemId);
|
|
if (index < 0) return;
|
|
|
|
// 해당 인덱스의 VisualElement 찾기
|
|
var itemElement = _treeView.GetRootElementForIndex(index);
|
|
if (itemElement == null) return;
|
|
|
|
// 토글과 체크마크 찾기
|
|
var toggle = itemElement.Q<Toggle>("unity-tree-view__item-toggle");
|
|
var checkmark = toggle?.Q<VisualElement>("unity-checkmark");
|
|
if (checkmark != null)
|
|
{
|
|
checkmark.style.rotate = new Rotate(isExpanded ? 0f : -90f);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 데이터 변환 (Data Conversion)
|
|
/// <summary>
|
|
/// UTKComponentListItemDataBase 리스트를 TreeView가 요구하는 TreeViewItemData 형식으로 변환합니다.
|
|
/// 재귀적으로 자식 항목도 변환하며, 순환 참조를 감지합니다.
|
|
/// </summary>
|
|
/// <param name="items">변환할 항목 리스트</param>
|
|
/// <param name="visited">순환 참조 감지용 방문 집합</param>
|
|
/// <param name="depth">현재 깊이 (디버깅용)</param>
|
|
/// <returns>TreeViewItemData 래퍼 리스트</returns>
|
|
private List<TreeViewItemData<UTKComponentListItemDataBase>> ConvertToTreeViewData(List<UTKComponentListItemDataBase> items, HashSet<UTKComponentListItemDataBase> visited, int depth)
|
|
{
|
|
var list = new List<TreeViewItemData<UTKComponentListItemDataBase>>(items.Count);
|
|
foreach (var item in items)
|
|
{
|
|
if (item == null) continue;
|
|
|
|
// 순환 참조 체크: 이미 방문한 항목이면 자식 무시
|
|
if (!visited.Add(item))
|
|
{
|
|
Debug.LogWarning($"[UTKComponentList] Cycle detected at item '{item.name}' → children 무시");
|
|
// 카테고리인 경우 자식 클리어
|
|
if (item is UTKComponentListCategoryData category)
|
|
{
|
|
category.Clear();
|
|
}
|
|
}
|
|
|
|
// ID가 없으면 자동 할당
|
|
if (item.id == 0) item.id = _idSeed++;
|
|
|
|
// 자식 항목 재귀 변환
|
|
List<TreeViewItemData<UTKComponentListItemDataBase>>? childData = null;
|
|
if (item.children != null && item.children.Count > 0)
|
|
childData = ConvertToTreeViewData(item.children, visited, depth + 1);
|
|
|
|
// TreeViewItemData 래퍼 생성
|
|
var treeItem = new TreeViewItemData<UTKComponentListItemDataBase>(item.id, item, childData);
|
|
list.Add(treeItem);
|
|
}
|
|
return list;
|
|
}
|
|
#endregion
|
|
|
|
#region TreeView 항목 바인딩 (TreeView Item Creation/Binding)
|
|
|
|
/// <summary>
|
|
/// TreeView 항목의 UI 요소를 생성합니다.
|
|
/// makeItem은 데이터 없이 호출되므로 기본 컨테이너만 생성합니다.
|
|
/// 실제 템플릿은 bindItem에서 데이터에 따라 교체됩니다.
|
|
/// </summary>
|
|
/// <returns>생성된 UI 요소</returns>
|
|
private VisualElement MakeTreeItem()
|
|
{
|
|
// 빈 컨테이너 생성 (bindItem에서 템플릿 교체)
|
|
var container = new VisualElement();
|
|
container.style.flexGrow = 1;
|
|
return container;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 데이터를 UI 요소에 바인딩합니다.
|
|
/// TreeView의 bindItem 콜백으로 사용됩니다.
|
|
/// 스크롤 시 재사용되는 항목에 새 데이터를 연결합니다.
|
|
/// 자식 유무에 따라 다른 템플릿을 사용합니다.
|
|
/// </summary>
|
|
/// <param name="element">바인딩할 UI 요소</param>
|
|
/// <param name="index">TreeView 내부 인덱스</param>
|
|
private void BindTreeItem(VisualElement element, int index)
|
|
{
|
|
// 인덱스로 데이터 획득
|
|
var item = _treeView!.GetItemDataForIndex<UTKComponentListItemDataBase>(index);
|
|
if (item == null) return;
|
|
|
|
// 자식 유무 확인: 실제 자식이 있거나, 검색 결과 그룹인 경우 (TreeViewItemData에만 자식이 있음)
|
|
bool hasChildren = (item.children != null && item.children.Count > 0) || item.isSearchResultGroup;
|
|
|
|
// 0. 자식이 없는 항목의 토글(화살표) 영역 너비 제거 및 회전 설정
|
|
// element는 TemplateContainer이고, Toggle은 부모(unity-tree-view__item)의 자식
|
|
var treeViewItem = element.parent?.parent;
|
|
var toggle = treeViewItem?.Q<Toggle>("unity-tree-view__item-toggle");
|
|
if (toggle != null)
|
|
{
|
|
toggle.style.width = hasChildren ? StyleKeyword.Auto : 0;
|
|
|
|
// 확장 상태에 따라 화살표 회전 설정
|
|
if (hasChildren)
|
|
{
|
|
var checkmark = toggle.Q<VisualElement>("unity-checkmark");
|
|
if (checkmark != null)
|
|
{
|
|
bool isExpanded = _treeView!.IsExpanded(item.id);
|
|
checkmark.style.rotate = new Rotate(isExpanded ? 0f : -90f);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 카테고리 항목인 경우: 선택 클릭 이벤트 차단 (Shift/Ctrl 클릭 포함)
|
|
// treeViewItem 레벨에서 PointerDownEvent를 캡처하여 선택 로직 차단
|
|
if (item.IsCategory && treeViewItem != null)
|
|
{
|
|
// 카테고리 항목에 클래스 추가 (hover 스타일 비활성화용)
|
|
treeViewItem.AddToClassList("category-item");
|
|
|
|
// 기존 핸들러 제거를 위해 userData 사용
|
|
if (treeViewItem.userData is EventCallback<PointerDownEvent> oldHandler)
|
|
{
|
|
treeViewItem.UnregisterCallback(oldHandler, TrickleDown.TrickleDown);
|
|
}
|
|
|
|
EventCallback<PointerDownEvent> categoryClickHandler = (evt) =>
|
|
{
|
|
// 토글(화살표) 영역 클릭은 허용 (펼치기/접기 기능 유지)
|
|
if (toggle != null && toggle.worldBound.Contains(evt.position))
|
|
{
|
|
return; // 토글 클릭은 통과
|
|
}
|
|
|
|
// 카테고리 클릭 시 이벤트 전파 완전 차단
|
|
// 이렇게 하면 TreeView의 선택 로직이 실행되지 않음
|
|
evt.StopImmediatePropagation();
|
|
|
|
// 펼치기/접기 토글 (카테고리 본문 클릭 시)
|
|
if (_treeView != null)
|
|
{
|
|
bool isExpanded = _treeView.IsExpanded(item.id);
|
|
if (isExpanded)
|
|
_treeView.CollapseItem(item.id);
|
|
else
|
|
_treeView.ExpandItem(item.id);
|
|
}
|
|
};
|
|
|
|
treeViewItem.userData = categoryClickHandler;
|
|
treeViewItem.RegisterCallback(categoryClickHandler, TrickleDown.TrickleDown);
|
|
}
|
|
else if (treeViewItem != null)
|
|
{
|
|
// 일반 항목인 경우: 카테고리 클래스 제거 및 이전에 등록된 핸들러 제거
|
|
treeViewItem.RemoveFromClassList("category-item");
|
|
|
|
if (treeViewItem.userData is EventCallback<PointerDownEvent> oldHandler)
|
|
{
|
|
treeViewItem.UnregisterCallback(oldHandler, TrickleDown.TrickleDown);
|
|
treeViewItem.userData = null;
|
|
}
|
|
}
|
|
|
|
// 1. 기존 내용 제거 후 적절한 템플릿으로 교체
|
|
element.Clear();
|
|
var template = hasChildren ? _groupItemTemplate : _itemTemplate;
|
|
if (template != null) template.CloneTree(element);
|
|
|
|
// 2. 항목 이름 레이블 설정
|
|
var label = element.Q<Label>("item-label");
|
|
if (label != null) label.text = item.name;
|
|
|
|
// 3. 그룹 항목의 badge-label 및 setting-btn 설정
|
|
if (hasChildren)
|
|
{
|
|
var badgeLabel = element.Q<Label>("badge-label");
|
|
if (badgeLabel != null)
|
|
{
|
|
// 검색 결과 그룹은 이름에 이미 개수가 포함되어 있으므로 badge 숨김
|
|
if (item.isSearchResultGroup)
|
|
{
|
|
badgeLabel.style.display = DisplayStyle.None;
|
|
}
|
|
else
|
|
{
|
|
badgeLabel.style.display = DisplayStyle.Flex;
|
|
badgeLabel.text = item.children!.Count.ToString();
|
|
}
|
|
}
|
|
|
|
// setting-btn 클릭 이벤트 연결
|
|
var settingBtn = element.Q<Button>("setting-btn");
|
|
if (settingBtn != null)
|
|
{
|
|
// 검색 결과 그룹인 경우 setting-btn 숨기기
|
|
if (item.isSearchResultGroup)
|
|
{
|
|
settingBtn.style.display = DisplayStyle.None;
|
|
settingBtn.SetEnabled(false);
|
|
}
|
|
else
|
|
{
|
|
settingBtn.style.display = DisplayStyle.Flex;
|
|
settingBtn.SetEnabled(true);
|
|
|
|
// 기존 이벤트 제거 후 재등록 (메모리 누수 방지)
|
|
if (settingBtn.userData is Action oldSettingAction) settingBtn.clicked -= oldSettingAction;
|
|
Action settingClickAction = () =>
|
|
{
|
|
OnItemIconClicked?.Invoke("setting-btn", item);
|
|
};
|
|
settingBtn.userData = settingClickAction;
|
|
settingBtn.clicked += settingClickAction;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// 일반 항목의 search-btn 클릭 이벤트 연결
|
|
var searchBtn = element.Q<Button>("search-btn");
|
|
if (searchBtn != null)
|
|
{
|
|
// 기존 이벤트 제거 후 재등록 (메모리 누수 방지)
|
|
if (searchBtn.userData is Action oldSearchAction) searchBtn.clicked -= oldSearchAction;
|
|
Action searchClickAction = () =>
|
|
{
|
|
OnItemIconClicked?.Invoke("search-btn", item);
|
|
};
|
|
searchBtn.userData = searchClickAction;
|
|
searchBtn.clicked += searchClickAction;
|
|
}
|
|
|
|
// 4. 가시성 아이콘 버튼 설정 (일반 항목만)
|
|
var toggleBtn = element.Q<Button>("visibility-btn");
|
|
if (toggleBtn != null)
|
|
{
|
|
UpdateVisibilityIcon(toggleBtn, item.IsVisible);
|
|
|
|
// 가시성 버튼 클릭 이벤트 연결
|
|
// 주의: bindItem은 스크롤 시 재호출되므로 기존 이벤트 제거 후 재등록
|
|
if (toggleBtn.userData is Action oldAction) toggleBtn.clicked -= oldAction;
|
|
Action clickAction = () =>
|
|
{
|
|
// 가시성 상태 토글
|
|
item.IsVisible = !item.IsVisible;
|
|
UpdateVisibilityIcon(toggleBtn, item.IsVisible);
|
|
|
|
// 자식이 없기에 자식들에게 동일 상태 전파 안함
|
|
//SetChildrenVisibility(item, item.IsVisible);
|
|
|
|
// 자식이 없기에 화면에 보이는 자식 아이콘도 갱신 않함
|
|
//_treeView!.RefreshItems();
|
|
|
|
// 외부에 이벤트 발송 (3D 모델 가시성 동기화)
|
|
OnItemVisibilityChanged?.Invoke(item, item.IsVisible);
|
|
};
|
|
toggleBtn.userData = clickAction;
|
|
toggleBtn.clicked += clickAction;
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 가시성 처리 (Visibility Handling)
|
|
/// <summary>
|
|
/// 자식 항목들의 가시성을 재귀적으로 동기화합니다.
|
|
/// 부모의 가시성이 변경되면 모든 하위 항목에 동일하게 적용됩니다.
|
|
/// </summary>
|
|
/// <param name="node">현재 노드</param>
|
|
/// <param name="isVisible">설정할 가시성 상태</param>
|
|
private void SetChildrenVisibility(UTKComponentListItemDataBase node, bool isVisible)
|
|
{
|
|
if (node.children == null || node.children.Count == 0) return;
|
|
foreach (var child in node.children)
|
|
{
|
|
if (child == null) continue;
|
|
child.IsVisible = isVisible;
|
|
// 재귀적으로 하위 자식에도 적용
|
|
SetChildrenVisibility(child, isVisible);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 가시성 버튼의 아이콘 스타일을 업데이트합니다.
|
|
/// USS 클래스를 토글하여 아이콘 변경을 처리합니다.
|
|
/// </summary>
|
|
/// <param name="btn">가시성 토글 버튼</param>
|
|
/// <param name="isVisible">현재 가시성 상태</param>
|
|
private void UpdateVisibilityIcon(Button btn, bool isVisible)
|
|
{
|
|
if (isVisible)
|
|
{
|
|
btn.RemoveFromClassList("visibility-off");
|
|
btn.AddToClassList("visibility-on");
|
|
}
|
|
else
|
|
{
|
|
btn.RemoveFromClassList("visibility-on");
|
|
btn.AddToClassList("visibility-off");
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region 검색 기능 (Search Functionality)
|
|
/// <summary>
|
|
/// 검색 필드에서 Enter 키를 눌렀을 때 검색을 실행합니다.
|
|
/// </summary>
|
|
/// <param name="evt">키 입력 이벤트</param>
|
|
private void OnSearchFieldKeyDown(KeyDownEvent evt)
|
|
{
|
|
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
|
|
{
|
|
OnSearch(_searchField?.value ?? string.Empty);
|
|
evt.StopPropagation();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 검색 필드가 포커스를 잃었을 때 검색을 실행합니다.
|
|
/// </summary>
|
|
/// <param name="evt">포커스 아웃 이벤트</param>
|
|
private void OnSearchFieldFocusOut(FocusOutEvent evt)
|
|
{
|
|
OnSearch(_searchField?.value ?? string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 검색어에 따라 트리를 필터링합니다.
|
|
/// 검색어가 비어있으면 원본 데이터로 복원됩니다.
|
|
/// </summary>
|
|
/// <param name="query">검색어</param>
|
|
private void OnSearch(string query)
|
|
{
|
|
// Clear 버튼 가시성 토글
|
|
if (_clearButton != null)
|
|
{
|
|
_clearButton.style.display = string.IsNullOrEmpty(query) ? DisplayStyle.None : DisplayStyle.Flex;
|
|
}
|
|
|
|
// 검색어가 없으면 원본 데이터 복원
|
|
if (string.IsNullOrEmpty(query))
|
|
{
|
|
_treeView?.SetRootItems<UTKComponentListItemDataBase>(_rootData);
|
|
_treeView?.Rebuild();
|
|
ExpandByData(_originalRoots);
|
|
return;
|
|
}
|
|
|
|
// 대소문자 무시 검색
|
|
string qLower = query.Trim().ToLowerInvariant();
|
|
var filteredWrappers = FilterTree(qLower);
|
|
|
|
// 필터링된 결과로 TreeView 갱신
|
|
_treeView?.SetRootItems<UTKComponentListItemDataBase>(filteredWrappers);
|
|
_treeView?.Rebuild();
|
|
|
|
// 검색 결과 모두 펼치기
|
|
ExpandAll(filteredWrappers);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 모든 루트 항목에 대해 필터링을 수행하고, "검색결과 N건" 그룹으로 감쌉니다.
|
|
/// </summary>
|
|
/// <param name="qLower">소문자로 변환된 검색어</param>
|
|
/// <returns>필터링된 TreeViewItemData 리스트 (검색 결과 그룹 포함)</returns>
|
|
private List<TreeViewItemData<UTKComponentListItemDataBase>> FilterTree(string qLower)
|
|
{
|
|
// 1. 매칭되는 리프 노드들만 수집
|
|
var matchedItems = new List<UTKComponentListItemDataBase>();
|
|
CollectMatchingLeafNodes(_originalRoots, qLower, matchedItems);
|
|
|
|
// 2. 검색 결과가 없으면 빈 리스트 반환
|
|
if (matchedItems.Count == 0)
|
|
{
|
|
return new List<TreeViewItemData<UTKComponentListItemDataBase>>();
|
|
}
|
|
|
|
string localeName = LocalizationManager.Instance.GetString("search_result_count");
|
|
if(localeName == "search_result_count")
|
|
{
|
|
localeName = "검색결과 {0}건";
|
|
}
|
|
localeName = localeName.Replace("{0}", matchedItems.Count.ToString());
|
|
|
|
// 3. "검색결과 N건" 그룹 데이터 생성 (카테고리로 생성)
|
|
var searchResultGroup = new UTKComponentListCategoryData
|
|
{
|
|
id = -1000, // 특별한 ID (충돌 방지)
|
|
name = localeName,
|
|
isSearchResultGroup = true,
|
|
isExpanded = true
|
|
};
|
|
|
|
// 4. 매칭된 항목들을 자식으로 래핑 (TreeViewItemData 형태로)
|
|
var childWrappers = new List<TreeViewItemData<UTKComponentListItemDataBase>>();
|
|
foreach (var item in matchedItems)
|
|
{
|
|
childWrappers.Add(new TreeViewItemData<UTKComponentListItemDataBase>(item.id, item));
|
|
}
|
|
|
|
// 5. 그룹을 TreeViewItemData로 래핑
|
|
var groupWrapper = new TreeViewItemData<UTKComponentListItemDataBase>(
|
|
searchResultGroup.id,
|
|
searchResultGroup,
|
|
childWrappers
|
|
);
|
|
|
|
return new List<TreeViewItemData<UTKComponentListItemDataBase>> { groupWrapper };
|
|
}
|
|
|
|
/// <summary>
|
|
/// 검색어와 매칭되는 리프 노드(자식이 없는 항목)들을 재귀적으로 수집합니다.
|
|
/// </summary>
|
|
/// <param name="items">검색할 항목 리스트</param>
|
|
/// <param name="qLower">소문자로 변환된 검색어</param>
|
|
/// <param name="result">매칭된 항목을 저장할 리스트</param>
|
|
private void CollectMatchingLeafNodes(List<UTKComponentListItemDataBase> items, string qLower, List<UTKComponentListItemDataBase> result)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
// 자식이 있으면 자식들을 재귀적으로 검색
|
|
if (item.children != null && item.children.Count > 0)
|
|
{
|
|
CollectMatchingLeafNodes(item.children, qLower, result);
|
|
}
|
|
else
|
|
{
|
|
// 리프 노드인 경우에만 매칭 검사
|
|
if (NodeMatches(item, qLower))
|
|
{
|
|
result.Add(item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 단일 노드를 필터링합니다.
|
|
/// 자신 또는 자식이 검색어와 매치되면 결과에 포함됩니다.
|
|
/// </summary>
|
|
/// <param name="node">필터링할 노드</param>
|
|
/// <param name="qLower">소문자로 변환된 검색어</param>
|
|
/// <returns>매치된 경우 TreeViewItemData, 아니면 null</returns>
|
|
private TreeViewItemData<UTKComponentListItemDataBase>? FilterNode(UTKComponentListItemDataBase node, string qLower)
|
|
{
|
|
// 자기 자신이 매치되는지 확인
|
|
bool selfMatch = NodeMatches(node, qLower);
|
|
List<TreeViewItemData<UTKComponentListItemDataBase>>? childFiltered = null;
|
|
|
|
// 자식들도 재귀적으로 필터링
|
|
if (node.children != null && node.children.Count > 0)
|
|
{
|
|
foreach (var child in node.children)
|
|
{
|
|
TreeViewItemData<UTKComponentListItemDataBase>? f = FilterNode(child, qLower);
|
|
if (f != null)
|
|
{
|
|
childFiltered ??= new List<TreeViewItemData<UTKComponentListItemDataBase>>();
|
|
childFiltered.Add(f.Value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 자신이 매치되거나 매치된 자식이 있으면 포함
|
|
if (selfMatch || (childFiltered != null && childFiltered.Count > 0))
|
|
{
|
|
return new TreeViewItemData<UTKComponentListItemDataBase>(node.id, node, childFiltered);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 노드가 검색어와 매치되는지 확인합니다.
|
|
/// name, option, ExternalKey 필드를 검색 대상으로 합니다.
|
|
/// </summary>
|
|
/// <param name="item">검사할 항목</param>
|
|
/// <param name="qLower">소문자로 변환된 검색어</param>
|
|
/// <returns>매치 여부</returns>
|
|
private bool NodeMatches(UTKComponentListItemDataBase item, string qLower)
|
|
{
|
|
if (item.name != null && item.name.ToLowerInvariant().Contains(qLower)) return true;
|
|
|
|
// 일반 항목인 경우 option과 ExternalKey도 검색
|
|
if (item is UTKComponentListItemData itemData)
|
|
{
|
|
if (!string.IsNullOrEmpty(itemData.option) && itemData.option.ToLowerInvariant().Contains(qLower)) return true;
|
|
if (!string.IsNullOrEmpty(itemData.ExternalKey) && itemData.ExternalKey.ToLowerInvariant().Contains(qLower)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 필터링된 결과의 모든 항목을 펼칩니다.
|
|
/// </summary>
|
|
/// <param name="roots">펼칠 루트 항목들</param>
|
|
private void ExpandAll(List<TreeViewItemData<UTKComponentListItemDataBase>> roots)
|
|
{
|
|
foreach (var r in roots)
|
|
ExpandRecursive(r);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 노드와 그 자식들을 재귀적으로 펼칩니다.
|
|
/// </summary>
|
|
/// <param name="wrapper">펼칠 TreeViewItemData</param>
|
|
private void ExpandRecursive(TreeViewItemData<UTKComponentListItemDataBase> wrapper)
|
|
{
|
|
_treeView.ExpandItem(wrapper.id);
|
|
if (wrapper.children != null)
|
|
{
|
|
foreach (var c in wrapper.children)
|
|
ExpandRecursive(c);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region IDisposable
|
|
/// <summary>
|
|
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
// 검색 필드 이벤트 해제
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.UnregisterCallback<KeyDownEvent>(OnSearchFieldKeyDown);
|
|
_searchField.UnregisterCallback<FocusOutEvent>(OnSearchFieldFocusOut);
|
|
}
|
|
|
|
// TreeView 이벤트 핸들러 해제
|
|
if (_treeView != null)
|
|
{
|
|
_treeView.selectionChanged -= OnTreeViewSelectionChanged;
|
|
_treeView.itemsChosen -= OnTreeViewItemsChosen;
|
|
_treeView.itemExpandedChanged -= OnTreeViewItemExpandedChanged;
|
|
_treeView.UnregisterCallback<KeyDownEvent>(OnTreeViewKeyDown);
|
|
_treeView.bindItem = null;
|
|
_treeView.makeItem = null;
|
|
}
|
|
|
|
// 외부 이벤트 구독자 정리
|
|
OnItemVisibilityChanged = null;
|
|
OnItemSelected = null;
|
|
OnItemDeselected = null;
|
|
OnItemDeleted = null;
|
|
OnItemDoubleClicked = null;
|
|
OnItemIconClicked = null;
|
|
|
|
// 데이터 정리
|
|
_originalRoots.Clear();
|
|
_rootData?.Clear();
|
|
_rootData = null;
|
|
_previouslySelectedItems.Clear();
|
|
|
|
// ID 시드 초기화
|
|
_idSeed = 1;
|
|
|
|
// UI 참조 정리
|
|
_searchField = null;
|
|
_treeView = null;
|
|
_clearButton = null;
|
|
}
|
|
#endregion
|
|
}
|
|
} |