diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 9857b4a..4805f97 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ A1B2C3D12FFB84950033C305 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; }; A1B2C3D22FFB84950033C305 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; }; B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */; }; + C3D4E5F12FFB84950033C307 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = C3D4E5F32FFB84950033C307 /* Lottie */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,6 +64,7 @@ 93FF17E42FFB84950033C304 /* SnapKit in Frameworks */, 93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */, B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */, + C3D4E5F12FFB84950033C307 /* Lottie in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,6 +121,7 @@ 93FF17E22FFB84950033C304 /* SnapKit */, 93FF17E32FFB84950033C304 /* Kingfisher */, B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */, + C3D4E5F32FFB84950033C307 /* Lottie */, ); productName = suixinkan; productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */; @@ -181,6 +184,7 @@ 93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */, 93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */, B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */, + C3D4E5F22FFB84950033C307 /* XCRemoteSwiftPackageReference "lottie-spm" */, ); preferredProjectObjectVersion = 77; productRefGroup = 93FF17C62FFB84950033C304 /* Products */; @@ -532,6 +536,14 @@ minimumVersion = 0.3.0; }; }; + C3D4E5F22FFB84950033C307 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/lottie-spm.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.6.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -550,6 +562,11 @@ package = B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */; productName = AlibabaCloudOSS; }; + C3D4E5F32FFB84950033C307 /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = C3D4E5F22FFB84950033C307 /* XCRemoteSwiftPackageReference "lottie-spm" */; + productName = Lottie; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 93FF17BD2FFB84950033C304 /* Project object */; diff --git a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5aca56a..db54d78 100644 --- a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bdf5586d6171744520b6e9c884c2dbe9a16818e8af708876240fda75c94cb2f0", + "originHash" : "cb8f20fce1c6d5182e1505245f7fa3cd864954e3b45b7397e3bc7cd403003882", "pins" : [ { "identity" : "alibabacloud-oss-swift-sdk-v2", @@ -19,6 +19,15 @@ "version" : "8.10.0" } }, + { + "identity" : "lottie-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-spm.git", + "state" : { + "revision" : "0aff16288f8dc1e3dab35cbf5cf45979673fd3df", + "version" : "4.6.1" + } + }, { "identity" : "snapkit", "kind" : "remoteSourceControl", diff --git a/suixinkan/Base.lproj/Main.storyboard b/suixinkan/Base.lproj/Main.storyboard deleted file mode 100644 index 25a7638..0000000 --- a/suixinkan/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/suixinkan/Base/BaseViewController.swift b/suixinkan/Base/BaseViewController.swift index e226d35..4b20aef 100644 --- a/suixinkan/Base/BaseViewController.swift +++ b/suixinkan/Base/BaseViewController.swift @@ -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() } } diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index a5c2fca..141be24 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -117,7 +117,6 @@ final class ProfileViewModel { realNameInfo = nil bankCardInfo = nil } - notifyStateChange() } func beginEditing() { diff --git a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift index 644c53e..897c554 100644 --- a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift +++ b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -73,7 +73,6 @@ final class StatisticsViewModel { do { statistics = try await fetchSummary(range: range, api: api) - notifyStateChange() } catch is CancellationError { return } catch { diff --git a/suixinkan/Resources/Lottie/loading.json b/suixinkan/Resources/Lottie/loading.json new file mode 100644 index 0000000..7bd0c82 --- /dev/null +++ b/suixinkan/Resources/Lottie/loading.json @@ -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":[]} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index cbf3526..80445bb 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -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() diff --git a/suixinkan/UI/Loading/GlobalLoadingManager.swift b/suixinkan/UI/Loading/GlobalLoadingManager.swift new file mode 100644 index 0000000..bb382b9 --- /dev/null +++ b/suixinkan/UI/Loading/GlobalLoadingManager.swift @@ -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 } + } +} diff --git a/suixinkan/UI/Loading/GlobalLoadingOverlayView.swift b/suixinkan/UI/Loading/GlobalLoadingOverlayView.swift new file mode 100644 index 0000000..d5ce98f --- /dev/null +++ b/suixinkan/UI/Loading/GlobalLoadingOverlayView.swift @@ -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 + } +} diff --git a/suixinkan/UI/Loading/LottieLoadingView.swift b/suixinkan/UI/Loading/LottieLoadingView.swift new file mode 100644 index 0000000..a6acbdf --- /dev/null +++ b/suixinkan/UI/Loading/LottieLoadingView.swift @@ -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") + } +} diff --git a/suixinkan/UI/Profile/ProfileViewController.swift b/suixinkan/UI/Profile/ProfileViewController.swift index d60a61d..79e8068 100644 --- a/suixinkan/UI/Profile/ProfileViewController.swift +++ b/suixinkan/UI/Profile/ProfileViewController.swift @@ -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) } diff --git a/suixinkan/UI/Statistics/StatisticsViewController.swift b/suixinkan/UI/Statistics/StatisticsViewController.swift index 8016a92..a58b817 100644 --- a/suixinkan/UI/Statistics/StatisticsViewController.swift +++ b/suixinkan/UI/Statistics/StatisticsViewController.swift @@ -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() } } diff --git a/suixinkanTests/GlobalLoadingManagerTests.swift b/suixinkanTests/GlobalLoadingManagerTests.swift new file mode 100644 index 0000000..fd3f953 --- /dev/null +++ b/suixinkanTests/GlobalLoadingManagerTests.swift @@ -0,0 +1,54 @@ +// +// GlobalLoadingManagerTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 全局 Loading 管理器测试。 +final class GlobalLoadingManagerTests: XCTestCase { + + override func tearDown() { + GlobalLoadingManager.shared.hideAll() + super.tearDown() + } + + func testShowAndHideToggleVisibilityState() { + let manager = GlobalLoadingManager.shared + XCTAssertFalse(manager.isShowing) + + manager.show() + XCTAssertTrue(manager.isShowing) + + manager.hide() + XCTAssertFalse(manager.isShowing) + } + + func testNestedShowRequiresMatchingHideCount() { + let manager = GlobalLoadingManager.shared + + manager.show() + manager.show() + XCTAssertTrue(manager.isShowing) + + manager.hide() + XCTAssertTrue(manager.isShowing) + + manager.hide() + XCTAssertFalse(manager.isShowing) + } + + func testHideAllResetsNestedCount() { + let manager = GlobalLoadingManager.shared + + manager.show() + manager.show() + manager.hideAll() + XCTAssertFalse(manager.isShowing) + + manager.hide() + XCTAssertFalse(manager.isShowing) + } +}