#nullable enable using System; using UnityEngine; using UnityEngine.UIElements; namespace UVC.UIToolkit { /// /// 회전하면서 호가 길어졌다 짧아지는 원형 로딩 스피너 컴포넌트. /// Painter2D Drawing API로 자연스러운 원호를 직접 그립니다. /// /// /// 두 가지 사용 방식 비교: /// /// /// 항목 /// Static API / UXML 태그 /// /// /// 배치 위치 /// Static: panel.visualTree 최상단 / UXML: 레이아웃 내 인라인 /// /// /// Blocker /// Static: 있음 (전체 클릭 차단) / UXML: 없음 /// /// /// 위치 /// Static: 화면 정중앙 고정 / UXML: CSS 레이아웃 따름 /// /// /// 시작 /// Static: Show() 호출 시 / UXML: AttachToPanel 자동 (auto-play=true) /// /// /// 정지 /// Static: Hide() 호출 시 / UXML: DetachFromPanel 자동 /// /// /// /// 색상·두께·크기 설정: /// /// 기본 색상은 현재 테마의 --color-primary를 자동 적용합니다. /// arc-color 속성을 지정하면 테마 색상을 무시하고 해당 색상을 사용합니다. /// thickness: 선 두께 (px, 기본값: 4) /// size: 스피너 크기 (px, 기본값: 48) /// speed: 회전 속도 배율 (기본값: 1.0) /// /// /// /// Static API (전체 화면 로딩 + Blocker): /// /// UTKLoading.SetRoot(rootVisualElement); /// UTKLoading.Show(); // 기본 속도 /// UTKLoading.Show(speed: 2f); // 빠른 회전 /// UTKLoading.Hide(); /// /// UXML 태그 (인라인 배치, Blocker 없음): /// /// <utk:UTKLoading speed="1.5" auto-play="true" /> /// <utk:UTKLoading arc-color="#73C991" thickness="6" size="64" auto-play="true" /> /// /// [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 /// 회전 속도 배율. 1.0 = 기본 속도 [UxmlAttribute("speed")] public float Speed { get => _speedDeg / DEFAULT_SPEED_DEG; set => _speedDeg = DEFAULT_SPEED_DEG * Mathf.Max(0.1f, value); } /// 패널에 연결될 때 자동으로 애니메이션을 시작할지 여부 [UxmlAttribute("auto-play")] public bool AutoPlay { get => _autoPlay; set => _autoPlay = value; } /// /// 스피너 색상. 지정하지 않으면 현재 테마의 --color-primary가 적용됩니다. /// [UxmlAttribute("arc-color")] public Color ArcColor { get => _arcColorOverride ?? _resolvedThemeColor; set { _arcColorOverride = value; MarkDirtyRepaint(); } } /// 선 두께 (px) [UxmlAttribute("thickness")] public float Thickness { get => _thickness; set { _thickness = Mathf.Max(1f, value); MarkDirtyRepaint(); } } /// 스피너 전체 크기 (px) [UxmlAttribute("size")] public float Size { get => _size; set { _size = Mathf.Max(8f, value); style.width = _size; style.height = _size; MarkDirtyRepaint(); } } #endregion #region Constructor /// /// UTKLoading 기본 생성자. /// UXML 태그로 배치하면 패널에 연결될 때 auto-play 설정에 따라 자동 시작됩니다. /// public UTKLoading() { // 테마 스타일시트 적용 (--color-primary 변수 상속용) UTKThemeManager.Instance.ApplyThemeToElement(this); var uss = Resources.Load(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(OnCustomStyleResolved); // Drawing API 콜백 등록 generateVisualContent += OnGenerateVisualContent; SubscribeToThemeChanges(); } #endregion #region Static API /// /// 기본 루트 요소 설정. /// 로딩 스피너는 이 루트의 panel.visualTree에 표시됩니다. /// public static void SetRoot(VisualElement root) => _root = root; /// 기본 루트 요소 반환 public static VisualElement? GetRoot() => _root; /// /// 로딩 스피너 표시. /// 이미 표시 중이면 중복 생성하지 않고 기존 인스턴스를 반환합니다. /// /// 회전 속도 배율 (기본값 1.0) 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; } /// /// 로딩 스피너 숨기기 및 제거. /// public static void Hide() { if (_activeInstance == null) return; var instance = _activeInstance; _activeInstance = null; instance.Close(); } /// 현재 로딩이 표시 중인지 여부 public static bool IsShowing => _activeInstance != null; #endregion #region Public Methods /// 이 인스턴스를 닫고 계층에서 제거합니다. public void Close() { StopAnimation(); if (_blocker is not null) { _blocker.RemoveFromHierarchy(); _blocker = null; } RemoveFromHierarchy(); Dispose(); if (ReferenceEquals(_activeInstance, this)) _activeInstance = null; } #endregion #region Drawing /// /// Painter2D로 트랙 원과 로딩 호를 직접 그립니다. /// 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(evt => evt.StopPropagation()); blocker.RegisterCallback(evt => evt.StopPropagation()); blocker.RegisterCallback(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(OnAttachToPanel); RegisterCallback(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(OnAttachToPanel); UnregisterCallback(OnDetachFromPanel); UnregisterCallback(OnCustomStyleResolved); } #endregion } }