522 lines
17 KiB
C#
522 lines
17 KiB
C#
#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
|
|
{
|
|
/// <summary>
|
|
/// 계층 데이터를 표시/검색/선택하는 창(View)입니다.
|
|
///
|
|
/// 책임:
|
|
/// - 메인 트리(`treeList`)와 검색 트리(`treeListSearch`)를 관리
|
|
/// - 입력창으로 검색을 수행하고 결과를 검색 트리에 표시(청크 처리 + 로딩 애니메이션)
|
|
/// - `TreeList.OnItemSelectionChanged`를 구독해 외부로 선택/해제 이벤트를 전달
|
|
/// - 외부에서 호출 가능한 간단한 항목 추가/삭제 API 제공(실제 렌더링/상태는 `TreeList`가 담당)
|
|
///
|
|
/// 사용 예:
|
|
/// <example>
|
|
/// <![CDATA[
|
|
/// // 외부에서 창을 참조했다고 가정
|
|
/// accordionWindow.OnItemSelected += item => Debug.Log($"Selected: {item.Name}");
|
|
/// accordionWindow.AddItem(new TreeListItemData("Root A"));
|
|
/// accordionWindow.AddItemAt(new TreeListItemData("Root B"),0);
|
|
/// ]]>
|
|
/// </example>
|
|
/// </summary>
|
|
public class HierarchyWindow : MonoBehaviour
|
|
{
|
|
[SerializeField]
|
|
protected TreeList treeList;
|
|
|
|
/// <summary>
|
|
/// 검색 결과 용 목록
|
|
/// </summary>
|
|
[SerializeField]
|
|
protected TreeList treeListSearch;
|
|
|
|
[SerializeField]
|
|
protected TMP_InputField inputField;
|
|
|
|
[SerializeField]
|
|
protected Button clearTextButton;
|
|
|
|
[SerializeField]
|
|
protected Image loadingImage;
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목이 선택될 때 발생합니다.
|
|
/// </summary>
|
|
public System.Action<TreeListItemData>? OnItemSelected;
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목이 선택 해제될 때 발생합니다.
|
|
/// </summary>
|
|
public System.Action<TreeListItemData>? OnItemDeselected;
|
|
|
|
/// <summary>
|
|
/// 메인/검색 리스트에서 항목의 가시성 상태가 변경될 때 발생합니다.
|
|
/// </summary>
|
|
public System.Action<TreeListItemData, bool>? OnItemVisibilityChanged;
|
|
|
|
// 검색 목록에서 선택된 항목(클론된 데이터)
|
|
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;
|
|
}
|
|
|
|
// 검색 리스트의 선택 변경을 감지 (선택 결과를 원본 트리에 반영하는 용도)
|
|
if (treeListSearch != null)
|
|
{
|
|
treeListSearch.OnItemSelectionChanged += OnSearchSelectionChanged;
|
|
treeListSearch.OnItemVisibilityChanged += HandleMainVisibilityChanged;
|
|
}
|
|
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Guid 기반 선택
|
|
/// </summary>
|
|
/// <param name="id"></param>
|
|
public void SelectByItemId(Guid id)
|
|
{
|
|
var target = treeList.AllItemDataFlattened.FirstOrDefault(t => t.Id == id);
|
|
if (target != null)
|
|
{
|
|
ClearSelection();
|
|
treeList.RevealAndSelectItem(target, true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메인 트리에 항목을 추가합니다.
|
|
/// </summary>
|
|
/// <param name="data">추가할 데이터.</param>
|
|
public void AddItem(TreeListItemData data)
|
|
{
|
|
treeList.AddItem<TreeListItem>(data);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메인 트리에 항목들을 추가합니다.
|
|
/// </summary>
|
|
/// <param name="dataList">추가 할 데이터들</param>
|
|
public void AddItems(System.Collections.Generic.IEnumerable<TreeListItemData> dataList)
|
|
{
|
|
treeList.AddItems<TreeListItem>(dataList);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메인 트리에 항목을 특정 인덱스에 삽입합니다.
|
|
/// </summary>
|
|
/// <param name="data">삽입할 데이터.</param>
|
|
/// <param name="index">삽입 인덱스(0 기반).</param>
|
|
public void AddItemAt(TreeListItemData data, int index)
|
|
{
|
|
treeList.AddItemAt<TreeListItem>(data, index);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메인 트리에서 항목을 제거합니다(뷰만 제거, 데이터는 호출 측 정책에 따름).
|
|
/// </summary>
|
|
/// <param name="data">제거할 데이터.</param>
|
|
public void RemoveItem(TreeListItemData data)
|
|
{
|
|
treeList.RemoveItem(data);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 메인 트리에서 항목을 완전히 삭제합니다(뷰+데이터 정리).
|
|
/// </summary>
|
|
/// <param name="data">삭제할 데이터.</param>
|
|
public void DeleteItem(TreeListItemData data)
|
|
{
|
|
treeList.DeleteItem(data);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 아이템의 이름을 변경합니다.
|
|
/// </summary>
|
|
/// <param name="data">변경할 아이템 데이터</param>
|
|
/// <param name="newName">새 이름</param>
|
|
public void SetItemName(TreeListItemData data, string newName)
|
|
{
|
|
treeList.SetItemName(data, newName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이름으로 아이템 선택
|
|
/// </summary>
|
|
/// <param name="name"></param>
|
|
public void SelectItem(string name)
|
|
{
|
|
//검색 중이면 취소
|
|
|
|
CancelSearch();
|
|
|
|
treeList.SelectItem(name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 이름으로 아이템 선택 해제
|
|
/// </summary>
|
|
/// <param name="name"></param>
|
|
public void DeselectItem(string name)
|
|
{
|
|
treeList.DeselectItem(name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 선택해제 및 검색 취소
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
ClearSelection();
|
|
CancelSearch();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 선택 해제
|
|
/// </summary>
|
|
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 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);
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// 검색을 메인 스레드에서 분할 처리하여 UI 업데이트(로딩 애니메이션)가 가능하도록 구현합니다.
|
|
/// </summary>
|
|
protected async UniTaskVoid PerformSearchAsync(string text, CancellationToken token)
|
|
{
|
|
isSearching = true;
|
|
searchProgress = 0f;
|
|
|
|
var results = new System.Collections.Generic.List<TreeListItemData>();
|
|
|
|
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<TreeListItem>(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;
|
|
}
|
|
|
|
if (treeList != null)
|
|
{
|
|
treeList.OnItemSelectionChanged -= HandleMainSelectionChanged;
|
|
treeList.OnItemVisibilityChanged -= HandleMainVisibilityChanged;
|
|
}
|
|
|
|
// 4. 외부 이벤트 핸들러 정리
|
|
OnItemSelected = null;
|
|
OnItemDeselected = null;
|
|
|
|
// 5. 참조 정리
|
|
selectedSearchItem = null;
|
|
}
|
|
}
|
|
}
|