Files
XRLib/Assets/Scripts/UVC/UIToolkit/Modal/UTKLoading.cs

465 lines
16 KiB
C#

#nullable enable
using System;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
/// <summary>
/// 회전하면서 호가 길어졌다 짧아지는 원형 로딩 스피너 컴포넌트.
/// Painter2D Drawing API로 자연스러운 원호를 직접 그립니다.
/// </summary>
/// <remarks>
/// <para><b>두 가지 사용 방식 비교:</b></para>
/// <list type="table">
/// <listheader>
/// <term>항목</term>
/// <description>Static API / UXML 태그</description>
/// </listheader>
/// <item>
/// <term>배치 위치</term>
/// <description>Static: panel.visualTree 최상단 / UXML: 레이아웃 내 인라인</description>
/// </item>
/// <item>
/// <term>Blocker</term>
/// <description>Static: 있음 (전체 클릭 차단) / UXML: 없음</description>
/// </item>
/// <item>
/// <term>위치</term>
/// <description>Static: 화면 정중앙 고정 / UXML: CSS 레이아웃 따름</description>
/// </item>
/// <item>
/// <term>시작</term>
/// <description>Static: Show() 호출 시 / UXML: AttachToPanel 자동 (auto-play=true)</description>
/// </item>
/// <item>
/// <term>정지</term>
/// <description>Static: Hide() 호출 시 / UXML: DetachFromPanel 자동</description>
/// </item>
/// </list>
///
/// <para><b>색상·두께·크기 설정:</b></para>
/// <list type="bullet">
/// <item><description>기본 색상은 현재 테마의 <c>--color-primary</c>를 자동 적용합니다.</description></item>
/// <item><description><c>arc-color</c> 속성을 지정하면 테마 색상을 무시하고 해당 색상을 사용합니다.</description></item>
/// <item><description><c>thickness</c>: 선 두께 (px, 기본값: 4)</description></item>
/// <item><description><c>size</c>: 스피너 크기 (px, 기본값: 48)</description></item>
/// <item><description><c>speed</c>: 회전 속도 배율 (기본값: 1.0)</description></item>
/// </list>
/// </remarks>
/// <example>
/// <para><b>Static API (전체 화면 로딩 + Blocker):</b></para>
/// <code>
/// UTKLoading.SetRoot(rootVisualElement);
/// UTKLoading.Show(); // 기본 속도
/// UTKLoading.Show(speed: 2f); // 빠른 회전
/// UTKLoading.Hide();
/// </code>
/// <para><b>UXML 태그 (인라인 배치, Blocker 없음):</b></para>
/// <code>
/// &lt;utk:UTKLoading speed="1.5" auto-play="true" /&gt;
/// &lt;utk:UTKLoading arc-color="#73C991" thickness="6" size="64" auto-play="true" /&gt;
/// </code>
/// </example>
[UxmlElement]
public partial class UTKLoading : VisualElement, IDisposable
{
#region Constants
private const string USS_PATH = "UIToolkit/Modal/UTKLoadingUss";
// 애니메이션 업데이트 간격 (16ms ≈ 60fps)
private const long UPDATE_INTERVAL_MS = 16;
// 기본 회전 속도 (도/초) — 180도/초 = 0.5바퀴/초
private const float DEFAULT_SPEED_DEG = 270f;
// 호 최대 길이 (도) — 360도
private const float ARC_MAX_DEG = 360f;
// 호 길이 변화 주기 (라디안/초) — 0~2π 사이클용 시간 누적 (내부 phase 계산용)
// 0.8π ≈ 한 사이클 약 2.5초
private const float ARC_CYCLE_SPEED = Mathf.PI * 0.8f;
// 기본 크기/두께
private const float DEFAULT_SIZE = 48f;
private const float DEFAULT_THICKNESS = 4f;
#endregion
#region Fields
private static VisualElement? _root;
private static UTKLoading? _activeInstance;
private bool _disposed;
private bool _isAnimating;
private float _speedDeg = DEFAULT_SPEED_DEG;
private bool _autoPlay = true;
// 애니메이션 상태
private float _rotation = 0f; // 현재 회전 각도 (도, 계속 누적)
private float _cycleTime = 0f; // 0~2π 범위 phase 누적 (라디안)
// 렌더링 파라미터
private Color? _arcColorOverride; // null이면 테마 색상 사용
private Color _resolvedThemeColor = UTKStyleGuide.Blue05; // OnCustomStyleResolved에서 갱신
private float _thickness = DEFAULT_THICKNESS;
private float _size = DEFAULT_SIZE;
// 클릭 차단 레이어 (Static Show()에서만 사용)
private VisualElement? _blocker;
// 스케줄 핸들
private IVisualElementScheduledItem? _animationSchedule;
#endregion
#region UxmlAttributes
/// <summary>회전 속도 배율. 1.0 = 기본 속도</summary>
[UxmlAttribute("speed")]
public float Speed
{
get => _speedDeg / DEFAULT_SPEED_DEG;
set => _speedDeg = DEFAULT_SPEED_DEG * Mathf.Max(0.1f, value);
}
/// <summary>패널에 연결될 때 자동으로 애니메이션을 시작할지 여부</summary>
[UxmlAttribute("auto-play")]
public bool AutoPlay
{
get => _autoPlay;
set => _autoPlay = value;
}
/// <summary>
/// 스피너 색상. 지정하지 않으면 현재 테마의 --color-primary가 적용됩니다.
/// </summary>
[UxmlAttribute("arc-color")]
public Color ArcColor
{
get => _arcColorOverride ?? _resolvedThemeColor;
set
{
_arcColorOverride = value;
MarkDirtyRepaint();
}
}
/// <summary>선 두께 (px)</summary>
[UxmlAttribute("thickness")]
public float Thickness
{
get => _thickness;
set
{
_thickness = Mathf.Max(1f, value);
MarkDirtyRepaint();
}
}
/// <summary>스피너 전체 크기 (px)</summary>
[UxmlAttribute("size")]
public float Size
{
get => _size;
set
{
_size = Mathf.Max(8f, value);
style.width = _size;
style.height = _size;
MarkDirtyRepaint();
}
}
#endregion
#region Constructor
/// <summary>
/// UTKLoading 기본 생성자.
/// UXML 태그로 배치하면 패널에 연결될 때 auto-play 설정에 따라 자동 시작됩니다.
/// </summary>
public UTKLoading()
{
// 테마 스타일시트 적용 (--color-primary 변수 상속용)
UTKThemeManager.Instance.ApplyThemeToElement(this);
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
styleSheets.Add(uss);
AddToClassList("utk-loading");
// 고정 크기 지정 (Drawing API는 contentRect 기준)
style.width = _size;
style.height = _size;
style.flexShrink = 0;
// customStyle 변경 시 테마 색상 갱신
RegisterCallback<CustomStyleResolvedEvent>(OnCustomStyleResolved);
// Drawing API 콜백 등록
generateVisualContent += OnGenerateVisualContent;
SubscribeToThemeChanges();
}
#endregion
#region Static API
/// <summary>
/// 기본 루트 요소 설정.
/// 로딩 스피너는 이 루트의 panel.visualTree에 표시됩니다.
/// </summary>
public static void SetRoot(VisualElement root) => _root = root;
/// <summary>기본 루트 요소 반환</summary>
public static VisualElement? GetRoot() => _root;
/// <summary>
/// 로딩 스피너 표시.
/// 이미 표시 중이면 중복 생성하지 않고 기존 인스턴스를 반환합니다.
/// </summary>
/// <param name="speed">회전 속도 배율 (기본값 1.0)</param>
public static UTKLoading Show(float speed = 1.0f)
{
ValidateRoot();
if (_activeInstance != null)
return _activeInstance;
var loading = new UTKLoading
{
_speedDeg = DEFAULT_SPEED_DEG * Mathf.Max(0.1f, speed),
_blocker = CreateBlocker(),
};
var visualTree = _root!.panel?.visualTree ?? _root!;
visualTree.Add(loading._blocker);
visualTree.Add(loading);
// 화면 중앙 고정
loading.style.position = Position.Absolute;
loading.style.left = Length.Percent(50);
loading.style.top = Length.Percent(50);
loading.style.translate = new Translate(Length.Percent(-50), Length.Percent(-50));
loading.StartAnimation();
_activeInstance = loading;
return loading;
}
/// <summary>
/// 로딩 스피너 숨기기 및 제거.
/// </summary>
public static void Hide()
{
if (_activeInstance == null) return;
var instance = _activeInstance;
_activeInstance = null;
instance.Close();
}
/// <summary>현재 로딩이 표시 중인지 여부</summary>
public static bool IsShowing => _activeInstance != null;
#endregion
#region Public Methods
/// <summary>이 인스턴스를 닫고 계층에서 제거합니다.</summary>
public void Close()
{
StopAnimation();
if (_blocker is not null)
{
_blocker.RemoveFromHierarchy();
_blocker = null;
}
RemoveFromHierarchy();
Dispose();
if (ReferenceEquals(_activeInstance, this))
_activeInstance = null;
}
#endregion
#region Drawing
/// <summary>
/// Painter2D로 트랙 원과 로딩 호를 직접 그립니다.
/// </summary>
private void OnGenerateVisualContent(MeshGenerationContext ctx)
{
var painter = ctx.painter2D;
var rect = contentRect;
var center = new Vector2(rect.width * 0.5f, rect.height * 0.5f);
float radius = Mathf.Min(rect.width, rect.height) * 0.5f - _thickness * 0.5f;
if (radius <= 0f) return;
// arc-color 미지정 시 OnCustomStyleResolved에서 캐싱된 테마 색상 사용
var color = _arcColorOverride ?? _resolvedThemeColor;
// --- 로딩 호 ---
// _cycleTime을 0~2π 한 사이클로 사용:
// Phase 1 (0~π): head가 0→1로 달려나감, tail은 0 고정 → 호가 늘어남
// Phase 2 (π~2π): head는 1 고정, tail이 0→1로 따라잡음 → 호가 줄어듦
// 두 페이즈 경계(0, π, 2π)에서 arc length = 0 이 보장됨
float cycle = _cycleTime; // 이미 0~2π 범위로 유지됨
float headT, tailT;
if (cycle < Mathf.PI)
{
// Phase 1: 호 늘어남
headT = EaseInOut(cycle / Mathf.PI);
tailT = 0f;
}
else
{
// Phase 2: 호 줄어듦
headT = 1f;
tailT = EaseInOut((cycle - Mathf.PI) / Mathf.PI);
}
float baseAngle = _rotation - 90f; // 12시 방향 기준 (도)
float startRad = baseAngle + tailT * ARC_MAX_DEG;
float endRad = baseAngle + headT * ARC_MAX_DEG;
painter.BeginPath();
painter.Arc(center, radius, startRad, endRad);
painter.strokeColor = color;
painter.lineWidth = _thickness;
painter.lineCap = LineCap.Round;
painter.Stroke();
}
// cubic ease-in-out: 0→0, 1→1, 가운데 S커브
private static float EaseInOut(float t) => t * t * (3f - 2f * t);
#endregion
#region Animation
private void StartAnimation()
{
if (_isAnimating) return;
_isAnimating = true;
_rotation = 0f;
_cycleTime = 0f;
_animationSchedule = schedule
.Execute(UpdateAnimation)
.Every(UPDATE_INTERVAL_MS);
}
private void StopAnimation()
{
if (!_isAnimating) return;
_isAnimating = false;
_animationSchedule?.Pause();
_animationSchedule = null;
}
private void UpdateAnimation(TimerState timerState)
{
if (!_isAnimating) return;
// timerState.deltaTime: 이전 콜백 이후 실제 경과 시간 (ms, long) 16ms 나옴
float deltaSeconds = timerState.deltaTime / 1000f;
// 회전 각도 누적 (도, 범위 제한 없이 누적해도 float 정밀도 안전)
_rotation += _speedDeg * deltaSeconds;
_cycleTime += ARC_CYCLE_SPEED * deltaSeconds;
// 오버플로우 방지
if (_rotation > 360f * 1000f) _rotation -= 360f * 1000f;
// _cycleTime은 0~2π 범위로 유지 (% 연산으로 순간 점프 방지)
if (_cycleTime >= Mathf.PI * 2f) _cycleTime -= Mathf.PI * 2f;
// 다시 그리기 요청
MarkDirtyRepaint();
}
#endregion
#region Blocker
private static VisualElement CreateBlocker()
{
var blocker = new VisualElement();
blocker.style.position = Position.Absolute;
blocker.style.left = 0;
blocker.style.top = 0;
blocker.style.right = 0;
blocker.style.bottom = 0;
blocker.style.backgroundColor = new Color(0, 0, 0, 0);
blocker.RegisterCallback<ClickEvent>(evt => evt.StopPropagation());
blocker.RegisterCallback<PointerDownEvent>(evt => evt.StopPropagation());
blocker.RegisterCallback<PointerUpEvent>(evt => evt.StopPropagation());
return blocker;
}
#endregion
#region Theme
private void OnCustomStyleResolved(CustomStyleResolvedEvent evt)
{
// --color-primary 값을 읽어 캐싱. arc-color 미지정 시 다음 프레임부터 반영됨
if (evt.customStyle.TryGetValue(UTKStyleGuide.VarColorPrimary, out var color))
_resolvedThemeColor = color;
if (_arcColorOverride == null)
MarkDirtyRepaint();
}
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
private void OnAttachToPanel(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
if (_autoPlay)
StartAnimation();
}
private void OnDetachFromPanel(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
StopAnimation();
}
private void OnThemeChanged(UTKTheme theme)
{
// ApplyThemeToElement가 스타일시트를 교체하면
// UI Toolkit이 CustomStyleResolvedEvent를 자동 발생 → OnCustomStyleResolved에서 색상 갱신
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
#endregion
#region Static Helper
private static void ValidateRoot()
{
if (_root == null)
throw new InvalidOperationException(
"UTKLoading.SetRoot()를 먼저 호출하여 기본 루트 요소를 설정해야 합니다.");
}
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
StopAnimation();
generateVisualContent -= OnGenerateVisualContent;
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanel);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
UnregisterCallback<CustomStyleResolvedEvent>(OnCustomStyleResolved);
}
#endregion
}
}