1336 lines
46 KiB
C#
1336 lines
46 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>실시간 검색 필터링 (2글자 이상)</item>
|
|
/// <item>드래그 앤 드롭 지원</item>
|
|
/// <item>가상화를 통한 대량 데이터 성능 최적화</item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>UXML 사용 예시:</b></para>
|
|
/// <code><![CDATA[
|
|
/// <!-- UXML 파일에서 UTKImageList 사용 -->
|
|
/// <ui:UXML xmlns:utk="UVC.UIToolkit">
|
|
/// <utk:UTKImageList name="image-list" />
|
|
/// </ui:UXML>
|
|
/// ]]></code>
|
|
///
|
|
/// <para><b>C# 사용 예시:</b></para>
|
|
/// <code><![CDATA[
|
|
/// // 1. 이미지 리스트 참조 획득
|
|
/// var imageList = root.Q<UTKImageList>("image-list");
|
|
///
|
|
/// // 2. 데이터 구성 - 이미지 경로와 이름을 가진 아이템들
|
|
/// var data = new List<UTKImageListItemData>
|
|
/// {
|
|
/// new UTKImageListItemData
|
|
/// {
|
|
/// itemName = "의자",
|
|
/// imagePath = "Prefabs/Thumbnails/chair",
|
|
/// prefabPath = "Prefabs/Furniture/Chair"
|
|
/// },
|
|
/// new UTKImageListItemData
|
|
/// {
|
|
/// itemName = "책상",
|
|
/// imagePath = "Prefabs/Thumbnails/desk",
|
|
/// prefabPath = "Prefabs/Furniture/Desk"
|
|
/// },
|
|
/// new UTKImageListItemData
|
|
/// {
|
|
/// itemName = "소파",
|
|
/// imagePath = "Prefabs/Thumbnails/sofa",
|
|
/// prefabPath = "Prefabs/Furniture/Sofa"
|
|
/// }
|
|
/// };
|
|
///
|
|
/// // 3. 데이터 설정
|
|
/// imageList.SetData(data);
|
|
///
|
|
/// // 4. 아이템 클릭 이벤트 구독
|
|
/// imageList.OnItemClick += (item) =>
|
|
/// {
|
|
/// Debug.Log($"클릭된 아이템: {item.itemName}");
|
|
/// };
|
|
///
|
|
/// // 5. 드래그 앤 드롭 이벤트 (씬에 프리팹 배치용)
|
|
/// imageList.OnItemBeginDrag += (item, screenPos) =>
|
|
/// {
|
|
/// Debug.Log($"드래그 시작: {item.itemName}");
|
|
/// };
|
|
///
|
|
/// imageList.OnItemDrag += (item, screenPos) =>
|
|
/// {
|
|
/// // 드래그 중 - 3D 미리보기 위치 업데이트 등
|
|
/// };
|
|
///
|
|
/// imageList.OnItemEndDrag += (item, screenPos) =>
|
|
/// {
|
|
/// Debug.Log($"드래그 종료: {item.itemName}");
|
|
/// };
|
|
///
|
|
/// imageList.OnItemDrop += (item) =>
|
|
/// {
|
|
/// // 드롭 완료 - 프리팹 인스턴스화
|
|
/// if (!string.IsNullOrEmpty(item.prefabPath))
|
|
/// {
|
|
/// var prefab = Resources.Load<GameObject>(item.prefabPath);
|
|
/// Instantiate(prefab, dropPosition, Quaternion.identity);
|
|
/// }
|
|
/// };
|
|
///
|
|
/// // 6. 리스트 영역 밖으로 드래그 시 3D 미리보기 표시
|
|
/// imageList.OnDragExitList += (item, screenPos) =>
|
|
/// {
|
|
/// // 리스트 밖 = 3D 씬 영역
|
|
/// Show3DPreview(item.prefabPath, screenPos);
|
|
/// };
|
|
///
|
|
/// // 7. 리스트 영역 안으로 다시 들어왔을 때 미리보기 숨김
|
|
/// imageList.OnDragEnterList += (item, screenPos) =>
|
|
/// {
|
|
/// Hide3DPreview();
|
|
/// };
|
|
///
|
|
/// // 8. 드래그 영역 설정 (부모 윈도우 영역 기준으로 체크)
|
|
/// imageList.DragBoundsElement = parentWindow;
|
|
///
|
|
/// // 9. 드래그 고스트 이미지 따라다니기 설정
|
|
/// imageList.DragImageFollowCursor = true;
|
|
///
|
|
/// // 10. 검색 실행 (2글자 이상)
|
|
/// imageList.ApplySearch("의자");
|
|
///
|
|
/// // 11. 현재 검색어 확인
|
|
/// string currentQuery = imageList.SearchQuery;
|
|
///
|
|
/// // 12. 아이템 추가/제거
|
|
/// var newItem = new UTKImageListItemData { itemName = "새 아이템" };
|
|
/// imageList.AddItem(newItem);
|
|
/// imageList.RemoveItem(newItem);
|
|
///
|
|
/// // 13. 전체 삭제
|
|
/// imageList.Clear();
|
|
///
|
|
/// // 14. 아이템 개수 확인
|
|
/// int count = imageList.ItemCount;
|
|
///
|
|
/// // 15. 리스트 표시/숨김
|
|
/// imageList.Show();
|
|
/// imageList.Hide();
|
|
///
|
|
/// // 16. 리소스 해제 (OnDestroy에서 호출)
|
|
/// imageList.Dispose();
|
|
/// ]]></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/UTKImageListUss.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";
|
|
private const string USS_PATH = "UIToolkit/List/UTKImageListUss";
|
|
|
|
/// <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 UTKInputField? _searchField;
|
|
|
|
/// <summary>Unity UI Toolkit의 ListView 컴포넌트</summary>
|
|
private ListView? _listView;
|
|
|
|
/// <summary>검색어 지우기 버튼 (UTKButton)</summary>
|
|
private UTKButton? _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. 테마 적용 및 변경 구독
|
|
UTKThemeManager.Instance.ApplyThemeToElement(this);
|
|
SubscribeToThemeChanges();
|
|
|
|
// USS 로드 (테마 변수 스타일시트 이후에 로드되어야 변수가 해석됨)
|
|
var uss = Resources.Load<StyleSheet>(USS_PATH);
|
|
if (uss != null)
|
|
{
|
|
styleSheets.Add(uss);
|
|
}
|
|
|
|
// 4. UI 요소 참조 획득
|
|
_searchField = this.Q<UTKInputField>("search-field");
|
|
_listView = this.Q<ListView>("main-list-view");
|
|
_clearButton = this.Q<UTKButton>("clear-btn");
|
|
_searchResultLabel = this.Q<Label>("search-result-label");
|
|
|
|
// 6. 이벤트 연결 및 로직 초기화
|
|
InitializeLogic();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 초기화 (Initialization)
|
|
|
|
/// <summary>
|
|
/// 내부 로직과 이벤트 핸들러를 초기화합니다.
|
|
/// </summary>
|
|
private void InitializeLogic()
|
|
{
|
|
// 검색 필드 이벤트 등록 (Enter 키 또는 포커스 잃을 때 검색)
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.OnSubmit += OnSearch;
|
|
}
|
|
|
|
// 검색어 지우기 버튼 이벤트 등록
|
|
if (_clearButton != null)
|
|
{
|
|
_clearButton.OnClicked += 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 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 테마 (Theme)
|
|
|
|
private void SubscribeToThemeChanges()
|
|
{
|
|
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
|
|
RegisterCallback<DetachFromPanelEvent>(_ =>
|
|
{
|
|
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
|
|
});
|
|
}
|
|
|
|
private void OnThemeChanged(UTKTheme theme)
|
|
{
|
|
UTKThemeManager.Instance.ApplyThemeToElement(this);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region IDisposable
|
|
|
|
/// <summary>
|
|
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
// 테마 변경 이벤트 해제
|
|
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
|
|
|
|
// 이미지 로딩 취소
|
|
CancelImageLoading();
|
|
|
|
// 루트 레벨 포인터 이벤트 해제 (메모리 누수 방지)
|
|
UnregisterCallback<PointerMoveEvent>(OnRootPointerMove);
|
|
UnregisterCallback<PointerUpEvent>(OnRootPointerUp);
|
|
|
|
// 포인터 캡처 해제
|
|
if (_dragPointerId != 0 && this.HasPointerCapture(_dragPointerId))
|
|
{
|
|
this.ReleasePointer(_dragPointerId);
|
|
}
|
|
|
|
// 이벤트 핸들러 해제
|
|
if (_searchField != null)
|
|
{
|
|
_searchField.OnSubmit -= OnSearch;
|
|
}
|
|
|
|
if (_clearButton != null)
|
|
{
|
|
_clearButton.OnClicked -= OnClearButtonClicked;
|
|
_clearButton.Dispose();
|
|
}
|
|
|
|
// 드래그 고스트 정리
|
|
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
|
|
}
|
|
}
|