Add Lottie global loading overlay and fix stuck loading states.
Replace per-VC activity indicators with a ref-counted GlobalLoadingManager, and resolve duplicate show/hide calls on Profile, Statistics, and Home tabs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -122,8 +122,9 @@ final class HomeViewController: BaseViewController {
|
||||
|
||||
private func initializeHome() async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
await viewModel.initialize(api: homeAPI)
|
||||
hideLoading()
|
||||
|
||||
applyViewModel()
|
||||
await evaluateDialogsWithDelay()
|
||||
await viewModel.refreshLocationInfo()
|
||||
|
||||
74
suixinkan/UI/Loading/GlobalLoadingManager.swift
Normal file
74
suixinkan/UI/Loading/GlobalLoadingManager.swift
Normal file
@ -0,0 +1,74 @@
|
||||
//
|
||||
// GlobalLoadingManager.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 全局 Loading 管理器,对齐 Android `GlobalLoadingManager`。
|
||||
@MainActor
|
||||
final class GlobalLoadingManager {
|
||||
|
||||
static let shared = GlobalLoadingManager()
|
||||
|
||||
private var loadingCount = 0
|
||||
private weak var hostWindow: UIWindow?
|
||||
private var overlayView: GlobalLoadingOverlayView?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 当前是否正在展示 Loading。
|
||||
var isShowing: Bool {
|
||||
loadingCount > 0
|
||||
}
|
||||
|
||||
/// 增加 Loading 引用计数并展示遮罩。
|
||||
func show() {
|
||||
loadingCount += 1
|
||||
guard loadingCount == 1 else { return }
|
||||
attachOverlayIfNeeded()
|
||||
overlayView?.present()
|
||||
}
|
||||
|
||||
/// 减少 Loading 引用计数,归零后立即隐藏遮罩。
|
||||
func hide() {
|
||||
guard loadingCount > 0 else { return }
|
||||
loadingCount -= 1
|
||||
guard loadingCount == 0 else { return }
|
||||
overlayView?.dismiss()
|
||||
overlayView?.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 强制关闭所有 Loading。
|
||||
func hideAll() {
|
||||
loadingCount = 0
|
||||
overlayView?.dismiss()
|
||||
overlayView?.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func attachOverlayIfNeeded() {
|
||||
guard let window = keyWindow else { return }
|
||||
hostWindow = window
|
||||
|
||||
if overlayView == nil {
|
||||
let overlay = GlobalLoadingOverlayView(frame: window.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.isHidden = true
|
||||
overlayView = overlay
|
||||
}
|
||||
|
||||
guard let overlayView else { return }
|
||||
if overlayView.superview !== window {
|
||||
overlayView.removeFromSuperview()
|
||||
window.addSubview(overlayView)
|
||||
}
|
||||
window.bringSubviewToFront(overlayView)
|
||||
}
|
||||
|
||||
private var keyWindow: UIWindow? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }
|
||||
}
|
||||
}
|
||||
53
suixinkan/UI/Loading/GlobalLoadingOverlayView.swift
Normal file
53
suixinkan/UI/Loading/GlobalLoadingOverlayView.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// GlobalLoadingOverlayView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 全局 Loading 遮罩,对齐 Android `LoadingScrim` 样式。
|
||||
final class GlobalLoadingOverlayView: UIView {
|
||||
|
||||
private let cardView = UIView()
|
||||
private let loadingView = LottieLoadingView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
isUserInteractionEnabled = true
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 15
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
addSubview(cardView)
|
||||
cardView.addSubview(loadingView)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.width.height.equalTo(100)
|
||||
}
|
||||
loadingView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.width.height.equalTo(80)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 显示并开始动画。
|
||||
func present() {
|
||||
isHidden = false
|
||||
loadingView.startAnimating()
|
||||
}
|
||||
|
||||
/// 隐藏并停止动画。
|
||||
func dismiss() {
|
||||
loadingView.stopAnimating()
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
51
suixinkan/UI/Loading/LottieLoadingView.swift
Normal file
51
suixinkan/UI/Loading/LottieLoadingView.swift
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// LottieLoadingView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Lottie
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// Lottie 循环 Loading 动画视图,使用 `Resources/Lottie/loading.json`。
|
||||
final class LottieLoadingView: UIView {
|
||||
|
||||
private let animationView: LottieAnimationView
|
||||
|
||||
override init(frame: CGRect) {
|
||||
animationView = LottieLoadingView.makeAnimationView()
|
||||
super.init(frame: frame)
|
||||
addSubview(animationView)
|
||||
animationView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 开始循环播放。
|
||||
func startAnimating() {
|
||||
animationView.loopMode = .loop
|
||||
animationView.play()
|
||||
}
|
||||
|
||||
/// 停止播放。
|
||||
func stopAnimating() {
|
||||
animationView.stop()
|
||||
}
|
||||
|
||||
private static func makeAnimationView() -> LottieAnimationView {
|
||||
if let url = loadingAnimationURL() {
|
||||
return LottieAnimationView(filePath: url.path)
|
||||
}
|
||||
return LottieAnimationView()
|
||||
}
|
||||
|
||||
private static func loadingAnimationURL() -> URL? {
|
||||
Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources/Lottie")
|
||||
?? Bundle.main.url(forResource: "loading", withExtension: "json")
|
||||
}
|
||||
}
|
||||
@ -75,7 +75,7 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
self?.applyViewModel()
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
@ -103,7 +103,7 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
Task { await reloadProfile(showLoading: false) }
|
||||
Task { await reloadProfile(showGlobalLoading: true) }
|
||||
}
|
||||
|
||||
private let headerContainer = UIView()
|
||||
@ -228,12 +228,6 @@ final class ProfileViewController: BaseViewController {
|
||||
value: viewModel.currentScenicName,
|
||||
badge: .scenic
|
||||
)
|
||||
|
||||
if viewModel.isLoading, !refreshControl.isRefreshing {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureWithdrawalRow() {
|
||||
@ -286,13 +280,13 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reloadProfile(showLoading: false)
|
||||
await reloadProfile(showGlobalLoading: false)
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleAccountDidSwitch() {
|
||||
Task { await reloadProfile(showLoading: true) }
|
||||
Task { await reloadProfile(showGlobalLoading: true) }
|
||||
}
|
||||
|
||||
@objc private func nicknameChanged() {
|
||||
@ -370,13 +364,19 @@ final class ProfileViewController: BaseViewController {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func reloadProfile(showLoading: Bool) async {
|
||||
if showLoading { self.showLoading() }
|
||||
private func reloadProfile(showGlobalLoading: Bool) async {
|
||||
let shouldShowOverlay = showGlobalLoading && !refreshControl.isRefreshing
|
||||
if shouldShowOverlay {
|
||||
showLoading()
|
||||
}
|
||||
defer {
|
||||
if showLoading { hideLoading() }
|
||||
if shouldShowOverlay {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await viewModel.reload(api: profileAPI)
|
||||
applyViewModel()
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
|
||||
@ -91,16 +91,13 @@ final class StatisticsViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func initializeStatistics() async {
|
||||
showLoading()
|
||||
await viewModel.initStatistics(api: statisticsAPI)
|
||||
hideLoading()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func selectPeriod(_ period: String) {
|
||||
Task {
|
||||
showLoading()
|
||||
await viewModel.selectPeriod(period, api: statisticsAPI)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user