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

839 lines
28 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#nullable enable
using Cysharp.Threading.Tasks;
using DG.Tweening;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using UVC.UI.Buttons;
namespace UVC.UI.List.Tree
{
/// <summary>
/// 트리 리스트의 개별 아이템을 표시하고 관리하는 UI 클래스입니다.
///
/// 역할:
/// - 아이템 이름 표시
/// - 자식 아이템 펼침/접힘 관리
/// - 선택 상태 UI 표시 및 입력 처리
/// - 데이터 변경 감지 후 UI 동기화
/// - 드래그 & 드롭 핸들러 연결
///
/// 구성:
/// "TreeListItem"
/// ├─ "valueText": 이름 텍스트
/// ├─ "childExpand": 펼침/접힘 버튼
/// ├─ "childContainer": 자식 컨테이너
/// ├─ "childRoot": 자식 배치 부모
/// ├─ "selectedBg": 선택 배경
/// └─ "itemButton": 아이템 클릭 버튼
/// </summary>
public class TreeListItem : MonoBehaviour
{
#region UI (UI Component References)
/// <summary>
/// 이 아이템을 관리하는 상위 컨트롤입니다. (선택/평탄화/프리팹 액세스 등에 사용)
/// </summary>
[SerializeField]
protected TreeList treeList;
/// <summary>
/// 아이템 이름 텍스트(UI).
/// </summary>
[SerializeField]
protected TMPro.TextMeshProUGUI valueText;
/// <summary>
/// 자식 펼침/접힘 버튼. onClick에서 ToggleChild를 호출합니다.
/// </summary>
[SerializeField]
protected Button childExpand;
/// <summary>
/// 자식 TreeListItem이 배치될 부모 RectTransform.
/// </summary>
[SerializeField]
protected RectTransform childRoot;
/// <summary>
/// 선택 상태를 표시하는 배경 이미지.
/// </summary>
[SerializeField]
protected Image selectedBg;
/// <summary>
/// 아이템 전체 클릭 버튼. onClick에서 OnItemClicked를 호출합니다.
/// </summary>
[SerializeField]
protected Button itemButton;
/// <summary>
/// text가 배치 된 RectTransform.
/// </summary>
[SerializeField]
protected RectTransform layout;
/// <summary>
/// 가시성 상태를 표시하는 배경 이미지.
/// </summary>
[SerializeField]
protected ImageToggle visibleToggle;
#endregion
#region (Data Fields)
/// <summary>
/// 이 UI가 표시하는 데이터(Nullable). UI는 이 데이터의 변경 이벤트에 반응합니다.
/// </summary>
protected TreeListItemData? data;
/// <summary>
/// 표시 데이터의 읽기 전용 접근자.
/// </summary>
public TreeListItemData? Data => data;
/// <summary>
/// 펼침/접힘 애니메이션 진행 여부(중복 입력 방지).
/// </summary>
protected bool isAnimating = false;
protected VerticalLayoutGroup? childRootLayoutGroup = null;
/// <summary>
/// 마지막 클릭 시간 (더블클릭 감지용)
/// </summary>
protected float _lastClickTime = 0f;
/// <summary>
/// 더블클릭 감지 시간 간격 (초)
/// </summary>
protected const float DoubleClickThreshold = 0.5f;
#endregion
#region (Initialization)
/// <summary>
/// TreeListItem을 초기화합니다.
///
/// 절차:
/// 1) 데이터/컨트롤 연결 및 이름 표시
/// 2) 자식 UI 생성
/// 3) 데이터/선택 변경 이벤트 구독
/// 4) 버튼(onClick) 이벤트 연결
/// 5) 선택 UI 반영 및 선택 배경 정렬
/// 6) 드래그 핸들러 연결
///
/// 매개변수:
/// - data: 표시할 데이터
/// - control: 상위 TreeList
/// - dragDropManager: 드래그 & 드롭 매니저
/// </summary>
public virtual void Init(TreeListItemData data, TreeList control, TreeListDragDropManager dragDropManager)
{
// 1. 기본 정보 할당
this.treeList = control;
this.data = data;
gameObject.name = "TreeListItem_" + data.Name;
// 2. 아이템 이름을 UI에 표시
valueText.text = data.Name;
// 3. 자식 아이템들을 UI로 생성
//Debug.Log("Creating Children for " + data.Name + ", " + data.Children.Count);
if (childRootLayoutGroup == null)
{
childRootLayoutGroup = childRoot.GetComponent<VerticalLayoutGroup>();
}
// 성능 최적화: 펼쳐진 상태에서만 자식 UI 생성 (지연 생성)
if (data.IsExpanded && data.Children.Count > 0)
{
// 각 자식 데이터에 대해 UI 생성
foreach (var childData in data.Children)
{
CreateItem(childData); // 재귀적으로 트리 구조 생성
}
childRoot.gameObject.SetActive(true);
}
else
{
// 접힌 상태면 자식 컨테이너 비활성화
childRoot.gameObject.SetActive(false);
}
// 화살표 방향 설정
SetExpand();
// 4. 버튼 클릭 이벤트 구독
// 아이템을 클릭하면 OnItemClicked 메서드 호출
if (itemButton != null)
{
itemButton.onClick.AddListener(OnItemClicked);
}
if(visibleToggle != null)
{
// 풀에서 재사용 시 동일 값이어도 시각 업데이트를 위해 강제 갱신
visibleToggle.ForceSetIsOnWithoutNotify(data.IsVisible);
visibleToggle.OnValueChanged.AddListener(OnItemVisibilityChanged);
}
// 6. 현재 선택 상태 UI에 반영
// data.IsSelected가 true이면 배경 표시
UpdateSelectionUI();
// 7. 선택 배경의 왼쪽 정렬 (모든 레벨에서 일직선)
AlignSelectedBgToRoot();
// ✅ 드래그 & 드롭 핸들러 설정
var dragHandler = gameObject.GetComponent<TreeListItemDragHandler>();
if (dragHandler == null)
{
dragHandler = gameObject.AddComponent<TreeListItemDragHandler>();
//Debug.Log($"[TreeListItem.Init] 새로운 TreeListItemDragHandler 추가: {data.Name}");
}
dragHandler.SetDragDropManager(this, control, dragDropManager);
dragHandler.enabled = true;
// Register view to map
treeList.RegisterView(data, this);
//Debug.Log($"[TreeListItem.Init] 초기화 완료: {data.Name}");
}
#endregion
#region (Data Change Handlers)
/// <summary>
/// 데이터 변경에 반응하여 UI를 갱신합니다.
/// TreeList에서 호출됩니다.
///
/// 매개변수:
/// - changedType: 변경 종류(이름/확장/자식 추가 등)
/// - changedData: 변경 대상 데이터(자식 추가 등일 때 유효)
/// - index: 삽입/이동 시 기준 인덱스(해당되는 경우)
/// </summary>
internal virtual void HandleDataChanged(ChangedType changedType, TreeListItemData changedData, int index)
{
if (data == null) return;
if (changedType == ChangedType.Expanded)
{
if (data.IsExpanded)
{
// 펼침 시: 자식 UI가 없으면 생성 (지연 생성)
EnsureChildrenCreated();
childRoot.gameObject.SetActive(true);
}
else
{
// 접힘 시: 자식 UI를 풀에 반환 (메모리 절약)
ReturnChildrenToPool();
childRoot.gameObject.SetActive(false);
}
// 펼침/접힘 상태 변경 처리
SetExpand();
return;
}
if (changedType == ChangedType.TailButtons)
{
// TailButtons 변경 처리
if (visibleToggle != null)
{
visibleToggle.isOn = data.IsVisible;
}
return;
}
if (changedType == ChangedType.Name)
{
// 이름이 변경된 경우
if (valueText.text != data.Name)
{
valueText.text = data.Name;
}
return;
}
if (changedType == ChangedType.ResetChildren)
{
// 전체 리셋 처리
// 자식 모두 삭제 후 재생성
for (int i = childRoot.childCount - 1; i >= 0; i--)
{
var childItem = childRoot.GetChild(i).GetComponent<TreeListItem>();
if (childItem != null)
{
childItem.Delete(true);
}
}
foreach (var childData in data.Children)
{
CreateItem(childData);
}
treeList.ScheduleFlattenedUpdate();
}
else if (changedType == ChangedType.AddChild)
{
CreateItem(changedData);
UniTask.DelayFrame(1).ContinueWith(() =>
{
treeList.ScheduleFlattenedUpdate();
});
}
else if (changedType == ChangedType.AddAtChild)
{
TreeListItem item = CreateItem(changedData);
UniTask.DelayFrame(1).ContinueWith(() =>
{
item.transform.SetSiblingIndex(index);
treeList.ScheduleFlattenedUpdate();
});
}
else if (changedType == ChangedType.AddCloneChild)
{
//데이터 복사
TreeListItemData clone = changedData.CloneWithChild();
//호출 순서 중요
// TreeListItem 제거
TreeListItem? item = treeList.AllItemFlattened.FirstOrDefault(x => x.Data == changedData);
//changedData 부모에게 알림 - UI 갱신 용
if (changedData.Parent != null)
{
treeList.RemoveChild(changedData.Parent, changedData);
}
else
{
treeList.RemoveItem(changedData);
}
if (item != null) item.Delete(true);
treeList.AddChild(data, clone);
}
else if (changedType == ChangedType.AddCloneAtChild)
{
//데이터 복사
TreeListItemData clone = changedData.CloneWithChild();
//호출 순서 중요
// TreeListItem 제거
TreeListItem? item = treeList.AllItemFlattened.FirstOrDefault(x => x.Data == changedData);
//changedData 부모에게 알림
if (changedData.Parent != null)
{
treeList.RemoveChild(changedData.Parent, changedData);
}
else
{
treeList.RemoveItem(changedData);
}
if (item != null) item.Delete(true);
treeList.AddChildAt(data, clone, index);
}
else if (changedType == ChangedType.SwapChild)
{
UniTask.DelayFrame(1).ContinueWith(() =>
{
TreeListItem? item = treeList.AllItemFlattened.FirstOrDefault(x => x.Data == changedData);
if (item != null) item.transform.SetSiblingIndex(index);
treeList.ScheduleFlattenedUpdate();
});
}
else if (changedType == ChangedType.RemoveChild)
{
//따로 할것 없음 - 펼침 버튼 갱신 용
//Debug.Log($"RemoveChild 처리 완료 {changedData.Name}, {index}");
}
UniTask.DelayFrame(1).ContinueWith(() =>
{
// 5. 펼침 버튼 표시 여부 결정
childExpand.gameObject.SetActive(data.Children.Count > 0);
});
}
#endregion
#region (Selection Management)
/// <summary>
/// 선택 상태 변경에 반응하여 UI를 갱신합니다.
/// TreeList에서 호출됩니다.
/// </summary>
internal void HandleSelectionChanged()
{
// 선택 상태 UI 업데이트 (배경 표시/숨김)
UpdateSelectionUI();
}
/// <summary>
/// 선택 상태에 따라 배경 표시/숨김을 갱신합니다.
/// </summary>
protected void UpdateSelectionUI()
{
if (data == null) return;
// IsSelected 상태에 따라 배경 표시/숨김
selectedBg.gameObject.SetActive(data.IsSelected);
}
#endregion
#region (Position Alignment)
/// <summary>
/// 모든 레벨에서 선택 배경의 왼쪽 정렬을 루트와 맞춥니다.
/// </summary>
protected void AlignSelectedBgToRoot()
{
if (selectedBg == null) return;
// 최상위 부모(루트)의 TreeListItem 찾기
TreeListItem? rootItem = GetRootTreeListItem();
// 루트가 없거나 이 아이템이 루트면 정렬할 필요 없음
if (rootItem == null || rootItem == this)
{
return;
}
// 루트의 selectedBg와 현재 selectedBg의 RectTransform 가져오기
RectTransform rootSelectedBgRect = rootItem.selectedBg.rectTransform;
RectTransform currentSelectedBgRect = selectedBg.rectTransform;
// 루트의 배경 왼쪽 끝 위치를 월드 좌표로 계산
// TransformPoint: 로컬 좌표 → 월드 좌표 변환
// xMin: RectTransform의 왼쪽 끝 위치
Vector3 rootLeftPos = rootSelectedBgRect.TransformPoint(new Vector3(rootSelectedBgRect.rect.xMin, 0, 0));
// 현재 배경의 왼쪽 끝 위치를 월드 좌표로 계산
Vector3 currentLeftPos = currentSelectedBgRect.TransformPoint(new Vector3(currentSelectedBgRect.rect.xMin, 0, 0));
// 두 위치의 차이 계산
// 루트 위치 - 현재 위치 = 조정해야 할 거리
Vector3 offset = rootLeftPos - currentLeftPos;
// 현재 배경의 위치를 조정
// anchoredPosition: 부모 기준 위치 (로컬 좌표)
currentSelectedBgRect.anchoredPosition += new Vector2(offset.x, 0);
}
#endregion
#region (Hierarchy Navigation)
/// <summary>
/// 현재 아이템의 루트 TreeListItem을 반환합니다.
///
/// 반환:
/// - 루트 TreeListItem, 없으면 null
/// </summary>
protected TreeListItem? GetRootTreeListItem()
{
// 현재 객체의 부모부터 시작
Transform current = transform.parent;
// 루트에 도달할 때까지 계속 탐색
while (current != null)
{
// 현재 레벨에 TreeListItem 컴포넌트가 있는지 확인
TreeListItem parentItem = current.GetComponent<TreeListItem>();
if (parentItem != null)
{
// TreeListItem을 찾았으므로, 그 부모의 루트를 다시 찾음 (재귀)
TreeListItem? grandParent = parentItem.GetRootTreeListItem();
if (grandParent != null)
{
// 할아버지 레벨의 루트 반환
return grandParent;
}
else
{
// 할아버지가 없으면 이 부모가 루트
return parentItem;
}
}
// 현재 레벨에 TreeListItem이 없으면 더 상위로
current = current.parent;
}
// 루트까지 탐색해도 TreeListItem을 찾지 못함 (이 객체가 루트)
return null;
}
#endregion
#region (Input Handling)
/// <summary>
/// 아이템 클릭 시 호출됩니다.
///
/// 동작:
/// - TreeList의 OnItemClickAction 이벤트 실행
/// - Ctrl/Shift 상태를 읽어 TreeList에 전달
/// (일반/토글/범위 선택)
/// - 더블클릭 감지 시 OnItemDoubleClicked 이벤트 실행
/// </summary>
protected void OnItemClicked()
{
if (data == null) return;
// 더블클릭 감지
float currentTime = Time.unscaledTime;
if (currentTime - _lastClickTime <= DoubleClickThreshold)
{
// 더블클릭 이벤트 발생
treeList.OnItemDoubleClicked?.Invoke(data);
_lastClickTime = 0f; // 연속 더블클릭 방지를 위해 초기화
return; // 더블클릭 시 일반 클릭 처리 스킵
}
_lastClickTime = currentTime;
// 1. TreeList의 클릭 액션 이벤트 실행
treeList.OnItemClickAction?.Invoke(data);
// 2. Ctrl 키 상태 감지
bool ctrlPressed = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
// 3. Shift 키 상태 감지
bool shiftPressed = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
// 4. 부모 TreeList에 클릭 정보 전달
// TreeList는 이 정보를 받아서 선택 로직을 처리합니다.
treeList.HandleItemClicked(data, ctrlPressed, shiftPressed);
}
/// <summary>
/// 아이템 가시성 변경 시 호출됩니다.
/// </summary>
/// <param name="isVisible"></param>
protected void OnItemVisibilityChanged(bool isVisible)
{
if (data == null) return;
// 데이터에 가시성 상태 저장 (풀에서 재사용 시 복원을 위해)
data.IsVisible = isVisible;
treeList.OnItemVisibilityChanged?.Invoke(data, isVisible);
}
#endregion
#region / (Expand/Collapse)
/// <summary>
/// 자식의 펼침/접힘을 토글합니다.
/// (중복 입력은 애니메이션 종료까지 무시)
/// </summary>
public void ToggleChild()
{
// 1. 애니메이션 진행 중이면 중복 호출 방지
if (isAnimating || data == null) return;
// 플래그 설정: 애니메이션 시작
isAnimating = true;
// 2. 펼침/접힘 상태 토글 (TreeList를 통해 UI도 갱신)
treeList.SetItemExpanded(data, !data.IsExpanded);
}
/// <summary>
/// 펼침/접힘 화살표 회전과 컨테이너 표시를 갱신합니다.
/// </summary>
/// <param name="duration">회전 애니메이션 시간(초). 0이면 즉시 적용.</param>
internal void SetExpand(float duration = 0.0f)
{
// 펼침 버튼
childExpand.gameObject.SetActive(data!.Children.Count > 0);
childRoot.gameObject.SetActive(data!.Children.Count > 0 && data!.IsExpanded);
// DORotate(목표 각도, 지속 시간)
// IsExpanded가 true면 0도 (▼), false면 90도 (▶)
childExpand.transform.DOKill();
childExpand.transform.DORotate(new Vector3(0, 0, data!.IsExpanded ? 0 : 90), duration)
.OnComplete(() =>
{
// 4. 애니메이션이 완료되면 플래그 리셋
// 이제 다시 ToggleChild() 호출 가능
isAnimating = false;
LayoutRebuilder.ForceRebuildLayoutImmediate(transform as RectTransform);
});
}
#endregion
#region (Child Management)
/// <summary>
/// 자식 UI가 필요할 때만 생성 (Lazy Instantiation)
/// </summary>
protected void EnsureChildrenCreated()
{
if (data == null || treeList == null) return;
// 데이터에 있지만 UI가 없는 자식들 생성
foreach (var childData in data.Children)
{
// 이미 뷰가 있는지 확인
if (treeList.ItemPool?.ActiveItems.ContainsKey(childData.Id.ToString()) == true)
{
continue;
}
CreateItem(childData);
}
}
/// <summary>
/// 새로운 자식 아이템을 추가하고 UI를 갱신합니다.
/// </summary>
/// <param name="data">추가할 자식 데이터</param>
public void AddChild(TreeListItemData data)
{
// 1. 새 자식 UI 생성
CreateItem(data);
// 2. 자식 컨테이너 활성화 (표시)
childRoot.gameObject.SetActive(true);
// 3. 0.3초에 걸쳐 펼침 애니메이션 실행
// 사용자가 새 자식이 추가되었음을 자연스럽게 인식
SetExpand(0.3f);
}
/// <summary>
/// 자식 데이터를 받아 UI TreeListItem을 생성합니다. (풀에서 획득)
/// </summary>
/// <param name="data">생성할 아이템 데이터</param>
/// <returns>생성된 TreeListItem</returns>
protected TreeListItem CreateItem(TreeListItemData data)
{
// 1. 풀에서 아이템 획득 (ID를 키로 사용)
string key = data.Id.ToString();
TreeListItem? item = treeList.GetPooledItem(key, childRoot);
if (item == null)
{
// 풀에서 가져올 수 없으면 기존 방식으로 생성 (fallback)
item = GameObject.Instantiate<TreeListItem>(
treeList.ItemPrefab,
childRoot
);
}
// 2. layout의 너비를 childRootLayoutGroup의 padding.left 만큼 줄이기
if (item != null && item.layout != null && childRootLayoutGroup != null)
{
item.layout.sizeDelta = new Vector2(layout.sizeDelta.x - childRootLayoutGroup.padding.left, item.layout.sizeDelta.y);
}
// 3. 생성된 아이템 초기화
item!.Init(data, treeList, treeList.DragDropManager);
// 4. 생성된 아이템 반환
return item;
}
#endregion
#region (Deletion)
/// <summary>
/// 이벤트 구독 해제, 애니메이션 중지 후 GameObject를 풀에 반환합니다.
/// </summary>
/// <param name="deleteData">데이터도 삭제할지 여부</param>
public void Delete(bool deleteData = false)
{
if (data == null || treeList == null)
{
// 데이터나 treeList가 없으면 기존 방식으로 삭제
CleanupAndDestroy();
return;
}
string key = data.Id.ToString();
// 자식들 먼저 풀에 반환
ReturnChildrenToPool();
// 데이터 정리 (선택적)
if (deleteData)
{
data.Dispose();
}
// 풀에 등록된 아이템인지 확인
bool isPooledItem = treeList.ItemPool?.ActiveItems.ContainsKey(key) == true;
if (isPooledItem)
{
// 풀에 반환
ResetForPool();
treeList.ReturnItemToPool(this, key);
}
else
{
// 풀에 없는 아이템 (루트 레벨 등)은 직접 삭제
CleanupAndDestroy();
}
}
/// <summary>
/// 풀에서 재사용 시 초기화를 위한 메서드
/// </summary>
public virtual void ResetForPool()
{
// 이벤트 리스너 해제
if (itemButton != null)
{
itemButton.onClick.RemoveListener(OnItemClicked);
}
if (visibleToggle != null)
{
visibleToggle.OnValueChanged.RemoveAllListeners();
}
// 뷰 맵에서 해제
if (data != null && treeList != null)
{
treeList.UnregisterView(data, this);
}
// 데이터 참조만 해제 (Dispose 안 함 - 데이터는 외부에서 관리)
data = null;
// 애니메이션 정리
if (childExpand != null)
{
childExpand.transform.DOKill();
}
// 상태 초기화
isAnimating = false;
_lastClickTime = 0f;
// 선택 배경 숨기기
if (selectedBg != null)
{
selectedBg.gameObject.SetActive(false);
}
}
/// <summary>
/// 자식 TreeListItem들을 풀에 반환 (재귀)
/// </summary>
protected void ReturnChildrenToPool()
{
if (childRoot == null || treeList == null) return;
for (int i = childRoot.childCount - 1; i >= 0; i--)
{
var childTransform = childRoot.GetChild(i);
var childItem = childTransform.GetComponent<TreeListItem>();
if (childItem != null && childItem.data != null)
{
string childKey = childItem.data.Id.ToString();
// 재귀적으로 자식의 자식도 반환
childItem.ReturnChildrenToPool();
// 풀에 반환
childItem.ResetForPool();
treeList.ReturnItemToPool(childItem, childKey);
}
}
}
/// <summary>
/// 정리 후 GameObject 삭제 (풀 미사용 시 fallback)
/// </summary>
protected void CleanupAndDestroy()
{
// 맵에서 해제
if (data != null && treeList != null)
{
treeList.UnregisterView(data, this);
}
// 데이터 정리
if (data != null)
{
data.Dispose();
data = null;
}
// 버튼 클릭 이벤트 구독 해제
if (itemButton != null)
{
itemButton.onClick.RemoveListener(OnItemClicked);
}
if (visibleToggle != null)
{
visibleToggle.OnValueChanged.RemoveAllListeners();
}
if (childExpand != null)
{
childExpand.transform.DOKill();
}
treeList = null!;
GameObject.Destroy(gameObject);
}
/// <summary>
/// Unity 파괴 시점에 정리합니다. (중복 정리 방지)
/// </summary>
protected virtual void OnDestroy()
{
// 맵 해제
if (data != null && treeList != null)
{
treeList.UnregisterView(data, this);
}
// 1. 데이터 정리
if (data != null)
{
data.Dispose();
data = null;
}
// 2. 버튼 클릭 이벤트 구독 해제
if (itemButton != null)
{
itemButton.onClick.RemoveListener(OnItemClicked);
}
if (visibleToggle != null)
{
visibleToggle.OnValueChanged.RemoveAllListeners();
}
if (childExpand != null)
{
childExpand.transform.DOKill(); // 진행 중인 회전 애니메이션 중단
}
// 3. 참조 정리
treeList = null!;
}
#endregion
}
}