#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 { /// /// 트리 리스트의 개별 아이템을 표시하고 관리하는 UI 클래스입니다. /// /// 역할: /// - 아이템 이름 표시 /// - 자식 아이템 펼침/접힘 관리 /// - 선택 상태 UI 표시 및 입력 처리 /// - 데이터 변경 감지 후 UI 동기화 /// - 드래그 & 드롭 핸들러 연결 /// /// 구성: /// "TreeListItem" /// ├─ "valueText": 이름 텍스트 /// ├─ "childExpand": 펼침/접힘 버튼 /// ├─ "childContainer": 자식 컨테이너 /// ├─ "childRoot": 자식 배치 부모 /// ├─ "selectedBg": 선택 배경 /// └─ "itemButton": 아이템 클릭 버튼 /// public class TreeListItem : MonoBehaviour { #region UI 컴포넌트 참조 (UI Component References) /// /// 이 아이템을 관리하는 상위 컨트롤입니다. (선택/평탄화/프리팹 액세스 등에 사용) /// [SerializeField] protected TreeList treeList; /// /// 아이템 이름 텍스트(UI). /// [SerializeField] protected TMPro.TextMeshProUGUI valueText; /// /// 자식 펼침/접힘 버튼. onClick에서 ToggleChild를 호출합니다. /// [SerializeField] protected Button childExpand; /// /// 자식 TreeListItem이 배치될 부모 RectTransform. /// [SerializeField] protected RectTransform childRoot; /// /// 선택 상태를 표시하는 배경 이미지. /// [SerializeField] protected Image selectedBg; /// /// 아이템 전체 클릭 버튼. onClick에서 OnItemClicked를 호출합니다. /// [SerializeField] protected Button itemButton; /// /// text가 배치 된 RectTransform. /// [SerializeField] protected RectTransform layout; /// /// 가시성 상태를 표시하는 배경 이미지. /// [SerializeField] protected ImageToggle visibleToggle; #endregion #region 데이터 필드 (Data Fields) /// /// 이 UI가 표시하는 데이터(Nullable). UI는 이 데이터의 변경 이벤트에 반응합니다. /// protected TreeListItemData? data; /// /// 표시 데이터의 읽기 전용 접근자. /// public TreeListItemData? Data => data; /// /// 펼침/접힘 애니메이션 진행 여부(중복 입력 방지). /// protected bool isAnimating = false; protected VerticalLayoutGroup? childRootLayoutGroup = null; /// /// 마지막 클릭 시간 (더블클릭 감지용) /// protected float _lastClickTime = 0f; /// /// 더블클릭 감지 시간 간격 (초) /// protected const float DoubleClickThreshold = 0.5f; #endregion #region 초기화 (Initialization) /// /// TreeListItem을 초기화합니다. /// /// 절차: /// 1) 데이터/컨트롤 연결 및 이름 표시 /// 2) 자식 UI 생성 /// 3) 데이터/선택 변경 이벤트 구독 /// 4) 버튼(onClick) 이벤트 연결 /// 5) 선택 UI 반영 및 선택 배경 정렬 /// 6) 드래그 핸들러 연결 /// /// 매개변수: /// - data: 표시할 데이터 /// - control: 상위 TreeList /// - dragDropManager: 드래그 & 드롭 매니저 /// 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(); } if (data.Children.Count > 0) { // 각 자식 데이터에 대해 UI 생성 foreach (var childData in data.Children) { CreateItem(childData); // 재귀적으로 트리 구조 생성 } } // 화살표 방향 설정 (초기에는 펼쳐짐) SetExpand(); // 4. 버튼 클릭 이벤트 구독 // 아이템을 클릭하면 OnItemClicked 메서드 호출 if (itemButton != null) { itemButton.onClick.AddListener(OnItemClicked); } if(visibleToggle != null) { visibleToggle.isOn = data.IsVisible; visibleToggle.OnValueChanged.AddListener(OnItemVisibilityChanged); } // 6. 현재 선택 상태 UI에 반영 // data.IsSelected가 true이면 배경 표시 UpdateSelectionUI(); // 7. 선택 배경의 왼쪽 정렬 (모든 레벨에서 일직선) AlignSelectedBgToRoot(); // ✅ 드래그 & 드롭 핸들러 설정 var dragHandler = gameObject.GetComponent(); if (dragHandler == null) { dragHandler = gameObject.AddComponent(); //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) /// /// 데이터 변경에 반응하여 UI를 갱신합니다. /// TreeList에서 호출됩니다. /// /// 매개변수: /// - changedType: 변경 종류(이름/확장/자식 추가 등) /// - changedData: 변경 대상 데이터(자식 추가 등일 때 유효) /// - index: 삽입/이동 시 기준 인덱스(해당되는 경우) /// internal virtual void HandleDataChanged(ChangedType changedType, TreeListItemData changedData, int index) { if (data == null) return; if (changedType == ChangedType.Expanded) { childRoot.gameObject.SetActive(data.IsExpanded); // 펼침/접힘 상태 변경 처리 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(); 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) /// /// 선택 상태 변경에 반응하여 UI를 갱신합니다. /// TreeList에서 호출됩니다. /// internal void HandleSelectionChanged() { // 선택 상태 UI 업데이트 (배경 표시/숨김) UpdateSelectionUI(); } /// /// 선택 상태에 따라 배경 표시/숨김을 갱신합니다. /// protected void UpdateSelectionUI() { if (data == null) return; // IsSelected 상태에 따라 배경 표시/숨김 selectedBg.gameObject.SetActive(data.IsSelected); } #endregion #region 위치 정렬 (Position Alignment) /// /// 모든 레벨에서 선택 배경의 왼쪽 정렬을 루트와 맞춥니다. /// 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) /// /// 현재 아이템의 루트 TreeListItem을 반환합니다. /// /// 반환: /// - 루트 TreeListItem, 없으면 null /// protected TreeListItem? GetRootTreeListItem() { // 현재 객체의 부모부터 시작 Transform current = transform.parent; // 루트에 도달할 때까지 계속 탐색 while (current != null) { // 현재 레벨에 TreeListItem 컴포넌트가 있는지 확인 TreeListItem parentItem = current.GetComponent(); 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) /// /// 아이템 클릭 시 호출됩니다. /// /// 동작: /// - TreeList의 OnItemClickAction 이벤트 실행 /// - Ctrl/Shift 상태를 읽어 TreeList에 전달 /// (일반/토글/범위 선택) /// - 더블클릭 감지 시 OnItemDoubleClicked 이벤트 실행 /// 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); } /// /// 아이템 가시성 변경 시 호출됩니다. /// /// protected void OnItemVisibilityChanged(bool isVisible) { if (data == null) return; treeList.OnItemVisibilityChanged?.Invoke(data, isVisible); } #endregion #region 펼침/접힘 (Expand/Collapse) /// /// 자식의 펼침/접힘을 토글합니다. /// (중복 입력은 애니메이션 종료까지 무시) /// public void ToggleChild() { // 1️. 애니메이션 진행 중이면 중복 호출 방지 if (isAnimating || data == null) return; // 플래그 설정: 애니메이션 시작 isAnimating = true; // 2️. 펼침/접힘 상태 토글 (TreeList를 통해 UI도 갱신) treeList.SetItemExpanded(data, !data.IsExpanded); } /// /// 펼침/접힘 화살표 회전과 컨테이너 표시를 갱신합니다. /// /// 회전 애니메이션 시간(초). 0이면 즉시 적용. 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) /// /// 새로운 자식 아이템을 추가하고 UI를 갱신합니다. /// /// 추가할 자식 데이터 public void AddChild(TreeListItemData data) { // 1️. 새 자식 UI 생성 CreateItem(data); // 2️. 자식 컨테이너 활성화 (표시) childRoot.gameObject.SetActive(true); // 3️. 0.3초에 걸쳐 펼침 애니메이션 실행 // 사용자가 새 자식이 추가되었음을 자연스럽게 인식 SetExpand(0.3f); } /// /// 자식 데이터를 받아 UI TreeListItem을 생성합니다. /// /// 생성할 아이템 데이터 /// 생성된 TreeListItem protected TreeListItem CreateItem(TreeListItemData data) { // 1️. 프리팹을 복제해서 새로운 TreeListItem 생성 // Instantiate(원본, 부모, 옵션) // treeList.ItemPrefab: UI 아이템 템플릿 // childRoot: 새 아이템의 부모 Transform TreeListItem item = GameObject.Instantiate( treeList.ItemPrefab, // 복제할 프리팹 childRoot // 부모로 배치할 위치 ); //item.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); } // 2️. 생성된 아이템 초기화 item.Init(data, treeList, treeList.DragDropManager); // 3️. 생성된 아이템 반환 return item; } #endregion #region 제거 (Deletion) /// /// 이벤트 구독 해제, 애니메이션 중지 후 GameObject를 삭제합니다. /// /// 데이터도 삭제할지 여부 public void Delete(bool deleteData = false) { // 맵에서 해제 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!; // 4. 이 GameObject 삭제 GameObject.Destroy(gameObject); } /// /// Unity 파괴 시점에 정리합니다. (중복 정리 방지) /// 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 } }