494 lines
19 KiB
C#
494 lines
19 KiB
C#
#nullable enable
|
|
using Cysharp.Threading.Tasks;
|
|
using Gpm.Ui;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UVC.Extention;
|
|
using UVC.Locale;
|
|
using UVC.UI.Modal;
|
|
using UVC.UI.Tab;
|
|
|
|
namespace UVC.UI.List.ComponentList
|
|
{
|
|
/// <summary>
|
|
/// 컴포넌트 목록 UI를 관리하는 클래스입니다.
|
|
/// InfiniteScroll을 사용하여 대량의 데이터를 효율적으로 표시하고,
|
|
/// 검색 및 필터링 기능을 제공합니다.
|
|
/// </summary>
|
|
public class ComponentList : MonoBehaviour, ITabContent
|
|
{
|
|
[Tooltip("데이터를 표시할 InfiniteScroll 컴포넌트입니다.")]
|
|
[SerializeField]
|
|
protected InfiniteScroll? scrollList = null;
|
|
|
|
[Tooltip("검색어 입력을 위한 TMP_InputField 컴포넌트입니다.")]
|
|
[SerializeField]
|
|
protected TMP_InputField? inputField = null;
|
|
|
|
// InfiniteScroll에 표시될 원본 데이터 리스트입니다.
|
|
protected List<ComponentListItemData>? data;
|
|
protected List<ComponentListItemData>? filteredData;
|
|
|
|
#region 이벤트 (Events)
|
|
|
|
/// <summary>
|
|
/// 아이템 클릭 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnClickItem;
|
|
|
|
/// <summary>
|
|
/// 아이템 마우스 오른쪽 클릭 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnRightClickItem;
|
|
|
|
/// <summary>
|
|
/// 카테고리 확장/축소 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnCategoryExpand;
|
|
|
|
/// <summary>
|
|
/// 설정 버튼 클릭 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnSettingButtonClick;
|
|
|
|
/// <summary>
|
|
/// 보이기 버튼 클릭 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnShowButtonClick;
|
|
|
|
/// <summary>
|
|
/// 숨기기 버튼 클릭 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnHideButtonClick;
|
|
|
|
/// <summary>
|
|
/// 검색/이동 버튼 클릭 이벤트.
|
|
/// </summary>
|
|
public Action<ComponentListItemData>? OnSearchButtonClick;
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// SingletonScene 초기화 과정에서 호출됩니다.
|
|
/// 필요한 컴포넌트를 찾고, 이벤트 리스너를 등록합니다.
|
|
/// </summary>
|
|
protected void Start()
|
|
{
|
|
// scrollList가 인스펙터에서 할당되지 않았을 경우, 자식에서 찾아봅니다.
|
|
if (scrollList == null)
|
|
{
|
|
scrollList = GetComponentInChildren<InfiniteScroll>();
|
|
}
|
|
|
|
if (scrollList == null)
|
|
{
|
|
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
|
|
return;
|
|
}
|
|
|
|
// inputField가 인스펙터에서 할당되지 않았을 경우, 자식에서 찾아봅니다.
|
|
if (inputField == null)
|
|
{
|
|
inputField = GetComponentInChildren<TMP_InputField>();
|
|
}
|
|
if (inputField != null)
|
|
{
|
|
// 사용자가 검색어를 입력하고 Enter 키를 누르거나 입력을 완료했을 때 OnInputFieldChanged 메서드가 호출되도록 이벤트를 등록합니다.
|
|
//inputField.onEndEdit.AddListener(OnInputFieldChanged);
|
|
inputField.onSubmit.AddListener(OnInputFieldChanged);
|
|
}
|
|
}
|
|
|
|
protected virtual void OnDestroy()
|
|
{
|
|
if (inputField != null)
|
|
{
|
|
// Init에서 등록한 리스너를 제거합니다.
|
|
inputField.onSubmit.RemoveListener(OnInputFieldChanged);
|
|
}
|
|
|
|
// 이벤트 핸들러 정리
|
|
OnClickItem = null;
|
|
OnRightClickItem = null;
|
|
OnCategoryExpand = null;
|
|
OnSettingButtonClick = null;
|
|
OnShowButtonClick = null;
|
|
OnHideButtonClick = null;
|
|
OnSearchButtonClick = null;
|
|
|
|
// 데이터 참조 정리
|
|
data?.Clear();
|
|
data = null;
|
|
filteredData?.Clear();
|
|
filteredData = null;
|
|
|
|
// 컴포넌트 참조 정리
|
|
scrollList = null;
|
|
inputField = null;
|
|
}
|
|
|
|
#region 내부 이벤트 핸들러 (Internal Event Handlers)
|
|
|
|
internal void HandleClickItem(ComponentListItemData itemData)
|
|
{
|
|
OnClickItem?.Invoke(itemData);
|
|
}
|
|
|
|
internal void HandleRightClickItem(ComponentListItemData itemData)
|
|
{
|
|
OnRightClickItem?.Invoke(itemData);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 카테고리 확장/축소 처리 (ComponentListItem에서 호출)
|
|
/// </summary>
|
|
internal void HandleCategoryExpand(ComponentListItemData itemData)
|
|
{
|
|
ToggleCategory(itemData);
|
|
OnCategoryExpand?.Invoke(itemData);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 설정 버튼 클릭 처리 (ComponentListItem에서 호출)
|
|
/// </summary>
|
|
internal void HandleSettingButtonClick(ComponentListItemData itemData)
|
|
{
|
|
OnSettingButtonClick?.Invoke(itemData);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 보이기 버튼 클릭 처리 (ComponentListItem에서 호출)
|
|
/// </summary>
|
|
internal void HandleShowButtonClick(ComponentListItemData itemData)
|
|
{
|
|
OnShowButtonClick?.Invoke(itemData);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 숨기기 버튼 클릭 처리 (ComponentListItem에서 호출)
|
|
/// </summary>
|
|
internal void HandleHideButtonClick(ComponentListItemData itemData)
|
|
{
|
|
OnHideButtonClick?.Invoke(itemData);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 검색/이동 버튼 클릭 처리 (ComponentListItem에서 호출)
|
|
/// </summary>
|
|
internal void HandleSearchButtonClick(ComponentListItemData itemData)
|
|
{
|
|
OnSearchButtonClick?.Invoke(itemData);
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// 카테고리별로 그룹화된 데이터로 스크롤 리스트를 설정합니다.
|
|
/// </summary>
|
|
/// <param name="objectsData">카테고리 이름을 키로, 해당 카테고리의 아이템 목록을 값으로 하는 딕셔너리</param>
|
|
public virtual void SetupData(SortedDictionary<string, List<ComponentListItemData>> objectsData)
|
|
{
|
|
if (scrollList == null)
|
|
{
|
|
Debug.LogError("InfiniteScroll component is not assigned.");
|
|
return;
|
|
}
|
|
|
|
if (inputField != null) inputField.text = string.Empty;
|
|
|
|
// 축소된 카테고리 찾기
|
|
List<string> notExpendedCategory = new List<string>();
|
|
if (scrollList.GetDataCount() > 0)
|
|
{
|
|
List<InfiniteScrollData> scrollDatalist = scrollList.GetDataList();
|
|
foreach (var scrollData in scrollDatalist)
|
|
{
|
|
if (scrollData is ComponentListItemData itemData && itemData.IsCategory)
|
|
{
|
|
if (!itemData.IsExpanded) notExpendedCategory.Add(itemData.CategoryName);
|
|
}
|
|
}
|
|
}
|
|
|
|
scrollList.ClearData();
|
|
|
|
if (data != null) data?.Clear();
|
|
data = new List<ComponentListItemData>();
|
|
|
|
// 표시용 리스트
|
|
List<ComponentListItemData> listData = new List<ComponentListItemData>();
|
|
|
|
// 각 카테고리에 대해 루프
|
|
foreach (var info in objectsData)
|
|
{
|
|
bool isExpended = !notExpendedCategory.Contains(info.Key);
|
|
// 카테고리 아이템 데이터 추가
|
|
var categoryData = new ComponentListItemData
|
|
{
|
|
IsCategory = true,
|
|
CategoryName = info.Key,
|
|
CategoryBadgeCount = info.Value.Count,
|
|
IsExpanded = isExpended,
|
|
};
|
|
data.Add(categoryData);
|
|
listData.Add(categoryData);
|
|
|
|
// 해당 카테고리에 속한 모든 일반 항목 데이터 추가
|
|
foreach (var item in info.Value)
|
|
{
|
|
data.Add(item);
|
|
// 카테고리가 축소된 상태라면 자식 항목들을 표시 리스트에 추가하지 않음
|
|
if (isExpended) listData.Add(item);
|
|
}
|
|
}
|
|
|
|
scrollList.InsertData(listData.ToArray());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 카테고리의 확장/축소 상태를 토글하고, 그에 따라 자식 아이템들의 표시 여부를 업데이트합니다.
|
|
/// </summary>
|
|
/// <param name="categoryData">상태를 변경할 카테고리 데이터</param>
|
|
public virtual void ToggleCategory(ComponentListItemData categoryData)
|
|
{
|
|
if (scrollList == null || data == null) return;
|
|
|
|
// 1. 현재 스크롤 리스트에서 토글할 카테고리의 인덱스를 찾습니다.
|
|
List<InfiniteScrollData> scrollDatalist = scrollList.GetDataList();
|
|
int categoryIndex = scrollDatalist.FindIndex(0, (item) =>
|
|
{
|
|
if (item is ComponentListItemData data) return data.CategoryName == categoryData.CategoryName;
|
|
return false;
|
|
});
|
|
|
|
if (categoryIndex == -1) return; // 카테고리를 찾지 못하면 중단
|
|
|
|
List<ComponentListItemData> list = filteredData != null ? filteredData : data;
|
|
|
|
// 2. 카테고리의 확장 상태를 변경합니다.
|
|
categoryData.IsExpanded = !categoryData.IsExpanded;
|
|
|
|
// 3. 확장/축소 상태에 따라 자식 아이템을 추가하거나 제거합니다.
|
|
if (categoryData.IsExpanded) // 카테고리 확장
|
|
{
|
|
// 원본 데이터 리스트에서 이 카테고리에 속한 자식들을 찾습니다.
|
|
int originalDataIndex = list.IndexOf(categoryData);
|
|
if (originalDataIndex != -1)
|
|
{
|
|
var childrenToAdd = list.Skip(originalDataIndex + 1)
|
|
.Take(categoryData.CategoryBadgeCount)
|
|
.ToArray();
|
|
|
|
// 카테고리 바로 다음 위치에 자식 아이템들을 삽입합니다.
|
|
if (childrenToAdd.Length > 0)
|
|
{
|
|
scrollList.InsertData(childrenToAdd, categoryIndex + 1, true);
|
|
}
|
|
}
|
|
}
|
|
else // 카테고리 축소
|
|
{
|
|
// 카테고리 바로 다음에 있는 자식 아이템들을 제거합니다.
|
|
// 한 번에 하나씩 제거하므로, 항상 categoryIndex + 1 위치의 아이템을 제거합니다.
|
|
for (int i = 0; i < categoryData.CategoryBadgeCount; i++)
|
|
{
|
|
// 제거할 아이템이 리스트 범위 내에 있는지 확인
|
|
if (categoryIndex + 1 < scrollList.GetDataCount())
|
|
{
|
|
scrollList.RemoveData(categoryIndex + 1, true);
|
|
}
|
|
else
|
|
{
|
|
break; // 범위를 벗어나면 중단
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// 검색 입력 필드의 내용이 변경되고 제출(Enter)되었을 때 호출됩니다.
|
|
/// 입력된 텍스트를 기반으로 scrollList의 내용을 필터링합니다.
|
|
/// </summary>
|
|
/// <param name="text">사용자가 입력한 검색어</param>
|
|
protected virtual void OnInputFieldChanged(string text)
|
|
{
|
|
Debug.Log($"Searching1 for: {text}");
|
|
if (scrollList == null || inputField == null) return;
|
|
// 검색어가 비어있으면 필터링을 수행하지 않습니다.
|
|
if (string.IsNullOrEmpty(text)) return;
|
|
|
|
// 검색어가 너무 짧으면 사용자에게 알림을 표시하고 입력을 초기화합니다.
|
|
if (text.Length < 3)
|
|
{
|
|
inputField.text = string.Empty; // 입력 필드 초기화
|
|
inputField.ActivateInputField(); // 입력 필드에 다시 포커스 설정
|
|
Toast.Show("검색어는 3글자 이상 입력해주세요.", 2f);
|
|
return;
|
|
}
|
|
|
|
Debug.Log($"Searching2 for: {text}");
|
|
|
|
// 특수 필터 키워드가 있는지 확인합니다.
|
|
// 예: "@Category 전기" -> '전기' 카테고리 필터
|
|
bool hasCategoryFilter = text.StartsWith("@Category ");
|
|
// bool hasAreaFilter = text.StartsWith("@Area ");
|
|
// bool hasFloorFilter = text.StartsWith("@Floor ");
|
|
|
|
// 원본 데이터(data)를 기반으로 필터링된 새로운 리스트를 생성합니다.
|
|
List<ComponentListItemData> filteredItems = data.Where(itemData =>
|
|
{
|
|
if (itemData.IsCategory) return false; // 카테고리 항목은 항상 제외
|
|
|
|
if (hasCategoryFilter)
|
|
{
|
|
string categoryText = text.Substring("@Category ".Length).Trim();
|
|
return itemData.CategoryName.Contains(categoryText, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
/* else if (hasAreaFilter)
|
|
{
|
|
string areaText = text.Substring("@Area ".Length).Trim();
|
|
return itemData.Area.Contains(areaText, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
else if (hasFloorFilter)
|
|
{
|
|
string floorText = text.Substring("@Floor ".Length).Trim();
|
|
return itemData.Floor.Contains(floorText, StringComparison.OrdinalIgnoreCase);
|
|
} */
|
|
else
|
|
{
|
|
return itemData.Name.Contains(text, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}).ToList();
|
|
|
|
if (filteredData == null)
|
|
{
|
|
filteredData = new List<ComponentListItemData>();
|
|
}
|
|
else
|
|
{
|
|
filteredData.Clear();
|
|
}
|
|
string resultText = LocalizationManager.Instance.GetString("검색결과");
|
|
|
|
// 검색 결과 카테고리 아이템을 추가합니다.
|
|
filteredData.Add(new ComponentListItemData
|
|
{
|
|
IsCategory = true,
|
|
CategoryName = $"{resultText}: {filteredItems.Count}건",
|
|
CategoryBadgeCount = filteredItems.Count,
|
|
IsExpanded = true, // 검색 결과는 항상 펼쳐진 상태로 시작
|
|
});
|
|
|
|
// 필터링된 아이템들을 결과 리스트에 추가합니다.
|
|
filteredData.AddRange(filteredItems);
|
|
|
|
// 스크롤 리스트를 비우고 필터링된 결과로 새로 채웁니다.
|
|
scrollList.Clear();
|
|
scrollList.InsertData(filteredData.ToArray(), true);
|
|
}
|
|
|
|
public void SetSearchText(string text)
|
|
{
|
|
if (inputField != null)
|
|
{
|
|
inputField.text = $"@{text} ";
|
|
inputField.SetCaretToEndAsync().Forget();
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// 검색창의 'X' 버튼 클릭 시 호출됩니다.
|
|
/// 입력 필드를 비우고, 적용되었던 모든 필터를 제거합니다.
|
|
/// </summary>
|
|
public virtual void OnClickClearText()
|
|
{
|
|
if (inputField != null && scrollList != null && data != null)
|
|
{
|
|
inputField.text = string.Empty; // 입력 필드 초기화
|
|
inputField.ActivateInputField(); // 입력 필드에 다시 포커스 설정
|
|
if (filteredData != null)
|
|
{
|
|
filteredData.Clear(); // 필터링된 데이터 리스트를 비웁니다.
|
|
filteredData = null;
|
|
}
|
|
// scrollList에 설정된 필터를 제거(null)하여 모든 항목이 보이도록 합니다.
|
|
scrollList.SetFilter(null);
|
|
scrollList.ClearData(); // 스크롤 리스트의 내용을 비웁니다.
|
|
scrollList.InsertData(data.ToArray());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 샘플 데이터를 생성합니다.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public static SortedDictionary<string, List<ComponentListItemData>> GenerateData()
|
|
{
|
|
var data = new SortedDictionary<string, List<ComponentListItemData>>();
|
|
for (int i = 1; i <= 5; i++)
|
|
{
|
|
string categoryName = $"Category {i}";
|
|
var itemList = new List<ComponentListItemData>();
|
|
for (int j = 1; j <= 20; j++)
|
|
{
|
|
var itemData = new ComponentListItemData
|
|
{
|
|
Id = $"Item_{i}_{j}",
|
|
Name = $"Item {i}-{j}",
|
|
Option = $"Option {j}",
|
|
IsCategory = false,
|
|
};
|
|
itemList.Add(itemData);
|
|
}
|
|
data.Add(categoryName, itemList);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭 콘텐츠에 데이터를 전달합니다.
|
|
/// </summary>
|
|
/// <param name="data">전달할 데이터 객체</param>
|
|
public void SetContentData(object? data)
|
|
{
|
|
if (data is SortedDictionary<string, List<ComponentListItemData>> objectsData)
|
|
{
|
|
SetupData(objectsData);
|
|
}
|
|
else if (data is List<ComponentListItemData> list)
|
|
{
|
|
if (list.Count == 0) return;
|
|
var groupedData = new SortedDictionary<string, List<ComponentListItemData>>();
|
|
groupedData[list.First().CategoryName] = list;
|
|
SetupData(groupedData);
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// 탭 전환 시 데이터가 있는 경우 전달 되는 데이터. SetContentData 이후 호출 됨
|
|
/// </summary>
|
|
/// <param name="data">전달할 데이터 객체</param>
|
|
public void UpdateContentData(object? data)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// 닫힐 때 실행되는 로직을 처리합니다.
|
|
/// </summary>
|
|
/// <returns>비동기 닫기 작업을 나타내는 <see cref="UniTask"/>입니다.</returns>
|
|
public UniTask OnCloseAsync()
|
|
{
|
|
Debug.Log("TabContentTabComponentList: OnClose called");
|
|
return UniTask.CompletedTask;
|
|
}
|
|
|
|
|
|
}
|
|
}
|