#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 { /// /// 계층적 트리 구조를 표시하는 커스텀 UI Toolkit 컴포넌트입니다. /// /// 개요: /// /// UTKComponentList는 Unity UI Toolkit의 TreeView를 래핑하여 검색, 가시성 토글, /// 닫기 기능 등을 제공하는 재사용 가능한 컴포넌트입니다. /// UXML 파일(UTKComponentList.uxml, UTKComponentListItem.uxml)과 함께 사용됩니다. /// /// /// 주요 기능: /// /// 계층적 트리 구조 표시 (펼치기/접기 지원) /// 실시간 검색 필터링 /// 항목별 가시성(눈 아이콘) 토글 /// 선택 이벤트 처리 /// /// /// UXML 사용 예시: /// /// /// /// /// ]]> /// /// C# 사용 예시: /// ("component-list"); /// /// // 2. 데이터 구성 - 카테고리(그룹)와 일반 아이템 /// var data = new List /// { /// // 카테고리(그룹) 생성 /// new UTKComponentListCategoryData /// { /// name = "모델 그룹 A", /// isExpanded = true, /// children = new List /// { /// // 일반 아이템 /// new UTKComponentListItemData /// { /// name = "의자 모델", /// ExternalKey = "chair_001", /// IsVisible = true /// }, /// new UTKComponentListItemData /// { /// name = "책상 모델", /// ExternalKey = "desk_001", /// IsVisible = true /// } /// } /// } /// }; /// /// // 3. 데이터 설정 /// componentList.SetData(data); /// /// // 4. 선택 이벤트 구독 - 아이템 선택 시 호출 /// componentList.OnItemSelected += (selectedItems) => /// { /// foreach (var item in selectedItems) /// { /// Debug.Log($"선택됨: {item.name}"); /// } /// }; /// /// // 5. 선택 해제 이벤트 구독 /// componentList.OnItemDeselected += (deselectedItems) => /// { /// foreach (var item in deselectedItems) /// { /// Debug.Log($"선택 해제: {item.name}"); /// } /// }; /// /// // 6. 가시성 변경 이벤트 구독 - 눈 아이콘 클릭 시 호출 /// componentList.OnItemVisibilityChanged += (item, isVisible) => /// { /// // 3D 모델의 GameObject 활성화/비활성화 /// var gameObject = FindGameObjectByKey(item.ExternalKey); /// if (gameObject != null) /// { /// gameObject.SetActive(isVisible); /// } /// }; /// /// // 7. 삭제 이벤트 구독 (Delete/Backspace 키) /// componentList.EnabledDeleteItem = true; // 삭제 기능 활성화 필수 /// componentList.OnItemDeleted += (item) => /// { /// Debug.Log($"삭제 요청: {item.name}"); /// componentList.DeleteItem(item); // 리스트에서 제거 /// }; /// /// // 8. 더블클릭 이벤트 구독 /// componentList.OnItemDoubleClicked += (item) => /// { /// Debug.Log($"더블클릭: {item.name}"); /// // 카메라 포커스 등의 동작 수행 /// }; /// /// // 9. 아이콘 클릭 이벤트 (setting-btn, search-btn) /// componentList.OnItemIconClicked += (iconName, item) => /// { /// if (iconName == "setting-btn") /// { /// ShowCategorySettings(item); /// } /// }; /// /// // 10. 검색 실행 /// componentList.ApplySearch("의자"); /// /// // 11. 프로그래밍 방식 선택 /// componentList.SelectItem("의자 모델", notify: true); /// componentList.DeselectItem("의자 모델", notify: false); /// componentList.ClearSelection(); /// /// // 12. 아이템 추가/삭제 /// var newItem = new UTKComponentListItemData { name = "새 아이템" }; /// componentList.AddItem(newItem); // 루트에 추가 /// componentList.AddItem(categoryData, newItem); // 특정 카테고리에 추가 /// componentList.DeleteItem(newItem); // 삭제 /// /// // 13. 리소스 해제 (OnDestroy에서 호출) /// componentList.Dispose(); /// ]]> /// /// 관련 리소스: /// /// Resources/UIToolkit/List/UTKComponentList.uxml - 메인 레이아웃 /// Resources/UIToolkit/List/UTKComponentListItem.uxml - 개별 항목 템플릿 /// Resources/UIToolkit/List/UTKComponentListGroupItem.uxml - 그룹 항목 템플릿 /// /// /// 선택 해제 방지: /// /// 빈 영역 클릭 시 선택이 해제되지 않도록 하려면 EventSystem의 InputSystemUIInputModule 컴포넌트에서 /// Deselect On Background Click 옵션을 해제해야 합니다. /// /// [UxmlElement] public partial class UTKComponentList : VisualElement, IDisposable { #region IDisposable private bool _disposed = false; #endregion #region 상수 (Constants) /// 메인 UXML 파일 경로 (Resources 폴더 기준) private const string UXML_PATH = "UIToolkit/List/UTKComponentList"; private const string USS_PATH = "UIToolkit/List/UTKComponentListUss"; /// 일반 항목 UXML 파일 경로 (Resources 폴더 기준) private const string ITEM_UXML_PATH = "UIToolkit/List/UTKComponentListItem"; /// 그룹 항목 UXML 파일 경로 (Resources 폴더 기준) private const string GROUP_ITEM_UXML_PATH = "UIToolkit/List/UTKComponentListGroupItem"; #endregion #region 캐싱된 리소스 (Cached Resources) /// 일반 항목 UXML 템플릿 private VisualTreeAsset? _itemTemplate; /// 그룹 항목 UXML 템플릿 private VisualTreeAsset? _groupItemTemplate; #endregion #region UI 컴포넌트 참조 (UI Component References) /// 검색어 입력 필드 private UTKInputField? _searchField; /// Unity UI Toolkit의 TreeView 컴포넌트 private TreeView? _treeView; /// 검색어 지우기 버튼 (UTKButton) private UTKButton? _clearButton; #endregion #region 내부 데이터 (Internal Data) /// /// 원본 루트 데이터입니다. /// 검색 필터 해제 시 원래 데이터로 복원하는 데 사용됩니다. /// private List _originalRoots = new(); /// /// TreeView에 바인딩되는 데이터 소스입니다. /// TreeViewItemData는 Unity의 TreeView가 요구하는 래퍼 타입입니다. /// private List>? _rootData; /// /// 항목 ID 자동 생성을 위한 시드 값입니다. /// SetData() 호출 시 id가 0인 항목에 순차적으로 ID를 할당합니다. /// private int _idSeed = 1; /// /// 이전에 선택된 항목들입니다. /// 선택 해제 이벤트 발송에 사용됩니다. /// private List _previouslySelectedItems = new(); /// /// 선택 이벤트 발송을 일시적으로 억제하는 플래그입니다. /// 프로그래밍 방식으로 선택 시 이벤트를 발송하지 않으려면 true로 설정합니다. /// private bool _suppressSelectionEvent = false; /// /// 펼침/접힘 이벤트 처리를 일시적으로 억제하는 플래그입니다. /// ExpandByData() 실행 중 이벤트로 인한 데이터 덮어쓰기를 방지합니다. /// private bool _suppressExpandEvent = false; #endregion #region 공개 속성 (Public Properties) /// /// 항목 삭제 기능 활성화 여부입니다. /// true일 때만 Delete/Backspace 키로 항목 삭제 이벤트가 발생합니다. /// 기본값은 false입니다. /// public bool EnabledDeleteItem { get; set; } = false; /// /// 현재 검색어를 가져오거나 설정합니다. /// 설정 시 검색 필드의 값만 변경하고 검색은 실행하지 않습니다. /// public string SearchQuery { get => _searchField?.value ?? string.Empty; set { if (_searchField != null) _searchField.value = value; } } #endregion #region 외부 이벤트 (Public Events) /// /// 메인/검색 리스트에서 항목이 선택될 때 발생합니다. /// public Action>? OnItemSelected; /// /// 메인/검색 리스트에서 항목이 선택 해제될 때 발생합니다. /// public Action>? OnItemDeselected; /// /// 항목의 가시성(눈 아이콘)이 변경될 때 발생합니다. /// 3D 모델의 GameObject 활성화/비활성화에 연동합니다. /// public event Action? OnItemVisibilityChanged; /// /// 메인/검색 리스트에서 항목이 삭제될 때 발생합니다 (Delete 키). /// public Action? OnItemDeleted; /// /// 메인/검색 리스트에서 항목이 더블클릭될 때 발생합니다. /// public Action? OnItemDoubleClicked; /// /// 아이콘을 클릭할 때 발생합니다. /// public Action? OnItemIconClicked; #endregion #region 생성자 (Constructor) /// /// UTKComponentList 컴포넌트를 초기화합니다. /// UXML 템플릿을 로드하고 내부 컴포넌트를 설정합니다. /// public UTKComponentList() { // 1. 메인 UXML 로드 및 복제 // CloneTree(this)로 UXML 내용이 이 클래스의 자식으로 추가됨 var visualTree = Resources.Load(UXML_PATH); if (visualTree == null) { Debug.LogError($"[TreeMenu] UXML not found at: {UXML_PATH}"); return; } visualTree!.CloneTree(this); // 2. 항목 템플릿 로드 _itemTemplate = Resources.Load(ITEM_UXML_PATH); _groupItemTemplate = Resources.Load(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}"); // 테마 적용 및 변경 구독 UTKThemeManager.Instance.ApplyThemeToElement(this); SubscribeToThemeChanges(); // USS 로드 (테마 변수 스타일시트 이후에 로드되어야 변수가 해석됨) var uss = Resources.Load(USS_PATH); if (uss != null) { styleSheets.Add(uss); } // 3. 자식 요소 참조 획득 (UXML의 name 속성으로 찾음) _searchField = this.Q("search-field"); _treeView = this.Q("main-tree-view"); _clearButton = this.Q("clear-btn"); // 5. 이벤트 연결 및 로직 초기화 InitializeLogic(); } #endregion #region 초기화 (Initialization) /// /// 내부 로직과 이벤트 핸들러를 초기화합니다. /// private void InitializeLogic() { // 검색창 이벤트: Enter 키를 눌렀을 때 또는 포커스를 잃었을 때 필터링 실행 if (_searchField != null) { _searchField.OnSubmit += OnSearch; } // TreeView 설정 // makeItem: 새 항목 UI 생성 시 호출 // bindItem: 데이터를 UI에 바인딩할 때 호출 // selectionChanged: 선택 변경 시 호출 if (_treeView != null) { _treeView.makeItem = MakeTreeItem; _treeView.bindItem = BindTreeItem; _treeView.selectionChanged += OnTreeViewSelectionChanged; _treeView.itemsChosen += OnTreeViewItemsChosen; _treeView.RegisterCallback(OnTreeViewKeyDown, TrickleDown.TrickleDown); // 펼침/접힘 이벤트 처리 _treeView.itemExpandedChanged += OnTreeViewItemExpandedChanged; } // 검색어 지우기 버튼 if(_clearButton != null) { _clearButton.style.display = DisplayStyle.None; // 초기에는 숨김 _clearButton.OnClicked += OnClearButtonClicked; } // 스크롤바 hover/active 색상 설정 SetupScrollerDraggerColors(); } /// /// 스크롤바 드래거의 hover/active 색상을 설정합니다. /// USS로는 드래거에 직접 hover/active 상태를 적용할 수 없어 코드로 구현합니다. /// private void SetupScrollerDraggerColors() { if (_treeView == null) return; // TreeView가 렌더링된 후에 스크롤러 설정 _treeView.RegisterCallback(OnTreeViewGeometryChanged); } // USS 변수 정의 (customStyle에서 읽기 위한 키) private static readonly CustomStyleProperty s_DraggerNormalColor = new("--scroller-dragger-normal"); private static readonly CustomStyleProperty s_DraggerHoverColor = new("--scroller-dragger-hover"); private static readonly CustomStyleProperty s_DraggerActiveColor = new("--scroller-dragger-active"); private static readonly CustomStyleProperty s_TrackerNormalColor = new("--scroller-tracker-normal"); private static readonly CustomStyleProperty s_TrackerHoverColor = new("--scroller-tracker-hover"); /// /// TreeView 렌더링 완료 후 스크롤러 색상을 설정합니다. /// private void OnTreeViewGeometryChanged(GeometryChangedEvent evt) { if (_treeView == null) return; // 한 번만 실행 _treeView.UnregisterCallback(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(); 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); } } /// /// 개별 스크롤러에 드래거 색상 이벤트를 설정합니다. /// 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(evt => { dragger.style.backgroundColor = hoverColor; }); dragger.RegisterCallback(evt => { dragger.style.backgroundColor = normalColor; }); dragger.RegisterCallback(evt => { dragger.style.backgroundColor = activeColor; }); dragger.RegisterCallback(evt => { dragger.style.backgroundColor = hoverColor; }); // 트래커(배경) 마우스 오버 시 색상 변경 if (tracker != null) { tracker.RegisterCallback(evt => { tracker.style.backgroundColor = trackerHoverColor; }); tracker.RegisterCallback(evt => { tracker.style.backgroundColor = trackerNormalColor; }); } } #endregion #region 공개 메서드 (Public Methods) /// /// 트리 리스트를 화면에 표시합니다. /// public void Show() { this.style.display = DisplayStyle.Flex; } /// /// 현재 검색 필드의 값으로 검색을 실행합니다. /// public void ApplySearch() { OnSearch(_searchField?.value ?? string.Empty); } /// /// 지정된 검색어로 검색을 실행합니다. /// 검색 필드의 값도 함께 업데이트됩니다. /// /// 검색어 public void ApplySearch(string query) { if (_searchField != null) { _searchField.value = query; } OnSearch(query); } /// /// 트리 데이터를 설정합니다. /// UTKComponentListItemDataBase 리스트를 받아 TreeView에 바인딩합니다. /// /// 루트 항목들의 리스트 (계층 구조의 최상위 항목들) public void SetData(List roots) { // 원본 데이터 저장 (필터 복원용) _originalRoots = roots ?? new List(); // ID 시드 초기화 _idSeed = 1; // 순환 참조 감지를 위한 방문 집합 var visited = new HashSet(); // TreeView 형식으로 데이터 변환 _rootData = ConvertToTreeViewData(_originalRoots, visited, 0); // TreeView에 데이터 설정 _treeView!.SetRootItems(_rootData); // UI 갱신 및 데이터의 isExpanded 상태에 따라 펼치기 _treeView!.Rebuild(); ExpandByData(_originalRoots); } /// /// 데이터의 isExpanded 값에 따라 항목을 펼치거나 접습니다. /// /// 처리할 항목 리스트 private void ExpandByData(List items) { if (_treeView == null || items == null) return; // 이벤트 억제 시작 (재귀 호출 고려하여 최상위에서만 설정) bool wasSupressed = _suppressExpandEvent; _suppressExpandEvent = true; try { ExpandByDataInternal(items); } finally { _suppressExpandEvent = wasSupressed; } } /// /// ExpandByData의 내부 재귀 구현입니다. /// private void ExpandByDataInternal(List 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); } } } /// /// 지정된 ID의 항목을 프로그래밍 방식으로 선택합니다. /// /// 선택할 항목의 ID public void SelectByItemId(int itemId) { _treeView?.SetSelection(new List { itemId }); } /// /// 지정된 이름 목록에 해당하는 항목만 표시하고 나머지는 숨깁니다. /// /// 표시할 항목들의 이름 목록 /// 검색 깊이 (1=1뎁스 자식만, 2=2뎁스까지, 0이하=전체) public void ShowItems(List items, int depth = 1) { if (_originalRoots == null || _originalRoots.Count == 0) return; var visibleNames = new HashSet(items ?? new List()); foreach (var root in _originalRoots) { SetVisibilityByNames(root, visibleNames, depth, 0); } // UI 갱신 _treeView?.RefreshItems(); } /// /// 루트 레벨에 새 항목을 추가합니다. /// ID가 0이면 자동으로 할당됩니다. /// /// 추가할 항목 데이터 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 래퍼 생성 및 추가 var childData = data.children != null && data.children.Count > 0 ? ConvertToTreeViewData(data.children, new HashSet(), 1) : null; var treeItem = new TreeViewItemData(data.id, data, childData); _rootData.Add(treeItem); // TreeView 갱신 _treeView?.SetRootItems(_rootData); _treeView?.Rebuild(); ExpandByData(_originalRoots); } /// /// 지정된 부모 카테고리의 자식으로 새 항목을 추가합니다. /// /// 부모 카테고리 (null이면 루트에 추가) /// 추가할 항목 데이터 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(); } /// /// 트리에서 항목을 완전히 삭제합니다 (데이터 + 뷰 + 선택 상태). /// 자식 항목이 있는 경우 함께 삭제됩니다. /// /// 삭제할 항목 데이터 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(); } } /// /// 항목의 이름을 변경하고 UI를 갱신합니다. /// /// 변경할 항목 데이터 /// 새 이름 public void SetItemName(UTKComponentListItemDataBase data, string newName) { if (data == null || string.IsNullOrEmpty(newName)) return; data.name = newName; // UI 갱신 (해당 항목의 레이블 업데이트) _treeView?.RefreshItems(); } /// /// 이름으로 항목을 찾아 선택합니다. /// 검색 모드 중이면 검색을 취소하고 원본 데이터에서 선택합니다. /// SelectionType에 따라 단일 선택이면 기존 선택을 대체하고, 다중 선택이면 기존 선택에 추가합니다. /// /// 선택할 항목의 이름 /// 선택 이벤트 발송 여부 (false면 OnItemSelected/OnItemDeselected 이벤트 억제) 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 newSelection; // SelectionType에 따라 선택 방식 결정 if (_treeView.selectionType == SelectionType.Multiple) { // 다중 선택: 기존 선택에 추가 newSelection = _previouslySelectedItems.Select(i => i.id).ToList(); newSelection.Add(item.id); } else { // 단일 선택: 기존 선택 대체 newSelection = new List { item.id }; } // 이벤트 억제 플래그 설정 _suppressSelectionEvent = !notify; try { _treeView.SetSelection(newSelection); } finally { _suppressSelectionEvent = false; } } /// /// 이름으로 항목을 찾아 선택을 해제합니다. /// 단일 선택 모드에서는 모든 선택이 해제되고, 다중 선택 모드에서는 해당 항목만 제외됩니다. /// /// 선택 해제할 항목의 이름 /// 선택 해제 이벤트 발송 여부 (false면 OnItemDeselected 이벤트 억제) 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 newSelection; // SelectionType에 따라 선택 해제 방식 결정 if (_treeView.selectionType == SelectionType.Multiple) { // 다중 선택: 해당 항목만 제외 newSelection = _previouslySelectedItems .Where(i => i.id != item.id) .Select(i => i.id) .ToList(); } else { // 단일 선택: 전체 선택 해제 newSelection = new List(); } // 이벤트 억제 플래그 설정 _suppressSelectionEvent = !notify; try { _treeView.SetSelection(newSelection); } finally { _suppressSelectionEvent = false; } } /// /// 모든 항목의 선택을 해제합니다. /// public void ClearSelection() { _treeView?.ClearSelection(); } /// /// 원본 데이터를 기반으로 TreeView 데이터를 재구성하고 UI를 갱신합니다. /// private void RefreshTreeViewData() { var visited = new HashSet(); _rootData = ConvertToTreeViewData(_originalRoots, visited, 0); _treeView?.SetRootItems(_rootData); _treeView?.Rebuild(); ExpandByData(_originalRoots); } /// /// 부모 노드의 자식들에서 대상 항목을 재귀적으로 찾아 삭제합니다. /// /// 부모 노드 /// 삭제할 대상 항목 /// 삭제 성공 여부 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; } /// /// 이름으로 항목을 재귀적으로 검색합니다. /// /// 찾을 항목의 이름 /// 찾은 항목 또는 null private UTKComponentListItemDataBase? FindItemByName(string name) { foreach (var root in _originalRoots) { var found = FindItemByNameRecursive(root, name); if (found != null) return found; } return null; } /// /// 삭제된 항목과 그 자식들을 선택 상태에서 제거합니다. /// _previouslySelectedItems와 TreeView의 선택 상태 모두에서 제거합니다. /// /// 삭제된 항목 private void RemoveFromSelection(UTKComponentListItemDataBase data) { // 삭제할 항목과 모든 자식들의 ID 수집 var idsToRemove = new HashSet(); CollectItemIds(data, idsToRemove); // _previouslySelectedItems에서 제거 _previouslySelectedItems.RemoveAll(item => idsToRemove.Contains(item.id)); // TreeView 선택 상태에서 제거 if (_treeView != null) { var currentSelection = _treeView.selectedIndices.ToList(); var newSelection = new List(); foreach (var index in currentSelection) { var selectedItem = _treeView.GetItemDataForIndex(index); if (selectedItem != null && !idsToRemove.Contains(selectedItem.id)) { newSelection.Add(selectedItem.id); } } if (currentSelection.Count != newSelection.Count) { _treeView.SetSelection(newSelection); } } } /// /// 항목과 모든 자식들의 ID를 재귀적으로 수집합니다. /// /// 수집 시작 노드 /// ID를 저장할 HashSet private void CollectItemIds(UTKComponentListItemDataBase node, HashSet ids) { ids.Add(node.id); if (node.children != null) { foreach (var child in node.children) { CollectItemIds(child, ids); } } } /// /// 노드와 그 자식들에서 이름으로 항목을 재귀적으로 검색합니다. /// /// 검색 시작 노드 /// 찾을 항목의 이름 /// 찾은 항목 또는 null 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; } /// /// 재귀적으로 항목의 가시성을 이름 목록에 따라 설정합니다. /// /// 현재 노드 /// 표시할 이름 목록 /// 최대 검색 깊이 (0이하=무제한) /// 현재 깊이 private void SetVisibilityByNames(UTKComponentListItemDataBase node, HashSet 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) /// /// TreeView의 선택 변경 이벤트를 처리합니다. /// SelectionType에 따라 TreeView가 자동으로 선택 항목 수를 제한합니다: /// - None: 선택 불가 (selectedItems 항상 비어있음) /// - Single: 최대 1개 선택 /// - Multiple: 여러 개 선택 가능 /// /// 선택된 항목들 private void OnTreeViewSelectionChanged(IEnumerable selectedItems) { // SelectionType.None인 경우 선택 변경 무시 if (_treeView?.selectionType == SelectionType.None) return; // 카테고리가 선택되었는지 확인 var allSelectedItems = selectedItems.OfType().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; } /// /// TreeView에서 항목이 더블클릭되거나 Enter 키로 선택될 때 호출됩니다. /// /// 선택된 항목들 private void OnTreeViewItemsChosen(IEnumerable chosenItems) { var item = chosenItems.FirstOrDefault() as UTKComponentListItemDataBase; if (item != null) { OnItemDoubleClicked?.Invoke(item); } } /// /// TreeView에서 키보드 입력 이벤트를 처리합니다. /// EnabledDeleteItem이 true이고 Delete 키 입력 시 선택된 항목 삭제 이벤트를 발송합니다. /// /// 키 입력 이벤트 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(); } } } /// /// TreeView 항목의 펼침/접힘 상태가 변경될 때 호출됩니다. /// 데이터의 isExpanded 값을 동기화합니다. /// /// 펼침/접힘 변경 이벤트 private void OnTreeViewItemExpandedChanged(TreeViewExpansionChangedArgs evt) { // ExpandByData() 실행 중에는 이벤트 무시 (데이터 덮어쓰기 방지) if (_suppressExpandEvent) return; var item = _treeView?.GetItemDataForId(evt.id); if (item != null) { item.isExpanded = evt.isExpanded; } // 해당 항목의 화살표 회전 업데이트 UpdateToggleRotation(evt.id, evt.isExpanded); } /// /// 특정 항목의 토글 화살표 회전을 업데이트합니다. /// /// 항목 ID /// 확장 상태 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("unity-tree-view__item-toggle"); var checkmark = toggle?.Q("unity-checkmark"); if (checkmark != null) { checkmark.style.rotate = new Rotate(isExpanded ? 0f : -90f); } } #endregion #region 데이터 변환 (Data Conversion) /// /// UTKComponentListItemDataBase 리스트를 TreeView가 요구하는 TreeViewItemData 형식으로 변환합니다. /// 재귀적으로 자식 항목도 변환하며, 순환 참조를 감지합니다. /// /// 변환할 항목 리스트 /// 순환 참조 감지용 방문 집합 /// 현재 깊이 (디버깅용) /// TreeViewItemData 래퍼 리스트 private List> ConvertToTreeViewData(List items, HashSet visited, int depth) { var list = new List>(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>? childData = null; if (item.children != null && item.children.Count > 0) childData = ConvertToTreeViewData(item.children, visited, depth + 1); // TreeViewItemData 래퍼 생성 var treeItem = new TreeViewItemData(item.id, item, childData); list.Add(treeItem); } return list; } #endregion #region TreeView 항목 바인딩 (TreeView Item Creation/Binding) /// /// TreeView 항목의 UI 요소를 생성합니다. /// makeItem은 데이터 없이 호출되므로 기본 컨테이너만 생성합니다. /// 실제 템플릿은 bindItem에서 데이터에 따라 교체됩니다. /// /// 생성된 UI 요소 private VisualElement MakeTreeItem() { // 빈 컨테이너 생성 (bindItem에서 템플릿 교체) var container = new VisualElement(); container.style.flexGrow = 1; return container; } /// /// 데이터를 UI 요소에 바인딩합니다. /// TreeView의 bindItem 콜백으로 사용됩니다. /// 스크롤 시 재사용되는 항목에 새 데이터를 연결합니다. /// 자식 유무에 따라 다른 템플릿을 사용합니다. /// /// 바인딩할 UI 요소 /// TreeView 내부 인덱스 private void BindTreeItem(VisualElement element, int index) { // 인덱스로 데이터 획득 var item = _treeView!.GetItemDataForIndex(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("unity-tree-view__item-toggle"); if (toggle != null) { toggle.style.width = hasChildren ? StyleKeyword.Auto : 0; // 확장 상태에 따라 화살표 회전 설정 if (hasChildren) { var checkmark = toggle.Q("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 oldHandler) { treeViewItem.UnregisterCallback(oldHandler, TrickleDown.TrickleDown); } // setting-btn 참조 캡처 (클릭 영역 확인용) var settingBtnForHandler = element.Q("setting-btn"); EventCallback categoryClickHandler = (evt) => { // 토글(화살표) 영역 클릭은 허용 (펼치기/접기 기능 유지) if (toggle != null && toggle.worldBound.Contains(evt.position)) { return; // 토글 클릭은 통과 } // setting-btn 클릭 영역은 허용 (버튼 이벤트가 처리되도록) if (settingBtnForHandler != null && settingBtnForHandler.worldBound.Contains(evt.position)) { return; // setting-btn 클릭은 통과 } // 카테고리 클릭 시 이벤트 전파 완전 차단 // 이렇게 하면 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 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