Files
XRLib/Assets/Scripts/UVC/UIToolkit/List/UTKImageList.cs
2026-01-21 20:43:54 +09:00

1221 lines
42 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.Util;
namespace UVC.UIToolkit
{
/// <summary>
/// 이미지가 포함된 그리드/리스트를 표시하는 UIToolkit 컴포넌트입니다.
/// PrefabGrid의 UIToolkit 버전입니다.
///
/// <para><b>개요:</b></para>
/// <para>
/// UTKImageList는 Unity UI Toolkit의 ListView를 래핑하여 이미지+텍스트 형태의
/// 아이템 목록을 2열 그리드로 표시합니다. 검색 필터링, 드래그 앤 드롭 기능을 제공합니다.
/// </para>
///
/// <para><b>주요 기능:</b></para>
/// <list type="bullet">
/// <item>이미지+텍스트 형태의 아이템을 2열 그리드로 표시</item>
/// <item>실시간 검색 필터링 (3글자 이상)</item>
/// <item>드래그 앤 드롭 지원</item>
/// <item>가상화를 통한 대량 데이터 성능 최적화</item>
/// </list>
///
/// <para><b>UXML에서 사용:</b></para>
/// <code>
/// <uvc:UTKImageList name="image-list" />
/// </code>
///
/// <para><b>코드에서 사용:</b></para>
/// <code>
/// var list = root.Q<UTKImageList>();
/// list.OnItemClick += (item) => Debug.Log($"클릭: {item.itemName}");
/// list.SetData(imageItems);
/// </code>
///
/// <para><b>관련 리소스:</b></para>
/// <list type="bullet">
/// <item>Resources/UIToolkit/List/UTKImageList.uxml - 메인 레이아웃</item>
/// <item>Resources/UIToolkit/List/UTKImageListItem.uxml - 행 템플릿 (2열)</item>
/// <item>Resources/UIToolkit/List/UTKImageList.uss - 스타일</item>
/// </list>
/// </summary>
[UxmlElement]
public partial class UTKImageList : VisualElement, IDisposable
{
#region (Constants)
/// <summary>메인 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string UXML_PATH = "UIToolkit/List/UTKImageList";
/// <summary>아이템 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string ITEM_UXML_PATH = "UIToolkit/List/UTKImageListItem";
/// <summary>최소 검색어 길이</summary>
private const int MIN_SEARCH_LENGTH = 2;
/// <summary>이미지 로딩 디바운스 시간 (ms)</summary>
private const int IMAGE_LOAD_DEBOUNCE_MS = 50;
/// <summary>한 행에 표시할 아이템 수</summary>
private const int ITEMS_PER_ROW = 2;
#endregion
#region (Cached Resources)
/// <summary>아이템 UXML 템플릿 (재사용을 위해 캐싱)</summary>
private VisualTreeAsset? _itemTemplate;
#endregion
#region UI (UI Component References)
/// <summary>검색어 입력 필드</summary>
private TextField? _searchField;
/// <summary>Unity UI Toolkit의 ListView 컴포넌트</summary>
private ListView? _listView;
/// <summary>검색어 지우기 버튼</summary>
private Button? _clearButton;
/// <summary>검색 결과 건수 라벨</summary>
private Label? _searchResultLabel;
#endregion
#region (Internal Data)
/// <summary>원본 데이터 (검색 필터 해제 시 복원용)</summary>
private List<UTKImageListItemData> _originalData = new();
/// <summary>필터링된 데이터 (검색 결과)</summary>
private List<UTKImageListItemData> _filteredData = new();
/// <summary>행 단위로 묶인 데이터 (ListView용)</summary>
private List<RowData> _rowData = new();
/// <summary>항목 ID 자동 생성을 위한 시드 값</summary>
private int _idSeed = 1;
/// <summary>현재 검색 모드 여부</summary>
private bool _isSearchMode;
/// <summary>이미지 로딩 취소 토큰 소스 (메모리 누수 방지)</summary>
private CancellationTokenSource? _imageLoadCts;
/// <summary>로드된 이미지 스프라이트 캐시 (경로 → 스프라이트)</summary>
private readonly Dictionary<string, Sprite?> _spriteCache = new();
/// <summary>현재 로딩 중인 이미지 경로 추적 (중복 로딩 방지)</summary>
private readonly HashSet<string> _loadingImages = new();
/// <summary>RowData 객체 풀 (GC 부담 감소)</summary>
private readonly Queue<RowData> _rowDataPool = new();
#endregion
#region (Internal Classes)
/// <summary>
/// 행 데이터를 저장하는 클래스입니다.
/// 한 행에 최대 2개의 아이템을 포함합니다.
/// </summary>
private class RowData
{
public UTKImageListItemData? LeftItem;
public UTKImageListItemData? RightItem;
}
#endregion
#region IDisposable
private bool _disposed;
#endregion
#region (Public Properties)
/// <summary>
/// 드래그 시 이미지가 커서를 따라다니도록 설정합니다.
/// 기본값은 true입니다.
/// </summary>
public bool DragImageFollowCursor { get; set; } = true;
/// <summary>
/// 드래그 영역 체크에 사용할 요소를 설정합니다.
/// null이면 자신(UTKImageList)의 worldBound를 사용합니다.
/// 부모 윈도우 등 다른 요소의 영역을 체크하려면 해당 요소를 설정하세요.
/// </summary>
public VisualElement? DragBoundsElement { get; set; }
/// <summary>
/// 현재 검색어를 가져오거나 설정합니다.
/// 설정 시 검색 필드의 값만 변경하고 검색은 실행하지 않습니다.
/// </summary>
public string SearchQuery
{
get => _searchField?.value ?? string.Empty;
set { if (_searchField != null) _searchField.value = value; }
}
/// <summary>
/// 현재 표시 중인 아이템 수를 반환합니다.
/// 검색 모드에서는 필터링된 결과 수를, 아니면 전체 수를 반환합니다.
/// </summary>
public int ItemCount => _isSearchMode ? _filteredData.Count : _originalData.Count;
#endregion
#region (Public Events)
/// <summary>
/// 아이템 클릭 이벤트입니다.
/// 아이템을 클릭하면 해당 데이터와 함께 발생합니다.
/// </summary>
public Action<UTKImageListItemData>? OnItemClick;
/// <summary>
/// 아이템 드롭 이벤트입니다.
/// 드래그 앤 드롭이 완료되면 발생합니다.
/// </summary>
public Action<UTKImageListItemData>? OnItemDrop;
/// <summary>
/// 드래그 시작 이벤트입니다.
/// (아이템 데이터, 화면 좌표)
/// </summary>
public Action<UTKImageListItemData, Vector2>? OnItemBeginDrag;
/// <summary>
/// 드래그 중 이벤트입니다.
/// (아이템 데이터, 화면 좌표)
/// </summary>
public Action<UTKImageListItemData, Vector2>? OnItemDrag;
/// <summary>
/// 드래그 종료 이벤트입니다.
/// (아이템 데이터, 화면 좌표)
/// </summary>
public Action<UTKImageListItemData, Vector2>? OnItemEndDrag;
/// <summary>
/// 드래그 중 리스트 영역을 벗어났을 때 발생하는 이벤트입니다.
/// (아이템 데이터, 화면 좌표)
/// 3D 프리팹 미리보기를 표시하는데 사용합니다.
/// </summary>
public Action<UTKImageListItemData, Vector2>? OnDragExitList;
/// <summary>
/// 드래그 중 리스트 영역에 다시 진입했을 때 발생하는 이벤트입니다.
/// (아이템 데이터, 화면 좌표)
/// 3D 프리팹 미리보기를 숨기는데 사용합니다.
/// </summary>
public Action<UTKImageListItemData, Vector2>? OnDragEnterList;
#endregion
#region (Constructor)
/// <summary>
/// UTKImageList 컴포넌트를 초기화합니다.
/// UXML 템플릿을 로드하고 내부 컴포넌트를 설정합니다.
/// </summary>
public UTKImageList()
{
// 1. 메인 UXML 로드 및 복제
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (visualTree == null)
{
Debug.LogError($"[UTKImageList] UXML not found at: {UXML_PATH}");
return;
}
visualTree.CloneTree(this);
// 2. 아이템 템플릿 로드 (성능: 한 번만 로드하여 재사용)
_itemTemplate = Resources.Load<VisualTreeAsset>(ITEM_UXML_PATH);
if (_itemTemplate == null)
{
Debug.LogError($"[UTKImageList] Item UXML not found at: {ITEM_UXML_PATH}");
}
// 3. UI 요소 참조 획득
_searchField = this.Q<TextField>("search-field");
_listView = this.Q<ListView>("main-list-view");
_clearButton = this.Q<Button>("clear-btn");
_searchResultLabel = this.Q<Label>("search-result-label");
// 4. 이벤트 연결 및 로직 초기화
InitializeLogic();
}
#endregion
#region (Initialization)
/// <summary>
/// 내부 로직과 이벤트 핸들러를 초기화합니다.
/// </summary>
private void InitializeLogic()
{
// 검색 필드 이벤트 등록 (Enter 키 또는 포커스 잃을 때 검색)
if (_searchField != null)
{
_searchField.RegisterCallback<KeyDownEvent>(OnSearchFieldKeyDown);
_searchField.RegisterCallback<FocusOutEvent>(OnSearchFieldFocusOut);
}
// 검색어 지우기 버튼 이벤트 등록
if (_clearButton != null)
{
_clearButton.clicked += OnClearButtonClicked;
_clearButton.style.display = DisplayStyle.None; // 초기에는 숨김
}
// ListView 설정
SetupListView();
// 루트 레벨 포인터 이벤트 등록 (드래그 처리용)
RegisterRootPointerEvents();
}
/// <summary>
/// 루트 레벨 포인터 이벤트를 등록합니다.
/// 포인터 캡처 후에도 이벤트를 받을 수 있도록 합니다.
/// </summary>
private void RegisterRootPointerEvents()
{
// UTKImageList 자체에서 포인터 이벤트 수신 (캡처 후에도 받을 수 있음)
RegisterCallback<PointerMoveEvent>(OnRootPointerMove);
RegisterCallback<PointerUpEvent>(OnRootPointerUp);
}
/// <summary>
/// 루트 레벨 포인터 이동 이벤트 핸들러
/// </summary>
private void OnRootPointerMove(PointerMoveEvent evt)
{
if (_dragData == null) return;
// 드래그 시작 임계값 체크 (5픽셀)
if (!_isDragging)
{
var distance = Vector2.Distance(_dragStartPosition, evt.position);
if (distance < 5f) return;
// 드래그 시작
_isDragging = true;
_isInsideListArea = true; // 시작 시 항상 내부
OnItemBeginDrag?.Invoke(_dragData, evt.position);
// 드래그 고스트 생성
if (DragImageFollowCursor)
{
CreateDragGhost(_dragData, evt.position);
}
}
// 드래그 중 이벤트
OnItemDrag?.Invoke(_dragData, evt.position);
// 리스트 영역 체크 (진입/이탈 이벤트 발생)
CheckListAreaBounds(evt.position);
// 드래그 고스트 이동 (리스트 영역 내부일 때만 표시)
if (_dragGhost != null)
{
UpdateDragGhostPosition(evt.position);
}
}
/// <summary>
/// 포인터가 리스트 영역 내부인지 체크하고, 진입/이탈 이벤트를 발생시킵니다.
/// </summary>
private void CheckListAreaBounds(Vector2 position)
{
if (_dragData == null || !_isDragging) return;
// 현재 포인터 위치가 리스트 영역 내부인지 체크
// DragBoundsElement가 설정되어 있으면 해당 요소의 영역을, 아니면 자신의 영역을 사용
var boundsElement = DragBoundsElement ?? this;
bool isInside = boundsElement.worldBound.Contains(position);
// 상태 변경 시 이벤트 발생
if (_isInsideListArea && !isInside)
{
// 내부 → 외부로 이동 (3D 프리팹 표시)
_isInsideListArea = false;
if (_dragGhost != null)
{
_dragGhost.style.visibility = Visibility.Hidden;
}
OnDragExitList?.Invoke(_dragData, position);
}
else if (!_isInsideListArea && isInside)
{
// 외부 → 내부로 이동 (고스트 이미지 표시)
_isInsideListArea = true;
if (_dragGhost != null)
{
_dragGhost.style.visibility = Visibility.Visible;
}
OnDragEnterList?.Invoke(_dragData, position);
}
}
/// <summary>
/// 루트 레벨 포인터 업 이벤트 핸들러
/// </summary>
private void OnRootPointerUp(PointerUpEvent evt)
{
if (_dragData == null) return;
// 포인터 캡처 해제
if (this.HasPointerCapture(_dragPointerId))
{
this.ReleasePointer(_dragPointerId);
}
if (_isDragging)
{
OnItemEndDrag?.Invoke(_dragData, evt.position);
OnItemDrop?.Invoke(_dragData);
}
// 드래그 상태 초기화
DestroyDragGhost();
_isDragging = false;
_isInsideListArea = false;
_dragData = null;
}
/// <summary>
/// ListView의 바인딩을 설정합니다.
/// 행 기반 레이아웃을 위해 FixedHeight 가상화 사용
/// </summary>
private void SetupListView()
{
if (_listView == null) return;
// 행 높이가 고정되므로 FixedHeight 사용 가능
_listView.virtualizationMethod = CollectionVirtualizationMethod.FixedHeight;
_listView.fixedItemHeight = 148; // 140px 아이템 + 8px 마진
// 바인딩 콜백 설정
_listView.makeItem = MakeItem;
_listView.bindItem = BindItem;
_listView.unbindItem = UnbindItem;
// ListView 기본 선택 비활성화 (개별 아이템에서 처리)
_listView.selectionType = SelectionType.None;
}
#endregion
#region (Public Methods)
/// <summary>
/// 리스트 데이터를 설정합니다.
/// 기존 데이터를 모두 교체하고 ID를 자동 할당합니다.
/// </summary>
/// <param name="data">표시할 아이템 데이터 리스트</param>
public void SetData(List<UTKImageListItemData> data)
{
// 기존 데이터 정리
ClearSpriteCache();
CancelImageLoading();
_originalData.Clear();
_filteredData.Clear();
_rowData.Clear();
_idSeed = 1;
_isSearchMode = false;
// 데이터 복사 및 ID 할당
if (data != null)
{
foreach (var item in data)
{
if (item.id == 0) item.id = _idSeed++;
_originalData.Add(item);
}
}
// 행 데이터 구성
BuildRowData(_originalData);
// ListView에 데이터 설정
if (_listView != null)
{
_listView.itemsSource = _rowData;
_listView.RefreshItems();
}
// 검색 UI 초기화
if (_searchField != null) _searchField.value = string.Empty;
if (_searchResultLabel != null) _searchResultLabel.style.display = DisplayStyle.None;
}
/// <summary>
/// 리스트에 단일 아이템을 추가합니다.
/// </summary>
/// <param name="item">추가할 아이템 데이터</param>
public void AddItem(UTKImageListItemData item)
{
if (item == null) return;
if (item.id == 0) item.id = _idSeed++;
_originalData.Add(item);
// 검색 모드가 아닐 때만 즉시 갱신
if (!_isSearchMode)
{
BuildRowData(_originalData);
_listView?.RefreshItems();
}
}
/// <summary>
/// 리스트에서 아이템을 제거합니다.
/// </summary>
/// <param name="item">제거할 아이템 데이터</param>
/// <returns>제거 성공 여부</returns>
public bool RemoveItem(UTKImageListItemData item)
{
if (item == null) return false;
bool removed = _originalData.Remove(item);
if (removed)
{
_filteredData.Remove(item);
// 캐시에서 해당 아이템의 이미지 제거
if (!string.IsNullOrEmpty(item.imagePath))
{
_spriteCache.Remove(item.imagePath);
}
// 행 데이터 재구성
var currentData = _isSearchMode ? _filteredData : _originalData;
BuildRowData(currentData);
_listView?.RefreshItems();
}
return removed;
}
/// <summary>
/// 모든 데이터를 제거합니다.
/// </summary>
public void Clear()
{
ClearSpriteCache();
CancelImageLoading();
_originalData.Clear();
_filteredData.Clear();
_rowData.Clear();
_isSearchMode = false;
if (_listView != null)
{
_listView.itemsSource = _rowData;
_listView.RefreshItems();
}
if (_searchField != null) _searchField.value = string.Empty;
if (_searchResultLabel != null) _searchResultLabel.style.display = DisplayStyle.None;
}
/// <summary>
/// 리스트를 화면에 표시합니다.
/// </summary>
public void Show()
{
this.style.display = DisplayStyle.Flex;
}
/// <summary>
/// 리스트를 화면에서 숨깁니다.
/// </summary>
public void Hide()
{
this.style.display = DisplayStyle.None;
}
/// <summary>
/// 지정된 검색어로 검색을 실행합니다.
/// </summary>
/// <param name="query">검색어</param>
public void ApplySearch(string query)
{
if (_searchField != null)
{
_searchField.value = query;
}
OnSearch(query);
}
#endregion
#region (Row Data Building)
/// <summary>
/// 아이템 데이터를 행 단위로 묶습니다.
/// 2개씩 묶어서 RowData 리스트를 생성합니다.
/// ObjectPool 패턴으로 GC 부담을 줄입니다.
/// </summary>
/// <param name="items">원본 아이템 리스트</param>
private void BuildRowData(List<UTKImageListItemData> items)
{
// 기존 RowData를 풀에 반환
ReturnRowDataToPool();
for (int i = 0; i < items.Count; i += ITEMS_PER_ROW)
{
var row = GetRowDataFromPool();
row.LeftItem = items[i];
row.RightItem = (i + 1 < items.Count) ? items[i + 1] : null;
_rowData.Add(row);
}
}
/// <summary>
/// ObjectPool에서 RowData를 가져옵니다.
/// 풀이 비어있으면 새로 생성합니다.
/// </summary>
private RowData GetRowDataFromPool()
{
return _rowDataPool.Count > 0 ? _rowDataPool.Dequeue() : new RowData();
}
/// <summary>
/// 사용 중인 RowData를 모두 풀에 반환합니다.
/// </summary>
private void ReturnRowDataToPool()
{
foreach (var row in _rowData)
{
row.LeftItem = null;
row.RightItem = null;
_rowDataPool.Enqueue(row);
}
_rowData.Clear();
}
#endregion
#region ListView (ListView Binding)
/// <summary>
/// ListView 항목의 UI 요소를 생성합니다.
/// 성능: 템플릿을 복제하여 재사용 가능한 요소 생성
/// </summary>
/// <returns>생성된 UI 요소</returns>
private VisualElement MakeItem()
{
if (_itemTemplate == null)
{
return new VisualElement();
}
var element = _itemTemplate.CloneTree();
// 드래그 이벤트를 위한 사용자 데이터 초기화
element.userData = null;
return element;
}
/// <summary>
/// 데이터를 UI 요소에 바인딩합니다.
/// 행 기반: 한 번에 2개의 아이템을 바인딩합니다.
/// </summary>
/// <param name="element">바인딩할 UI 요소</param>
/// <param name="index">행 인덱스</param>
private void BindItem(VisualElement element, int index)
{
if (index < 0 || index >= _rowData.Count) return;
var rowData = _rowData[index];
// 이전 바인딩 데이터 정리 (메모리 누수 방지)
UnbindItemEvents(element);
// 현재 데이터 저장
element.userData = rowData;
// 왼쪽 아이템 바인딩
BindSingleItem(element, "item-left", "item-image-left", "item-label-left", rowData.LeftItem);
// 오른쪽 아이템 바인딩
BindSingleItem(element, "item-right", "item-image-right", "item-label-right", rowData.RightItem);
}
/// <summary>
/// 단일 아이템을 UI 요소에 바인딩합니다.
/// </summary>
/// <param name="rowElement">행 UI 요소</param>
/// <param name="itemName">아이템 요소 이름</param>
/// <param name="imageName">이미지 요소 이름</param>
/// <param name="labelName">라벨 요소 이름</param>
/// <param name="data">아이템 데이터 (null이면 숨김)</param>
private void BindSingleItem(VisualElement rowElement, string itemName, string imageName, string labelName, UTKImageListItemData? data)
{
var itemRoot = rowElement.Q<VisualElement>(itemName);
var label = rowElement.Q<Label>(labelName);
var image = rowElement.Q<VisualElement>(imageName);
if (itemRoot == null) return;
// 데이터가 없으면 숨김 처리
if (data == null)
{
itemRoot.AddToClassList("image-list-item--hidden");
if (label != null) label.text = string.Empty;
if (image != null) image.style.backgroundImage = StyleKeyword.None;
return;
}
// 숨김 클래스 제거
itemRoot.RemoveFromClassList("image-list-item--hidden");
// 텍스트 바인딩
if (label != null)
{
label.text = data.itemName;
}
// 이미지 바인딩 (비동기)
if (image != null)
{
BindImageAsync(image, data.imagePath).Forget();
}
// 이벤트 등록
RegisterItemEvents(itemRoot, data);
}
/// <summary>
/// UI 요소에서 데이터 바인딩을 해제합니다.
/// 메모리 누수 방지를 위해 이벤트 핸들러를 정리합니다.
/// </summary>
/// <param name="element">해제할 UI 요소</param>
/// <param name="index">데이터 인덱스</param>
private void UnbindItem(VisualElement element, int index)
{
UnbindItemEvents(element);
element.userData = null;
}
/// <summary>
/// 아이템의 이벤트 핸들러를 해제합니다.
/// </summary>
/// <param name="element">대상 UI 요소</param>
private void UnbindItemEvents(VisualElement element)
{
// 왼쪽 아이템 이벤트 해제
UnbindSingleItemEvents(element, "item-left");
// 오른쪽 아이템 이벤트 해제
UnbindSingleItemEvents(element, "item-right");
}
/// <summary>
/// 단일 아이템의 이벤트 핸들러를 해제합니다.
/// </summary>
private void UnbindSingleItemEvents(VisualElement rowElement, string itemName)
{
var itemRoot = rowElement.Q<VisualElement>(itemName);
if (itemRoot == null) return;
// 저장된 콜백 제거
if (itemRoot.userData is ItemEventHandlers handlers)
{
itemRoot.UnregisterCallback<ClickEvent>(handlers.ClickHandler);
itemRoot.UnregisterCallback<PointerDownEvent>(handlers.PointerDownHandler, TrickleDown.TrickleDown);
itemRoot.userData = null;
}
}
/// <summary>
/// 아이템에 이벤트 핸들러를 등록합니다.
/// </summary>
/// <param name="itemRoot">아이템 루트 요소</param>
/// <param name="data">아이템 데이터</param>
private void RegisterItemEvents(VisualElement itemRoot, UTKImageListItemData data)
{
// 기존 핸들러 제거
if (itemRoot.userData is ItemEventHandlers oldHandlers)
{
itemRoot.UnregisterCallback<ClickEvent>(oldHandlers.ClickHandler);
itemRoot.UnregisterCallback<PointerDownEvent>(oldHandlers.PointerDownHandler, TrickleDown.TrickleDown);
}
// 핸들러 생성 및 등록 (PointerMove/Up은 루트 레벨에서 처리)
var handlers = new ItemEventHandlers
{
Data = data,
ClickHandler = evt => HandleItemClick(data),
PointerDownHandler = evt => HandlePointerDown(evt, data)
};
itemRoot.RegisterCallback(handlers.ClickHandler);
// TrickleDown으로 등록하여 ListView 선택 전에 이벤트를 받음
itemRoot.RegisterCallback(handlers.PointerDownHandler, TrickleDown.TrickleDown);
// 나중에 해제할 수 있도록 저장
itemRoot.userData = handlers;
}
/// <summary>
/// 아이템 이벤트 핸들러를 저장하는 내부 클래스입니다.
/// 메모리 누수 방지를 위해 핸들러 참조를 유지합니다.
/// </summary>
private class ItemEventHandlers
{
public UTKImageListItemData? Data;
public EventCallback<ClickEvent>? ClickHandler;
public EventCallback<PointerDownEvent>? PointerDownHandler;
}
#endregion
#region (Image Loading)
/// <summary>
/// 이미지를 비동기로 로드하여 UI 요소에 바인딩합니다.
/// 성능 최적화: 캐싱, 중복 로딩 방지, 취소 토큰 지원
/// </summary>
/// <param name="imageElement">이미지를 표시할 UI 요소</param>
/// <param name="imagePath">이미지 리소스 경로</param>
private async UniTaskVoid BindImageAsync(VisualElement imageElement, string imagePath)
{
if (imageElement == null) return;
// 경로가 비어있으면 기본 상태 유지
if (string.IsNullOrEmpty(imagePath))
{
imageElement.style.backgroundImage = StyleKeyword.None;
return;
}
// 캐시에서 확인
if (_spriteCache.TryGetValue(imagePath, out var cachedSprite))
{
if (cachedSprite != null)
{
imageElement.style.backgroundImage = new StyleBackground(cachedSprite);
}
return;
}
// 이미 로딩 중인 이미지는 대기
if (_loadingImages.Contains(imagePath))
{
// 로딩 완료 대기 후 캐시에서 가져오기
await UniTask.WaitUntil(() => _spriteCache.ContainsKey(imagePath) || _disposed);
if (_disposed) return;
if (_spriteCache.TryGetValue(imagePath, out var sprite) && sprite != null)
{
imageElement.style.backgroundImage = new StyleBackground(sprite);
}
return;
}
// 로딩 시작 표시
_loadingImages.Add(imagePath);
imageElement.AddToClassList("item-image-loading");
try
{
// 취소 토큰 생성/갱신
_imageLoadCts ??= new CancellationTokenSource();
// 디바운스 (빠른 스크롤 시 불필요한 로딩 방지)
await UniTask.Delay(IMAGE_LOAD_DEBOUNCE_MS, cancellationToken: _imageLoadCts.Token);
// 비동기 이미지 로딩
var loadedSprite = await ResourceManager.LoadSpriteAsync(imagePath);
// 캐시에 저장
_spriteCache[imagePath] = loadedSprite;
// UI에 적용 (disposed 체크)
if (!_disposed && loadedSprite != null)
{
imageElement.style.backgroundImage = new StyleBackground(loadedSprite);
}
}
catch (OperationCanceledException)
{
// 취소됨 - 정상 동작
}
catch (Exception ex)
{
Debug.LogWarning($"[UTKImageList] Failed to load image: {imagePath}, Error: {ex.Message}");
}
finally
{
_loadingImages.Remove(imagePath);
imageElement.RemoveFromClassList("item-image-loading");
}
}
/// <summary>
/// 이미지 로딩을 취소합니다.
/// </summary>
private void CancelImageLoading()
{
if (_imageLoadCts != null)
{
_imageLoadCts.Cancel();
_imageLoadCts.Dispose();
_imageLoadCts = null;
}
_loadingImages.Clear();
}
/// <summary>
/// 스프라이트 캐시를 정리합니다.
/// </summary>
private void ClearSpriteCache()
{
_spriteCache.Clear();
}
#endregion
#region (Event Handlers)
/// <summary>
/// 아이템 클릭 이벤트를 처리합니다.
/// </summary>
private void HandleItemClick(UTKImageListItemData data)
{
// 선택 상태 업데이트
foreach (var item in _originalData)
{
item.isSelected = (item == data);
}
OnItemClick?.Invoke(data);
}
// 드래그 상태 추적
private bool _isDragging;
private UTKImageListItemData? _dragData;
private Vector2 _dragStartPosition;
private VisualElement? _dragGhost;
private int _dragPointerId;
private bool _isInsideListArea; // 드래그 중 리스트 영역 내부 여부
/// <summary>
/// 포인터 다운 이벤트를 처리합니다 (드래그 시작 준비).
/// </summary>
private void HandlePointerDown(PointerDownEvent evt, UTKImageListItemData data)
{
_dragData = data;
_dragStartPosition = evt.position;
_isDragging = false;
_dragPointerId = evt.pointerId;
// UTKImageList 자체에서 포인터 캡처하여 영역 외부에서도 이벤트 수신
this.CapturePointer(evt.pointerId);
}
#endregion
#region (Drag Ghost)
/// <summary>
/// 드래그 고스트 UI를 생성합니다. (이미지만 표시)
/// </summary>
private void CreateDragGhost(UTKImageListItemData data, Vector2 position)
{
// 이미지만 표시하는 고스트 생성
_dragGhost = new VisualElement();
_dragGhost.name = "drag-ghost";
_dragGhost.pickingMode = PickingMode.Ignore;
// 고스트 컨테이너 스타일 (인라인) - UTKAccordionList와 동일한 크기
_dragGhost.style.width = 116;
_dragGhost.style.height = 87;
_dragGhost.style.position = Position.Absolute;
_dragGhost.style.opacity = 0.8f;
// 이미지 요소
var image = new VisualElement();
image.name = "drag-ghost-image";
image.pickingMode = PickingMode.Ignore;
image.style.width = Length.Percent(100);
image.style.height = Length.Percent(100);
image.style.backgroundPositionX = new BackgroundPosition(BackgroundPositionKeyword.Center);
image.style.backgroundPositionY = new BackgroundPosition(BackgroundPositionKeyword.Center);
image.style.backgroundRepeat = new BackgroundRepeat(Repeat.NoRepeat, Repeat.NoRepeat);
image.style.backgroundSize = new BackgroundSize(BackgroundSizeType.Contain);
// 스프라이트 캐시에서 이미지 로드
if (!string.IsNullOrEmpty(data.imagePath) && _spriteCache.TryGetValue(data.imagePath, out var sprite) && sprite != null)
{
image.style.backgroundImage = new StyleBackground(sprite);
}
_dragGhost.Add(image);
// 위치 설정
UpdateDragGhostPosition(position);
// 루트에 추가
var root = this.panel?.visualTree;
if (root != null)
{
root.Add(_dragGhost);
#if UNITY_EDITOR && UTK_DEBUG
UnityEngine.Debug.Log($"[UTKImageList] 드래그 고스트 생성됨, 루트에 추가: {root.name}");
#endif
}
#if UNITY_EDITOR && UTK_DEBUG
else
{
UnityEngine.Debug.LogWarning("[UTKImageList] 드래그 고스트 생성 실패: panel.visualTree가 null입니다.");
}
#endif
}
/// <summary>
/// 드래그 고스트 위치를 업데이트합니다.
/// </summary>
private void UpdateDragGhostPosition(Vector2 position)
{
if (_dragGhost == null) return;
// 위치만 업데이트 (visibility는 CheckListAreaBounds에서 관리)
// 고스트 크기: 116x87, 커서를 이미지 중앙에 배치
_dragGhost.style.left = position.x - 58;
_dragGhost.style.top = position.y - 43;
}
/// <summary>
/// 드래그 고스트를 제거합니다.
/// </summary>
private void DestroyDragGhost()
{
if (_dragGhost != null)
{
_dragGhost.RemoveFromHierarchy();
_dragGhost = null;
}
}
#endregion
#region (Search Functionality)
/// <summary>
/// 검색 필드 키 입력 이벤트를 처리합니다.
/// </summary>
private void OnSearchFieldKeyDown(KeyDownEvent evt)
{
if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
{
OnSearch(_searchField?.value ?? string.Empty);
evt.StopPropagation();
}
}
/// <summary>
/// 검색 필드가 포커스를 잃었을 때 검색을 실행합니다.
/// </summary>
private void OnSearchFieldFocusOut(FocusOutEvent evt)
{
OnSearch(_searchField?.value ?? string.Empty);
}
/// <summary>
/// 검색어 지우기 버튼 클릭 이벤트를 처리합니다.
/// </summary>
private void OnClearButtonClicked()
{
if (_searchField != null && !string.IsNullOrEmpty(_searchField.value))
{
_searchField.value = string.Empty;
OnSearch(string.Empty);
}
// 클리어 후 버튼 숨김
if (_clearButton != null)
{
_clearButton.style.display = DisplayStyle.None;
}
}
/// <summary>
/// 검색을 실행합니다.
/// 성능 최적화: LINQ 대신 직접 반복, StringComparison 사용
/// </summary>
/// <param name="query">검색어</param>
private void OnSearch(string query)
{
// Clear 버튼 가시성 토글
if (_clearButton != null)
{
_clearButton.style.display = string.IsNullOrEmpty(query) ? DisplayStyle.None : DisplayStyle.Flex;
}
// 빈 검색어 → 원본 데이터 복원
if (string.IsNullOrEmpty(query))
{
RestoreOriginalData();
return;
}
// 최소 검색어 길이 체크
if (query.Length < MIN_SEARCH_LENGTH)
{
#if UNITY_EDITOR && UTK_DEBUG
Debug.Log($"[UTKImageList] 검색어는 {MIN_SEARCH_LENGTH}글자 이상 입력해주세요.");
#endif
return;
}
// 검색 실행 (대소문자 무시, 문화권 독립적)
var trimmedQuery = query.Trim();
_filteredData.Clear();
// 성능: foreach 루프 + StringComparison.OrdinalIgnoreCase (ToLower 할당 방지)
foreach (var item in _originalData)
{
if (item.itemName.Contains(trimmedQuery, StringComparison.OrdinalIgnoreCase))
{
_filteredData.Add(item);
}
}
_isSearchMode = true;
// 행 데이터 재구성
BuildRowData(_filteredData);
// ListView 갱신
if (_listView != null)
{
_listView.itemsSource = _rowData;
_listView.RefreshItems();
}
// 검색 결과 표시
if (_searchResultLabel != null)
{
_searchResultLabel.style.display = DisplayStyle.Flex;
_searchResultLabel.text = $"검색결과: {_filteredData.Count}건";
}
}
/// <summary>
/// 원본 데이터를 복원합니다.
/// </summary>
private void RestoreOriginalData()
{
_isSearchMode = false;
_filteredData.Clear();
// 행 데이터 재구성
BuildRowData(_originalData);
if (_listView != null)
{
_listView.itemsSource = _rowData;
_listView.RefreshItems();
}
if (_searchResultLabel != null)
{
_searchResultLabel.style.display = DisplayStyle.None;
}
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 이미지 로딩 취소
CancelImageLoading();
// 루트 레벨 포인터 이벤트 해제 (메모리 누수 방지)
UnregisterCallback<PointerMoveEvent>(OnRootPointerMove);
UnregisterCallback<PointerUpEvent>(OnRootPointerUp);
// 포인터 캡처 해제
if (_dragPointerId != 0 && this.HasPointerCapture(_dragPointerId))
{
this.ReleasePointer(_dragPointerId);
}
// 이벤트 핸들러 해제
if (_searchField != null)
{
_searchField.UnregisterCallback<KeyDownEvent>(OnSearchFieldKeyDown);
_searchField.UnregisterCallback<FocusOutEvent>(OnSearchFieldFocusOut);
}
if (_clearButton != null)
{
_clearButton.clicked -= OnClearButtonClicked;
}
// 드래그 고스트 정리
DestroyDragGhost();
// 데이터 정리
foreach (var item in _originalData)
{
item.Dispose();
}
_originalData.Clear();
_filteredData.Clear();
_rowData.Clear();
// ObjectPool 정리 (메모리 해제)
_rowDataPool.Clear();
// 캐시 정리
ClearSpriteCache();
// 이벤트 핸들러 정리
OnItemClick = null;
OnItemDrop = null;
OnItemBeginDrag = null;
OnItemDrag = null;
OnItemEndDrag = null;
OnDragExitList = null;
OnDragEnterList = null;
// UI 참조 정리
_searchField = null;
_listView = null;
_clearButton = null;
_searchResultLabel = null;
_itemTemplate = null;
DragBoundsElement = null;
}
#endregion
}
}