accorionWindow 추가, libraryWindow 추가

This commit is contained in:
logonkhi
2025-11-06 15:51:29 +09:00
parent 0885f02871
commit 6b9af39cf1
36 changed files with 6356 additions and 533 deletions

View File

@@ -7,8 +7,7 @@ namespace UVC.UI.List.Accordion
{
/// <summary>
/// 섹션 내 아이템 배치 타입.
/// Horizontal: head-content-tail3영역 수평 배치
/// Grid: 이미지+캡션2열 그리드
/// Horizontal: head-content-tail3영역 수평 배치, Grid: 이미지+캡션 그리드.
/// </summary>
public enum AccordionItemLayoutType
{
@@ -33,34 +32,38 @@ namespace UVC.UI.List.Accordion
[Serializable]
public class AccordionContentSpec
{
/// <summary>콘텐츠 종류.</summary>
public AccordionContentKind Kind = AccordionContentKind.None;
// Text
/// <summary>표시 텍스트.</summary>
public string? Text;
public UnityEngine.Object? Font; // TMP_FontAsset 또는 Font
public Color TextColor = Color.white;
public int FontSize = 22;
/// <summary>클릭 콜백.</summary>
public Action<AccordionHorizontalItemData>? OnClick;
// Image
public Sprite? Sprite;
/// <summary>리소스 스프라이트 경로(Resources 기준).</summary>
public string? Sprite;
/// <summary>이미지 색.</summary>
public Color ImageColor = Color.white;
// IconButton
public Sprite? Icon;
public Action? OnClick;
/// <summary>아이콘 스프라이트 경로(Resources 기준).</summary>
public string? Icon;
/// <summary>툴팁 텍스트(선택).</summary>
public string? Tooltip;
public static AccordionContentSpec FromText(string text, Color? color = null, int fontSize = 22, UnityEngine.Object? font = null)
/// <summary>텍스트 콘텐츠 스펙을 생성합니다.</summary>
public static AccordionContentSpec FromText(string text, Action<AccordionHorizontalItemData>? OnClick = null)
=> new AccordionContentSpec
{
Kind = AccordionContentKind.Text,
Text = text,
TextColor = color ?? Color.white,
FontSize = fontSize,
Font = font
OnClick = OnClick
};
public static AccordionContentSpec FromImage(Sprite? sprite, Color? color = null)
/// <summary>이미지 콘텐츠 스펙을 생성합니다.</summary>
public static AccordionContentSpec FromImage(string? sprite, Color? color = null)
=> new AccordionContentSpec
{
Kind = AccordionContentKind.Image,
@@ -68,12 +71,13 @@ namespace UVC.UI.List.Accordion
ImageColor = color ?? Color.white
};
public static AccordionContentSpec FromIconButton(Sprite? icon, Action onClick, string? tooltip = null)
/// <summary>아이콘 버튼 스펙을 생성합니다.</summary>
public static AccordionContentSpec FromIconButton(string? icon, Action<AccordionHorizontalItemData> OnClick, string? tooltip = null)
=> new AccordionContentSpec
{
Kind = AccordionContentKind.IconButton,
Icon = icon,
OnClick = onClick,
OnClick = OnClick,
Tooltip = tooltip
};
}
@@ -83,6 +87,7 @@ namespace UVC.UI.List.Accordion
/// </summary>
public interface IAccordionItemData
{
/// <summary>레이아웃 종류.</summary>
AccordionItemLayoutType LayoutType { get; }
}
@@ -92,11 +97,15 @@ namespace UVC.UI.List.Accordion
[Serializable]
public class AccordionHorizontalItemData : IAccordionItemData
{
/// <inheritdoc />
public AccordionItemLayoutType LayoutType => AccordionItemLayoutType.Horizontal;
/// <summary>헤드 영역 콘텐츠.</summary>
public AccordionContentSpec Head = new AccordionContentSpec();
/// <summary>콘텐츠 영역(텍스트/버튼).</summary>
public AccordionContentSpec Content = new AccordionContentSpec();
public AccordionContentSpec Tail = new AccordionContentSpec();
/// <summary>테일 영역 아이콘 버튼 리스트.</summary>
public List<AccordionContentSpec> Tail = new List<AccordionContentSpec>();
/// <summary>
/// 임의의 태그/키 값.
@@ -110,13 +119,22 @@ namespace UVC.UI.List.Accordion
[Serializable]
public class AccordionGridItemData : IAccordionItemData
{
/// <inheritdoc />
public AccordionItemLayoutType LayoutType => AccordionItemLayoutType.Grid;
public Sprite? Image;
/// <summary>이미지 경로(Resources 기준).</summary>
public string? Image;
/// <summary>캡션 텍스트.</summary>
public string Caption = string.Empty;
public Color CaptionColor = Color.white;
public int CaptionSize = 20;
public Action? OnClick;
/// <summary>캡션 색(선택).</summary>
public Color? CaptionColor;// = Color.white;
/// <summary>캡션 폰트 크기(선택).</summary>
public int? CaptionSize;// =12;
/// <summary>클릭 콜백(선택).</summary>
public Action<AccordionGridItemData>? OnClick;
/// <summary>드랍 콜백(선택).</summary>
public Action<AccordionGridItemData>? OnDrop;
/// <summary>임의의 태그/키 값.</summary>
public string? Tag;
}
@@ -126,11 +144,16 @@ namespace UVC.UI.List.Accordion
[Serializable]
public class AccordionSectionData
{
/// <summary>섹션 제목.</summary>
public string Title = string.Empty;
/// <summary>초기 펼침 상태.</summary>
public bool IsExpanded = false;
/// <summary>아이템 레이아웃 유형.</summary>
public AccordionItemLayoutType LayoutType = AccordionItemLayoutType.Horizontal;
// 아이템 리스트. LayoutType에 따라 타입을 일치시켜 사용합니다.
/// <summary>
/// 아이템 리스트. <see cref="LayoutType"/>에 따라 타입을 일치시켜 사용합니다.
/// </summary>
public List<IAccordionItemData> Items = new List<IAccordionItemData>();
}
@@ -140,6 +163,7 @@ namespace UVC.UI.List.Accordion
[Serializable]
public class AccordionData
{
/// <summary>섹션 목록.</summary>
public List<AccordionSectionData> Sections = new List<AccordionSectionData>();
}
}

View File

@@ -1,28 +1,48 @@
#nullable enable
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using UVC.Util;
namespace UVC.UI.List.Accordion
{
/// <summary>
/// 그리드 셀 항목(이미지+캡션). 셀 크기는 상위 GridLayoutGroup에서 결정.
/// 그리드 셀 항목(이미지+캡션).
/// 셀 크기는 상위 <see cref="GridLayoutGroup"/> 또는 레이아웃 설정에 의해 결정됩니다.
/// 드래그 시 이미지 스프라이트를 복사한 고스트를 마우스에 따라 이동시키고, 드랍 시 제거 후 <see cref="AccordionGridItemData.OnDrop"/>를 발생시킵니다.
/// </summary>
public class AccordionGridItemView : MonoBehaviour
public class AccordionGridItemView : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private Image image = default!;
[SerializeField] private Component caption = default!; // TMP or Text
[SerializeField] private TMPro.TextMeshProUGUI caption = default!; // TMP or Text
[SerializeField] private Button button = default!;
// Drag state
private Canvas _canvas = default!;
private Image? _dragGhost;
private AccordionGridItemData _boundData = default!;
/// <summary>
/// 에디터에서 컴포넌트를 추가할 때 기본 레이아웃을 보장합니다.
/// </summary>
private void Reset()
{
EnsureLayout();
}
/// <summary>
/// 런타임 시작 시 레이아웃과 캐시를 초기화합니다.
/// </summary>
private void Awake()
{
EnsureLayout();
}
/// <summary>
/// 필수 레이아웃/컴포넌트를 확보하고 기본 속성을 설정합니다.
/// 누락 시 런타임에 동적으로 생성합니다.
/// </summary>
private void EnsureLayout()
{
if (image == null)
@@ -33,33 +53,151 @@ namespace UVC.UI.List.Accordion
image.preserveAspect = true;
}
if (caption == null)
{
caption = TMPProxy.CreateTextGO(transform, "Caption");
TMPProxy.SetAlignment(caption, "TopLeft");
{
var captionGO = new GameObject("Caption", typeof(RectTransform), typeof(TMPro.TextMeshProUGUI));
captionGO.transform.SetParent(transform, false);
caption = captionGO.GetComponent<TMPro.TextMeshProUGUI>();
caption.fontSize = 12;
}
if (button == null)
{
var btn = gameObject.GetComponent<Button>();
if (btn == null) btn = gameObject.AddComponent<Button>();
if (btn == null)
{
btn = gameObject.AddComponent<Button>();
btn.targetGraphic = image;
}
button = btn;
}
var v = GetComponent<VerticalLayoutGroup>();
if (v == null) v = gameObject.AddComponent<VerticalLayoutGroup>();
v.childControlHeight = true;
v.childForceExpandHeight = true;
v.spacing = 2f;
v.childControlWidth = true;
v.childForceExpandWidth = true;
v.spacing = 4f;
var pad = 4;
v.padding = new RectOffset(pad, pad, pad, pad);
// cache canvas for drag visuals
if (_canvas == null)
{
_canvas = GetComponentInParent<Canvas>();
if (_canvas == null)
{
// 최상위 Canvas가 없다면 씬에서 하나를 찾아 사용
_canvas = FindFirstObjectByType<Canvas>();
}
}
}
public void Initialize(AccordionGridItemData data)
/// <summary>
/// 셀을 데이터로 초기화합니다.
/// </summary>
/// <param name="data">이미지 경로/캡션/색/크기/클릭 콜백을 포함한 데이터.</param>
public async UniTask Initialize(AccordionGridItemData data)
{
image.sprite = data.Image;
TMPProxy.SetText(caption, data.Caption);
TMPProxy.SetColor(caption, data.CaptionColor);
TMPProxy.SetFontSize(caption, data.CaptionSize);
_boundData = data;
caption.text = data.Caption;
if (data.Image != null) image.sprite = await ResourceManager.LoadOnlyAsync<Sprite>(data.Image);
if (data.CaptionColor.HasValue) caption.color = data.CaptionColor.Value;
if (data.CaptionSize.HasValue) caption.fontSize = data.CaptionSize.Value;
button.onClick.RemoveAllListeners();
if (data.OnClick != null) button.onClick.AddListener(() => data.OnClick());
if (data.OnClick != null) button.onClick.AddListener(() => data.OnClick!(data));
}
/// <summary>
/// 드래그 시작 시 호출됩니다. 드래그 고스트 이미지를 생성하고 초기 위치를 설정합니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
public void OnBeginDrag(PointerEventData eventData)
{
if (image == null || image.sprite == null || _canvas == null) return;
// 고스트 생성 (이미지 스프라이트 복사)
var go = new GameObject("DragGhost", typeof(RectTransform), typeof(CanvasGroup), typeof(Image));
go.transform.SetParent(_canvas.transform, false);
go.transform.SetAsLastSibling();
var ghostImg = go.GetComponent<Image>();
ghostImg.sprite = image.sprite;
ghostImg.preserveAspect = true;
ghostImg.raycastTarget = false; // 드래그 중에는 이벤트 차단하지 않도록
ghostImg.color = new Color(1f, 1f, 1f, 0.6f);
// 크기/위치 초기화
var srcRT = image.rectTransform;
var dstRT = (RectTransform)go.transform;
dstRT.sizeDelta = srcRT.rect.size;
SetGhostPosition(eventData, dstRT);
_dragGhost = ghostImg;
}
/// <summary>
/// 드래그 중에 호출됩니다. 고스트 이미지를 현재 포인터 위치로 이동합니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
public void OnDrag(PointerEventData eventData)
{
if (_dragGhost == null || _canvas == null) return;
SetGhostPosition(eventData, (RectTransform)_dragGhost.transform);
}
/// <summary>
/// 드래그 종료 시 호출됩니다. 고스트를 제거하고 드랍 콜백을 발생시킵니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
public void OnEndDrag(PointerEventData eventData)
{
// 고스트 제거
if (_dragGhost != null)
{
Destroy(_dragGhost.gameObject);
_dragGhost = null;
}
// 드랍 이벤트 통지
_boundData?.OnDrop?.Invoke(_boundData);
}
/// <summary>
/// 고스트 RectTransform을 현재 포인터 위치로 배치합니다.
/// Canvas의 렌더 모드에 따라 화면/월드 좌표를 변환합니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
/// <param name="ghostRt">이동시킬 고스트 RectTransform.</param>
private void SetGhostPosition(PointerEventData eventData, RectTransform ghostRt)
{
if (_canvas == null)
return;
if (_canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
ghostRt.position = eventData.position;
}
else
{
var cam = _canvas.worldCamera != null ? _canvas.worldCamera : eventData.pressEventCamera;
if (RectTransformUtility.ScreenPointToWorldPointInRectangle(_canvas.transform as RectTransform, eventData.position, cam, out var worldPos))
{
ghostRt.position = worldPos;
}
else
{
ghostRt.position = eventData.position; // fallback
}
}
}
/// <summary>
/// 종료 시 등록된 콜백과 드래그 고스트를 정리합니다.
/// </summary>
private void OnDestroy()
{
button.onClick.RemoveAllListeners();
if (_dragGhost != null)
{
Destroy(_dragGhost.gameObject);
_dragGhost = null;
}
}
}
}

View File

@@ -1,18 +1,33 @@
#nullable enable
using Cysharp.Threading.Tasks;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using UVC.Util;
namespace UVC.UI.List.Accordion
{
/// <summary>
/// 수평 아이템(Head-Content-Tail) 뷰. 고정 높이35.
/// 수평 아이템(Head-Content-Tail) 뷰.
/// 각 영역은 Text, Image, IconButton를 지원합니다.
/// </summary>
public class AccordionItemView : MonoBehaviour
{
[SerializeField] private RectTransform headRoot = default!;
[SerializeField] private RectTransform contentRoot = default!;
[SerializeField] private RectTransform tailRoot = default!;
[SerializeField]
private Image headerImage = default!;
[SerializeField]
private Button content = default!;
[SerializeField]
private RectTransform tail = default!;
[SerializeField]
private GameObject tailIconButtonPrefab = default!;
private TMPro.TextMeshProUGUI contentText;
private AccordionHorizontalItemData data;
private void Reset()
{
@@ -24,109 +39,142 @@ namespace UVC.UI.List.Accordion
EnsureLayout();
}
/// <summary>
/// 뷰의 레이아웃 구성 요소를 보장하고, 누락 시 기본 구성으로 생성합니다.
/// </summary>
private void EnsureLayout()
{
var rt = (RectTransform)transform;
rt.sizeDelta = new Vector2(AccordionList.Width, AccordionList.ItemHeight);
var h = GetComponent<HorizontalLayoutGroup>();
if (h == null) h = gameObject.AddComponent<HorizontalLayoutGroup>();
h.childControlHeight = true;
h.childForceExpandHeight = true;
h.childControlWidth = true;
h.childForceExpandWidth = true;
h.spacing = 4f;
h.padding = new RectOffset(6, 6, 0, 0);
if (h == null)
{
var rt = (RectTransform)transform;
rt.sizeDelta = new Vector2(AccordionList.Width, AccordionList.ItemHeight);
h = gameObject.AddComponent<HorizontalLayoutGroup>();
h.childControlHeight = false;
h.childForceExpandHeight = false;
h.childControlWidth = true;
h.childForceExpandWidth = false;
h.spacing = 4f;
h.padding = new RectOffset(5, 5, 0, 0);
}
if (headRoot == null)
if (headerImage == null)
{
headRoot = CreateSlot("Head", 0.25f);
var go = new GameObject("HeadImage", typeof(RectTransform));
go.transform.SetParent(transform, false);
headerImage = go.AddComponent<Image>();
var layout = go.AddComponent<LayoutElement>();
layout.flexibleWidth = 0;
layout.preferredWidth = AccordionList.ItemHeight;
layout.preferredHeight = AccordionList.ItemHeight;
}
if (contentRoot == null)
if (content == null)
{
contentRoot = CreateSlot("Content", 0.5f);
var go = new GameObject("Content", typeof(RectTransform));
go.transform.SetParent(transform, false);
content = go.AddComponent<Button>();
contentText = content.GetComponentInChildren<TMPro.TextMeshProUGUI>();
contentText.fontSize = 13;
}
if (tailRoot == null)
else
{
tailRoot = CreateSlot("Tail", 0.25f);
contentText = content.GetComponentInChildren<TMPro.TextMeshProUGUI>();
}
if (tail == null)
{
Debug.LogWarning("AccordionItemView: tail RectTransform is not assigned.");
}
if (tailIconButtonPrefab == null)
{
Debug.LogWarning("AccordionItemView: tailIconButtonPrefab is not assigned.");
}
}
private RectTransform CreateSlot(string name, float flexible)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(transform, false);
var layout = go.AddComponent<LayoutElement>();
layout.flexibleWidth = flexible;
layout.minHeight = AccordionList.ItemHeight;
var rt = (RectTransform)go.transform;
return rt;
}
/// <summary>
/// 주어진 데이터로 수평 아이템을 초기화합니다.
/// </summary>
/// <param name="data">헤드/콘텐츠/테일 정보를 포함한 데이터.</param>
public void Initialize(AccordionHorizontalItemData data)
{
BuildArea(headRoot, data.Head);
BuildArea(contentRoot, data.Content);
BuildArea(tailRoot, data.Tail);
}
this.data = data;
SetImage(data.Head.Sprite, data.Head.ImageColor).Forget();
SetText(data.Content.Text, data.Content.OnClick);
tail.gameObject.SetActive(data.Tail.Count > 0);
if (data.Tail.Count > 0)
{
// 기존 버튼 제거
foreach (Transform child in tail)
{
child.GetComponent<Button>().onClick.RemoveAllListeners();
Destroy(child.gameObject);
}
private void BuildArea(RectTransform parent, AccordionContentSpec spec)
{
// clear
for (int i = parent.childCount - 1; i >= 0; i--)
{
Destroy(parent.GetChild(i).gameObject);
}
switch (spec.Kind)
{
case AccordionContentKind.Text:
BuildText(parent, spec.Text ?? string.Empty, spec.Font, spec.TextColor, spec.FontSize);
break;
case AccordionContentKind.Image:
BuildImage(parent, spec.Sprite, spec.ImageColor);
break;
case AccordionContentKind.IconButton:
BuildIconButton(parent, spec.Icon, spec.OnClick);
break;
default:
break;
foreach (var tail in data.Tail)
{
BuildIconButton(tail.Icon, tail.OnClick).Forget();
}
}
}
private void BuildText(RectTransform parent, string text, UnityEngine.Object? fontAsset, Color color, int size)
/// <summary>
/// 텍스트와 클릭 콜백을 설정합니다. 텍스트가 비어있으면 콘텐츠 영역을 숨깁니다.
/// </summary>
private void SetText(string? text, System.Action<AccordionHorizontalItemData>? onClick)
{
var comp = TMPProxy.CreateTextGO(parent, "Text");
TMPProxy.SetText(comp, text);
TMPProxy.SetColor(comp, color);
TMPProxy.SetFontSize(comp, size);
TMPProxy.SetFont(comp, fontAsset);
TMPProxy.SetAlignment(comp, "MidlineLeft");
if (string.IsNullOrEmpty(text))
{
content.gameObject.SetActive(false);
return;
}
content.gameObject.SetActive(true);
content.onClick.RemoveAllListeners();
if (onClick != null) content.onClick.AddListener(() => onClick(data));
contentText.text = text;
}
private void BuildImage(RectTransform parent, Sprite? sprite, Color color)
/// <summary>
/// 헤더 이미지를 비동기로 로드하고 색을 적용합니다. 스프라이트가 없으면 영역을 숨깁니다.
/// </summary>
private async UniTask SetImage(string? sprite, Color color)
{
var go = new GameObject("Image", typeof(RectTransform), typeof(Image));
go.transform.SetParent(parent, false);
var img = go.GetComponent<Image>();
img.sprite = sprite;
img.color = color;
img.preserveAspect = true;
var le = go.AddComponent<LayoutElement>();
le.minHeight = AccordionList.ItemHeight;
if (sprite == null)
{
headerImage.gameObject.SetActive(false);
return;
}
headerImage.gameObject.SetActive(true);
headerImage.sprite = await ResourceManager.LoadOnlyAsync<Sprite>(sprite);
headerImage.color = color;
}
private void BuildIconButton(RectTransform parent, Sprite? icon, System.Action? onClick)
/// <summary>
/// 테일 영역에 아이콘 버튼을 생성합니다. 아이콘이 없으면 생성하지 않습니다.
/// </summary>
private async UniTask BuildIconButton(string? icon, System.Action<AccordionHorizontalItemData>? onClick)
{
var go = new GameObject("IconButton", typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
if (icon == null) return;
var go = Instantiate(this.tailIconButtonPrefab, tail);
var button = go.GetComponent<Button>();
button.onClick.RemoveAllListeners();
if (onClick != null) button.onClick.AddListener(() => onClick(data));
var img = go.GetComponent<Image>();
img.sprite = icon;
img.preserveAspect = true;
var btn = go.GetComponent<Button>();
btn.onClick.RemoveAllListeners();
if (onClick != null) btn.onClick.AddListener(() => onClick());
var le = go.AddComponent<LayoutElement>();
le.minHeight = AccordionList.ItemHeight;
le.minWidth = AccordionList.ItemHeight;
img.sprite = await ResourceManager.LoadOnlyAsync<Sprite>(icon);
}
private void OnDestroy()
{
content.onClick.RemoveAllListeners();
foreach (Transform child in tail)
{
var btn = child.GetComponent<Button>();
if (btn != null)
{
btn.onClick.RemoveAllListeners();
}
}
}
}
}

View File

@@ -6,71 +6,58 @@ using UnityEngine.UI;
namespace UVC.UI.List.Accordion
{
/// <summary>
/// 아코디언 전체 리스트 뷰. 섹션 헤더(48)와 항목(35)을 생성/관리합니다.
/// 가로 너비는250으로 고정합니다.
/// 아코디언 전체 리스트 뷰.
/// 섹션 헤더와 항목 뷰를 생성/관리하고, 전달받은 데이터(<see cref="AccordionData"/>)를 기준으로 동적으로 빌드합니다.
/// </summary>
/// <remarks>
/// - 가로 너비는 <see cref="Width"/>로 고정됩니다.
/// - 섹션 헤더 높이는 <see cref="HeaderHeight"/>, 아이템 높이는 <see cref="ItemHeight"/>를 따릅니다.
/// - 그리드 레이아웃 기본 열 수는 <see cref="GridColumns"/>입니다.
/// </remarks>
[DisallowMultipleComponent]
public class AccordionList : MonoBehaviour
{
// Layout constants
public const float Width = 250f;
public const float HeaderHeight = 48f;
public const float ItemHeight = 35f;
public const int GridColumns = 2;
/// <summary>리스트 전체 가로 폭(px).</summary>
public const float Width =300f;
/// <summary>섹션 헤더 높이(px).</summary>
public const float HeaderHeight =30f;
/// <summary>수평 아이템 높이(px).</summary>
public const float ItemHeight =25f;
/// <summary>그리드 섹션의 기본 열 수.</summary>
public const int GridColumns =2;
[Header("Prefabs")]
[SerializeField] private AccordionSection sectionPrefab = default!;
[SerializeField] private AccordionItemView horizontalItemPrefab = default!;
[SerializeField] private AccordionItemView itemPrefab = default!;
[SerializeField] private AccordionGridItemView gridItemPrefab = default!;
[Header("Root")]
[SerializeField] private RectTransform contentRoot = default!; // VerticalLayoutGroup 권장
[Header("Style")]
[SerializeField] private Color sectionHeaderColor = new Color(0.16f, 0.16f, 0.16f);
[SerializeField] private Color itemBgColor = new Color(0.10f, 0.10f, 0.10f);
/// <summary>
/// 현재 바인딩된 데이터. <see langword="null"/>이면 비어있는 상태입니다.
/// </summary>
public AccordionData? Data { get; private set; }
private readonly List<AccordionSection> _sections = new();
private void Reset()
{
EnsureRootLayout();
EnsurePrefabs();
}
private void Awake()
{
EnsureRootLayout();
EnsurePrefabs();
}
private void EnsureRootLayout()
{
if (contentRoot == null)
if(sectionPrefab == null || itemPrefab == null || gridItemPrefab == null)
{
var go = new GameObject("ContentRoot", typeof(RectTransform));
go.transform.SetParent(transform, false);
contentRoot = (RectTransform)go.transform;
EnsurePrefabs();
}
var rt = (RectTransform)transform;
rt.sizeDelta = new Vector2(Width, rt.sizeDelta.y);
var v = contentRoot.GetComponent<VerticalLayoutGroup>();
if (v == null) v = contentRoot.gameObject.AddComponent<VerticalLayoutGroup>();
v.childForceExpandHeight = false;
v.childControlHeight = true;
v.childForceExpandWidth = true;
v.childControlWidth = true;
v.spacing = 0f;
var fitter = contentRoot.GetComponent<ContentSizeFitter>();
if (fitter == null) fitter = contentRoot.gameObject.AddComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
if(contentRoot == null)
{
Debug.LogWarning("AccordionList: contentRoot is not assigned.");
}
}
/// <summary>
/// 프리팹 참조가 누락된 경우, 런타임용 최소 프로토타입을 동적으로 생성합니다.
/// </summary>
private void EnsurePrefabs()
{
if (sectionPrefab == null)
@@ -80,12 +67,12 @@ namespace UVC.UI.List.Accordion
proto.SetActive(false);
sectionPrefab = proto.GetComponent<AccordionSection>();
}
if (horizontalItemPrefab == null)
if (itemPrefab == null)
{
var proto = new GameObject("HorizontalItemPrototype", typeof(RectTransform), typeof(AccordionItemView));
proto.transform.SetParent(transform, false);
proto.SetActive(false);
horizontalItemPrefab = proto.GetComponent<AccordionItemView>();
itemPrefab = proto.GetComponent<AccordionItemView>();
}
if (gridItemPrefab == null)
{
@@ -96,9 +83,12 @@ namespace UVC.UI.List.Accordion
}
}
/// <summary>
/// 전달된 아코디언 데이터를 사용해 뷰를 재구성합니다.
/// </summary>
/// <param name="data">섹션/아이템을 포함한 루트 데이터.</param>
public void SetData(AccordionData data)
{
EnsureRootLayout();
{
EnsurePrefabs();
Clear();
Data = data;
@@ -106,11 +96,14 @@ namespace UVC.UI.List.Accordion
{
var section = Instantiate(sectionPrefab, contentRoot);
section.gameObject.SetActive(true);
section.Initialize(sectionData, this, horizontalItemPrefab, gridItemPrefab);
section.Initialize(sectionData, this, itemPrefab, gridItemPrefab);
_sections.Add(section);
}
}
/// <summary>
/// 현재 생성된 모든 섹션/아이템 뷰를 파괴하고 상태를 초기화합니다.
/// </summary>
public void Clear()
{
foreach (var s in _sections)
@@ -120,5 +113,10 @@ namespace UVC.UI.List.Accordion
_sections.Clear();
Data = null;
}
private void OnDestroy()
{
Clear();
}
}
}

View File

@@ -1,19 +1,36 @@
#nullable enable
using DG.Tweening;
using NUnit.Framework.Interfaces;
using UnityEngine;
using UnityEngine.UI;
namespace UVC.UI.List.Accordion
{
/// <summary>
/// 단일 섹션 헤더(48높이)와 본문을 관리합니다.
/// 단일 섹션 .
/// 헤더(제목/접기버튼)와 본문(수평 아이템 또는 그리드 아이템 컨테이너)을 관리합니다.
/// </summary>
public class AccordionSection : MonoBehaviour
{
[SerializeField] private Button headerButton = default!;
[SerializeField] private Image headerBg = default!;
[SerializeField] private Component titleText = default!; // TMP or Text
[SerializeField] private RectTransform bodyRoot = default!; // items parent
[SerializeField] private GameObject bodyContainer = default!; // active toggle
[SerializeField]
private Button expandButton = default!;
[SerializeField]
private Image bg = default!;
[SerializeField]
private TMPro.TextMeshProUGUI titleText = default!; // TMP or Text
[SerializeField]
private GameObject verticalBodyContainer = default!; // active toggle
[SerializeField]
private GameObject gridBodyContainer = default!; // active toggle
[Header("Style")]
[SerializeField] private Color expandColor = new Color(0.16f, 0.16f, 0.16f);
private RectTransform bodyRoot = default!; // items parent
private AccordionSectionData? _data;
private AccordionList? _list;
@@ -30,49 +47,54 @@ namespace UVC.UI.List.Accordion
EnsureRuntimeComponents();
}
/// <summary>
/// 에디터/런타임에서 필요한 구성 요소가 연결되어 있는지 검사하고 경고를 출력합니다.
/// </summary>
private void EnsureRuntimeComponents()
{
var rt = (RectTransform)transform;
if (headerButton == null)
{
if (expandButton == null)
{
var headerGO = new GameObject("Header", typeof(RectTransform), typeof(Image), typeof(Button));
headerGO.transform.SetParent(transform, false);
var headerRT = (RectTransform)headerGO.transform;
headerRT.anchorMin = new Vector2(0, 1);
headerRT.anchorMax = new Vector2(1, 1);
headerRT.pivot = new Vector2(0.5f, 1);
headerRT.sizeDelta = new Vector2(0, AccordionList.HeaderHeight);
headerBg = headerGO.GetComponent<Image>();
headerButton = headerGO.GetComponent<Button>();
var textComp = TMPProxy.CreateTextGO(headerGO.transform, "Title");
var textRT = (RectTransform)textComp.transform;
textRT.anchorMin = new Vector2(0, 0);
textRT.anchorMax = new Vector2(1, 1);
textRT.offsetMin = new Vector2(12, 0);
textRT.offsetMax = new Vector2(-12, 0);
titleText = textComp;
TMPProxy.SetAlignment(titleText, "MidlineLeft");
Debug.LogWarning("AccordionSection: expandButton is not assigned.");
}
if (bodyContainer == null)
if (bg == null)
{
var bodyGO = new GameObject("BodyContainer", typeof(RectTransform));
bodyGO.transform.SetParent(transform, false);
bodyContainer = bodyGO;
bodyRoot = (RectTransform)bodyGO.transform;
var v = bodyGO.AddComponent<VerticalLayoutGroup>();
v.childControlHeight = true;
v.childForceExpandHeight = false;
v.spacing = 0f;
var fitter = bodyGO.AddComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
Debug.LogWarning("AccordionSection: bg is not assigned.");
}
else
{
bg.color = expandColor;
bg.enabled = false;
}
if (titleText == null)
{
Debug.LogWarning("AccordionSection: titleText is not assigned.");
}
if (bodyRoot == null)
{
Debug.LogWarning("AccordionSection: bodyRoot is not assigned.");
}
if (verticalBodyContainer == null)
{
Debug.LogWarning("AccordionSection: verticalBodyContainer is not assigned.");
}
if(gridBodyContainer == null)
{
Debug.LogWarning("AccordionSection: gridBodyContainer is not assigned.");
}
var layout = GetComponent<VerticalLayoutGroup>();
if (layout == null) layout = gameObject.AddComponent<VerticalLayoutGroup>();
layout.childControlHeight = true;
layout.childForceExpandHeight = false;
layout.spacing = 0f;
}
/// <summary>
/// 섹션을 초기화하고 레이아웃/아이템을 구성합니다.
/// </summary>
/// <param name="data">섹션 데이터(제목/펼침/아이템 목록).</param>
/// <param name="list">소속 리스트.</param>
/// <param name="horizontalItemPrefab">수평 아이템 프리팹.</param>
/// <param name="gridItemPrefab">그리드 아이템 프리팹.</param>
public void Initialize(AccordionSectionData data, AccordionList list, AccordionItemView horizontalItemPrefab, AccordionGridItemView gridItemPrefab)
{
_data = data;
@@ -84,33 +106,72 @@ namespace UVC.UI.List.Accordion
var rt = (RectTransform)transform;
rt.sizeDelta = new Vector2(AccordionList.Width, rt.sizeDelta.y);
headerBg.color = new Color(0.18f, 0.18f, 0.18f);
TMPProxy.SetText(titleText, data.Title);
bg.color = new Color(0.18f, 0.18f, 0.18f);
titleText.text = data.Title;
headerButton.onClick.RemoveAllListeners();
headerButton.onClick.AddListener(() => Toggle());
expandButton.onClick.RemoveAllListeners();
expandButton.onClick.AddListener(() => Toggle());
bodyContainer.SetActive(_data.IsExpanded);
bg.enabled = _data.IsExpanded;
expandButton.transform.DOKill(); // 이전 애니메이션이 있으면 종료
expandButton.transform.DORotate(new Vector3(0, 0, !_data.IsExpanded ? 0 : 180), 0.0f);
if (_data.LayoutType == AccordionItemLayoutType.Horizontal)
{
verticalBodyContainer.SetActive(_data.IsExpanded);
gridBodyContainer.SetActive(false);
bodyRoot = (RectTransform)verticalBodyContainer.transform;
}
else // Grid
{
verticalBodyContainer.SetActive(false);
gridBodyContainer.SetActive(_data.IsExpanded);
bodyRoot = (RectTransform)gridBodyContainer.transform;
}
BuildItems();
}
/// <summary>
/// 펼침/접힘 상태를 토글하고, 레이아웃을 반영합니다.
/// </summary>
private void Toggle()
{
if (_data == null) return;
_data.IsExpanded = !_data.IsExpanded;
bodyContainer.SetActive(_data.IsExpanded);
bg.enabled = _data.IsExpanded;
expandButton.transform.DOKill(); // 이전 애니메이션이 있으면 종료
expandButton.transform.DORotate(new Vector3(0, 0, !_data.IsExpanded ? 0 : 180), 0.0f);
if (_data.LayoutType == AccordionItemLayoutType.Horizontal)
{
verticalBodyContainer.SetActive(_data.IsExpanded);
}
else // Grid
{
gridBodyContainer.SetActive(_data.IsExpanded);
}
}
/// <summary>
/// 현재 본문 컨테이너의 모든 자식 뷰를 파괴합니다.
/// </summary>
private void ClearItems()
{
for (int i = bodyRoot.childCount - 1; i >= 0; i--)
{
var ch = bodyRoot.GetChild(i);
Destroy(ch.gameObject);
if(ch != null) Destroy(ch.gameObject);
}
}
/// <summary>
/// 섹션 데이터의 아이템을 순회하며 뷰를 생성합니다.
/// 레이아웃 타입에 따라 수평 아이템 또는 그리드 아이템을 인스턴스화합니다.
/// </summary>
private void BuildItems()
{
if (_data == null || _list == null) return;
@@ -129,30 +190,21 @@ namespace UVC.UI.List.Accordion
}
else // Grid
{
// Wrap to2 columns using GridLayoutGroup
var containerGO = new GameObject("Grid", typeof(RectTransform), typeof(GridLayoutGroup), typeof(ContentSizeFitter));
containerGO.transform.SetParent(bodyRoot, false);
var gridRT = (RectTransform)containerGO.transform;
gridRT.sizeDelta = new Vector2(AccordionList.Width, gridRT.sizeDelta.y);
var grid = containerGO.GetComponent<GridLayoutGroup>();
float padding = 6f;
grid.cellSize = new Vector2((AccordionList.Width - padding * 3) / 2f, AccordionList.ItemHeight * 2f);
grid.spacing = new Vector2(padding, padding);
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
grid.constraintCount = AccordionList.GridColumns;
var fitter = containerGO.GetComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
foreach (var item in _data.Items)
{
if (item is AccordionGridItemData g)
{
var view = Object.Instantiate(_gridItemPrefab!, gridRT);
view.Initialize(g);
var view = Object.Instantiate(_gridItemPrefab!, bodyRoot);
_ = view.Initialize(g);
}
}
}
}
private void OnDestroy()
{
ClearItems();
}
}
}

View File

@@ -1,105 +0,0 @@
#nullable enable
using System;
using UnityEngine;
using UnityEngine.UI;
namespace UVC.UI.List.Accordion
{
internal static class TMPProxy
{
private static Type? _tmpTextType;
private static Type? _tmpFontType;
private static Type? _tmpAlignType;
private static Type? TMPTextType => _tmpTextType ??= Type.GetType("TMPro.TextMeshProUGUI, Unity.TextMeshPro");
private static Type? TMPFontType => _tmpFontType ??= Type.GetType("TMPro.TMP_FontAsset, Unity.TextMeshPro");
private static Type? TMPAlignType => _tmpAlignType ??= Type.GetType("TMPro.TextAlignmentOptions, Unity.TextMeshPro");
public static Component CreateTextGO(Transform parent, string name)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
if (TMPTextType != null)
{
go.AddComponent(TMPTextType);
return (Component)go.GetComponent(TMPTextType);
}
else
{
var text = go.AddComponent<Text>();
text.alignment = TextAnchor.MiddleLeft;
text.text = string.Empty;
return text;
}
}
public static void SetText(Component comp, string text)
{
if (TMPTextType != null && TMPTextType.IsInstanceOfType(comp))
{
TMPTextType.GetProperty("text")?.SetValue(comp, text);
}
else if (comp is Text uText)
{
uText.text = text;
}
}
public static void SetColor(Component comp, Color color)
{
if (TMPTextType != null && TMPTextType.IsInstanceOfType(comp))
{
TMPTextType.GetProperty("color")?.SetValue(comp, color);
}
else if (comp is Text uText)
{
uText.color = color;
}
}
public static void SetFontSize(Component comp, int size)
{
if (TMPTextType != null && TMPTextType.IsInstanceOfType(comp))
{
TMPTextType.GetProperty("fontSize")?.SetValue(comp, (float)size);
}
else if (comp is Text uText)
{
uText.fontSize = size;
}
}
public static void SetFont(Component comp, UnityEngine.Object? fontAsset)
{
if (fontAsset == null) return;
if (TMPTextType != null && TMPTextType.IsInstanceOfType(comp) && TMPFontType != null && TMPFontType.IsInstanceOfType(fontAsset))
{
TMPTextType.GetProperty("font")?.SetValue(comp, fontAsset);
}
else if (comp is Text uText && fontAsset is Font uFont)
{
uText.font = uFont;
}
}
public static void SetAlignment(Component comp, string alignment)
{
if (TMPTextType != null && TMPTextType.IsInstanceOfType(comp) && TMPAlignType != null)
{
try
{
var enumVal = Enum.Parse(TMPAlignType, alignment);
TMPTextType.GetProperty("alignment")?.SetValue(comp, enumVal);
}
catch { }
}
else if (comp is Text uText)
{
if (string.Equals(alignment, "TopLeft", StringComparison.OrdinalIgnoreCase))
uText.alignment = TextAnchor.UpperLeft;
else
uText.alignment = TextAnchor.MiddleLeft;
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d2968af399993f94f8aa265253691829

View File

@@ -106,6 +106,7 @@ namespace UVC.UI.List.ComponentList
categoryBageText.text = itemData.categoryBadgeCount.ToString();
// 애니메이션을 위해 현재 각도에서 목표 각도로 회전시킵니다.
categoryExtendButton.transform.DOKill(); // 이전 애니메이션이 있으면 종료
categoryExtendButton.transform.DORotate(new Vector3(0, 0, itemData.isExpanded ? 0 : 90), 0.0f);
}
else

View File

@@ -1,3 +1,4 @@
#nullable enable
using Gpm.Ui;
using TMPro;
using UnityEngine;
@@ -7,7 +8,7 @@ using UVC.Util;
namespace UVC.UI.List
{
public class PrefabGridItem : InfiniteScrollItem, IPointerEnterHandler, IPointerExitHandler
public class PrefabGridItem : InfiniteScrollItem, IPointerEnterHandler, IPointerExitHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField]
private Image image;
@@ -15,6 +16,25 @@ namespace UVC.UI.List
[SerializeField]
private TextMeshProUGUI text;
// Drag state
private Canvas _canvas = default!;
private Image? _dragGhost;
private PrefabGridItemData _boundData = default!;
private void Awake()
{
// cache canvas for drag visuals
if (_canvas == null)
{
_canvas = GetComponentInParent<Canvas>();
if (_canvas == null)
{
// 최상위 Canvas가 없다면 씬에서 하나를 찾아 사용
_canvas = FindFirstObjectByType<Canvas>();
}
}
}
/// <summary>
/// InfiniteScroll에 의해 데이터가 이 아이템에 할당될 때 호출되는 핵심 메서드입니다.
/// 스크롤 시 아이템이 재활용될 때마다 새로운 데이터로 이 메서드가 호출되어 UI를 갱신합니다.
@@ -57,6 +77,8 @@ namespace UVC.UI.List
text.text = data.ItemName;
image.sprite = await ResourceManager.LoadOnlyAsync<Sprite>(data.ImagePrefabPath);
_boundData = data;
}
public void OnClick()
@@ -74,5 +96,100 @@ namespace UVC.UI.List
{
CursorManager.Instance.SetCursor(CursorType.Default);
}
/// <summary>
/// 드래그 시작 시 호출됩니다. 드래그 고스트 이미지를 생성하고 초기 위치를 설정합니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
public void OnBeginDrag(PointerEventData eventData)
{
if (image == null || image.sprite == null || _canvas == null) return;
// 고스트 생성 (이미지 스프라이트 복사)
var go = new GameObject("DragGhost", typeof(RectTransform), typeof(CanvasGroup), typeof(Image));
go.transform.SetParent(_canvas.transform, false);
go.transform.SetAsLastSibling();
var ghostImg = go.GetComponent<Image>();
ghostImg.sprite = image.sprite;
ghostImg.preserveAspect = true;
ghostImg.raycastTarget = false; // 드래그 중에는 이벤트 차단하지 않도록
ghostImg.color = new Color(1f, 1f, 1f, 0.6f);
// 크기/위치 초기화
var srcRT = image.rectTransform;
var dstRT = (RectTransform)go.transform;
dstRT.sizeDelta = srcRT.rect.size;
SetGhostPosition(eventData, dstRT);
_dragGhost = ghostImg;
}
/// <summary>
/// 드래그 중에 호출됩니다. 고스트 이미지를 현재 포인터 위치로 이동합니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
public void OnDrag(PointerEventData eventData)
{
if (_dragGhost == null || _canvas == null) return;
SetGhostPosition(eventData, (RectTransform)_dragGhost.transform);
}
/// <summary>
/// 드래그 종료 시 호출됩니다. 고스트를 제거하고 드랍 콜백을 발생시킵니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
public void OnEndDrag(PointerEventData eventData)
{
// 고스트 제거
if (_dragGhost != null)
{
Destroy(_dragGhost.gameObject);
_dragGhost = null;
}
// 드랍 이벤트 통지
_boundData?.OnDropAction?.Invoke(_boundData);
}
/// <summary>
/// 고스트 RectTransform을 현재 포인터 위치로 배치합니다.
/// Canvas의 렌더 모드에 따라 화면/월드 좌표를 변환합니다.
/// </summary>
/// <param name="eventData">포인터 이벤트 데이터.</param>
/// <param name="ghostRt">이동시킬 고스트 RectTransform.</param>
private void SetGhostPosition(PointerEventData eventData, RectTransform ghostRt)
{
if (_canvas == null)
return;
if (_canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
ghostRt.position = eventData.position;
}
else
{
var cam = _canvas.worldCamera != null ? _canvas.worldCamera : eventData.pressEventCamera;
if (RectTransformUtility.ScreenPointToWorldPointInRectangle(_canvas.transform as RectTransform, eventData.position, cam, out var worldPos))
{
ghostRt.position = worldPos;
}
else
{
ghostRt.position = eventData.position; // fallback
}
}
}
/// <summary>
/// 종료 시 등록된 콜백과 드래그 고스트를 정리합니다.
/// </summary>
private void OnDestroy()
{
if (_dragGhost != null)
{
Destroy(_dragGhost.gameObject);
_dragGhost = null;
}
}
}
}

