Files
suixinkan_ios_new/suixinkan/Core/Design/GlobalLoadingCenter.swift
汉秋 31a262fd08 Simplify global loading overlay to animation-only display.
Remove visible message text from the loading UI while keeping the message field for internal state and future use.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:27:20 +08:00

217 lines
7.1 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
}
.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 }
}
}