Files
suixinkan_ios_new/suixinkan/Core/Design/GlobalLoadingCenter.swift
汉秋 7fd964fe19 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>
2026-06-23 11:25:56 +08:00

226 lines
7.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 }
}
}