View File

@@ -29,5 +29,10 @@ namespace UVC.UI.List
/// 클릭 시 호출될 액션입니다.
/// </summary>
public Action<PrefabGridItemData>? OnClickAction;
/// <summary>
/// 화면에 드롭 시 호출될 액션입니다.
/// </summary>
public Action<PrefabGridItemData>? OnDropAction;
}
}

View File

@@ -6,63 +6,30 @@ using UVC.UI.List.Accordion;
namespace UVC.UI.Window
{
/// <summary>
/// 아코디언 리스트(<see cref="AccordionList"/>)를 포함하는 간단한 컨테이너 윈도우.
/// 외부에서 전달된 데이터로 내부 리스트를 구성합니다.
/// </summary>
public class AccordionWindow : MonoBehaviour
{
[SerializeField] private RectTransform root = default!;
[SerializeField] private AccordionList? accordionListPrefab = null;
private AccordionList? _instance;
private void Reset()
{
EnsureRoot();
EnsureInstance();
}
[SerializeField]
private AccordionList list = default!;
private void Awake()
{
EnsureRoot();
EnsureInstance();
}
private void EnsureRoot()
{
if (root == null)
if (list == null)
{
var go = new GameObject("AccordionRoot", typeof(RectTransform));
go.transform.SetParent(transform, false);
root = (RectTransform)go.transform;
var v = go.AddComponent<VerticalLayoutGroup>();
v.childControlHeight = true;
v.childForceExpandHeight = false;
v.spacing = 0f;
var fitter = go.AddComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
}
private void EnsureInstance()
{
if (_instance == null)
{
if (accordionListPrefab != null)
{
_instance = Instantiate(accordionListPrefab, root);
_instance.gameObject.SetActive(true);
}
else
{
var go = new GameObject("AccordionList", typeof(RectTransform), typeof(AccordionList));
go.transform.SetParent(root, false);
_instance = go.GetComponent<AccordionList>();
}
Debug.LogWarning("AccordionWindow: list is not assigned.");
}
}
/// <summary>
/// 내부 <see cref="AccordionList"/>에 데이터를 설정합니다.
/// </summary>
/// <param name="data">아코디언 루트 데이터.</param>
public void SetData(AccordionData data)
{
EnsureRoot();
EnsureInstance();
_instance!.SetData(data);
list!.SetData(data);
}
}
}

View File

@@ -0,0 +1,43 @@
#nullable enable
using System.Collections.Generic;
using UnityEngine;
using UVC.UI.List;
namespace UVC.UI.Window
{
public class LibraryWindow : MonoBehaviour
{
[SerializeField]
private PrefabGrid prefabGrid;
protected void Awake()
{
if (prefabGrid == null)
{
prefabGrid = GetComponentInChildren<PrefabGrid>();
}
if (prefabGrid == null)
{
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
return;
}
}
public void SetData(List<PrefabGridItemData> list)
{
prefabGrid?.SetupData(list);
}
/// <summary>
/// 닫기
/// </summary>
public void Close()
{
gameObject.SetActive(false);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 966dc2f3a492248468ab25267731e1d0