// // 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 = "", showsMessage: Bool = false) { if !message.isEmpty { state.message = message } state.messageDisplayLevels.append(showsMessage) state.showsMessage = state.messageDisplayLevels.contains(true) state.activeCount += 1 state.isVisible = true } /// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。 func hide() { state.activeCount = max(0, state.activeCount - 1) if !state.messageDisplayLevels.isEmpty { state.messageDisplayLevels.removeLast() } state.showsMessage = state.messageDisplayLevels.contains(true) guard state.activeCount == 0 else { return } state.isVisible = false state.message = "" state.messageDisplayLevels = [] state.showsMessage = false } /// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。 func updateMessage(_ message: String) { guard state.isVisible, !message.isEmpty else { return } state.message = message } /// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。 func withLoading( message: String = "", showsMessage: Bool = false, operation: () async throws -> T ) async rethrows -> T { show(message: message, showsMessage: showsMessage) defer { hide() } return try await operation() } /// 根据开关决定是否展示 Loading,适合下拉刷新等可选 loading 场景。 func withOptionalLoading( _ enabled: Bool, message: String = "", showsMessage: Bool = false, operation: () async throws -> T ) async rethrows -> T { if enabled { return try await withLoading(message: message, showsMessage: showsMessage, operation: operation) } return try await operation() } #if DEBUG /// 测试专用快照,业务代码不应读取 Loading 展示状态。 var snapshotForTests: GlobalLoadingSnapshot { GlobalLoadingSnapshot( isVisible: state.isVisible, message: state.message, activeCount: state.activeCount, showsMessage: state.showsMessage ) } #endif } @MainActor /// 全局 Loading 的私有可观察状态,只允许根部 Overlay 订阅。 fileprivate final class GlobalLoadingState: ObservableObject { @Published fileprivate var isVisible = false @Published fileprivate var message = "" @Published fileprivate var showsMessage = false @Published fileprivate var activeCount = 0 fileprivate var messageDisplayLevels: [Bool] = [] } /// 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.showsMessage, !state.message.isEmpty { Text(state.message) .font(.system(size: 15, weight: .medium)) .foregroundStyle(AppDesign.textPrimary) .multilineTextAlignment(.center) .padding(.horizontal, 8) .padding(.bottom, 6) } } .padding(.horizontal, 10) .padding(.vertical, 10) .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.showsMessage && !state.message.isEmpty ? state.message : "加载中") } } .animation(.easeInOut(duration: 0.2), value: state.isVisible) .animation(.easeInOut(duration: 0.2), value: state.showsMessage) .animation(.easeInOut(duration: 0.2), value: state.message) } @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 let showsMessage: Bool } #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 } } }