Files
XRLib/Assets/Scripts/UVC/UI/List/Tree/TreeList.cs
2025-12-23 18:23:44 +09:00

1439 lines
49 KiB
C#

#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
{
/// <summary>
/// 트리 구조의 리스트를 관리하고 제어하는 클래스입니다.
///
/// 주요 기능:
/// - 계층 구조 아이템의 추가/제거 관리
/// - 단일 및 다중 선택 지원 (파일 탐색기와 동일)
/// - 키보드 입력(Delete, 화살표, Ctrl, Shift) 처리
/// - 드래그 & 드롭 기능 지원
/// - 선택 상태 변경 이벤트 제공
///
/// 선택 동작:
/// - 클릭: 한 항목 선택
/// - Ctrl+클릭: 여러 항목 선택 (기존 선택 유지)
/// - Shift+클릭: 범위 선택 (시작~끝)
/// - 화살표: 선택된 아이템 이동
/// - Delete: 선택된 아이템 삭제
/// </summary>
public class TreeList : MonoBehaviour
{
/// <summary>
/// UI 아이템 프리팹 (복제할 템플릿)
/// </summary>
[SerializeField]
protected TreeListItem itemPrefab;
/// <summary>
/// itemPrefab 프로퍼티 (읽기 전용)
/// </summary>
public TreeListItem ItemPrefab => itemPrefab;
/// <summary>
/// 모든 아이템을 담는 부모 컨테이너 (RectTransform)
/// </summary>
[SerializeField]
protected RectTransform root;
/// <summary>
/// root 프로퍼티 (읽기 전용)
/// </summary>
public RectTransform Root => root;
/// <summary>
/// 다중 선택 허용 여부
/// (true: 여러 개 선택 가능, false: 한 개만 선택 가능)
/// </summary>
[SerializeField]
protected bool allowMultipleSelection = true;
/// <summary>
/// 드래그 & 드롭 기능 활성화 여부
/// </summary>
[SerializeField]
protected bool enableDragDrop = true;
/// <summary>
/// 현재 선택된 아이템 리스트
/// </summary>
protected List<TreeListItemData> selectedItems = new List<TreeListItemData>();
/// <summary>
/// 마지막으로 선택한 아이템 (Shift+클릭 범위 선택의 시작점)
/// </summary>
protected TreeListItemData? lastSelectedItem = null;
/// <summary>
/// 모든 아이템을 1차원으로 평탄화한 리스트 (범위 선택용)
/// </summary>
protected List<TreeListItemData> allItemDataFlattened = new List<TreeListItemData>();
/// <summary>
/// 평탄화된 아이템 데이터 리스트 (읽기 전용)
/// </summary>
public List<TreeListItemData> AllItemDataFlattened => allItemDataFlattened;
/// <summary>
/// 루트 레벨 아이템 리스트
/// </summary>
protected List<TreeListItemData> items = new List<TreeListItemData>();
/// <summary>
/// 루트 레벨 아이템 리스트 (읽기 전용)
/// </summary>
public List<TreeListItemData> Items => items;
/// <summary>
/// 평탄화된 UI 아이템(TreeListItem) 리스트
/// </summary>
protected List<TreeListItem> allItemFlattened = new List<TreeListItem>();
/// <summary>
/// 평탄화된 UI 아이템 리스트 (읽기 전용)
/// </summary>
public List<TreeListItem> AllItemFlattened => allItemFlattened;
/// <summary>
/// 드래그 & 드롭 매니저
/// </summary>
protected TreeListDragDropManager dragDropManager = new TreeListDragDropManager();
/// <summary>
/// 드래그 & 드롭 매니저 (읽기 전용)
/// </summary>
public TreeListDragDropManager DragDropManager => dragDropManager;
#region (Object Pooling)
/// <summary>
/// TreeListItem 오브젝트 풀
/// </summary>
protected GameObjectPool<TreeListItem>? _itemPool;
/// <summary>
/// 재활용 컨테이너 (비활성 아이템 보관용)
/// </summary>
[SerializeField]
protected Transform? recycledContainer;
/// <summary>
/// 오브젝트 풀 읽기 전용 접근자
/// </summary>
public GameObjectPool<TreeListItem>? ItemPool => _itemPool;
#endregion
/// <summary>
/// 아이템 선택 상태 변경 이벤트 (data, isSelected)
/// TreeListItem에서 호출됩니다.
/// </summary>
public Action<TreeListItemData, bool>? OnItemSelectionChanged;
/// <summary>
/// 아이템 데이터 변경 이벤트 (changedType, changedData, index)
/// TreeListItemData의 데이터가 변경될 때 호출됩니다.
/// </summary>
public Action<ChangedType, TreeListItemData, int>? OnItemDataChanged;
/// <summary>
/// 아이템 클릭 이벤트 (data)
/// TreeListItem이 클릭될 때 호출됩니다.
/// </summary>
public Action<TreeListItemData>? OnItemClickAction;
/// <summary>
/// 아이템 가시성 변경 이벤트 (changedData, isVisible)
/// </summary>
public Action<TreeListItemData, bool>? OnItemVisibilityChanged;
/// <summary>
/// 아이템 더블클릭 이벤트 (data)
/// TreeListItem이 더블클릭될 때 호출됩니다.
/// </summary>
public Action<TreeListItemData>? OnItemDoubleClicked;
/// <summary>
/// 현재 선택된 아이템 목록 (읽기 전용)
/// </summary>
public IReadOnlyList<TreeListItemData> SelectedItems => selectedItems.AsReadOnly();
/// <summary>
/// root 아래의 모든 TreeListItem 컴포넌트
/// </summary>
public List<TreeListItem> TreeLists => root.GetComponentsInChildren<TreeListItem>().ToList();
// 뷰 매핑: 데이터 ↔ View O(1) 조회
protected readonly Dictionary<TreeListItemData, TreeListItem> _viewMap = new Dictionary<TreeListItemData, TreeListItem>();
// 플래튼 업데이트 스로틀링 플래그
protected bool _flattenScheduled;
protected void Awake()
{
// 드래그 & 드롭 이벤트 구독
if (enableDragDrop)
{
dragDropManager.OnDropped += HandleItemDropped;
}
// 오브젝트 풀 초기화
InitializePool();
}
/// <summary>
/// 오브젝트 풀 초기화
/// </summary>
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<TreeListItem>(
itemPrefab.gameObject,
root,
recycledContainer
);
}
/// <summary>
/// 풀에서 TreeListItem 획득
/// </summary>
/// <param name="key">아이템 고유 키 (TreeListItemData.Id.ToString())</param>
/// <param name="parent">부모 Transform (null이면 root 사용)</param>
/// <returns>풀에서 획득한 TreeListItem</returns>
public TreeListItem? GetPooledItem(string key, Transform? parent = null)
{
if (_itemPool == null)
{
InitializePool();
}
var item = _itemPool!.GetItem(key, true, parent ?? root);
return item;
}
/// <summary>
/// TreeListItem을 풀에 반환
/// </summary>
/// <param name="item">반환할 TreeListItem</param>
/// <param name="key">아이템 고유 키</param>
public void ReturnItemToPool(TreeListItem item, string key)
{
if (_itemPool == null || item == null) return;
_itemPool.ReturnItem(key, true);
}
/// <summary>
/// 모든 아이템을 풀에 반환
/// </summary>
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)
/// <summary>
/// 아이템 이름을 변경하고 UI를 갱신합니다.
/// </summary>
public void SetItemName(TreeListItemData data, string name)
{
if (data.Name == name) return;
data.Name = name;
NotifyDataChanged(data, ChangedType.Name);
}
/// <summary>
/// 아이템 옵션을 변경하고 UI를 갱신합니다.
/// </summary>
public void SetItemOption(TreeListItemData data, string option)
{
if (data.Option == option) return;
data.Option = option;
NotifyDataChanged(data, ChangedType.Option);
}
/// <summary>
/// 아이템 펼침 상태를 변경하고 UI를 갱신합니다.
/// </summary>
public void SetItemExpanded(TreeListItemData data, bool expanded)
{
if (data.IsExpanded == expanded) return;
data.IsExpanded = expanded;
NotifyDataChanged(data, ChangedType.Expanded);
}
/// <summary>
/// 아이템 가시성 상태를 변경하고 UI를 갱신합니다.
/// </summary>
public void SetItemVisible(TreeListItemData data, bool visible)
{
if (data.IsVisible == visible) return;
data.IsVisible = visible;
NotifyDataChanged(data, ChangedType.TailButtons);
}
/// <summary>
/// 부모 아이템에 자식을 추가하고 UI를 갱신합니다.
/// </summary>
public void AddChild(TreeListItemData parent, TreeListItemData child)
{
parent.AddChild(child);
NotifyDataChanged(parent, ChangedType.AddChild, child);
}
/// <summary>
/// 부모 아이템의 특정 위치에 자식을 추가하고 UI를 갱신합니다.
/// </summary>
public void AddChildAt(TreeListItemData parent, TreeListItemData child, int index)
{
parent.AddChildAt(child, index);
NotifyDataChanged(parent, ChangedType.AddAtChild, child, index);
}
/// <summary>
/// 자식을 복제하여 부모에 추가하고 UI를 갱신합니다.
/// </summary>
public void AddCloneChild(TreeListItemData parent, TreeListItemData child)
{
NotifyDataChanged(parent, ChangedType.AddCloneChild, child);
}
/// <summary>
/// 자식을 복제하여 부모의 특정 위치에 추가하고 UI를 갱신합니다.
/// </summary>
public void AddCloneChildAt(TreeListItemData parent, TreeListItemData child, int index)
{
NotifyDataChanged(parent, ChangedType.AddCloneAtChild, child, index);
}
/// <summary>
/// 자식을 특정 위치로 이동하고 UI를 갱신합니다.
/// </summary>
public void SwapChild(TreeListItemData parent, TreeListItemData child, int index)
{
parent.SwapChild(child, index);
NotifyDataChanged(parent, ChangedType.SwapChild, child, index);
}
/// <summary>
/// 부모에서 자식을 제거하고 UI를 갱신합니다.
/// </summary>
public void RemoveChild(TreeListItemData parent, TreeListItemData child)
{
if (parent.RemoveChild(child))
{
NotifyDataChanged(parent, ChangedType.RemoveChild, child);
}
}
/// <summary>
/// 부모의 모든 자식을 제거하고 UI를 갱신합니다.
/// </summary>
public void ClearChildren(TreeListItemData parent)
{
parent.ClearChildren();
NotifyDataChanged(parent, ChangedType.ResetChildren);
}
/// <summary>
/// 데이터 변경을 View에 알립니다.
/// </summary>
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);
}
/// <summary>
/// 선택 상태 변경을 View에 알립니다.
/// </summary>
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();
}
}
/// <summary>
/// 입력 필드에 포커스가 있는지 확인합니다.
/// </summary>
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<TMP_InputField>();
if (tmpInput == null)
{
tmpInput = selected.GetComponentInParent<TMP_InputField>();
}
if (tmpInput != null && tmpInput.isFocused) return true;
// Legacy InputField 확인 (자기 자신 및 부모 계층에서 검색)
var legacyInput = selected.GetComponent<UnityEngine.UI.InputField>();
if (legacyInput == null)
{
legacyInput = selected.GetComponentInParent<UnityEngine.UI.InputField>();
}
if (legacyInput != null && legacyInput.isFocused) return true;
return false;
}
/// <summary>
/// View 등록(데이터↔View 맵). Init 시점에 호출됩니다.
/// </summary>
internal void RegisterView(TreeListItemData data, TreeListItem view)
{
if (data == null || view == null) return;
_viewMap[data] = view;
}
/// <summary>
/// View 등록 해제. Delete/OnDestroy 시점에 호출됩니다.
/// </summary>
internal void UnregisterView(TreeListItemData data, TreeListItem view)
{
if (data == null) return;
if (_viewMap.TryGetValue(data, out var existing) && existing == view)
{
_viewMap.Remove(data);
}
}
/// <summary>
/// 다음 프레임에 평탄화 리스트 업데이트를 예약합니다.
/// 과도한 재계산을 방지하기 위한 스로틀링 유틸입니다.
/// </summary>
public void ScheduleFlattenedUpdate()
{
if (_flattenScheduled) return;
_flattenScheduled = true;
UniTask.NextFrame().ContinueWith(() =>
{
_flattenScheduled = false;
UpdateFlattenedItemDataList();
});
}
/// <summary>
/// Escape 키 입력 시 모든 선택 해제
///
/// 동작:
/// 1. 선택된 아이템이 있는지 확인
/// 2. ClearSelection() 호출하여 모든 선택 해제
/// 3. lastSelectedItem 초기화
///
/// 용도: 사용자가 선택을 취소하고 싶을 때 빠른 해제
/// </summary>
protected void HandleEscapeKeyPressed()
{
// 선택된 아이템이 없으면 아무것도 하지 않음
if (selectedItems.Count == 0)
{
return;
}
// 디버그 로그
//Debug.Log($"Escape key pressed. Clearing {selectedItems.Count} selected item(s).");
// 모든 선택 해제
ClearSelection();
// 마지막 선택 아이템 초기화
lastSelectedItem = null;
}
/// <summary>
/// Delete 키 입력 시 선택된 모든 아이템 삭제
///
/// 동작:
/// 1. 선택된 아이템 목록 복사
/// 2. 각 아이템에 대해 DeleteItem() 호출
/// 3. 평탄화 리스트 업데이트
///
/// 주의: 반복 중 리스트 수정으로 인한 문제 방지를 위해
/// ToList()로 복사 후 순회합니다.
/// </summary>
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();
}
/// <summary>
/// 펼쳐진 아이템들만 포함하는 가시 리스트 생성
///
/// 동작:
/// 1. 루트 레벨 아이템부터 시작
/// 2. IsExpanded=true인 아이템의 자식들만 포함
/// 3. 재귀적으로 모든 가시 아이템 수집
///
/// 용도: 화살표 키 네비게이션 시 보이는 아이템만 선택 가능
/// </summary>
protected List<TreeListItemData> GetVisibleFlattenedItems()
{
List<TreeListItemData> visibleItems = new List<TreeListItemData>();
// 루트 레벨 아이템들부터 시작
foreach (var rootItem in items)
{
AddVisibleItemsRecursive(rootItem, visibleItems);
}
return visibleItems;
}
/// <summary>
/// 가시 아이템을 재귀적으로 추가
///
/// 동작:
/// 1. 현재 아이템을 리스트에 추가
/// 2. IsExpanded=true이면 모든 자식 재귀 처리
/// 3. IsExpanded=false이면 자식 무시 (접혀있음)
/// </summary>
/// <param name="item">현재 처리할 아이템</param>
/// <param name="visibleItems">수집 결과 리스트</param>
protected void AddVisibleItemsRecursive(TreeListItemData item, List<TreeListItemData> visibleItems)
{
// 현재 아이템을 리스트에 추가
visibleItems.Add(item);
// IsExpanded=true인 경우에만 자식들 처리
if (item.IsExpanded)
{
foreach (var child in item.Children)
{
AddVisibleItemsRecursive(child, visibleItems);
}
}
}
/// <summary>
/// 위쪽 화살표 키 처리: 선택 아이템을 위로 이동
///
/// 동작:
/// 1. 정확히 1개 아이템만 선택되어 있는지 확인
/// 2. 가시 리스트에서 현재 아이템의 위치 찾기
/// 3. 이전 아이템이 있으면 선택 변경
///
/// 제약: 단일 선택 상태에서만 작동
/// </summary>
protected void HandleUpArrowKeyPressed()
{
// 정확히 하나의 아이템만 선택되어 있어야 함
if (selectedItems.Count != 1)
{
return;
}
TreeListItemData currentSelected = selectedItems[0];
// 펼쳐진 아이템들만 포함하는 임시 리스트 생성
List<TreeListItemData> 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})");
}
}
/// <summary>
/// 아래쪽 화살표 키 처리: 선택 아이템을 아래로 이동
///
/// 동작:
/// 1. 가시 리스트 생성
/// 2. 선택 없음: 첫 번째 아이템 선택
/// 3. 단일 선택: 다음 아이템으로 이동
/// 4. 다중 선택: 무시
///
/// 제약: 단일 선택 상태에서만 작동
/// </summary>
protected void HandleDownArrowKeyPressed()
{
// 펼쳐진 아이템들만 포함하는 임시 리스트 생성
List<TreeListItemData> 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})");
}
}
/// <summary>
/// 우측 화살표 키 처리: 선택된 아이템 펼치기
///
/// 동작:
/// 1. 정확히 1개 아이템만 선택되어 있는지 확인
/// 2. IsExpanded를 true로 설정
/// 3. 평탄화 리스트 업데이트
///
/// 제약: 이미 펼쳐진 아이템은 동작 없음
/// </summary>
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();
}
/// <summary>
/// 좌측 화살표 키 처리: 선택된 아이템 접기
///
/// 동작:
/// 1. 정확히 1개 아이템만 선택되어 있는지 확인
/// 2. IsExpanded를 false로 설정
/// 3. 평탄화 리스트 업데이트
///
/// 제약: 이미 접혀진 아이템은 동작 없음
/// </summary>
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();
}
/// <summary>
/// TreeListItem 프리팹 인스턴스화
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
protected T? Create<T>() where T : TreeListItem
{
T? item = GameObject.Instantiate(ItemPrefab, root) as T;
return item;
}
/// <summary>
/// 새 아이템 추가
///
/// 동작:
/// 1. 아이템 프리팹 인스턴스화
/// 2. UI 초기화 (Init 호출)
/// 3. 데이터 리스트에 추가
/// 4. 평탄화 리스트 업데이트
///
/// 매개변수:
/// - data: 추가할 아이템 데이터
/// </summary>
/// <param name="data">추가할 아이템 데이터</param>
public void AddItem<T>(TreeListItemData data) where T : TreeListItem
{
data.Parent = null;
// Instantiate(템플릿, 부모 Transform)
// = 템플릿을 복제하고 부모의 자식으로 설정
T? item = Create<T>();
// 생성된 아이템 초기화
// 데이터를 UI에 바인딩하고 이벤트 리스너 등록
item?.Init(data, this, dragDropManager);
items.Add(data);
// 범위 선택에 필요한 평탄화 리스트 업데이트
UpdateFlattenedItemDataList();
}
/// <summary>
/// 새 아이템들 추가
/// </summary>
/// <typeparam name="T">추가 할 타입</typeparam>
/// <param name="datas">추가 할 데이터들</param>
public void AddItems<T>(IEnumerable<TreeListItemData> datas) where T : TreeListItem
{
foreach (var data in datas)
{
data.Parent = null;
// Instantiate(템플릿, 부모 Transform)
// = 템플릿을 복제하고 부모의 자식으로 설정
T? item = Create<T>();
// 생성된 아이템 초기화
// 데이터를 UI에 바인딩하고 이벤트 리스너 등록
item?.Init(data, this, dragDropManager);
items.Add(data);
}
UpdateFlattenedItemDataList();
}
/// <summary>
/// 지정 인덱스에 아이템 추가
///
/// 동작:
/// 1. 아이템 프리팹 인스턴스화
/// 2. 지정 위치에 데이터 삽입
/// 3. UI 순서 조정
/// 4. 평탄화 리스트 업데이트
/// </summary>
/// <param name="data">추가할 아이템 데이터</param>
/// <param name="index">삽입 위치</param>
public void AddItemAt<T>(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();
});
}
/// <summary>
/// 아이템 복제 추가
/// </summary>
/// <param name="data">복제할 원본 아이템 데이터</param>
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<TreeListItem>(clone);
}
/// <summary>
/// 아이템 복제 후 지정 인덱스에 추가
/// </summary>
/// <param name="data">복제할 원본 아이템 데이터</param>
/// <param name="index">삽입 위치</param>
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<TreeListItem>(clone, index);
}
/// <summary>
/// 아이템을 지정 인덱스로 이동합니다(기존 위치 제거 후 삽입).
/// </summary>
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();
}
/// <summary>
/// 아이템 삭제
///
/// 동작:
/// 1. 데이터 리스트에서 제거 (루트 또는 부모의 Children)
/// 2. 선택 리스트에서 제거
/// 3. UI 컴포넌트 삭제
/// 4. 데이터 메모리 해제
/// </summary>
/// <param name="data">삭제할 아이템 데이터</param>
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);
}
}
}
/// <summary>
/// 컬렉션에서 모든 항목을 지우고 관련 리소스를 해제합니다.
/// </summary>
/// <remarks>이 메서드는 컬렉션에서 모든 항목을 제거하고, 내부 데이터 구조를 지우고,
/// <see cref="Delete"/> 메서드를 <see
/// langword="true"/> 매개변수로 호출하여 각 항목을 삭제합니다.</remarks>
public void ClearItems()
{
foreach (var item in allItemFlattened)
{
item.Delete(true);
}
allItemFlattened.Clear();
allItemDataFlattened.Clear();
items.Clear();
selectedItems.Clear();
_viewMap.Clear();
}
/// <summary>
/// 모든 아이템을 1차원 리스트로 평탄화
///
/// 동작:
/// 1. 평탄화 리스트 초기화
/// 2. 루트 아이템부터 재귀적으로 추가
/// 3. 각 데이터에 대응하는 UI 컴포넌트 수집
///
/// 용도: 범위 선택 및 화살표 네비게이션
/// </summary>
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);
}
}
}
/// <summary>
/// 아이템과 자식들을 재귀적으로 평탄화
///
/// 동작:
/// 1. 현재 아이템 추가
/// 2. 모든 자식에 대해 재귀 호출
///
/// 트리 구조를 1차원 배열로 변환하여
/// 빠른 인덱스 검색 및 범위 선택을 가능하게 합니다.
/// </summary>
/// <param name="data">현재 처리할 아이템</param>
protected void AddItemDataToFlattened(TreeListItemData data)
{
// 현재 아이템을 평탄화 리스트에 추가
allItemDataFlattened.Add(data);
// 현재 아이템의 모든 자식을 순회
foreach (var child in data.Children)
{
// 각 자식에 대해 재귀 호출
// = 자식의 자식들도 모두 추가됨
AddItemDataToFlattened(child);
}
}
/// <summary>
/// 아이템 클릭 처리 (TreeListItem에서 호출)
///
/// 동작:
/// 1. 키 입력 상태 확인 (Ctrl, Shift)
/// 2. 선택 모드에 따라 로직 실행:
/// - 일반 클릭: 이 아이템만 선택
/// - Ctrl+클릭: 토글 (기존 선택 유지)
/// - Shift+클릭: 범위 선택
/// 3. 마지막 선택 아이템 기록
/// </summary>
/// <param name="data">클릭한 아이템</param>
/// <param name="ctrlPressed">Ctrl 키 입력 여부</param>
/// <param name="shiftPressed">Shift 키 입력 여부</param>
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;
}
}
}
/// <summary>
/// 두 아이템 사이의 범위 선택
///
/// 동작:
/// 1. 평탄화 리스트에서 두 아이템의 인덱스 찾기
/// 2. 범위 정렬 (시작 ≤ 끝)
/// 3. 범위 내 모든 아이템 선택
///
/// 매개변수:
/// - startItem: 범위 시작 아이템
/// - endItem: 범위 끝 아이템
/// </summary>
/// <param name="startItem">범위 시작 아이템</param>
/// <param name="endItem">범위 끝 아이템</param>
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]);
}
}
/// <summary>
/// 아이템 선택 상태 토글
///
/// 동작:
/// - 선택됨 → 해제
/// - 미선택 → 선택
/// </summary>
/// <param name="data">토글할 아이템</param>
public void ToggleItemSelection(TreeListItemData data)
{
if (data.IsSelected)
{
// 선택됨 → 해제
DeselectItem(data);
}
else
{
// 선택 안 됨 → 선택
SelectItem(data);
}
}
/// <summary>
/// 이름으로 아이템 선택
/// </summary>
/// <param name="name"></param>
public void SelectItem(string name)
{
var item = allItemDataFlattened.FirstOrDefault(x => x.Name == name);
if (item != null)
{
SelectItem(item);
}
}
/// <summary>
/// 아이템 선택
///
/// 동작:
/// 1. 중복 선택 방지
/// 2. 다중 선택 비활성화 시 기존 선택 해제
/// 3. 아이템 선택 표시
/// 4. 선택 리스트에 추가
/// 5. 선택 변경 이벤트 발생
/// </summary>
/// <param name="data">선택할 아이템</param>
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);
}
/// <summary>
/// 이름으로 아이템 선택 해제
/// </summary>
/// <param name="name"></param>
public void DeselectItem(string name)
{
var item = allItemDataFlattened.FirstOrDefault(x => x.Name == name);
if (item != null)
{
DeselectItem(item);
}
}
/// <summary>
/// 아이템 선택 해제
///
/// 동작:
/// 1. 중복 해제 방지
/// 2. 아이템 선택 상태 해제
/// 3. 선택 리스트에서 제거
/// 4. 선택 변경 이벤트 발생
/// </summary>
/// <param name="data">선택 해제할 아이템</param>
public void DeselectItem(TreeListItemData data)
{
// 이미 선택 해제되어 있으면 아무것도 안 함
if (!data.IsSelected)
{
return;
}
// 아이템의 선택 상태를 false로 설정
data.IsSelected = false;
// selectedItems 리스트에서 제거
selectedItems.Remove(data);
// View에 알리고 이벤트 발생
NotifySelectionChanged(data, false);
}
/// <summary>
/// 모든 아이템 선택 해제
///
/// 동작:
/// 1. 선택 리스트 복사 (반복 중 수정 방지)
/// 2. 각 아이템에 대해 DeselectItem() 호출
/// </summary>
public void ClearSelection()
{
// ToList()로 복사
// = selectedItems의 현재 상태를 새로운 리스트로 만듦
// 반복 중에 원본을 수정해도 안전하게 함
foreach (var item in selectedItems.ToList())
{
DeselectItem(item);
}
}
/// <summary>
/// 다중 선택 활성화/비활성화 설정
///
/// 동작:
/// 1. allowMultipleSelection 값 변경
/// 2. false로 변경 시 여러 선택이 있으면 첫 번째만 유지
/// </summary>
/// <param name="allow">true: 다중 선택 허용, false: 단일 선택만 허용</param>
public void SetAllowMultipleSelection(bool allow)
{
// 다중 선택 가능 여부 설정
allowMultipleSelection = allow;
// false로 변경되었는데 여러 개가 선택되어 있으면 정리
if (!allow && selectedItems.Count > 1)
{
// 첫 번째 아이템 보관
var firstItem = selectedItems[0];
// 모든 선택 해제
ClearSelection();
// 첫 번째 아이템만 다시 선택
SelectItem(firstItem);
}
}
/// <summary>
/// 드롭 완료 후 UI 업데이트
/// </summary>
/// <param name="draggedItem">드래그된 아이템</param>
/// <param name="targetItem">드롭 대상 아이템 (null: 루트)</param>
protected void HandleItemDropped(TreeListItemData draggedItem, TreeListItemData? targetItem)
{
// 평탄화 리스트 업데이트
UpdateFlattenedItemDataList();
// 필요시 UI 재구성 (예: 자식 컨테이너 위치 변경 등)
//Debug.Log($"Item '{draggedItem.Name}' dropped on '{(targetItem?.Name ?? "Root")}'");
}
/// <summary>
/// 지정한 데이터 항목을 트리에서 찾아 부모 체인을 펼치고 선택합니다.
/// 기존 선택이 있으면 해제합니다.
/// </summary>
/// <param name="data">선택 및 표시할 데이터 (트리의 실제 데이터 참조)</param>
/// <param name="clearExisting">true면 기존 선택을 모두 해제</param>
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);
}
}
}