465 lines
16 KiB
C#
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>
|
|
/// <utk:UTKLoading speed="1.5" auto-play="true" />
|
|
/// <utk:UTKLoading arc-color="#73C991" thickness="6" size="64" auto-play="true" />
|
|
/// </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
|
|
}
|
|
}
|