draggableList/Tab 개발 중

This commit is contained in:
logonkhi
2025-07-30 20:16:21 +09:00
parent 231af33e6f
commit f7befb048c
99 changed files with 8819 additions and 1172 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 01351942c80cc7042b2ec18f41173854
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,53 @@
#nullable enable
using System;
using UnityEngine;
using UnityEngine.EventSystems;
namespace UVC.UI.List
{
/// <summary>
/// 개별 드래그 가능한 아이템의 UI 컴포넌트
/// 드래그 동작과 시각적 피드백을 담당
/// </summary>
public class DraggableItem : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
// 이벤트
public Action<PointerEventData>? OnBeginDragEvent;
public Action<PointerEventData>? OnDragEvent;
public Action<PointerEventData>? OnEndDragEvent;
/// <summary>
/// 드래그 시작 시 호출
/// </summary>
public void OnBeginDrag(PointerEventData eventData)
{
OnBeginDragEvent?.Invoke(eventData);
}
/// <summary>
/// 드래그 중 호출
/// </summary>
public void OnDrag(PointerEventData eventData)
{
// 이벤트 발생
OnDragEvent?.Invoke(eventData);
}
/// <summary>
/// 드래그 종료 시 호출
/// </summary>
public void OnEndDrag(PointerEventData eventData)
{
// 이벤트 발생
OnEndDragEvent?.Invoke(eventData);
}
void OnDestroy()
{
// 이벤트 구독 해제
OnBeginDragEvent = null;
OnDragEvent = null;
OnEndDragEvent = null;
}
}
}

View File

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

View File

@@ -0,0 +1,42 @@
#nullable enable
using System;
using UnityEngine;
namespace UVC.UI.List
{
/// <summary>
/// 드래그 가능한 목록 아이템의 데이터 모델
/// </summary>
[Serializable]
public class DraggableItemData
{
[SerializeField] protected string id;
[SerializeField] protected int sortOrder;
public string Id => id;
public int SortOrder { get => sortOrder; set => sortOrder = value; }
public DraggableItemData(string id, int sortOrder = 0)
{
this.id = id ?? throw new ArgumentNullException(nameof(id));
this.sortOrder = sortOrder;
}
}
/// <summary>
/// 드래그 작업의 결과를 나타내는 이벤트 인자
/// </summary>
public class DraggableItemReorderEventArgs : EventArgs
{
public string ItemId { get; }
public int OldIndex { get; }
public int NewIndex { get; }
public DraggableItemReorderEventArgs(string itemId, int oldIndex, int newIndex)
{
ItemId = itemId;
OldIndex = oldIndex;
NewIndex = newIndex;
}
}
}

View File

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

View File

