移除 Loading UI 中的可见文案,同时保留 message 字段供内部状态及后续使用。 Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
7.1 KiB
Swift
217 lines
7.1 KiB
Swift
//
|
||
// 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
|
||
}
|
||
.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("加载中")
|
||
}
|
||
}
|
||
.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 }
|
||
}
|
||
}
|