#nullable enable using Cysharp.Threading.Tasks; using DG.Tweening; using System; using System.Linq; using System.Threading; using TMPro; using UnityEngine; using UnityEngine.UI; using UVC.UI.List.Tree; namespace UVC.UI.Window { /// /// 계층 데이터를 표시/검색/선택하는 창(View)입니다. /// /// 책임: /// - 메인 트리(`treeList`)와 검색 트리(`treeListSearch`)를 관리 /// - 입력창으로 검색을 수행하고 결과를 검색 트리에 표시(청크 처리 + 로딩 애니메이션) /// - `TreeList.OnItemSelectionChanged`를 구독해 외부로 선택/해제 이벤트를 전달 /// - 외부에서 호출 가능한 간단한 항목 추가/삭제 API 제공(실제 렌더링/상태는 `TreeList`가 담당) /// /// 사용 예: /// /// Debug.Log($"Selected: {item.Name}"); /// accordionWindow.AddItem(new TreeListItemData("Root A")); /// accordionWindow.AddItemAt(new TreeListItemData("Root B"),0); /// ]]> /// /// public class HierarchyWindow : MonoBehaviour { [SerializeField] protected TreeList treeList; /// /// 검색 결과 용 목록 /// [SerializeField] protected TreeList treeListSearch; [SerializeField] protected TMP_InputField inputField; [SerializeField] protected Button clearTextButton; [SerializeField] protected Image loadingImage; /// /// 메인/검색 리스트에서 항목이 선택될 때 발생합니다. /// public System.Action? OnItemSelected; /// /// 메인/검색 리스트에서 항목이 선택 해제될 때 발생합니다. /// public System.Action? OnItemDeselected; /// /// 메인/검색 리스트에서 항목의 가시성 상태가 변경될 때 발생합니다. /// public System.Action? OnItemVisibilityChanged; /// /// 메인/검색 리스트에서 항목이 삭제될 때 발생합니다 (Delete 키). /// public System.Action? OnItemDeleted; /// /// 메인/검색 리스트에서 항목이 더블클릭될 때 발생합니다. /// public System.Action? OnItemDoubleClicked; // 검색 목록에서 선택된 항목(클론된 데이터) protected TreeListItemData? selectedSearchItem; // 검색 작업 상태 protected CancellationTokenSource? searchCts; protected bool isSearching = false; protected float searchProgress = 0f; // 내부 진행도 추적용 [SerializeField] [Tooltip("로딩 아이콘 회전 속도(도/초)")] protected float loadingRotateSpeed = 360f; [SerializeField] [Tooltip("로딩 이미지의 채우기 애니메이션 속도(사이클/초)")] protected float loadingFillCycle = 0.5f; // cycles per second. loadingRotateSpeed360 일때, loadingFillCycle를0.5 보다 높게 설정하면 이상해 보임 // DOTween tweens protected Tween? loadingRotationTween; protected Tween? loadingFillTween; protected void Awake() { loadingImage.gameObject.SetActive(false); treeListSearch.gameObject.SetActive(false); inputField.onSubmit.AddListener(OnInputFieldSubmit); // 메인 리스트 선택 변경을 외부 이벤트로 전달 if (treeList != null) { treeList.OnItemSelectionChanged += HandleMainSelectionChanged; treeList.OnItemVisibilityChanged += HandleMainVisibilityChanged; treeList.OnItemDataChanged += HandleMainDataChanged; treeList.OnItemDoubleClicked += HandleMainDoubleClicked; } // 검색 리스트의 선택 변경을 감지 (선택 결과를 원본 트리에 반영하는 용도) if (treeListSearch != null) { treeListSearch.OnItemSelectionChanged += OnSearchSelectionChanged; treeListSearch.OnItemVisibilityChanged += HandleMainVisibilityChanged; treeListSearch.OnItemDataChanged += HandleMainDataChanged; treeListSearch.OnItemDoubleClicked += HandleMainDoubleClicked; } clearTextButton.onClick.AddListener(() => { // 취소 CancelSearch(); // 검색에서 선택한 항목이 있으면 원본 트리에서 동일 항목을 선택하고 펼침 if (selectedSearchItem != null && treeList != null) { // 원본 데이터 찾기 (TreeListItemData == 연산자는 Id 기반) var target = treeList.AllItemDataFlattened.FirstOrDefault(i => i == selectedSearchItem); if (target != null) { ClearSelection(); // 부모 체인을 펼치고 선택 처리 treeList.RevealAndSelectItem(target, true); } selectedSearchItem = null; } }); } /// /// Guid 기반 선택 /// /// public void SelectByItemId(Guid id) { var target = treeList.AllItemDataFlattened.FirstOrDefault(t => t.Id == id); if (target != null) { ClearSelection(); treeList.RevealAndSelectItem(target, true); } } /// /// 메인 트리에 항목을 추가합니다. /// /// 추가할 데이터. public void AddItem(TreeListItemData data) { treeList.AddItem(data); } /// /// 메인 트리에 항목들을 추가합니다. /// /// 추가 할 데이터들 public void AddItems(System.Collections.Generic.IEnumerable dataList) { treeList.AddItems(dataList); } /// /// 메인 트리에 항목을 특정 인덱스에 삽입합니다. /// /// 삽입할 데이터. /// 삽입 인덱스(0 기반). public void AddItemAt(TreeListItemData data, int index) { treeList.AddItemAt(data, index); } /// /// 메인 트리에서 항목을 제거합니다(뷰만 제거, 데이터는 호출 측 정책에 따름). /// /// 제거할 데이터. public void RemoveItem(TreeListItemData data) { treeList.RemoveItem(data); } /// /// 메인 트리에서 항목을 완전히 삭제합니다(뷰+데이터 정리). /// /// 삭제할 데이터. public void DeleteItem(TreeListItemData data) { treeList.DeleteItem(data); } /// /// 아이템의 이름을 변경합니다. /// /// 변경할 아이템 데이터 /// 새 이름 public void SetItemName(TreeListItemData data, string newName) { treeList.SetItemName(data, newName); } /// /// 이름으로 아이템 선택 /// /// public void SelectItem(string name) { //검색 중이면 취소 CancelSearch(); treeList.SelectItem(name); } /// /// 이름으로 아이템 선택 해제 /// /// public void DeselectItem(string name) { treeList.DeselectItem(name); } /// /// 선택해제 및 검색 취소 /// public void Clear() { ClearSelection(); CancelSearch(); } /// /// 선택 해제 /// public void ClearSelection() { treeListSearch.ClearSelection(); treeList.ClearSelection(); } protected void StartLoadingAnimation() { if (loadingImage == null) return; // 기존 트윈 정리 StopLoadingAnimation(); loadingImage.fillAmount = 0f; loadingImage.transform.localRotation = Quaternion.identity; loadingImage.gameObject.SetActive(true); // 회전 트윈 float rotDuration = (loadingRotateSpeed != 0f) ? (360f / Mathf.Abs(loadingRotateSpeed)) : 1f; loadingRotationTween = loadingImage.transform .DOLocalRotate(new Vector3(0f, 0f, -360f), rotDuration, RotateMode.LocalAxisAdd) .SetEase(Ease.Linear) .SetLoops(-1, LoopType.Restart); // 채우기 트윈 float fullDuration = (loadingFillCycle > 0f) ? (1f / loadingFillCycle) : 1f; loadingFillTween = DOTween .To(() => loadingImage.fillAmount, x => loadingImage.fillAmount = x, 1f, fullDuration) .SetEase(Ease.InOutSine) .SetLoops(-1, LoopType.Yoyo); } protected void StopLoadingAnimation() { if (loadingRotationTween != null) { loadingRotationTween.Kill(); loadingRotationTween = null; } if (loadingFillTween != null) { loadingFillTween.Kill(); loadingFillTween = null; } if (loadingImage != null) { loadingImage.gameObject.SetActive(false); loadingImage.transform.localRotation = Quaternion.identity; loadingImage.fillAmount = 0f; } } protected void CancelSearch() { // 검색 중이면 if (treeListSearch?.gameObject.activeSelf == true) { //검색 시 데이터 변경 된 내용 원본 트리에 반영 treeListSearch.AllItemDataFlattened.ToList().ForEach(searchItem => { var originalItem = treeList.AllItemDataFlattened.FirstOrDefault(i => i == searchItem); if (originalItem != null) { bool changed = false; //선택 상태 동기화 if (originalItem.IsSelected != searchItem.IsSelected) { originalItem.IsSelected = searchItem.IsSelected; changed = true; } if (changed) { //데이터 변경 알림 treeListSearch.NotifyDataChanged(originalItem, ChangedType.TailButtons, originalItem); } } }); } inputField.text = string.Empty; treeListSearch?.gameObject.SetActive(false); treeList?.gameObject.SetActive(true); if (searchCts != null) { try { searchCts.Cancel(); } catch { } searchCts.Dispose(); searchCts = null; } isSearching = false; searchProgress = 0f; StopLoadingAnimation(); } protected async void OnSearchSelectionChanged(TreeListItemData data, bool isSelected) { if (isSelected) { selectedSearchItem = data; OnItemSelected?.Invoke(data); } else if (selectedSearchItem == data) { selectedSearchItem = null; OnItemDeselected?.Invoke(data); } } protected void HandleMainSelectionChanged(TreeListItemData data, bool isSelected) { if (isSelected) { OnItemSelected?.Invoke(data); } else { OnItemDeselected?.Invoke(data); } } protected void HandleMainVisibilityChanged(TreeListItemData data, bool isVisible) { OnItemVisibilityChanged?.Invoke(data, isVisible); } protected void HandleMainDataChanged(ChangedType changedType, TreeListItemData data, int index) { // Delete 키로 삭제된 경우 외부 이벤트 발생 if (changedType == ChangedType.Delete) { OnItemDeleted?.Invoke(data); } } protected void HandleMainDoubleClicked(TreeListItemData data) { OnItemDoubleClicked?.Invoke(data); } protected void OnInputFieldSubmit(string text) { // 검색어가 있으면 검색 결과 목록 표시 if (!string.IsNullOrEmpty(text)) { treeListSearch.ClearSelection(); treeListSearch.gameObject.SetActive(true); treeList.gameObject.SetActive(false); // 시작 애니메이션 StartLoadingAnimation(); searchCts = new CancellationTokenSource(); // 비동기 검색 실행(UITask 스타일: 메인스레드에서 작업을 분할하여 UI가 멈추지 않게 함) _ = PerformSearchAsync(text, searchCts.Token); } else { // 기존 검색 취소 CancelSearch(); treeListSearch.gameObject.SetActive(false); treeList.gameObject.SetActive(true); } } /// /// 검색을 메인 스레드에서 분할 처리하여 UI 업데이트(로딩 애니메이션)가 가능하도록 구현합니다. /// protected async UniTaskVoid PerformSearchAsync(string text, CancellationToken token) { isSearching = true; searchProgress = 0f; var results = new System.Collections.Generic.List(); var sourceList = treeList?.AllItemDataFlattened; if (sourceList == null) { isSearching = false; StopLoadingAnimation(); return; } int total = sourceList.Count; if (total == 0) { isSearching = false; StopLoadingAnimation(); return; } // 소문자 비교 준비 string lower = text.ToLowerInvariant(); // 분할 처리: 일정 갯수마다 await으로 제어권을 반환 const int chunk = 100; for (int i = 0; i < total; i++) { token.ThrowIfCancellationRequested(); var item = sourceList[i]; if (!string.IsNullOrEmpty(item.Name) && item.Name.ToLowerInvariant().Contains(lower)) { results.Add(item); } // 진행도 업데이트 (내부 사용) if ((i % chunk) == 0) { searchProgress = (float)i / (float)total; await UniTask.Yield(PlayerLoopTiming.Update); } } // 최종 진행도 searchProgress = 1f; // UI 반영은 메인 스레드에서 if (!PlayerLoopHelper.IsMainThread) await UniTask.SwitchToMainThread(); try { if (token.IsCancellationRequested) return; treeListSearch.ClearItems(); foreach (var r in results) { var cloned = r.Clone(); treeListSearch.AddItem(cloned); if (cloned.IsSelected) { //선택된 항목은 펼치기 treeListSearch.SelectItem(cloned); } } // 로딩 종료 isSearching = false; searchProgress = 0f; StopLoadingAnimation(); } catch (System.Exception ex) { Debug.LogError($"PerformSearchAsync error: {ex}"); } finally { if (searchCts != null) { searchCts.Dispose(); searchCts = null; } } } protected void OnDestroy() { // 1. 검색 작업 취소 및 정리 (먼저 수행) CancelSearch(); // 2. 입력 이벤트 구독 해제 if (inputField != null) { inputField.onSubmit.RemoveListener(OnInputFieldSubmit); } if (clearTextButton != null) { clearTextButton.onClick.RemoveAllListeners(); } // 3. TreeList 이벤트 구독 해제 if (treeListSearch != null) { treeListSearch.OnItemSelectionChanged -= OnSearchSelectionChanged; treeListSearch.OnItemVisibilityChanged -= HandleMainVisibilityChanged; treeListSearch.OnItemDataChanged -= HandleMainDataChanged; treeListSearch.OnItemDoubleClicked -= HandleMainDoubleClicked; } if (treeList != null) { treeList.OnItemSelectionChanged -= HandleMainSelectionChanged; treeList.OnItemVisibilityChanged -= HandleMainVisibilityChanged; treeList.OnItemDataChanged -= HandleMainDataChanged; treeList.OnItemDoubleClicked -= HandleMainDoubleClicked; } // 4. 외부 이벤트 핸들러 정리 OnItemSelected = null; OnItemDeselected = null; OnItemDeleted = null; OnItemDoubleClicked = null; // 5. 참조 정리 selectedSearchItem = null; } } }