Add Lottie-based global loading overlay across key flows.

Introduce GlobalLoadingCenter with reference counting, wire it through RootView and major auth/order/profile screens, and add the loading animation asset plus unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 11:25:56 +08:00
parent 1d1eb46ed8
commit 7fd964fe19
17 changed files with 514 additions and 139 deletions

View File

@ -0,0 +1,225 @@
//
// GlobalLoadingCenter.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Combine
import Foundation
import Lottie
import SwiftUI
@MainActor
/// Loading
final class GlobalLoadingCenter {
fileprivate let state = GlobalLoadingState()
/// Loading
func show(message: String = "") {
if !message.isEmpty {
state.message = message
}
state.activeCount += 1
state.isVisible = true
}
/// Loading
func hide() {
state.activeCount = max(0, state.activeCount - 1)
guard state.activeCount == 0 else { return }
state.isVisible = false
state.message = ""
}
/// Loading Loading
func updateMessage(_ message: String) {
guard state.isVisible, !message.isEmpty else { return }
state.message = message
}
/// Loading
func withLoading<T>(
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
show(message: message)
defer { hide() }
return try await operation()
}
/// Loading loading
func withOptionalLoading<T>(
_ enabled: Bool,
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
if enabled {
return try await withLoading(message: message, operation: operation)
}
return try await operation()
}
#if DEBUG
/// Loading
var snapshotForTests: GlobalLoadingSnapshot {
GlobalLoadingSnapshot(
isVisible: state.isVisible,
message: state.message,
activeCount: state.activeCount
)
}
#endif
}
@MainActor
/// Loading Overlay
fileprivate final class GlobalLoadingState: ObservableObject {
@Published fileprivate var isVisible = false
@Published fileprivate var message = ""
fileprivate var activeCount = 0
}
/// Lottie Loading loading.json
struct LottieLoadingAnimationView: UIViewRepresentable {
/// Loading
static var hasAnimationResource: Bool {
loadAnimation() != nil
}
/// Lottie UIKit
func makeUIView(context: Context) -> UIView {
let container = UIView()
container.backgroundColor = .clear
let animationView = LottieAnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.backgroundBehavior = .pauseAndRestore
animationView.animation = Self.loadAnimation()
animationView.play()
container.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
animationView.topAnchor.constraint(equalTo: container.topAnchor),
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
return container
}
/// Loading SwiftUI
func updateUIView(_ uiView: UIView, context: Context) {}
/// loading Resources
private static func loadAnimation() -> LottieAnimation? {
if let animation = LottieAnimation.named("loading") {
return animation
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") {
return LottieAnimation.filepath(url.path)
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
return LottieAnimation.filepath(url.path)
}
return nil
}
}
/// Loading Overlay 宿 Loading
private struct GlobalLoadingOverlayHost: View {
@ObservedObject private var state: GlobalLoadingState
/// 使 Loading Overlay 宿
init(loadingCenter: GlobalLoadingCenter) {
_state = ObservedObject(wrappedValue: loadingCenter.state)
}
var body: some View {
ZStack {
if state.isVisible {
ZStack {
Color.black.opacity(0.28)
.ignoresSafeArea()
VStack(spacing: 14) {
loadingAnimation
if !state.message.isEmpty {
Text(state.message)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Color(hex: 0x333333))
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 28)
.padding(.vertical, 24)
.background(.white, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 10)
}
.transition(.opacity)
.zIndex(10_000)
.accessibilityElement(children: .combine)
.accessibilityLabel(state.message.isEmpty ? "加载中" : state.message)
}
}
.animation(.easeInOut(duration: 0.2), value: state.isVisible)
}
@ViewBuilder
private var loadingAnimation: some View {
if LottieLoadingAnimationView.hasAnimationResource {
LottieLoadingAnimationView()
.frame(width: 132, height: 132)
} else {
ProgressView()
.controlSize(.large)
.tint(AppDesign.primary)
.frame(width: 132, height: 132)
}
}
}
private struct GlobalLoadingOverlayModifier: ViewModifier {
let loadingCenter: GlobalLoadingCenter
/// Loading
func body(content: Content) -> some View {
ZStack {
content
GlobalLoadingOverlayHost(loadingCenter: loadingCenter)
}
}
}
extension View {
/// Loading RootView
func globalLoadingOverlay(loadingCenter: GlobalLoadingCenter) -> some View {
modifier(GlobalLoadingOverlayModifier(loadingCenter: loadingCenter))
}
}
#if DEBUG
/// Loading
struct GlobalLoadingSnapshot: Equatable {
let isVisible: Bool
let message: String
let activeCount: Int
}
#endif
private struct GlobalLoadingCenterKey: EnvironmentKey {
static let defaultValue = GlobalLoadingCenter()
}
extension EnvironmentValues {
/// Loading show/hide
var globalLoading: GlobalLoadingCenter {
get { self[GlobalLoadingCenterKey.self] }
set { self[GlobalLoadingCenterKey.self] = newValue }
}
}