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:
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@ -9,9 +9,6 @@ import UIKit
|
||||
/// 业务 ViewController 基类,提供统一的 UI 搭建流程与通用 UI 能力。
|
||||
class BaseViewController: UIViewController {
|
||||
|
||||
private var loadingOverlay: UIView?
|
||||
private var loadingIndicator: UIActivityIndicatorView?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
@ -47,36 +44,14 @@ class BaseViewController: UIViewController {
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
@MainActor
|
||||
func showLoading() {
|
||||
if loadingOverlay == nil {
|
||||
let overlay = UIView()
|
||||
overlay.backgroundColor = UIColor.black.withAlphaComponent(0.1)
|
||||
|
||||
let indicator = UIActivityIndicatorView(style: .large)
|
||||
indicator.hidesWhenStopped = true
|
||||
|
||||
view.addSubview(overlay)
|
||||
overlay.addSubview(indicator)
|
||||
|
||||
overlay.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
indicator.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
|
||||
loadingOverlay = overlay
|
||||
loadingIndicator = indicator
|
||||
}
|
||||
|
||||
loadingOverlay?.isHidden = false
|
||||
loadingIndicator?.startAnimating()
|
||||
view.bringSubviewToFront(loadingOverlay!)
|
||||
GlobalLoadingManager.shared.show()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func hideLoading() {
|
||||
loadingIndicator?.stopAnimating()
|
||||
loadingOverlay?.isHidden = true
|
||||
GlobalLoadingManager.shared.hide()
|
||||
}
|
||||
|
||||
// MARK: - Toast / Alert
|
||||
@ -156,7 +131,7 @@ class BaseViewController: UIViewController {
|
||||
}
|
||||
|
||||
@objc private func handleSessionDidExpireNotification() {
|
||||
hideLoading()
|
||||
GlobalLoadingManager.shared.hideAll()
|
||||
onSessionDidExpire()
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,7 +117,6 @@ final class ProfileViewModel {
|
||||
realNameInfo = nil
|
||||
bankCardInfo = nil
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
|
||||
@ -73,7 +73,6 @@ final class StatisticsViewModel {
|
||||
|
||||
do {
|
||||
statistics = try await fetchSummary(range: range, api: api)
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
|
||||
1
suixinkan/Resources/Lottie/loading.json
Normal file
1
suixinkan/Resources/Lottie/loading.json
Normal file
@ -0,0 +1 @@
|
||||
{"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #18","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[20]}],"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[-175,46,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2,0]],"o":[[0,0],[2,0]],"v":[[-268,46],[-82,46]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.2745,0.3608,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[45]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[0]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[45]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[0]},{"t":60,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[55]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[55]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line 5","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":80,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Line 4","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[470,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":22,"op":77,"st":22,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Line 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":19,"op":74,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Line 2","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[330,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":16,"op":71,"st":16,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Line 1","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[260,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":13,"op":68,"st":13,"bm":0}],"markers":[]}
|
||||
@ -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