accorionWindow 추가, libraryWindow 추가
This commit is contained in:
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2968af399993f94f8aa265253691829
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,10 @@ namespace UVC.UI.List
|
||||
/// 클릭 시 호출될 액션입니다.
|
||||
/// </summary>
|
||||
public Action<PrefabGridItemData>? OnClickAction;
|
||||
|
||||
/// <summary>
|
||||
/// 화면에 드롭 시 호출될 액션입니다.
|
||||
/// </summary>
|
||||
public Action<PrefabGridItemData>? OnDropAction;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
Assets/Scripts/UVC/UI/Window/LibraryWindow.cs
Normal file
43
Assets/Scripts/UVC/UI/Window/LibraryWindow.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/UVC/UI/Window/LibraryWindow.cs.meta
Normal file
2
Assets/Scripts/UVC/UI/Window/LibraryWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 966dc2f3a492248468ab25267731e1d0
|
||||
Reference in New Issue
Block a user