@@ -0,0 +1,191 @@
#nullable enable
using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
namespace UVC.UI.List
{
/// <summary>
/// 개별 드래그 가능한 아이템의 UI 컴포넌트
/// 드래그 동작과 시각적 피드백을 담당
/// </summary>
public class DraggableListItem : MonoBehaviour
{
[Header("UI 컴포넌트")]
[SerializeField] protected CanvasGroup? canvasGroup;
[SerializeField] protected RectTransform? rectTransform;
[SerializeField] protected DraggableItem? dragAnchor;
[SerializeField] protected TMP_InputField? inputField;
[Header("드래그 설정")]
[SerializeField] protected float dragAlpha = 0.6f;
[SerializeField] protected bool blockRaycastsWhileDragging = false;
// 프로퍼티
public DraggableItemData? Data { get; private set; }
public RectTransform? RectTransform => rectTransform;
public bool IsDragging { get; private set; }
public event Action<DraggableListItem>? OnBeginDragEvent;
public event Action<DraggableListItem, Vector2>? OnDragEvent;
public event Action<DraggableListItem>? OnEndDragEvent;
private Vector2 originalPosition;
private Transform? originalParent;
private int originalSiblingIndex;
/// <summary>
/// 컴포넌트 초기화
/// </summary>
private void Awake()
{
// null 체크 및 자동 할당
if (rectTransform == null)
rectTransform = GetComponent<RectTransform>();
if (canvasGroup == null)
canvasGroup = GetComponent<CanvasGroup>();
// CanvasGroup이 없으면 추가
if (canvasGroup == null)
canvasGroup = gameObject.AddComponent<CanvasGroup>();
if (dragAnchor == null)
{
Debug.LogError("Drag Anchor is not assigned. Please assign it in the inspector.");
return;
}
dragAnchor.OnBeginDragEvent += OnBeginDrag;
dragAnchor.OnDragEvent += OnDrag;
dragAnchor.OnEndDragEvent += OnEndDrag;
}
/// <summary>
/// 아이템 데이터로 UI 업데이트
/// </summary>
/// <param name="data">표시할 데이터</param>
public void SetData(DraggableItemData? data)
{
if (data == null) return;
Data = data;
UpdateUI();
}
/// <summary>
/// UI 요소들을 데이터에 맞게 업데이트
/// </summary>
protected virtual void UpdateUI()
{
if (Data == null) return;
if(inputField != null)
{
inputField.text = Data.Id;
}
}
/// <summary>
/// 드래그 시작 시 호출
/// </summary>
public void OnBeginDrag(PointerEventData eventData)
{
if (rectTransform == null) return;
IsDragging = true;
// 원래 위치와 부모 저장
originalPosition = rectTransform.anchoredPosition;
originalParent = transform.parent;
originalSiblingIndex = transform.GetSiblingIndex();
// 시각적 피드백 적용
ApplyDragVisuals(true);
// 이벤트 발생
OnBeginDragEvent?.Invoke(this);
}
/// <summary>
/// 드래그 중 호출
/// </summary>
public void OnDrag(PointerEventData eventData)
{
if (rectTransform == null) return;
// 마우스 위치로 아이템 이동
rectTransform.anchoredPosition += new Vector2(0, eventData.delta.y);//eventData.delta
// 이벤트 발생
OnDragEvent?.Invoke(this, rectTransform.anchoredPosition);
}
/// <summary>
/// 드래그 종료 시 호출
/// </summary>
public void OnEndDrag(PointerEventData eventData)
{
IsDragging = false;
// 시각적 피드백 복원
ApplyDragVisuals(false);
// 이벤트 발생
OnEndDragEvent?.Invoke(this);
}
/// <summary>
/// 드래그 시각적 효과 적용/해제
/// </summary>
/// <param name="isDragging">드래그 중인지 여부</param>
private void ApplyDragVisuals(bool isDragging)
{
if (canvasGroup == null) return;
if (isDragging)
{
canvasGroup.alpha = dragAlpha;
canvasGroup.blocksRaycasts = blockRaycastsWhileDragging;
}
else
{
canvasGroup.alpha = 1f;
canvasGroup.blocksRaycasts = true;
}
}
/// <summary>
/// 원래 위치로 되돌리기 (드래그 취소 시 사용)
/// </summary>
public void ResetToOriginalPosition()
{
if (rectTransform == null || originalParent == null) return;
transform.SetParent(originalParent);
transform.SetSiblingIndex(originalSiblingIndex);
rectTransform.anchoredPosition = originalPosition;
}
public void OnDestroy()
{
// 이벤트 구독 해제
if (dragAnchor != null)
{
dragAnchor.OnBeginDragEvent -= OnBeginDrag;
dragAnchor.OnDragEvent -= OnDrag;
dragAnchor.OnEndDragEvent -= OnEndDrag;
}
OnBeginDragEvent = null;
OnDragEvent = null;
OnEndDragEvent = null;
// 리소스 정리
canvasGroup = null;
rectTransform = null;
dragAnchor = null;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7cdfe032ad5874e4cbc571f344516b93

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71e6121c6103b0a4c9aeadc24c891b86

View File

@@ -2,101 +2,214 @@ using System.Collections;
using UnityEngine;
using UnityEngine.UI;
/*
* UILoading 스크립트 사용법 예시
*
* 다른 스크립트에서 아래와 같이 간단하게 호출하여 로딩 화면을 표시하거나 숨길 수 있습니다.
*
* public class MyGameManager : MonoBehaviour
* {
* public void LoadNextScene()
* {
* // 로딩 화면 표시
* UVC.UI.Loading.UILoading.Show();
*
* // 코루틴을 사용하여 비동기 씬 로딩 시작
* StartCoroutine(LoadSceneAsync());
* }
*
* private IEnumerator LoadSceneAsync()
* {
* // 씬을 비동기로 로드합니다.
* AsyncOperation asyncLoad = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("NextSceneName");
*
* // 씬 로딩이 완료될 때까지 대기합니다.
* while (!asyncLoad.isDone)
* {
* yield return null;
* }
*
* // 씬 로딩이 완료되면 로딩 화면을 숨깁니다.
* UVC.UI.Loading.UILoading.Hide();
* }
* }
*/
namespace UVC.UI.Loading
{
/// <summary>
/// 게임 전체에서 사용되는 로딩 UI를 제어하는 클래스입니다.
/// 싱글톤(Singleton)과 유사한 방식으로 구현되어 어디서든 쉽게 접근하고 사용할 수 있습니다.
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class UILoading : UnityEngine.MonoBehaviour
public class UILoading : MonoBehaviour
{
// 로딩 UI 프리팹이 Resources 폴더 내에 위치하는 경로입니다.
// Resources.Load를 통해 동적으로 프리팹을 불러올 때 사용됩니다.
public static string PrefabPath = "Prefabs/UI/Loading/UILoading";
// UILoading 클래스의 유일한 인스턴스를 저장하는 정적(static) 변수입니다.
// 이 변수를 통해 다른 모든 스크립트에서 동일한 로딩 화면 인스턴스에 접근할 수 있습니다.
private static UILoading instance;
/// <summary>
/// 로딩 화면을 표시합니다.
/// 만약 로딩 화면이 아직 생성되지 않았다면, 프리팹을 이용해 새로 생성합니다.
/// </summary>
public static void Show()
{
if (instance == null) {
// instance가 null일 경우, 아직 로딩 화면이 만들어지지 않았다는 의미입니다.
if (instance == null)
{
// Resources 폴더에서 프리팹을 불러옵니다.
GameObject prefab = Resources.Load<GameObject>(PrefabPath);
// 불러온 프리팹을 씬에 인스턴스(복제)하여 생성합니다.
GameObject go = Instantiate(prefab);
// 생성된 GameObject의 이름을 "UILoading"으로 설정하여 씬에서 쉽게 식별할 수 있도록 합니다.
go.name = "UILoading";
// 부모를 null로 설정하여 씬의 최상위 계층에 위치시킵니다.
// 이렇게 하면 다른 씬으로 전환될 때 함께 파괴되지 않도록 관리하기 용이합니다. (DontDestroyOnLoad와 함께 사용 가능)
go.transform.SetParent(null, false);
// 생성된 GameObject에서 UILoading 컴포넌트를 찾아 instance 변수에 할당합니다.
instance = go.GetComponent<UILoading>();
}
// 인스턴스의 ShowLoading 메서드를 호출하여 페이드인 애니메이션을 시작합니다.
instance.ShowLoading();
}
/// <summary>
/// 현재 표시되고 있는 로딩 화면을 숨깁니다.
/// </summary>
public static void Hide()
{
// instance가 null이 아닐 경우에만 (즉, 로딩 화면이 존재할 때만) 실행합니다.
if (instance != null)
{
// 인스턴스의 HideLoading 메서드를 호출하여 페이드아웃 애니메이션을 시작합니다.
instance.HideLoading();
}
}
// --- 인스펙터(Inspector)에서 설정할 변수들 ---
[Header("Component References")]
[Tooltip("페이드 효과를 제어할 CanvasGroup 컴포넌트")]
[SerializeField]
private CanvasGroup canvasGroup;
[Tooltip("채우기(Fill) 및 회전 효과를 적용할 Image 컴포넌트")]
[SerializeField]
private Image loadinImage;
private float duration = 0.25f;
private float target = 0;
private float alpha = 1;
private bool animatting = false;
private Transform loadingImageTransform;
// --- 내부 동작을 위한 변수들 ---
[Header("Animation Settings")]
[Tooltip("페이드인/아웃 애니메이션의 지속 시간 (초)")]
[SerializeField]
private float duration = 0.25f;
[Tooltip("로딩 아이콘의 회전 속도")]
[SerializeField]
private float loadingSpeed = -1.5f;
[Tooltip("이미지 채우기(Fill) 효과의 속도")]
[SerializeField]
private float rotationSpeed = -1.0f;
private float target = 0; // 애니메이션의 목표 알파 값 (0: 투명, 1: 불투명)
private bool animatting = false; // 현재 애니메이션이 진행 중인지 여부
private Transform loadingImageTransform; // 회전 애니메이션을 적용할 이미지의 Transform
/// <summary>
/// 스크립트 인스턴스가 처음 로드될 때 호출됩니다.
/// 변수 초기화에 사용됩니다.
/// </summary>
private void Awake()
{
{
// Image 컴포넌트의 Transform을 미리 찾아 변수에 저장해두어,
// LateUpdate에서 매번 찾는 비용을 절약합니다.
loadingImageTransform = loadinImage.transform;
}
/// <summary>
/// 로딩 화면을 나타나게 하는 애니메이션을 시작합니다.
/// </summary>
public void ShowLoading()
{
// 이미 애니메이션이 진행 중이고, 목표가 '나타나기(target=1)'라면 중복 실행을 방지합니다.
if (animatting && target == 1) return;
target = 1;
animatting = true;
StopCoroutine("Animate");
StartCoroutine(Animate());
target = 1; // 목표 알파 값을 1(불투명)로 설정
animatting = true; // 애니메이션 시작 상태로 변경
StopCoroutine("Animate"); // 이전에 실행 중이던 Animate 코루틴이 있다면 중지
StartCoroutine(Animate()); // Animate 코루틴을 새로 시작
}
/// <summary>
/// 로딩 화면을 사라지게 하는 애니메이션을 시작합니다.
/// </summary>
public void HideLoading()
{
// 이미 애니메이션이 진행 중이고, 목표가 '사라지기(target=0)'라면 중복 실행을 방지합니다.
if (animatting && target == 0) return;
target = 0;
animatting = true;
StopCoroutine("Animate");
StartCoroutine(Animate());
target = 0; // 목표 알파 값을 0(투명)으로 설정
animatting = true; // 애니메이션 시작 상태로 변경
StopCoroutine("Animate"); // 이전에 실행 중이던 Animate 코루틴이 있다면 중지
StartCoroutine(Animate()); // Animate 코루틴을 새로 시작
}
/// <summary>
/// CanvasGroup의 알파 값을 부드럽게 변경하여 페이드인/아웃 효과를 주는 코루틴입니다.
/// </summary>
private IEnumerator Animate()
{
float start = canvasGroup.alpha;
float start = canvasGroup.alpha; // 현재 알파 값을 시작 값으로 설정
float time = 0;
// 경과 시간이 duration에 도달할 때까지 반복합니다.
while (time < duration)
{
time += Time.deltaTime;
// Time.unscaledDeltaTime: Time.timeScale 값에 영향을 받지 않는 시간 간격입니다.
// 로딩 중 Time.timeScale이 0이 되어도 UI 애니메이션은 계속 실행되어야 하므로 사용합니다.
time += Time.unscaledDeltaTime;
// Mathf.Lerp(시작값, 목표값, 진행률): 두 값 사이를 선형 보간합니다.
// 진행률(time / duration)에 따라 부드럽게 알파 값이 변합니다.
canvasGroup.alpha = Mathf.Lerp(start, target, time / duration);
// 다음 프레임까지 대기합니다.
yield return null;
}
// 애니메이션이 끝난 후 목표 알파 값으로 정확히 맞춰줍니다.
canvasGroup.alpha = target;
animatting = false;
if(target == 0)
animatting = false; // 애니메이션 종료 상태로 변경
// 만약 목표가 '사라지기(target=0)'였다면, 애니메이션 종료 후 GameObject를 파괴합니다.
if (target == 0)
{
Destroy(gameObject);
}
}
private void Update()
/// <summary>
/// 모든 Update 함수가 호출된 후에 프레임마다 호출됩니다.
/// 주로 카메라 이동이나 다른 애니메이션이 끝난 후 최종적으로 UI를 업데이트할 때 사용되어,
/// 떨림(Jitter) 현상을 방지하는 데 도움이 됩니다.
/// </summary>
private void LateUpdate()
{
// 로딩 화면이 완전히 불투명할 때(애니메이션이 진행 중일 때)만 실행합니다.
if (canvasGroup.alpha == 1)
{
loadingImageTransform.Rotate(Vector3.forward, loadingSpeed * Time.deltaTime * 360);
loadinImage.fillAmount = Mathf.PingPong(Time.time * rotationSpeed, 1);
// 1. 이미지 회전 애니메이션
// Time.unscaledTime: Time.timeScale에 영향을 받지 않는 전체 게임 시간입니다.
// 시간에 비례하는 절대적인 회전 각도를 계산하여 매 프레임 설정합니다.
// 이렇게 하면 프레임 속도 변화에 관계없이 항상 일관되고 부드러운 회전을 보장할 수 있습니다.
float zRotation = Time.unscaledTime * loadingSpeed * 360;
loadingImageTransform.rotation = Quaternion.Euler(0, 0, zRotation);
// 2. 이미지 채우기(Fill) 애니메이션 (필요 시 주석 해제하여 사용)
// Mathf.PingPong(시간, 길이): 0과 '길이' 사이를 왕복하는 값을 반환합니다.
// 여기서는 fillAmount가 0과 1 사이를 계속 오가도록 하여 채워졌다 비워지는 효과를 만듭니다.
loadinImage.fillAmount = Mathf.PingPong(Time.unscaledTime * rotationSpeed, 1);
}
}
}

View File

@@ -5,16 +5,36 @@ using UnityEngine.UI;
namespace UVC.UI.Loading
{
/// <summary>
/// 로딩 UI의 표시와 숨김, 진행 상태를 관리하는 클래스입니다.
/// CanvasGroup 컴포넌트를 사용하여 페이드 인/아웃 효과를 구현합니다.
/// 이 클래스가 게임 오브젝트에 추가되면 CanvasGroup 컴포넌트도 자동으로 추가됩니다.
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class UILoadingBar : UnityEngine.MonoBehaviour
public class UILoadingBar : MonoBehaviour
{
/// <summary>
/// 로딩 바 UI 프리팹이 있는 Resources 폴더 내의 경로입니다.
/// Show() 메서드가 처음 호출될 때 이 경로를 사용하여 프리팹을 동적으로 생성합니다.
/// </summary>
public static string PrefabPath = "Prefabs/UI/Loading/UILoadingBar";
/// <summary>
/// UILoadingBar의 유일한 인스턴스(Singleton)입니다.
/// static으로 선언되어 어디서든 UILoadingBar.instance 형태로 접근할 수 있습니다.
/// </summary>
private static UILoadingBar instance;
/// <summary>
/// 0.0~1.0
/// 로딩 진행률을 0.0에서 1.0 사이의 값으로 설정하거나 가져옵니다.
/// 이 값은 로딩 이미지의 채워지는 양(fillAmount)에 직접 반영됩니다.
/// </summary>
/// <example>
/// <code>
/// // 로딩 진행률을 50%로 설정
/// UILoadingBar.Percent = 0.5f;
/// </code>
/// </example>
public static float Percent
{
get
@@ -29,6 +49,15 @@ namespace UVC.UI.Loading
}
}
/// <summary>
/// 로딩 화면에 표시될 메시지를 설정하거나 가져옵니다.
/// </summary>
/// <example>
/// <code>
/// // 로딩 메시지 설정
/// UILoadingBar.Message = "데이터를 불러오는 중입니다...";
/// </code>
/// </example>
public static string Message
{
get
@@ -43,6 +72,20 @@ namespace UVC.UI.Loading
}
}
/// <summary>
/// 로딩 바를 화면에 표시합니다.
/// 만약 로딩 바 인스턴스가 없다면 PrefabPath에서 프리팹을 불러와 생성합니다.
/// </summary>
/// <param name="message">로딩 화면에 표시할 메시지입니다.</param>
/// <example>
/// <code>
/// // 기본 메시지로 로딩 바 표시
/// UILoadingBar.Show();
///
/// // 특정 메시지와 함께 로딩 바 표시
/// UILoadingBar.Show("플레이어 정보를 로딩 중입니다...");
/// </code>
/// </example>
public static void Show(string message = "")
{
if (instance == null) {
@@ -56,6 +99,16 @@ namespace UVC.UI.Loading
instance.ShowLoading();
}
/// <summary>
/// 로딩 바를 화면에서 숨깁니다.
/// 숨겨진 후에는 자동으로 파괴됩니다.
/// </summary>
/// <example>
/// <code>
/// // 로딩 완료 후 로딩 바 숨기기
/// UILoadingBar.Hide();
/// </code>
/// </example>
public static void Hide()
{
if (instance != null)
@@ -64,64 +117,74 @@ namespace UVC.UI.Loading
}
}
[Tooltip("페이드 인/아웃 효과를 제어하는 CanvasGroup 컴포넌트입니다. 알파 값을 조정하여 UI의 투명도를 변경합니다.")]
[SerializeField]
private CanvasGroup canvasGroup; // 페이드 효과를 제어하기 위한 CanvasGroup 컴포넌트
[Tooltip("로딩 진행 상태를 표시하는 이미지 컴포넌트입니다. 로딩 중 진행률을 시각적으로 나타냅니다.")]
[SerializeField]
private Image loadinImage; // 로딩 진행 상태를 표시하는 이미지
[Tooltip("로딩 메시지를 표시하는 텍스트 컴포넌트입니다. 로딩 중 사용자에게 정보를 제공합니다.")]
[SerializeField]
private TextMeshProUGUI text; // 로딩 메시지를 표시하는 텍스트
private float target = 0; // 애니메이션의 목표 알파 값 (0: 투명, 1: 불투명)
private float duration = 0.25f; // 페이드 인/아웃 애니메이션 지속 시간
private bool animatting = false; // 현재 애니메이션이 진행 중인지 여부
private CanvasGroup canvasGroup;
private float target = 0;
private float duration = 0.25f;
private float alpha = 1;
private bool animatting = false;
private Image loadinImage;
private Transform loadingImageTransform;
private TextMeshProUGUI text;
private float loadingSpeed = 1.5f;
private float rotationSpeed = 1.0f;
private void Awake()
{
canvasGroup = GetComponent<CanvasGroup>();
loadinImage = transform.Find("loadingImage").GetComponent<Image>();
text = transform.Find("message").GetComponent<TextMeshProUGUI>();
loadingImageTransform = loadinImage.transform;
}
/// <summary>
/// 로딩 바를 부드럽게 나타나게 하는 애니메이션을 시작합니다.
/// 이미 나타나는 중이면 다시 호출되지 않습니다.
/// </summary>
public void ShowLoading()
{
// 이미 애니메이션 중이고 목표 알파 값이 1(불투명)이면 중복 실행 방지
if (animatting && target == 1) return;
target = 1;
animatting = true;
StopCoroutine("Animate");
StartCoroutine(Animate());
StopCoroutine("Animate"); // 이전에 실행 중이던 Animate 코루틴이 있다면 중지
StartCoroutine(Animate()); // Animate 코루틴 시작
}
/// <summary>
/// 로딩 바를 부드럽게 사라지게 하는 애니메이션을 시작합니다.
/// 이미 사라지는 중이면 다시 호출되지 않습니다.
/// </summary>
public void HideLoading()
{
// 이미 애니메이션 중이고 목표 알파 값이 0(투명)이면 중복 실행 방지
if (animatting && target == 0) return;
target = 0;
animatting = true;
StopCoroutine("Animate");
StartCoroutine(Animate());
StopCoroutine("Animate"); // 이전에 실행 중이던 Animate 코루틴이 있다면 중지
StartCoroutine(Animate()); // Animate 코루틴 시작
}
/// <summary>
/// CanvasGroup의 알파 값을 조절하여 페이드 인/아웃 효과를 주는 코루틴입니다.
/// 코루틴은 특정 시간 동안 또는 특정 조건이 만족될 때까지 함수의 실행을 잠시 멈출 수 있는 특별한 함수입니다.
/// </summary>
private IEnumerator Animate()
{
float start = canvasGroup.alpha;
float time = 0;
float start = canvasGroup.alpha; // 애니메이션 시작 시점의 알파 값
float time = 0; // 애니메이션 경과 시간
// 경과 시간이 duration에 도달할 때까지 반복
while (time < duration)
{
time += Time.deltaTime;
time += Time.deltaTime; // 한 프레임 동안의 시간을 더해줌
// Mathf.Lerp를 사용하여 시작 알파 값과 목표 알파 값 사이를 부드럽게 보간
canvasGroup.alpha = Mathf.Lerp(start, target, time / duration);
// 다음 프레임까지 대기
yield return null;
}
// 애니메이션이 끝난 후 목표 알파 값으로 정확히 설정
canvasGroup.alpha = target;
animatting = false;
if(target == 0)
// 만약 목표 알파 값이 0(사라지는 애니메이션)이었다면, 게임 오브젝트를 파괴
if (target == 0)
{
Destroy(gameObject);
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 87d7308ca55b2a34e818090493d0b897
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
#nullable enable
namespace UVC.UI.Tab
{
/// <summary>
/// 모든 탭 콘텐츠 클래스가 구현해야 하는 인터페이스입니다.
/// 데이터를 받을 수 있는 공통 메서드를 정의합니다.
/// </summary>
public interface ITabContent
{
/// <summary>
/// 탭 콘텐츠에 데이터를 전달합니다.
/// </summary>
/// <param name="data">전달할 데이터 객체</param>
void SetContentData(object? data);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2226fb1b6c4b11a4a9f899ab5e456a21

View File

@@ -0,0 +1,113 @@
#nullable enable
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace UVC.UI.Tab
{
/// <summary>
/// 단일 탭 버튼의 UI를 관리하는 컴포넌트입니다.
/// </summary>
public class TabButtonView : MonoBehaviour
{
[Tooltip("탭 버튼 컴포넌트")]
[SerializeField] private Button? button;
[Tooltip("탭 이름을 표시할 Text 컴포넌트")]
[SerializeField] private TextMeshProUGUI? tabText;
[Tooltip("탭 아이콘을 표시할 Image 컴포넌트")]
[SerializeField] private Image? tabIcon;
[Tooltip("탭 배경 이미지 컴포넌트")]
[SerializeField] private Image? background;
[Tooltip("탭이 활성화되었을 때의 색상")]
[SerializeField] private Color? activeColor;// = Color.white;
[Tooltip("탭이 비활성화되었을 때의 색상")]
[SerializeField] private Color? inactiveColor;// = new Color(0.8f, 0.8f, 0.8f);
private int _tabIndex;
private Action<int>? _onTabSelected;
private void Awake()
{
// Button 컴포넌트가 할당되지 않은 경우 자동으로 찾기
if (button == null)
button = GetComponent<Button>();
// 버튼 클릭 이벤트 연결
if (button != null)
button.onClick.AddListener(OnButtonClick);
}
/// <summary>
/// 탭 버튼을 초기화합니다.
/// </summary>
/// <param name="index">탭 인덱스</param>
/// <param name="tabName">탭 이름</param>
/// <param name="icon">탭 아이콘</param>
/// <param name="onSelectedCallback">탭 선택 시 호출될 콜백</param>
public void Setup(int index, string tabName, Sprite? icon, Action<int> onSelectedCallback)
{
_tabIndex = index;
_onTabSelected = onSelectedCallback;
// 탭 이름 설정
if (tabText != null)
tabText.text = tabName;
// 탭 아이콘 설정
if (tabIcon != null)
{
if (icon != null)
{
tabIcon.sprite = icon;
tabIcon.gameObject.SetActive(true);
}
else
{
tabIcon.gameObject.SetActive(false);
}
}
}
/// <summary>
/// 탭 버튼의 활성화 상태를 변경합니다.
/// </summary>
/// <param name="isActive">활성화 여부</param>
public void SetActive(bool isActive)
{
// 배경 색상 변경
if (background != null && activeColor.HasValue && inactiveColor.HasValue)
{
background.color = isActive ? activeColor.Value : inactiveColor.Value;
}
else if(button != null)
{
button.image.color = isActive ? button.colors.pressedColor : button.colors.normalColor;
}
// 텍스트 스타일 변경
if (tabText != null) {
tabText.fontStyle = isActive ? FontStyles.Bold : FontStyles.Normal;
}
}
// 버튼 클릭 이벤트 처리
private void OnButtonClick()
{
_onTabSelected?.Invoke(_tabIndex);
}
// 게임 오브젝트가 제거될 때 이벤트 리스너 제거
private void OnDestroy()
{
if (button != null)
button.onClick.RemoveListener(OnButtonClick);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 58af61b23f40877429f8889337d2006a

View File

@@ -0,0 +1,29 @@
#nullable enable
using UnityEngine;
namespace UVC.UI.Tab
{
/// <summary>
/// 탭 컨텐츠 설정 정보를 정의하는 클래스
/// </summary>
[System.Serializable]
public class TabContentConfig
{
public string tabID = "";
public string tabName = "";
public string contentPath = "";
public Sprite? tabIcon = null;
public bool useLazyLoading = false;
public object? initialData = null;
public TabContentConfig(string id, string name, string path, Sprite? icon = null, bool lazy = false, object? data = null)
{
tabID = id;
tabName = name;
contentPath = path;
tabIcon = icon;
useLazyLoading = lazy;
initialData = data;
}
}
}

View File

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

View File

@@ -0,0 +1,285 @@
#nullable enable
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace UVC.UI.Tab
{
/// <summary>
/// 탭 컨텐츠의 동적 로딩을 관리하는 클래스입니다.
/// TabView에 소속되어 지연 로딩 기능을 제공합니다.
/// </summary>
public class TabContentLoader : MonoBehaviour
{
[Tooltip("지연 로딩 사용 여부")]
[SerializeField] private bool lazyLoadTabs = true;
[Tooltip("최대 동시 로드 탭 수")]
[SerializeField] private int maxLoadedTabs = 3;
[Tooltip("탭 전환 시 자동 로드/언로드 여부")]
[SerializeField] private bool autoManageTabs = true;
/// <summary>
/// 지연 로딩할 탭 컨텐츠 정보를 정의하는 클래스입니다.
/// </summary>
[System.Serializable]
public class LazyTabContent
{
public string tabID = "";
public string contentPrefabPath = "";
public bool isLoaded = false;
[HideInInspector] public GameObject? instance = null;
[HideInInspector] public object? contentData = null;
public LazyTabContent(string id, string path, object? data = null)
{
tabID = id;
contentPrefabPath = path;
contentData = data;
isLoaded = false;
instance = null;
}
}
// 지연 로드 컨텐츠 목록
private List<LazyTabContent> _lazyContents = new List<LazyTabContent>();
// 초기화 여부
private bool _isInitialized = false;
// 현재 활성화된 탭 ID
private string? _currentTabID = null;
// 부모 TabView 참조
private TabView? _parentTabView;
// 이벤트
public UnityEvent<string> OnTabContentLoaded = new UnityEvent<string>();
public UnityEvent<string> OnTabContentUnloaded = new UnityEvent<string>();
private void Awake()
{
_parentTabView = GetComponentInParent<TabView>();
}
/// <summary>
/// 탭 로더를 초기화합니다.
/// </summary>
public void InitializeTabLoader()
{
if (_isInitialized) return;
if (!lazyLoadTabs)
{
PreloadAllTabs();
}
_isInitialized = true;
}
/// <summary>
/// 지연 로드 탭 설정을 추가합니다.
/// </summary>
public bool AddLazyTabContent(string tabID, string prefabPath, object? initialData = null)
{
LazyTabContent content = new LazyTabContent(tabID, prefabPath, initialData);
_lazyContents.Add(content);
return true;
}
/// <summary>
/// 모든 지연 로드 설정을 제거합니다.
/// </summary>
public void ClearLazyContents()
{
foreach (var content in _lazyContents)
{
if (content.isLoaded && content.instance != null)
{
Destroy(content.instance);
}
}
_lazyContents.Clear();
_isInitialized = false;
}
/// <summary>
/// 특정 탭의 컨텐츠를 로드합니다.
/// </summary>
public GameObject? LoadTabContent(string tabID, object? data = null)
{
LazyTabContent? contentToLoad = _lazyContents.Find(c => c.tabID == tabID);
if (contentToLoad == null)
{
Debug.LogWarning($"지연 로드할 탭 컨텐츠를 찾을 수 없음: {tabID}");
return null;
}
// 이미 로드된 경우
if (contentToLoad.isLoaded && contentToLoad.instance != null)
{
if (data != null)
{
contentToLoad.contentData = data;
UpdateContentData(contentToLoad);
}
return contentToLoad.instance;
}
try
{
// 프리팹 로드
GameObject prefab = Resources.Load<GameObject>(contentToLoad.contentPrefabPath);
if (prefab == null)
{
Debug.LogError($"탭 컨텐츠 프리팹을 찾을 수 없음: {contentToLoad.contentPrefabPath}");
return null;
}
// TabView의 ContentContainer를 부모로 사용
Transform parentTransform = _parentTabView?.ContentContainer ?? transform;
// 인스턴스 생성
contentToLoad.instance = Instantiate(prefab, parentTransform);
contentToLoad.instance.name = $"LazyTabContent_{tabID}";
contentToLoad.instance.SetActive(false);
contentToLoad.isLoaded = true;
// 데이터 설정
if (data != null)
{
contentToLoad.contentData = data;
}
UpdateContentData(contentToLoad);
// 이벤트 발생
OnTabContentLoaded.Invoke(tabID);
Debug.Log($"지연 로드 탭 컨텐츠 로드됨: {tabID}");
return contentToLoad.instance;
}
catch (System.Exception ex)
{
Debug.LogError($"탭 컨텐츠 로드 중 오류 발생: {ex.Message}");
return null;
}
}
/// <summary>
/// 특정 탭 컨텐츠의 데이터를 업데이트합니다.
/// </summary>
private void UpdateContentData(LazyTabContent content)
{
if (content.instance != null && content.contentData != null)
{
ITabContent? tabContent = content.instance.GetComponent<ITabContent>();
tabContent?.SetContentData(content.contentData);
}
}
/// <summary>
/// 특정 탭의 컨텐츠를 언로드합니다.
/// </summary>
public bool UnloadTabContent(string tabID)
{
LazyTabContent? contentToUnload = _lazyContents.Find(c => c.tabID == tabID);
if (contentToUnload == null || !contentToUnload.isLoaded || contentToUnload.instance == null)
{
return false;
}
try
{
Destroy(contentToUnload.instance);
contentToUnload.instance = null;
contentToUnload.isLoaded = false;
OnTabContentUnloaded.Invoke(tabID);
Debug.Log($"지연 로드 탭 컨텐츠 언로드됨: {tabID}");
return true;
}
catch (System.Exception ex)
{
Debug.LogError($"탭 컨텐츠 언로드 중 오류 발생: {ex.Message}");
return false;
}
}
/// <summary>
/// 현재 활성화된 탭을 제외한 모든 탭을 언로드합니다.
/// </summary>
public void UnloadAllExceptCurrent(string currentTabID)
{
foreach (var content in _lazyContents)
{
if (content.tabID != currentTabID && content.isLoaded)
{
UnloadTabContent(content.tabID);
}
}
}
/// <summary>
/// 모든 탭 컨텐츠를 미리 로드합니다.
/// </summary>
public void PreloadAllTabs()
{
foreach (var content in _lazyContents)
{
if (!content.isLoaded)
{
LoadTabContent(content.tabID);
}
}
}
/// <summary>
/// 특정 탭의 데이터를 설정합니다.
/// </summary>
public void SetTabContentData(string tabID, object? data)
{
LazyTabContent? content = _lazyContents.Find(c => c.tabID == tabID);
if (content != null)
{
content.contentData = data;
if (content.isLoaded && content.instance != null)
{
UpdateContentData(content);
}
}
}
/// <summary>
/// 특정 탭이 로드되었는지 확인합니다.
/// </summary>
public bool IsTabLoaded(string tabID)
{
LazyTabContent? content = _lazyContents.Find(c => c.tabID == tabID);
return content?.isLoaded == true && content.instance != null;
}
/// <summary>
/// 특정 탭의 컨텐츠 인스턴스를 가져옵니다.
/// </summary>
public GameObject? GetTabInstance(string tabID)
{
LazyTabContent? content = _lazyContents.Find(c => c.tabID == tabID);
return content?.isLoaded == true ? content.instance : null;
}
private void OnDestroy()
{
foreach (var content in _lazyContents)
{
if (content.isLoaded && content.instance != null)
{
Destroy(content.instance);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 19466bc7c0ad9c940820d52eb7e676a6

View File

@@ -0,0 +1,352 @@
#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
namespace UVC.UI.Tab
{
/// <summary>
/// 탭 시스템을 제어하는 컨트롤러 클래스입니다.
/// 설정 관리와 조정 역할만 담당하고, 실제 UI와 로딩은 TabView에서 처리합니다.
/// </summary>
/// <example>
/// <b>사용 예제:</b>
/// 1. TabContentConfig 설정
/// 2. 컨트롤러 초기화
/// <code>
/// public class TabSetup : MonoBehaviour
/// {
/// [SerializeField]
/// protected TabController? tabController;
///
/// protected virtual void Awake()
/// {
/// if (tabController == null)
/// {
/// Debug.LogError("TabController가 설정되지 않았습니다.");
/// return;
/// }
///
/// // 코드로 탭 설정하기
/// SetupTabs();
/// }
///
/// private void SetupTabs()
/// {
/// // 1. TabConfig 설정
/// tabController?.AddTabConfig("AGV", "AGV", "Prefabs/Factory/UI/Tab/DraggableListContent", null, CreateAGVData(), true);
/// tabController?.AddTabConfig("ALARM", "ALARM", "Prefabs/Factory/UI/Tab/DraggableListContent", null, CreateAlarmData(), true);
///
/// // 2. 컨트롤러 초기화
/// tabController?.Initialize();
/// }
///
/// // 샘플 데이터 생성 메서드들
///
/// private object CreateAGVData()
/// {
/// Dictionary<string, string> data = new Dictionary<string, string>();
/// return data;
/// }
///
/// private object CreateAlarmData()
/// {
/// Dictionary<string, string> data = new Dictionary<string, string>();
/// return data;
/// }
/// }
/// </code>
/// </example>
public class TabController : MonoBehaviour
{
[Header("탭 설정")]
[Tooltip("탭 뷰 컴포넌트")]
[SerializeField] private TabView? tabView;
[Header("탭 컨텐츠")]
[Tooltip("시작 시 자동 초기화 여부")]
[SerializeField] private bool initializeOnStart = false;
[Tooltip("탭 설정 목록")]
[SerializeField] private TabContentConfig[] tabConfigs = new TabContentConfig[0];
// 탭 모델
private TabModel? _tabModel;
// 초기화 여부
private bool _isInitialized = false;
// 코드로 추가된 탭 설정 저장
private List<TabContentConfig> _additionalTabConfigs = new List<TabContentConfig>();
// 탭 변경 이벤트
public event Action<int>? OnTabChanged;
private void Awake()
{
_tabModel = new TabModel();
}
private void Start()
{
if (initializeOnStart)
{
Initialize();
}
}
#region
/// <summary>
/// 코드에서 탭 설정을 추가합니다.
/// </summary>
public bool AddTabConfig(string id, string name, string path, Sprite? icon = null, object? data = null, bool useLazyLoading = false)
{
if (_isInitialized)
{
Debug.LogWarning("탭 시스템이 이미 초기화되었습니다.");
return false;
}
var config = new TabContentConfig(id, name, path, icon, useLazyLoading, data);
_additionalTabConfigs.Add(config);
return true;
}
/// <summary>
/// 모든 탭 설정을 가져옵니다.
/// </summary>
public List<TabContentConfig> GetAllTabConfigs()
{
List<TabContentConfig> allConfigs = new List<TabContentConfig>();
// Inspector 설정 추가
foreach (var config in tabConfigs)
{
if (!string.IsNullOrEmpty(config.tabID) && !string.IsNullOrEmpty(config.contentPath))
{
allConfigs.Add(config);
}
}
// 코드로 추가된 설정 추가
allConfigs.AddRange(_additionalTabConfigs);
return allConfigs;
}
#endregion
#region
/// <summary>
/// 탭 시스템을 초기화합니다.
/// </summary>
public void Initialize()
{
if (_isInitialized || _tabModel == null || tabView == null)
return;
// 탭 모델 초기화
InitializeModel();
// TabView에 설정 전달하여 초기화
List<TabContentConfig> allConfigs = GetAllTabConfigs();
tabView.InitializeTabs(_tabModel.Tabs, allConfigs, OnTabButtonSelected);
// 모델 이벤트 구독
_tabModel.OnTabChanged += HandleTabChanged;
// 초기 탭 활성화
if (_tabModel.Tabs.Count > 0)
{
HandleTabChanged(_tabModel.ActiveTabIndex);
}
_isInitialized = true;
}
/// <summary>
/// 탭 모델을 초기화합니다.
/// </summary>
private void InitializeModel()
{
if (_tabModel == null) return;
List<TabContentConfig> allConfigs = GetAllTabConfigs();
foreach (var config in allConfigs)
{
_tabModel.AddTab(new TabData(
config.tabID,
config.tabName,
config.contentPath,
config.tabIcon,
config.initialData
));
}
}
/// <summary>
/// 탭 버튼 선택 이벤트 처리
/// </summary>
private void OnTabButtonSelected(int tabIndex)
{
_tabModel?.SwitchToTab(tabIndex);
}
/// <summary>
/// 탭 변경 이벤트 처리
/// </summary>
private void HandleTabChanged(int newTabIndex)
{
if (_tabModel == null || tabView == null) return;
TabData? activeTabData = _tabModel.GetActiveTab();
if (activeTabData == null) return;
// TabView에 탭 변경 전달
tabView.UpdateActiveTab(newTabIndex, activeTabData);
// 외부 이벤트 발생
OnTabChanged?.Invoke(newTabIndex);
}
#endregion
#region
/// <summary>
/// ID로 특정 탭을 활성화합니다.
/// </summary>
public void ActivateTab(string tabID)
{
_tabModel?.SwitchToTab(tabID);
}
/// <summary>
/// 인덱스로 특정 탭을 활성화합니다.
/// </summary>
public void ActivateTab(int tabIndex)
{
_tabModel?.SwitchToTab(tabIndex);
}
/// <summary>
/// 현재 활성화된 탭 인덱스를 반환합니다.
/// </summary>
public int GetActiveTabIndex()
{
return _tabModel?.ActiveTabIndex ?? -1;
}
/// <summary>
/// 현재 활성화된 탭의 ID를 반환합니다.
/// </summary>
public string? GetActiveTabID()
{
TabData? activeTab = _tabModel?.GetActiveTab();
return activeTab?.tabID;
}
/// <summary>
/// 특정 탭의 데이터를 설정하고 갱신합니다.
/// </summary>
public void SetTabContentData(string tabID, object? data)
{
if (_tabModel == null || tabView == null) return;
// 모델 업데이트
_tabModel.UpdateTabContentData(tabID, data);
// TabView에 업데이트 전달
int tabIndex = GetTabIndexByID(tabID);
if (tabIndex >= 0)
{
tabView.UpdateTabContentData(tabIndex, data);
}
}
/// <summary>
/// 탭 ID로 인덱스를 찾습니다.
/// </summary>
private int GetTabIndexByID(string tabID)
{
if (_tabModel != null)
{
for (int i = 0; i < _tabModel.Tabs.Count; i++)
{
if (_tabModel.Tabs[i].tabID == tabID)
{
return i;
}
}
}
return -1;
}
#endregion
#region TabView
/// <summary>
/// 특정 탭의 컨텐츠 인스턴스를 가져옵니다.
/// </summary>
public GameObject? GetTabInstance(string tabID, bool autoLoad = false)
{
return tabView?.GetTabInstance(tabID, autoLoad);
}
/// <summary>
/// 특정 탭의 컴포넌트를 가져옵니다.
/// </summary>
public T? GetTabComponent<T>(string tabID, bool autoLoad = false) where T : Component
{
return tabView?.GetTabComponent<T>(tabID, autoLoad);
}
/// <summary>
/// 특정 탭이 로드되었는지 확인합니다.
/// </summary>
public bool IsTabLoaded(string tabID)
{
return tabView?.IsTabLoaded(tabID) ?? false;
}
/// <summary>
/// 현재 활성화된 탭을 제외한 모든 지연 로드 탭을 언로드합니다.
/// </summary>
public void UnloadAllExceptCurrent()
{
int currentIndex = GetActiveTabIndex();
tabView?.UnloadAllExceptCurrent(currentIndex);
}
/// <summary>
/// 특정 탭의 컨텐츠를 언로드합니다.
/// </summary>
public bool UnloadTabContent(string tabID)
{
return tabView?.UnloadTabContent(tabID) ?? false;
}
/// <summary>
/// 모든 탭 컨텐츠를 미리 로드합니다.
/// </summary>
public void PreloadAllTabs()
{
tabView?.PreloadAllTabs();
}
#endregion
private void OnDestroy()
{
if (_tabModel != null)
{
_tabModel.OnTabChanged -= HandleTabChanged;
}
}
}
}

View File

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

View File

@@ -0,0 +1,37 @@
#nullable enable
using System;
using UnityEngine;
namespace UVC.UI.Tab
{
/// <summary>
/// 단일 탭에 대한 데이터를 보관하는 클래스입니다.
/// </summary>
[Serializable]
public class TabData
{
public string tabID; // 탭 고유 식별자
public string tabName; // 탭 표시 이름
public string contentPath; // 탭 내용을 담고 있는 Prefab 경로 (Resources 폴더 기준)
public Sprite? tabIcon; // 탭 아이콘 (선택사항, null 가능)
public object? contentData; // 탭 콘텐츠에 전달할 데이터 객체 (null 가능)
// 프리팹 경로로 초기화하는 생성자
/// <summary>
/// 탭 데이터를 초기화합니다.
/// </summary>
/// <param name="id">탭의 고유 식별자 (예: "inventory", "settings")</param>
/// <param name="name">탭의 표시 이름 (예: "인벤토리", "설정")</param>
/// <param name="path">탭 컨텐츠 프리팹의 리소스 경로 (예: "Prefabs/UI/InventoryTab")</param>
/// <param name="icon">탭 아이콘 이미지 (선택사항)</param>
/// <param name="data">탭 컨텐츠에 전달할 초기 데이터 (선택사항)</param>
public TabData(string id, string name, string path, Sprite? icon = null, object? data = null)
{
tabID = id;
tabName = name;
contentPath = path;
tabIcon = icon;
contentData = data;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 11ef99aba8ccd6245ac008e719d0f81a

View File

@@ -0,0 +1,183 @@
#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
namespace UVC.UI.Tab
{
/// <summary>
/// 전체 탭 시스템의 데이터를 관리하는 모델 클래스입니다.
/// </summary>
public class TabModel
{
// 모든 탭 목록
private List<TabData> _tabs = new List<TabData>();
// 현재 활성화된 탭의 인덱스 (-1은 활성화된 탭이 없음을 의미)
private int _activeTabIndex = -1;
// 탭 컨텐츠 인스턴스를 저장하는 Dictionary (키: tabID)
private Dictionary<string, GameObject> _contentInstances = new Dictionary<string, GameObject>();
/// <summary>
/// 현재 활성화된 탭 인덱스를 가져옵니다.
/// </summary>
public int ActiveTabIndex => _activeTabIndex;
/// <summary>
/// 등록된 모든 탭의 목록을 가져옵니다.
/// </summary>
public IReadOnlyList<TabData> Tabs => _tabs;
/// <summary>
/// 탭이 변경될 때 발생하는 이벤트입니다.
/// </summary>
public event Action<int>? OnTabChanged;
/// <summary>
/// 새로운 탭을 모델에 추가합니다.
/// </summary>
/// <param name="tab">추가할 탭 데이터</param>
public void AddTab(TabData tab)
{
_tabs.Add(tab);
// 첫 번째 추가된 탭을 기본 활성화 탭으로 설정
if (_activeTabIndex == -1 && _tabs.Count == 1)
{
_activeTabIndex = 0;
}
}
/// <summary>
/// 탭 인덱스로 탭을 전환합니다.
/// </summary>
/// <param name="tabIndex">활성화할 탭의 인덱스</param>
public void SwitchToTab(int tabIndex)
{
// 인덱스 범위 확인
if (tabIndex < 0 || tabIndex >= _tabs.Count)
{
Debug.LogWarning($"잘못된 탭 인덱스: {tabIndex}");
return;
}
// 같은 탭을 다시 선택한 경우 무시
if (_activeTabIndex == tabIndex)
{
return;
}
// 활성화 탭 인덱스 업데이트
_activeTabIndex = tabIndex;
// 이벤트 발생
OnTabChanged?.Invoke(_activeTabIndex);
}
/// <summary>
/// 탭 ID로 탭을 전환합니다.
/// </summary>
/// <param name="tabID">활성화할 탭의 ID</param>
public void SwitchToTab(string tabID)
{
for (int i = 0; i < _tabs.Count; i++)
{
if (_tabs[i].tabID == tabID)
{
SwitchToTab(i);
return;
}
}
Debug.LogWarning($"해당 ID의 탭을 찾을 수 없음: {tabID}");
}
/// <summary>
/// 현재 활성화된 탭 데이터를 반환합니다.
/// </summary>
/// <returns>활성화된 탭 데이터 또는 활성화된 탭이 없는 경우 null</returns>
public TabData? GetActiveTab()
{
if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count)
{
return _tabs[_activeTabIndex];
}
return null;
}
/// <summary>
/// 특정 탭의 contentData를 업데이트합니다.
/// </summary>
/// <param name="tabID">업데이트할 탭의 ID</param>
/// <param name="newData">새로운 데이터</param>
public void UpdateTabContentData(string tabID, object? newData)
{
for (int i = 0; i < _tabs.Count; i++)
{
if (_tabs[i].tabID == tabID)
{
_tabs[i].contentData = newData;
break;
}
}
}
/// <summary>
/// 특정 탭의 contentData를 업데이트합니다.
/// </summary>
/// <param name="tabIndex">업데이트할 탭의 인덱스</param>
/// <param name="newData">새로운 데이터</param>
public void UpdateTabContentData(int tabIndex, object? newData)
{
if (tabIndex >= 0 && tabIndex < _tabs.Count)
{
_tabs[tabIndex].contentData = newData;
}
}
/// <summary>
/// 특정 탭의 컨텐츠 인스턴스를 저장합니다.
/// </summary>
/// <param name="tabID">탭 ID</param>
/// <param name="instance">인스턴스화된 컨텐츠 GameObject</param>
public void SetContentInstance(string tabID, GameObject instance)
{
if (_contentInstances.ContainsKey(tabID))
{
_contentInstances[tabID] = instance;
}
else
{
_contentInstances.Add(tabID, instance);
}
}
/// <summary>
/// 특정 탭의 컨텐츠 인스턴스를 가져옵니다.
/// </summary>
/// <param name="tabID">탭 ID</param>
/// <returns>컨텐츠 인스턴스 또는 인스턴스가 없는 경우 null</returns>
public GameObject? GetContentInstance(string tabID)
{
if (_contentInstances.TryGetValue(tabID, out GameObject instance))
{
return instance;
}
return null;
}
/// <summary>
/// 인덱스로 특정 탭의 컨텐츠 인스턴스를 가져옵니다.
/// </summary>
/// <param name="tabIndex">탭 인덱스</param>
/// <returns>컨텐츠 인스턴스 또는 인스턴스가 없는 경우 null</returns>
public GameObject? GetContentInstance(int tabIndex)
{
if (tabIndex >= 0 && tabIndex < _tabs.Count)
{
return GetContentInstance(_tabs[tabIndex].tabID);
}
return null;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 35d6d440b8fa06549b7df3c17961456d

View File

@@ -0,0 +1,481 @@
#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
namespace UVC.UI.Tab
{
/// <summary>
/// 전체 탭 시스템의 UI를 관리하는 View 클래스입니다.
/// TabContentLoader와 협력하여 컨텐츠 로딩도 담당합니다.
/// </summary>
public class TabView : MonoBehaviour
{
/// <summary>
/// 전체 탭 시스템의 UI를 관리하는 View 클래스입니다.
/// TabContentLoader와 협력하여 컨텐츠 로딩도 담당합니다.
/// </summary>
[Header("UI 설정")]
[Tooltip("탭 버튼들이 생성될 부모 Transform")]
[SerializeField] private Transform? tabButtonContainer;
[Tooltip("탭 컨텐츠들이 표시될 부모 Transform")]
[SerializeField] private Transform? contentContainer;
[Tooltip("탭 버튼 프리팹")]
[SerializeField] private GameObject? tabButtonPrefab;
[Header("컨텐츠 로딩")]
[Tooltip("컨텐츠 로더 (지연 로딩 기능, 선택사항)")]
[SerializeField] private TabContentLoader? contentLoader;
// 생성된 탭 버튼 컴포넌트들
private List<TabButtonView> _tabButtons = new List<TabButtonView>();
// 모든 컨텐츠 인스턴스를 저장
private List<GameObject?> _allContentInstances = new List<GameObject?>();
// 탭 설정 정보 (TabController에서 전달받음)
private List<TabContentConfig> _tabConfigs = new List<TabContentConfig>();
// 탭 버튼 클릭 시 호출될 콜백
private Action<int>? _onTabButtonClicked;
/// <summary>
/// 컨텐츠 컨테이너를 반환합니다.
/// </summary>
public Transform? ContentContainer => contentContainer;
/// <summary>
/// 탭 시스템을 초기화합니다.
/// </summary>
/// <param name="tabs">탭 데이터 목록</param>
/// <param name="configs">탭 설정 목록 (TabController에서 전달)</param>
/// <param name="onTabSelected">탭 선택 시 호출될 콜백</param>
public void InitializeTabs(IReadOnlyList<TabData> tabs, List<TabContentConfig> configs, Action<int> onTabSelected)
{
// 기존 탭 정리
ClearTabs();
_onTabButtonClicked = onTabSelected;
_tabConfigs = new List<TabContentConfig>(configs);
// 컨텐츠 인스턴스 리스트 초기화
_allContentInstances.Clear();
for (int i = 0; i < tabs.Count; i++)
{
_allContentInstances.Add(null);
}
// TabContentLoader 초기화 (있는 경우)
if (contentLoader != null)
{
InitializeContentLoader();
}
// 탭 버튼 생성
for (int i = 0; i < tabs.Count; i++)
{
CreateTabButton(i, tabs[i]);
}
// 지연 로딩을 사용하지 않는 탭들 미리 로드
PreloadNonLazyTabs();
}
/// <summary>
/// TabContentLoader를 초기화합니다.
/// </summary>
private void InitializeContentLoader()
{
if (contentLoader == null) return;
// 기존 설정 클리어
contentLoader.ClearLazyContents();
// 지연 로딩 탭들을 ContentLoader에 등록
foreach (var config in _tabConfigs)
{
if (config.useLazyLoading)
{
contentLoader.AddLazyTabContent(config.tabID, config.contentPath, config.initialData);
}
}
// ContentLoader 초기화
contentLoader.InitializeTabLoader();
}
/// <summary>
/// 지연 로딩을 사용하지 않는 탭들을 미리 로드합니다.
/// </summary>
private void PreloadNonLazyTabs()
{
for (int i = 0; i < _tabConfigs.Count; i++)
{
var config = _tabConfigs[i];
if (!config.useLazyLoading)
{
GameObject? instance = LoadTabContentDirectly(config);
if (instance != null)
{
_allContentInstances[i] = instance;
}
}
}
Debug.Log("지연 로딩을 사용하지 않는 탭들이 미리 로드되었습니다.");
}
/// <summary>
/// 탭 컨텐츠를 직접 로드합니다.
/// </summary>
private GameObject? LoadTabContentDirectly(TabContentConfig config)
{
try
{
// 프리팹 로드
GameObject prefab = Resources.Load<GameObject>(config.contentPath);
if (prefab == null)
{
Debug.LogError($"탭 컨텐츠 프리팹을 찾을 수 없음: {config.contentPath}");
return null;
}
// 인스턴스 생성
GameObject instance = Instantiate(prefab, contentContainer);
instance.name = $"TabContent_{config.tabID}";
instance.SetActive(false);
// 초기 데이터 전달
if (config.initialData != null)
{
ITabContent? tabContent = instance.GetComponent<ITabContent>();
tabContent?.SetContentData(config.initialData);
}
Debug.Log($"탭 컨텐츠 직접 로드됨: {config.tabID}");
return instance;
}
catch (System.Exception ex)
{
Debug.LogError($"탭 컨텐츠 직접 로드 중 오류 발생: {ex.Message}");
return null;
}
}
/// <summary>
/// 탭 버튼을 생성합니다.
/// </summary>
private void CreateTabButton(int index, TabData tabData)
{
if (tabButtonPrefab == null)
{
Debug.LogError("탭 버튼 프리팹이 설정되지 않았습니다.");
return;
}
if (tabButtonContainer == null)
{
Debug.LogError("탭 버튼 컨테이너가 설정되지 않았습니다.");
return;
}
// 탭 버튼 인스턴스 생성
GameObject tabButtonGO = Instantiate(tabButtonPrefab, tabButtonContainer);
TabButtonView? tabButton = tabButtonGO.GetComponent<TabButtonView>();
if (tabButton == null)
{
Debug.LogError("탭 버튼 프리팹에 TabButtonView 컴포넌트가 없습니다.");
Destroy(tabButtonGO);
return;
}
// 탭 버튼 설정
tabButton.Setup(index, tabData.tabName, tabData.tabIcon, OnTabButtonClicked);
_tabButtons.Add(tabButton);
}
/// <summary>
/// 탭 버튼 클릭 이벤트 처리
/// </summary>
private void OnTabButtonClicked(int tabIndex)
{
_onTabButtonClicked?.Invoke(tabIndex);
}
/// <summary>
/// 활성화된 탭을 업데이트합니다.
/// 필요한 경우 컨텐츠를 로드합니다.
/// </summary>
/// <param name="tabIndex">활성화할 탭 인덱스</param>
/// <param name="tabData">탭 데이터</param>
public void UpdateActiveTab(int tabIndex, TabData tabData)
{
if (tabIndex < 0 || tabIndex >= _tabConfigs.Count) return;
var config = _tabConfigs[tabIndex];
// 컨텐츠가 아직 로드되지 않은 경우 로드
if (_allContentInstances[tabIndex] == null)
{
if (config.useLazyLoading && contentLoader != null)
{
// 지연 로딩을 통해 로드
GameObject? lazyInstance = contentLoader.LoadTabContent(config.tabID, tabData.contentData);
if (lazyInstance != null)
{
// ContentContainer로 이동 (일관성 확보)
if (lazyInstance.transform.parent != contentContainer)
{
lazyInstance.transform.SetParent(contentContainer, false);
}
_allContentInstances[tabIndex] = lazyInstance;
}
}
else
{
// 직접 로드
_allContentInstances[tabIndex] = LoadTabContentDirectly(config);
}
}
// 모든 컨텐츠 비활성화
foreach (var content in _allContentInstances)
{
if (content != null)
content.SetActive(false);
}
// 활성화된 컨텐츠만 활성화
GameObject? activeContent = _allContentInstances[tabIndex];
if (activeContent != null)
{
activeContent.SetActive(true);
// 데이터 업데이트
if (tabData.contentData != null)
{
ITabContent? tabContent = activeContent.GetComponent<ITabContent>();
tabContent?.SetContentData(tabData.contentData);
}
}
// 탭 버튼 상태 업데이트
for (int i = 0; i < _tabButtons.Count; i++)
{
if (_tabButtons[i] != null)
_tabButtons[i].SetActive(i == tabIndex);
}
}
/// <summary>
/// 특정 탭의 컨텐츠 데이터를 업데이트합니다.
/// </summary>
/// <param name="tabIndex">업데이트할 탭 인덱스</param>
/// <param name="data">새로운 데이터</param>
public void UpdateTabContentData(int tabIndex, object? data)
{
if (tabIndex < 0 || tabIndex >= _allContentInstances.Count) return;
GameObject? contentInstance = _allContentInstances[tabIndex];
if (contentInstance != null && data != null)
{
ITabContent? tabContent = contentInstance.GetComponent<ITabContent>();
tabContent?.SetContentData(data);
}
// 지연 로딩 탭인 경우 ContentLoader에도 업데이트
if (tabIndex < _tabConfigs.Count)
{
var config = _tabConfigs[tabIndex];
if (config.useLazyLoading && contentLoader != null)
{
contentLoader.SetTabContentData(config.tabID, data);
}
}
}
/// <summary>
/// 특정 탭의 컨텐츠 인스턴스를 가져옵니다.
/// </summary>
/// <param name="tabID">탭 ID</param>
/// <param name="autoLoad">자동 로드 여부</param>
/// <returns>컨텐츠 인스턴스</returns>
public GameObject? GetTabInstance(string tabID, bool autoLoad = false)
{
int tabIndex = GetTabIndexByID(tabID);
if (tabIndex < 0) return null;
// 이미 로드된 인스턴스가 있으면 반환
if (_allContentInstances[tabIndex] != null)
{
return _allContentInstances[tabIndex];
}
// 자동 로드가 활성화된 경우
if (autoLoad && tabIndex < _tabConfigs.Count)
{
var config = _tabConfigs[tabIndex];
if (config.useLazyLoading && contentLoader != null)
{
// 지연 로딩을 통해 로드
GameObject? instance = contentLoader.LoadTabContent(tabID);
if (instance != null)
{
// ContentContainer로 이동
if (instance.transform.parent != contentContainer)
{
instance.transform.SetParent(contentContainer, false);
}
_allContentInstances[tabIndex] = instance;
}
return instance;
}
else
{
// 직접 로드
GameObject? instance = LoadTabContentDirectly(config);
if (instance != null)
{
_allContentInstances[tabIndex] = instance;
}
return instance;
}
}
return null;
}
/// <summary>
/// 특정 탭의 컴포넌트를 가져옵니다.
/// </summary>
public T? GetTabComponent<T>(string tabID, bool autoLoad = false) where T : Component
{
GameObject? instance = GetTabInstance(tabID, autoLoad);
return instance?.GetComponent<T>();
}
/// <summary>
/// 탭 ID로 인덱스를 찾습니다.
/// </summary>
private int GetTabIndexByID(string tabID)
{
for (int i = 0; i < _tabConfigs.Count; i++)
{
if (_tabConfigs[i].tabID == tabID)
{
return i;
}
}
return -1;
}
/// <summary>
/// 특정 탭이 로드되었는지 확인합니다.
/// </summary>
public bool IsTabLoaded(string tabID)
{
int tabIndex = GetTabIndexByID(tabID);
if (tabIndex < 0) return false;
return _allContentInstances[tabIndex] != null;
}
/// <summary>
/// ContentLoader 관련 메서드들 (있는 경우에만 동작)
/// </summary>
public void UnloadAllExceptCurrent(int currentTabIndex)
{
if (contentLoader != null && currentTabIndex < _tabConfigs.Count)
{
string currentTabID = _tabConfigs[currentTabIndex].tabID;
contentLoader.UnloadAllExceptCurrent(currentTabID);
// 언로드된 탭들의 인스턴스 참조도 제거
for (int i = 0; i < _allContentInstances.Count; i++)
{
if (i != currentTabIndex && _tabConfigs[i].useLazyLoading)
{
_allContentInstances[i] = null;
}
}
}
}
public bool UnloadTabContent(string tabID)
{
if (contentLoader != null)
{
bool result = contentLoader.UnloadTabContent(tabID);
if (result)
{
// 인스턴스 참조도 제거
int tabIndex = GetTabIndexByID(tabID);
if (tabIndex >= 0)
{
_allContentInstances[tabIndex] = null;
}
}
return result;
}
return false;
}
public void PreloadAllTabs()
{
if (contentLoader != null)
{
contentLoader.PreloadAllTabs();
// 지연 로딩 탭들의 인스턴스를 _allContentInstances에 등록
for (int i = 0; i < _tabConfigs.Count; i++)
{
var config = _tabConfigs[i];
if (config.useLazyLoading)
{
GameObject? instance = contentLoader.GetTabInstance(config.tabID);
if (instance != null)
{
// ContentContainer로 이동
if (instance.transform.parent != contentContainer)
{
instance.transform.SetParent(contentContainer, false);
}
_allContentInstances[i] = instance;
}
}
}
}
}
/// <summary>
/// 모든 탭과 관련 게임오브젝트를 정리합니다.
/// </summary>
private void ClearTabs()
{
// 탭 버튼 제거
foreach (var button in _tabButtons)
{
if (button != null)
Destroy(button.gameObject);
}
_tabButtons.Clear();
// 컨텐츠 인스턴스 제거
foreach (var content in _allContentInstances)
{
if (content != null)
Destroy(content);
}
_allContentInstances.Clear();
// 설정 클리어
_tabConfigs.Clear();
}
}
}

View File

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

View File

@@ -19,88 +19,151 @@ namespace UVC.UI
/// using UnityEngine;
/// using UVC.UI;
///
/// public class DraggableWindowController : MonoBehaviour
/// {
/// // UIDragger 컴포넌트가 부착된 드래그 핸들 오브젝트
/// public UIDragger draggerHandle;
///
/// void Start()
/// {
/// // 드래그 시작 이벤트 구독
/// draggerHandle.onBeginDragHandler += () =>
/// {
/// Debug.Log("창 드래그가 시작되었습니다!");
/// };
///
/// // 드래그 종료 이벤트 구독
/// draggerHandle.OnEndDragHandler += pos =>
/// {
/// Debug.Log($"창 드래그가 종료되었습니다! 최종 위치: {pos}");
/// };
/// }
/// }
///public class DraggableWindowController : MonoBehaviour
///{
/// [SerializeField] private UIDragger draggerHandle;
/// [SerializeField] private RectTransform customDragArea;
///
/// private void Start()
/// {
/// // 커스텀 드래그 영역 설정
/// if (customDragArea != null)
/// {
/// draggerHandle.SetDragArea(customDragArea);
/// }
///
/// // 이벤트 구독
/// draggerHandle.OnBeginDragHandler += OnDragStart;
/// draggerHandle.OnDragHandler += OnDragging;
/// draggerHandle.OnEndDragHandler += OnDragEnd;
///
/// // 창을 중앙으로 이동
/// draggerHandle.CenterInDragArea();
/// }
///
/// private void OnDragStart()
/// {
/// Debug.Log("창 드래그 시작!");
/// // 드래그 시작 시 추가 로직
/// }
///
/// private void OnDragging(Vector2 position)
/// {
/// // 드래그 중 실시간 처리
/// Debug.Log($"드래그 중: {position}");
/// }
///
/// private void OnDragEnd(Vector2 finalPosition)
/// {
/// Debug.Log($"드래그 완료! 최종 위치: {finalPosition}");
/// // 위치 저장 등의 후처리
/// }
///
/// // 런타임에서 드래그 기능 제어
/// public void ToggleDragging()
/// {
/// draggerHandle.SetDraggingEnabled(!draggerHandle.enabled);
/// }
///}
/// </code>
/// </example>
public class UIDragger : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Header("드래그 설정")]
[SerializeField]
[Tooltip("드래그 가능한 영역을 지정합니다. 이 영역 내에서 드래그가 가능합니다.")]
[Tooltip("드래그 가능한 영역을 지정합니다. null인 경우 Canvas를 자동으로 찾습니다.")]
private RectTransform dragArea; // 드래그가 가능한 영역
[SerializeField]
[Tooltip("드래그할 UI 요소를 지정합니다. 이 요소가 실제로 드래그됩니다.")]
[Tooltip("드래그할 UI 요소를 지정합니다. null인 경우 부모를 자동으로 설정합니다.")]
private RectTransform dragObject; // 실제로 드래그될 UI 요소 (예: 창 전체)
[SerializeField]
[Tooltip("드래그 시작 시 해당 UI 요소를 맨 앞으로 가져올지 여부를 설정합니다.")]
private bool topOnDrag = true; // 드래그 시작 시 맨 앞으로 가져올지 여부
private bool bringToFrontOnDrag = true; // 드래그 시작 시 맨 앞으로 가져올지 여부
[SerializeField]
[Tooltip("드래그 영역 제한 시 사용할 최소 높이값")]
private float yMinHeight = 30;
private float yMinHeight = 0;
private Vector2 originalLocalPointerPosition; // 드래그 시작 시 마우스 포인터의 로컬 위치
private Vector3 originalPanelLocalPosition; // 드래그 시작 시 패널의 로컬 위치
[SerializeField]
[Tooltip("드래그 중 실시간으로 영역 제한을 적용할지 여부")]
private bool constrainDuringDrag = true;
/// <summary>
/// 드래그가 끝났을 때 발생하는 이벤트입니다. 최종 위치 정보를 전달합니다.
/// </summary>
public Action<Vector3> OnEndDragHandler { get; set; }
// 이벤트
public Action OnBeginDragHandler { get; set; }
public Action<Vector2> OnDragHandler { get; set; }
public Action<Vector2> OnEndDragHandler { get; set; }
/// <summary>
/// 드래그가 시작될 때 발생하는 액션입니다.
/// </summary>
public Action onBeginDragHandler;
// 캐시된 변수들
private Vector2 originalLocalPointerPosition;
private Vector2 originalAnchoredPosition;
private int originalSiblingIndex;
private Canvas parentCanvas;
private Camera canvasCamera;
// 원래의 형제 순서(UI 렌더링 순서)
private int baseSibling;
// 프로퍼티
public RectTransform DragObject => dragObject;
public RectTransform DragArea => dragArea;
public bool IsDragging { private set; get; }
private void Awake()
{
InitializeComponents();
ValidateSetup();
}
/// <summary>
/// 컴포넌트들을 초기화합니다.
/// </summary>
private void InitializeComponents()
{
// dragObject가 설정되지 않았다면, 부모를 드래그 대상으로 설정
if (dragObject == null)
{
dragObject = transform.parent as RectTransform;
if (dragObject == null)
{
Debug.LogError("<b>[UIDragger]</b> 드래그할 객체(dragObject)를 찾을 수 없습니다. 부모가 RectTransform이 아닙니다.", this);
enabled = false;
return;
}
}
// dragArea가 설정되지 않았다면, 최상위 Canvas를 드래그 영역으로 설정
if (dragArea == null)
// Canvas와 Camera 캐싱
parentCanvas = GetComponentInParent<Canvas>();
if (parentCanvas != null)
{
dragArea = GetComponentInParent<Canvas>()?.transform as RectTransform;
canvasCamera = parentCanvas.worldCamera;
// dragArea가 설정되지 않았다면, Canvas를 드래그 영역으로 설정
if (dragArea == null)
{
Debug.Log("<b>[UIDragger]</b> 드래그 영역(dragArea)으로 사용할 Canvas를 찾을 수 없습니다.", this);
enabled = false;
return;
dragArea = parentCanvas.transform as RectTransform;
}
}
}
/// <summary>
/// 설정이 올바른지 검증합니다.
/// </summary>
private void ValidateSetup()
{
if (dragObject == null)
{
Debug.LogError("[UIDragger] dragObject를 찾을 수 없습니다. 부모가 RectTransform이 아닙니다.", this);
enabled = false;
return;
}
if (dragArea == null)
{
Debug.LogError("[UIDragger] dragArea를 찾을 수 없습니다. Canvas를 찾을 수 없습니다.", this);
enabled = false;
return;
}
if (parentCanvas == null)
{
Debug.LogWarning("[UIDragger] Canvas를 찾을 수 없습니다. 드래그 기능이 제한될 수 있습니다.", this);
}
}
/// <summary>
/// 드래그가 허용되는 영역을 설정합니다.
/// </summary>
@@ -109,43 +172,84 @@ namespace UVC.UI
/// <param name="area">드래그 영역의 경계를 정의하는 <see cref="RectTransform"/>입니다. null일 수 없습니다.</param>
public void SetDragArea(RectTransform area)
{
if (area == null)
{
Debug.LogWarning("[UIDragger] null인 dragArea를 설정하려고 했습니다.", this);
return;
}
dragArea = area;
enabled = true;
}
/// <summary>
/// 드래그 대상을 동적으로 설정합니다.
/// </summary>
public void SetDragObject(RectTransform target)
{
if (target == null)
{
Debug.LogWarning("[UIDragger] null인 dragObject를 설정하려고 했습니다.", this);
return;
}
dragObject = target;
}
/// <summary>
/// 드래그가 시작될 때 호출됩니다. (IBeginDragHandler)
/// </summary>
public void OnBeginDrag(PointerEventData data)
public void OnBeginDrag(PointerEventData eventData)
{
if (data.button != PointerEventData.InputButton.Left) return;
if (eventData.button != PointerEventData.InputButton.Left) return;
if (!IsValidForDrag()) return;
originalPanelLocalPosition = dragObject.localPosition;
baseSibling = dragObject.GetSiblingIndex();
IsDragging = true;
originalAnchoredPosition = dragObject.anchoredPosition;
originalSiblingIndex = dragObject.GetSiblingIndex();
RectTransformUtility.ScreenPointToLocalPointInRectangle(dragArea, data.position, data.pressEventCamera, out originalLocalPointerPosition);
// 마우스 포인터의 로컬 위치 계산
RectTransformUtility.ScreenPointToLocalPointInRectangle(
dragArea,
eventData.position,
canvasCamera,
out originalLocalPointerPosition);
if (topOnDrag)
// 맨 앞으로 가져오기
if (bringToFrontOnDrag)
{
dragObject.SetAsLastSibling();
}
onBeginDragHandler?.Invoke();
OnBeginDragHandler?.Invoke();
}
/// <summary>
/// 드래그 중일 때 매 프레임 호출됩니다. (IDragHandler)
/// </summary>
public void OnDrag(PointerEventData data)
public void OnDrag(PointerEventData eventData)
{
if (data.button != PointerEventData.InputButton.Left) return;
if (eventData.button != PointerEventData.InputButton.Left) return;
if (!IsDragging || !IsValidForDrag()) return;
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(dragArea, data.position, data.pressEventCamera, out Vector2 localPointerPosition))
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
dragArea,
eventData.position,
canvasCamera,
out Vector2 localPointerPosition))
{
Vector3 offsetToOriginal = localPointerPosition - originalLocalPointerPosition;
dragObject.localPosition = originalPanelLocalPosition + offsetToOriginal;
}
Vector2 offsetToOriginal = localPointerPosition - originalLocalPointerPosition;
Vector2 newPosition = originalAnchoredPosition + offsetToOriginal;
ClampToArea();
// 실시간 제약 적용
if (constrainDuringDrag)
{
newPosition = ClampToArea(newPosition);
}
dragObject.anchoredPosition = newPosition;
OnDragHandler?.Invoke(newPosition);
}
}
/// <summary>
@@ -154,34 +258,100 @@ namespace UVC.UI
public void OnEndDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left) return;
if (!IsDragging) return;
if (topOnDrag)
IsDragging = false;
// 원래 형제 순서로 복원
if (bringToFrontOnDrag)
{
dragObject.SetSiblingIndex(baseSibling);
dragObject.SetSiblingIndex(originalSiblingIndex);
}
OnEndDragHandler?.Invoke(dragObject.anchoredPosition);
// 최종 위치 제약 적용
Vector2 finalPosition = ClampToArea(dragObject.anchoredPosition);
dragObject.anchoredPosition = finalPosition;
OnEndDragHandler?.Invoke(finalPosition);
}
/// <summary>
/// 드래그가 가능한 상태인지 확인합니다.
/// </summary>
private bool IsValidForDrag()
{
return dragObject != null && dragArea != null && enabled;
}
/// <summary>
/// UI 요소가 드래그 영역 내에 있도록 위치를 제한합니다.
/// </summary>
private void ClampToArea()
private Vector2 ClampToArea(Vector2 position)
{
Vector3 pos = dragObject.localPosition;
if (dragArea == null || dragObject == null)
return position;
Rect dragObjectRect = dragObject.rect;
Rect dragAreaRect = dragArea.rect;
// Pivot을 기준으로 최소/최대 위치를 계산합니다.
float minX = dragAreaRect.xMin - dragObjectRect.xMin;
float maxX = dragAreaRect.xMax - dragObjectRect.xMax;
float minY = dragAreaRect.yMin - dragObjectRect.yMin;
float maxY = dragAreaRect.yMax - dragObjectRect.yMax;
// Pivot과 앵커를 고려한 경계 계산
Vector2 pivot = dragObject.pivot;
Vector2 size = dragObjectRect.size;
pos.x = Mathf.Clamp(pos.x, minX, maxX);
pos.y = Mathf.Clamp(pos.y, minY, maxY - yMinHeight);
float leftBoundary = dragAreaRect.xMin + (size.x * pivot.x);
float rightBoundary = dragAreaRect.xMax - (size.x * (1f - pivot.x));
float bottomBoundary = dragAreaRect.yMin + (size.y * pivot.y) + yMinHeight;
float topBoundary = dragAreaRect.yMax - (size.y * (1f - pivot.y));
dragObject.localPosition = pos;
position.x = Mathf.Clamp(position.x, leftBoundary, rightBoundary);
position.y = Mathf.Clamp(position.y, bottomBoundary, topBoundary);
return position;
}
}
/// <summary>
/// 드래그 객체를 특정 위치로 이동시킵니다.
/// </summary>
public void SetPosition(Vector2 position, bool clampToArea = true)
{
if (dragObject == null) return;
if (clampToArea)
{
position = ClampToArea(position);
}
dragObject.anchoredPosition = position;
}
/// <summary>
/// 드래그 객체를 중앙으로 이동시킵니다.
/// </summary>
public void CenterInDragArea()
{
if (dragArea == null || dragObject == null) return;
Vector2 centerPosition = dragArea.rect.center;
SetPosition(centerPosition, false);
}
/// <summary>
/// 드래그 기능을 활성화/비활성화합니다.
/// </summary>
public void SetDraggingEnabled(bool enabled)
{
this.enabled = enabled;
}
#if UNITY_EDITOR
private void OnValidate()
{
// 에디터에서 값 변경 시 유효성 검사
if (Application.isPlaying)
{
ValidateSetup();
}
}
#endif
}
}