#nullable enable using Cysharp.Threading.Tasks; using System; using System.Collections.Generic; using System.Linq; using TMPro; using UnityEngine; using UnityEngine.EventSystems; using UVC.Pool; using UVC.UI.Modal.DatePicker; namespace UVC.UI.List.Tree { /// /// 트리 구조의 리스트를 관리하고 제어하는 클래스입니다. /// /// 주요 기능: /// - 계층 구조 아이템의 추가/제거 관리 /// - 단일 및 다중 선택 지원 (파일 탐색기와 동일) /// - 키보드 입력(Delete, 화살표, Ctrl, Shift) 처리 /// - 드래그 & 드롭 기능 지원 /// - 선택 상태 변경 이벤트 제공 /// /// 선택 동작: /// - 클릭: 한 항목 선택 /// - Ctrl+클릭: 여러 항목 선택 (기존 선택 유지) /// - Shift+클릭: 범위 선택 (시작~끝) /// - 화살표: 선택된 아이템 이동 /// - Delete: 선택된 아이템 삭제 /// public class TreeList : MonoBehaviour { /// /// UI 아이템 프리팹 (복제할 템플릿) /// [SerializeField] protected TreeListItem itemPrefab; /// /// itemPrefab 프로퍼티 (읽기 전용) /// public TreeListItem ItemPrefab => itemPrefab; /// /// 모든 아이템을 담는 부모 컨테이너 (RectTransform) /// [SerializeField] protected RectTransform root; /// /// root 프로퍼티 (읽기 전용) /// public RectTransform Root => root; /// /// 다중 선택 허용 여부 /// (true: 여러 개 선택 가능, false: 한 개만 선택 가능) /// [SerializeField] protected bool allowMultipleSelection = true; /// /// 드래그 & 드롭 기능 활성화 여부 /// [SerializeField] protected bool enableDragDrop = true; /// /// 현재 선택된 아이템 리스트 /// protected List selectedItems = new List(); /// /// 마지막으로 선택한 아이템 (Shift+클릭 범위 선택의 시작점) /// protected TreeListItemData? lastSelectedItem = null; /// /// 모든 아이템을 1차원으로 평탄화한 리스트 (범위 선택용) /// protected List allItemDataFlattened = new List(); /// /// 평탄화된 아이템 데이터 리스트 (읽기 전용) /// public List AllItemDataFlattened => allItemDataFlattened; /// /// 루트 레벨 아이템 리스트 /// protected List items = new List(); /// /// 루트 레벨 아이템 리스트 (읽기 전용) /// public List Items => items; /// /// 평탄화된 UI 아이템(TreeListItem) 리스트 /// protected List allItemFlattened = new List(); /// /// 평탄화된 UI 아이템 리스트 (읽기 전용) /// public List AllItemFlattened => allItemFlattened; /// /// 드래그 & 드롭 매니저 /// protected TreeListDragDropManager dragDropManager = new TreeListDragDropManager(); /// /// 드래그 & 드롭 매니저 (읽기 전용) /// public TreeListDragDropManager DragDropManager => dragDropManager; #region 오브젝트 풀링 (Object Pooling) /// /// TreeListItem 오브젝트 풀 /// protected GameObjectPool? _itemPool; /// /// 재활용 컨테이너 (비활성 아이템 보관용) /// [SerializeField] protected Transform? recycledContainer; /// /// 오브젝트 풀 읽기 전용 접근자 /// public GameObjectPool? ItemPool => _itemPool; #endregion /// /// 아이템 선택 상태 변경 이벤트 (data, isSelected) /// TreeListItem에서 호출됩니다. /// public Action? OnItemSelectionChanged; /// /// 아이템 데이터 변경 이벤트 (changedType, changedData, index) /// TreeListItemData의 데이터가 변경될 때 호출됩니다. /// public Action? OnItemDataChanged; /// /// 아이템 클릭 이벤트 (data) /// TreeListItem이 클릭될 때 호출됩니다. /// public Action? OnItemClickAction; /// /// 아이템 가시성 변경 이벤트 (changedData, isVisible) /// public Action? OnItemVisibilityChanged; /// /// 아이템 더블클릭 이벤트 (data) /// TreeListItem이 더블클릭될 때 호출됩니다. /// public Action? OnItemDoubleClicked; /// /// 현재 선택된 아이템 목록 (읽기 전용) /// public IReadOnlyList SelectedItems => selectedItems.AsReadOnly(); /// /// root 아래의 모든 TreeListItem 컴포넌트 /// public List TreeLists => root.GetComponentsInChildren().ToList(); // 뷰 매핑: 데이터 ↔ View O(1) 조회 protected readonly Dictionary _viewMap = new Dictionary(); // 플래튼 업데이트 스로틀링 플래그 protected bool _flattenScheduled; protected void Awake() { // 드래그 & 드롭 이벤트 구독 if (enableDragDrop) { dragDropManager.OnDropped += HandleItemDropped; } // 오브젝트 풀 초기화 InitializePool(); } /// /// 오브젝트 풀 초기화 /// protected virtual void InitializePool() { if (itemPrefab == null) return; // recycledContainer가 없으면 동적 생성 if (recycledContainer == null) { var recycledGO = new GameObject("_RecycledItems"); recycledGO.transform.SetParent(transform, false); recycledGO.SetActive(false); // 비활성 상태로 유지 recycledContainer = recycledGO.transform; } _itemPool = new GameObjectPool( itemPrefab.gameObject, root, recycledContainer ); } /// /// 풀에서 TreeListItem 획득 /// /// 아이템 고유 키 (TreeListItemData.Id.ToString()) /// 부모 Transform (null이면 root 사용) /// 풀에서 획득한 TreeListItem public TreeListItem? GetPooledItem(string key, Transform? parent = null) { if (_itemPool == null) { InitializePool(); } var item = _itemPool!.GetItem(key, true, parent ?? root); return item; } /// /// TreeListItem을 풀에 반환 /// /// 반환할 TreeListItem /// 아이템 고유 키 public void ReturnItemToPool(TreeListItem item, string key) { if (_itemPool == null || item == null) return; _itemPool.ReturnItem(key, true); } /// /// 모든 아이템을 풀에 반환 /// public void ReturnAllToPool() { _itemPool?.ReturnAll(); allItemFlattened.Clear(); allItemDataFlattened.Clear(); selectedItems.Clear(); _viewMap.Clear(); } protected void OnDestroy() { if (enableDragDrop) { dragDropManager.OnDropped -= HandleItemDropped; } // 이벤트 핸들러 정리 OnItemSelectionChanged = null; OnItemDataChanged = null; OnItemClickAction = null; OnItemVisibilityChanged = null; // 데이터 및 뷰 맵 정리 _viewMap.Clear(); allItemFlattened.Clear(); allItemDataFlattened.Clear(); selectedItems.Clear(); items.Clear(); // 풀 정리 _itemPool?.ClearRecycledItems(); } #region 데이터 변경 메서드 (Data Modification Methods) /// /// 아이템 이름을 변경하고 UI를 갱신합니다. /// public void SetItemName(TreeListItemData data, string name) { if (data.Name == name) return; data.Name = name; NotifyDataChanged(data, ChangedType.Name); } /// /// 아이템 옵션을 변경하고 UI를 갱신합니다. /// public void SetItemOption(TreeListItemData data, string option) { if (data.Option == option) return; data.Option = option; NotifyDataChanged(data, ChangedType.Option); } /// /// 아이템 펼침 상태를 변경하고 UI를 갱신합니다. /// public void SetItemExpanded(TreeListItemData data, bool expanded) { if (data.IsExpanded == expanded) return; data.IsExpanded = expanded; NotifyDataChanged(data, ChangedType.Expanded); } /// /// 아이템 가시성 상태를 변경하고 UI를 갱신합니다. /// public void SetItemVisible(TreeListItemData data, bool visible) { if (data.IsVisible == visible) return; data.IsVisible = visible; NotifyDataChanged(data, ChangedType.TailButtons); } /// /// 부모 아이템에 자식을 추가하고 UI를 갱신합니다. /// public void AddChild(TreeListItemData parent, TreeListItemData child) { parent.AddChild(child); NotifyDataChanged(parent, ChangedType.AddChild, child); } /// /// 부모 아이템의 특정 위치에 자식을 추가하고 UI를 갱신합니다. /// public void AddChildAt(TreeListItemData parent, TreeListItemData child, int index) { parent.AddChildAt(child, index); NotifyDataChanged(parent, ChangedType.AddAtChild, child, index); } /// /// 자식을 복제하여 부모에 추가하고 UI를 갱신합니다. /// public void AddCloneChild(TreeListItemData parent, TreeListItemData child) { NotifyDataChanged(parent, ChangedType.AddCloneChild, child); } /// /// 자식을 복제하여 부모의 특정 위치에 추가하고 UI를 갱신합니다. /// public void AddCloneChildAt(TreeListItemData parent, TreeListItemData child, int index) { NotifyDataChanged(parent, ChangedType.AddCloneAtChild, child, index); } /// /// 자식을 특정 위치로 이동하고 UI를 갱신합니다. /// public void SwapChild(TreeListItemData parent, TreeListItemData child, int index) { parent.SwapChild(child, index); NotifyDataChanged(parent, ChangedType.SwapChild, child, index); } /// /// 부모에서 자식을 제거하고 UI를 갱신합니다. /// public void RemoveChild(TreeListItemData parent, TreeListItemData child) { if (parent.RemoveChild(child)) { NotifyDataChanged(parent, ChangedType.RemoveChild, child); } } /// /// 부모의 모든 자식을 제거하고 UI를 갱신합니다. /// public void ClearChildren(TreeListItemData parent) { parent.ClearChildren(); NotifyDataChanged(parent, ChangedType.ResetChildren); } /// /// 데이터 변경을 View에 알립니다. /// internal void NotifyDataChanged(TreeListItemData owner, ChangedType changedType, TreeListItemData? target = null, int index = -1) { // View가 있으면 알림 if (_viewMap.TryGetValue(owner, out var view) && view != null) { view.HandleDataChanged(changedType, target ?? owner, index); } // 외부 이벤트 발생 OnItemDataChanged?.Invoke(changedType, target ?? owner, index); } /// /// 선택 상태 변경을 View에 알립니다. /// internal void NotifySelectionChanged(TreeListItemData data, bool isSelected) { // View가 있으면 알림 if (_viewMap.TryGetValue(data, out var view) && view != null) { view.HandleSelectionChanged(); } // 외부 이벤트 발생 OnItemSelectionChanged?.Invoke(data, isSelected); } #endregion protected void Update() { // 입력 필드에 포커스가 있으면 키보드 입력 무시 if (IsInputFieldFocused()) return; // Escape 키 입력 감지 - 선택 해제 if (Input.GetKeyDown(KeyCode.Escape)) { HandleEscapeKeyPressed(); } // Delete 키 입력 감지 if (Input.GetKeyDown(KeyCode.Delete)) { HandleDeleteKeyPressed(); } // 위/아래 화살표 키로 선택 아이템 변경 if (Input.GetKeyDown(KeyCode.UpArrow)) { HandleUpArrowKeyPressed(); } if (Input.GetKeyDown(KeyCode.DownArrow)) { HandleDownArrowKeyPressed(); } // 좌/우 키로 선택된 아이템 접힘/펼침접힘 if (Input.GetKeyDown(KeyCode.RightArrow)) { HandleRightArrowKeyPressed(); } if (Input.GetKeyDown(KeyCode.LeftArrow)) { HandleLeftArrowKeyPressed(); } } /// /// 입력 필드에 포커스가 있는지 확인합니다. /// private bool IsInputFieldFocused() { var eventSystem = EventSystem.current; if (eventSystem == null) return false; var selected = eventSystem.currentSelectedGameObject; if (selected == null) return false; // TMP_InputField 확인 (자기 자신 및 부모 계층에서 검색) var tmpInput = selected.GetComponent(); if (tmpInput == null) { tmpInput = selected.GetComponentInParent(); } if (tmpInput != null && tmpInput.isFocused) return true; // Legacy InputField 확인 (자기 자신 및 부모 계층에서 검색) var legacyInput = selected.GetComponent(); if (legacyInput == null) { legacyInput = selected.GetComponentInParent(); } if (legacyInput != null && legacyInput.isFocused) return true; return false; } /// /// View 등록(데이터↔View 맵). Init 시점에 호출됩니다. /// internal void RegisterView(TreeListItemData data, TreeListItem view) { if (data == null || view == null) return; _viewMap[data] = view; } /// /// View 등록 해제. Delete/OnDestroy 시점에 호출됩니다. /// internal void UnregisterView(TreeListItemData data, TreeListItem view) { if (data == null) return; if (_viewMap.TryGetValue(data, out var existing) && existing == view) { _viewMap.Remove(data); } } /// /// 다음 프레임에 평탄화 리스트 업데이트를 예약합니다. /// 과도한 재계산을 방지하기 위한 스로틀링 유틸입니다. /// public void ScheduleFlattenedUpdate() { if (_flattenScheduled) return; _flattenScheduled = true; UniTask.NextFrame().ContinueWith(() => { _flattenScheduled = false; UpdateFlattenedItemDataList(); }); } /// /// Escape 키 입력 시 모든 선택 해제 /// /// 동작: /// 1. 선택된 아이템이 있는지 확인 /// 2. ClearSelection() 호출하여 모든 선택 해제 /// 3. lastSelectedItem 초기화 /// /// 용도: 사용자가 선택을 취소하고 싶을 때 빠른 해제 /// protected void HandleEscapeKeyPressed() { // 선택된 아이템이 없으면 아무것도 하지 않음 if (selectedItems.Count == 0) { return; } // 디버그 로그 //Debug.Log($"Escape key pressed. Clearing {selectedItems.Count} selected item(s)."); // 모든 선택 해제 ClearSelection(); // 마지막 선택 아이템 초기화 lastSelectedItem = null; } /// /// Delete 키 입력 시 선택된 모든 아이템 삭제 /// /// 동작: /// 1. 선택된 아이템 목록 복사 /// 2. 각 아이템에 대해 DeleteItem() 호출 /// 3. 평탄화 리스트 업데이트 /// /// 주의: 반복 중 리스트 수정으로 인한 문제 방지를 위해 /// ToList()로 복사 후 순회합니다. /// protected void HandleDeleteKeyPressed() { // 선택된 아이템이 없으면 아무것도 하지 않음 if (selectedItems.Count == 0) { return; } // 선택된 아이템 목록을 복사 (역순 삭제 시 안전) var itemsToDelete = selectedItems.ToList(); // 디버그 로그 //Debug.Log($"Delete key pressed. Removing {itemsToDelete.Count} selected item(s)."); // 각 선택된 아이템에 대해 삭제 이벤트 발생 후 삭제 foreach (var item in itemsToDelete) { // 삭제 전 이벤트 발생 (View 업데이트 + 외부에서 연관 데이터 정리 가능) NotifyDataChanged(item, ChangedType.Delete, item); DeleteItem(item); } UpdateFlattenedItemDataList(); } /// /// 펼쳐진 아이템들만 포함하는 가시 리스트 생성 /// /// 동작: /// 1. 루트 레벨 아이템부터 시작 /// 2. IsExpanded=true인 아이템의 자식들만 포함 /// 3. 재귀적으로 모든 가시 아이템 수집 /// /// 용도: 화살표 키 네비게이션 시 보이는 아이템만 선택 가능 /// protected List GetVisibleFlattenedItems() { List visibleItems = new List(); // 루트 레벨 아이템들부터 시작 foreach (var rootItem in items) { AddVisibleItemsRecursive(rootItem, visibleItems); } return visibleItems; } /// /// 가시 아이템을 재귀적으로 추가 /// /// 동작: /// 1. 현재 아이템을 리스트에 추가 /// 2. IsExpanded=true이면 모든 자식 재귀 처리 /// 3. IsExpanded=false이면 자식 무시 (접혀있음) /// /// 현재 처리할 아이템 /// 수집 결과 리스트 protected void AddVisibleItemsRecursive(TreeListItemData item, List visibleItems) { // 현재 아이템을 리스트에 추가 visibleItems.Add(item); // IsExpanded=true인 경우에만 자식들 처리 if (item.IsExpanded) { foreach (var child in item.Children) { AddVisibleItemsRecursive(child, visibleItems); } } } /// /// 위쪽 화살표 키 처리: 선택 아이템을 위로 이동 /// /// 동작: /// 1. 정확히 1개 아이템만 선택되어 있는지 확인 /// 2. 가시 리스트에서 현재 아이템의 위치 찾기 /// 3. 이전 아이템이 있으면 선택 변경 /// /// 제약: 단일 선택 상태에서만 작동 /// protected void HandleUpArrowKeyPressed() { // 정확히 하나의 아이템만 선택되어 있어야 함 if (selectedItems.Count != 1) { return; } TreeListItemData currentSelected = selectedItems[0]; // 펼쳐진 아이템들만 포함하는 임시 리스트 생성 List visibleItems = GetVisibleFlattenedItems(); // 임시 리스트에서 현재 선택된 아이템의 인덱스 찾기 int currentIndex = visibleItems.IndexOf(currentSelected); // 아이템을 찾을 수 없으면 종료 if (currentIndex == -1) { return; } // 이전 아이템이 있는지 확인 (0보다 크면 이전 아이템이 있음) if (currentIndex > 0) { int previousIndex = currentIndex - 1; TreeListItemData previousItem = visibleItems[previousIndex]; // 현재 아이템 선택 해제 DeselectItem(currentSelected); // 이전 아이템 선택 SelectItem(previousItem); lastSelectedItem = previousItem; // 디버그 로그 //Debug.Log($"Up arrow: Selected '{previousItem.Name}' (visible index {previousIndex})"); } } /// /// 아래쪽 화살표 키 처리: 선택 아이템을 아래로 이동 /// /// 동작: /// 1. 가시 리스트 생성 /// 2. 선택 없음: 첫 번째 아이템 선택 /// 3. 단일 선택: 다음 아이템으로 이동 /// 4. 다중 선택: 무시 /// /// 제약: 단일 선택 상태에서만 작동 /// protected void HandleDownArrowKeyPressed() { // 펼쳐진 아이템들만 포함하는 임시 리스트 생성 List visibleItems = GetVisibleFlattenedItems(); // 선택된 아이템이 없는 경우 if (selectedItems.Count == 0) { // 펼쳐진 아이템 리스트에 아이템이 있으면 첫 번째 아이템 선택 if (visibleItems.Count > 0) { TreeListItemData firstItem = visibleItems[0]; SelectItem(firstItem); lastSelectedItem = firstItem; // 디버그 로그 //Debug.Log($"Down arrow (no selection): Selected first visible item '{firstItem.Name}'"); } return; } // 정확히 하나의 아이템만 선택되어 있어야 함 if (selectedItems.Count != 1) { return; } TreeListItemData currentSelected = selectedItems[0]; // 임시 리스트에서 현재 선택된 아이템의 인덱스 찾기 int currentIndex = visibleItems.IndexOf(currentSelected); // 아이템을 찾을 수 없으면 종료 if (currentIndex == -1) { return; } // 다음 아이템이 있는지 확인 if (currentIndex < visibleItems.Count - 1) { int nextIndex = currentIndex + 1; TreeListItemData nextItem = visibleItems[nextIndex]; // 현재 아이템 선택 해제 DeselectItem(currentSelected); // 다음 아이템 선택 SelectItem(nextItem); lastSelectedItem = nextItem; // 디버그 로그 //Debug.Log($"Down arrow: Selected '{nextItem.Name}' (visible index {nextIndex})"); } } /// /// 우측 화살표 키 처리: 선택된 아이템 펼치기 /// /// 동작: /// 1. 정확히 1개 아이템만 선택되어 있는지 확인 /// 2. IsExpanded를 true로 설정 /// 3. 평탄화 리스트 업데이트 /// /// 제약: 이미 펼쳐진 아이템은 동작 없음 /// protected void HandleRightArrowKeyPressed() { // 선택된 아이템이 없으면 아무것도 하지 않음 if (selectedItems.Count == 0) { return; } // 정확히 하나의 아이템만 선택되어 있어야 함 // (다중 선택 상태에서는 작동하지 않음) if (selectedItems.Count != 1) { return; } TreeListItemData selectedItem = selectedItems[0]; if (selectedItem.IsExpanded) return; // IsExpanded 상태를 true로 설정하고 UI 갱신 SetItemExpanded(selectedItem, true); // 펼침/접힘 상태 변경을 UI에 반영하기 위해 평탄화 리스트 업데이트 UpdateFlattenedItemDataList(); } /// /// 좌측 화살표 키 처리: 선택된 아이템 접기 /// /// 동작: /// 1. 정확히 1개 아이템만 선택되어 있는지 확인 /// 2. IsExpanded를 false로 설정 /// 3. 평탄화 리스트 업데이트 /// /// 제약: 이미 접혀진 아이템은 동작 없음 /// protected void HandleLeftArrowKeyPressed() { // 선택된 아이템이 없으면 아무것도 하지 않음 if (selectedItems.Count == 0) { return; } // 정확히 하나의 아이템만 선택되어 있어야 함 // (다중 선택 상태에서는 작동하지 않음) if (selectedItems.Count != 1) { return; } TreeListItemData selectedItem = selectedItems[0]; if (!selectedItem.IsExpanded) return; // IsExpanded 상태를 false로 설정하고 UI 갱신 SetItemExpanded(selectedItem, false); // 펼침/접힘 상태 변경을 UI에 반영하기 위해 평탄화 리스트 업데이트 UpdateFlattenedItemDataList(); } /// /// TreeListItem 프리팹 인스턴스화 /// /// /// protected T? Create() where T : TreeListItem { T? item = GameObject.Instantiate(ItemPrefab, root) as T; return item; } /// /// 새 아이템 추가 /// /// 동작: /// 1. 아이템 프리팹 인스턴스화 /// 2. UI 초기화 (Init 호출) /// 3. 데이터 리스트에 추가 /// 4. 평탄화 리스트 업데이트 /// /// 매개변수: /// - data: 추가할 아이템 데이터 /// /// 추가할 아이템 데이터 public void AddItem(TreeListItemData data) where T : TreeListItem { data.Parent = null; // Instantiate(템플릿, 부모 Transform) // = 템플릿을 복제하고 부모의 자식으로 설정 T? item = Create(); // 생성된 아이템 초기화 // 데이터를 UI에 바인딩하고 이벤트 리스너 등록 item?.Init(data, this, dragDropManager); items.Add(data); // 범위 선택에 필요한 평탄화 리스트 업데이트 UpdateFlattenedItemDataList(); } /// /// 새 아이템들 추가 /// /// 추가 할 타입 /// 추가 할 데이터들 public void AddItems(IEnumerable datas) where T : TreeListItem { foreach (var data in datas) { data.Parent = null; // Instantiate(템플릿, 부모 Transform) // = 템플릿을 복제하고 부모의 자식으로 설정 T? item = Create(); // 생성된 아이템 초기화 // 데이터를 UI에 바인딩하고 이벤트 리스너 등록 item?.Init(data, this, dragDropManager); items.Add(data); } UpdateFlattenedItemDataList(); } /// /// 지정 인덱스에 아이템 추가 /// /// 동작: /// 1. 아이템 프리팹 인스턴스화 /// 2. 지정 위치에 데이터 삽입 /// 3. UI 순서 조정 /// 4. 평탄화 리스트 업데이트 /// /// 추가할 아이템 데이터 /// 삽입 위치 public void AddItemAt(TreeListItemData data, int index) where T : TreeListItem { data.Parent = null; T item = GameObject.Instantiate(ItemPrefab, root) as T; item.Init(data, this, dragDropManager); index = Mathf.Clamp(index, 0, items.Count); items.Insert(index, data); UniTask.DelayFrame(1).ContinueWith(() => { //gameObject 순서 조절 item.transform.SetSiblingIndex(index); // 범위 선택에 필요한 평탄화 리스트 업데이트 UpdateFlattenedItemDataList(); }); } /// /// 아이템 복제 추가 /// /// 복제할 원본 아이템 데이터 public void AddCloneItem(TreeListItemData data) { TreeListItemData clone = data.CloneWithChild(); //호출 순서 중요 //data에 해당하는 TreeListItem 찾기 TreeListItem? item = allItemFlattened.FirstOrDefault(x => x.Data == data); //changedData 부모에게 알림 if (data.Parent != null) { RemoveChild(data.Parent, data); } else { RemoveItem(data); } if (item != null) item.Delete(true); AddItem(clone); } /// /// 아이템 복제 후 지정 인덱스에 추가 /// /// 복제할 원본 아이템 데이터 /// 삽입 위치 public void AddCloneItemAt(TreeListItemData data, int index) { TreeListItemData clone = data.CloneWithChild(); //호출 순서 중요 //data에 해당하는 TreeListItem 찾기 TreeListItem? item = allItemFlattened.FirstOrDefault(x => x.Data == data); //changedData 부모에게 알림 if (data.Parent != null) { RemoveChild(data.Parent, data); } else { RemoveItem(data); } if (item != null) item.Delete(true); AddItemAt(clone, index); } /// /// 아이템을 지정 인덱스로 이동합니다(기존 위치 제거 후 삽입). /// public void SwapItem(TreeListItemData data, int index) { // 기존 위치 제거 items.Remove(data); // 인덱스 보정 후 삽입 index = Mathf.Clamp(index, 0, items.Count); items.Insert(index, data); TreeListItem? item = allItemFlattened.FirstOrDefault(x => x.Data == data); UniTask.DelayFrame(1).ContinueWith(() => { if (item != null) item.transform.SetSiblingIndex(index); UpdateFlattenedItemDataList(); }); } public void RemoveItem(TreeListItemData data) { if (items.Contains(data)) items.Remove(data); UpdateFlattenedItemDataList(); } /// /// 아이템 삭제 /// /// 동작: /// 1. 데이터 리스트에서 제거 (루트 또는 부모의 Children) /// 2. 선택 리스트에서 제거 /// 3. UI 컴포넌트 삭제 /// 4. 데이터 메모리 해제 /// /// 삭제할 아이템 데이터 public void DeleteItem(TreeListItemData data) { // 루트 레벨이면 items에서 제거, 자식이면 부모의 Children에서 제거 if (data.Parent != null) { data.Parent.RemoveChild(data); } else { items.Remove(data); } // selectedItems에서도 제거 if (selectedItems.Contains(data)) { selectedItems.Remove(data); } // UI 삭제 - _viewMap에서 먼저 찾기 (풀링 적용 후 더 정확함) if (_viewMap.TryGetValue(data, out var viewItem) && viewItem != null) { viewItem.Delete(true); } else { // fallback: allItemFlattened에서 찾기 TreeListItem? item = allItemFlattened.FirstOrDefault(x => x.Data == data); if (item != null) { item.Delete(true); } } } /// /// 컬렉션에서 모든 항목을 지우고 관련 리소스를 해제합니다. /// /// 이 메서드는 컬렉션에서 모든 항목을 제거하고, 내부 데이터 구조를 지우고, /// 메서드를 매개변수로 호출하여 각 항목을 삭제합니다. public void ClearItems() { foreach (var item in allItemFlattened) { item.Delete(true); } allItemFlattened.Clear(); allItemDataFlattened.Clear(); items.Clear(); selectedItems.Clear(); _viewMap.Clear(); } /// /// 모든 아이템을 1차원 리스트로 평탄화 /// /// 동작: /// 1. 평탄화 리스트 초기화 /// 2. 루트 아이템부터 재귀적으로 추가 /// 3. 각 데이터에 대응하는 UI 컴포넌트 수집 /// /// 용도: 범위 선택 및 화살표 네비게이션 /// internal void UpdateFlattenedItemDataList() { // 기존 평탄화 리스트 비우기 allItemDataFlattened.Clear(); // root의 모든 직접 자식을 순회 // (손자, 증손자는 재귀로 처리됨) foreach (TreeListItemData itemData in items) { // 이 아이템과 자식들을 재귀적으로 평탄화 리스트에 추가 AddItemDataToFlattened(itemData); } allItemFlattened.Clear(); foreach (var data in allItemDataFlattened) { if (_viewMap.TryGetValue(data, out var item) && item != null) { allItemFlattened.Add(item); } } } /// /// 아이템과 자식들을 재귀적으로 평탄화 /// /// 동작: /// 1. 현재 아이템 추가 /// 2. 모든 자식에 대해 재귀 호출 /// /// 트리 구조를 1차원 배열로 변환하여 /// 빠른 인덱스 검색 및 범위 선택을 가능하게 합니다. /// /// 현재 처리할 아이템 protected void AddItemDataToFlattened(TreeListItemData data) { // 현재 아이템을 평탄화 리스트에 추가 allItemDataFlattened.Add(data); // 현재 아이템의 모든 자식을 순회 foreach (var child in data.Children) { // 각 자식에 대해 재귀 호출 // = 자식의 자식들도 모두 추가됨 AddItemDataToFlattened(child); } } /// /// 아이템 클릭 처리 (TreeListItem에서 호출) /// /// 동작: /// 1. 키 입력 상태 확인 (Ctrl, Shift) /// 2. 선택 모드에 따라 로직 실행: /// - 일반 클릭: 이 아이템만 선택 /// - Ctrl+클릭: 토글 (기존 선택 유지) /// - Shift+클릭: 범위 선택 /// 3. 마지막 선택 아이템 기록 /// /// 클릭한 아이템 /// Ctrl 키 입력 여부 /// Shift 키 입력 여부 internal void HandleItemClicked(TreeListItemData data, bool ctrlPressed, bool shiftPressed) { // 디버그 로그: 클릭 정보를 콘솔에 출력 (개발 중 확인용) //Debug.Log($"OnItemClicked {data.Name}, ctrlPressed:{ctrlPressed}, shiftPressed:{shiftPressed}, lastSelectedItem:{lastSelectedItem}"); // 다중 선택 비활성화 if (!allowMultipleSelection) { ToggleItemSelection(data); return; } // Ctrl+클릭: 토글 if (ctrlPressed) { ToggleItemSelection(data); lastSelectedItem = data; } // Shift+클릭: 범위 선택 else if (shiftPressed) { if (lastSelectedItem != null) { // 마지막 선택부터 현재 아이템까지 범위 선택 SelectRange(lastSelectedItem, data); } else { // 아직 선택한 게 없으면 단순 선택 ClearSelection(); SelectItem(data); } // 이 아이템을 "마지막 선택"으로 기억 lastSelectedItem = data; } // 일반 클릭: 단일 선택 else { if (data.IsSelected) { // 이미 선택된 아이템을 다시 클릭 → 선택 해제 DeselectItem(data); lastSelectedItem = null; } else { // 선택되지 않은 아이템 클릭 → 다른 선택 모두 해제하고 이것 선택 ClearSelection(); SelectItem(data); lastSelectedItem = data; } } } /// /// 두 아이템 사이의 범위 선택 /// /// 동작: /// 1. 평탄화 리스트에서 두 아이템의 인덱스 찾기 /// 2. 범위 정렬 (시작 ≤ 끝) /// 3. 범위 내 모든 아이템 선택 /// /// 매개변수: /// - startItem: 범위 시작 아이템 /// - endItem: 범위 끝 아이템 /// /// 범위 시작 아이템 /// 범위 끝 아이템 protected void SelectRange(TreeListItemData startItem, TreeListItemData endItem) { // 평탄화 리스트에서 시작 아이템의 위치(인덱스) 찾기 int startIndex = allItemDataFlattened.IndexOf(startItem); // 평탄화 리스트에서 끝 아이템의 위치(인덱스) 찾기 int endIndex = allItemDataFlattened.IndexOf(endItem); // 두 아이템 모두 리스트에 없으면 종료 if (startIndex == -1 || endIndex == -1) { return; } // 시작과 끝의 순서를 정렬 // 사용자가 거꾸로 선택할 수도 있으므로 (끝→시작) // 항상 작은 인덱스 = 시작, 큰 인덱스 = 끝으로 정렬 int minIndex = Mathf.Min(startIndex, endIndex); int maxIndex = Mathf.Max(startIndex, endIndex); // 범위 내의 모든 아이템 선택 // i = minIndex부터 i = maxIndex까지 (포함) for (int i = minIndex; i <= maxIndex; i++) { SelectItem(allItemDataFlattened[i]); } } /// /// 아이템 선택 상태 토글 /// /// 동작: /// - 선택됨 → 해제 /// - 미선택 → 선택 /// /// 토글할 아이템 public void ToggleItemSelection(TreeListItemData data) { if (data.IsSelected) { // 선택됨 → 해제 DeselectItem(data); } else { // 선택 안 됨 → 선택 SelectItem(data); } } /// /// 이름으로 아이템 선택 /// /// public void SelectItem(string name) { var item = allItemDataFlattened.FirstOrDefault(x => x.Name == name); if (item != null) { SelectItem(item); } } /// /// 아이템 선택 /// /// 동작: /// 1. 중복 선택 방지 /// 2. 다중 선택 비활성화 시 기존 선택 해제 /// 3. 아이템 선택 표시 /// 4. 선택 리스트에 추가 /// 5. 선택 변경 이벤트 발생 /// /// 선택할 아이템 public void SelectItem(TreeListItemData data) { // 이미 선택되어 있으면 중복 선택 방지 if (data.IsSelected && selectedItems.Contains(data)) { return; } // 다중 선택이 불가능한 경우, 기존 선택 해제 // (새 선택만 유지) if (!allowMultipleSelection && selectedItems.Count > 0) { //선택 항목 해제 ClearSelection(); } // 아이템의 선택 상태를 true로 설정 data.IsSelected = true; // 아직 리스트에 없으면 추가 if (!selectedItems.Contains(data)) { selectedItems.Add(data); } // View에 알리고 이벤트 발생 NotifySelectionChanged(data, true); } /// /// 이름으로 아이템 선택 해제 /// /// public void DeselectItem(string name) { var item = allItemDataFlattened.FirstOrDefault(x => x.Name == name); if (item != null) { DeselectItem(item); } } /// /// 아이템 선택 해제 /// /// 동작: /// 1. 중복 해제 방지 /// 2. 아이템 선택 상태 해제 /// 3. 선택 리스트에서 제거 /// 4. 선택 변경 이벤트 발생 /// /// 선택 해제할 아이템 public void DeselectItem(TreeListItemData data) { // 이미 선택 해제되어 있으면 아무것도 안 함 if (!data.IsSelected) { return; } // 아이템의 선택 상태를 false로 설정 data.IsSelected = false; // selectedItems 리스트에서 제거 selectedItems.Remove(data); // View에 알리고 이벤트 발생 NotifySelectionChanged(data, false); } /// /// 모든 아이템 선택 해제 /// /// 동작: /// 1. 선택 리스트 복사 (반복 중 수정 방지) /// 2. 각 아이템에 대해 DeselectItem() 호출 /// public void ClearSelection() { // ToList()로 복사 // = selectedItems의 현재 상태를 새로운 리스트로 만듦 // 반복 중에 원본을 수정해도 안전하게 함 foreach (var item in selectedItems.ToList()) { DeselectItem(item); } } /// /// 다중 선택 활성화/비활성화 설정 /// /// 동작: /// 1. allowMultipleSelection 값 변경 /// 2. false로 변경 시 여러 선택이 있으면 첫 번째만 유지 /// /// true: 다중 선택 허용, false: 단일 선택만 허용 public void SetAllowMultipleSelection(bool allow) { // 다중 선택 가능 여부 설정 allowMultipleSelection = allow; // false로 변경되었는데 여러 개가 선택되어 있으면 정리 if (!allow && selectedItems.Count > 1) { // 첫 번째 아이템 보관 var firstItem = selectedItems[0]; // 모든 선택 해제 ClearSelection(); // 첫 번째 아이템만 다시 선택 SelectItem(firstItem); } } /// /// 드롭 완료 후 UI 업데이트 /// /// 드래그된 아이템 /// 드롭 대상 아이템 (null: 루트) protected void HandleItemDropped(TreeListItemData draggedItem, TreeListItemData? targetItem) { // 평탄화 리스트 업데이트 UpdateFlattenedItemDataList(); // 필요시 UI 재구성 (예: 자식 컨테이너 위치 변경 등) //Debug.Log($"Item '{draggedItem.Name}' dropped on '{(targetItem?.Name ?? "Root")}'"); } /// /// 지정한 데이터 항목을 트리에서 찾아 부모 체인을 펼치고 선택합니다. /// 기존 선택이 있으면 해제합니다. /// /// 선택 및 표시할 데이터 (트리의 실제 데이터 참조) /// true면 기존 선택을 모두 해제 public void RevealAndSelectItem(TreeListItemData? data, bool clearExisting = true) { if (data == null) return; if (clearExisting) { ClearSelection(); } // 부모 체인 펼치기 var parent = data.Parent; while (parent != null) { SetItemExpanded(parent, true); parent = parent.Parent; } // 평탄화 리스트 갱신 및 대상 선택 UpdateFlattenedItemDataList(); SelectItem(data); } } }