基于 Lottie 在关键流程中新增全局 Loading 遮罩
引入带引用计数的 GlobalLoadingCenter,通过 RootView 及主要登录/订单/个人中心页面接入,并添加 Loading 动画资源与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
225
suixinkan/Core/Design/GlobalLoadingCenter.swift
Normal file
225
suixinkan/Core/Design/GlobalLoadingCenter.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user