添加 Lottie 全局加载层并修复加载状态卡住问题
This commit is contained in:
@ -12,6 +12,7 @@
|
|||||||
A1B2C3D12FFB84950033C305 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; };
|
A1B2C3D12FFB84950033C305 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; };
|
||||||
A1B2C3D22FFB84950033C305 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; };
|
A1B2C3D22FFB84950033C305 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; };
|
||||||
B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */; };
|
B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */; };
|
||||||
|
C3D4E5F12FFB84950033C307 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = C3D4E5F32FFB84950033C307 /* Lottie */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -63,6 +64,7 @@
|
|||||||
93FF17E42FFB84950033C304 /* SnapKit in Frameworks */,
|
93FF17E42FFB84950033C304 /* SnapKit in Frameworks */,
|
||||||
93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */,
|
93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */,
|
||||||
B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */,
|
B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */,
|
||||||
|
C3D4E5F12FFB84950033C307 /* Lottie in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -119,6 +121,7 @@
|
|||||||
93FF17E22FFB84950033C304 /* SnapKit */,
|
93FF17E22FFB84950033C304 /* SnapKit */,
|
||||||
93FF17E32FFB84950033C304 /* Kingfisher */,
|
93FF17E32FFB84950033C304 /* Kingfisher */,
|
||||||
B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */,
|
B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */,
|
||||||
|
C3D4E5F32FFB84950033C307 /* Lottie */,
|
||||||
);
|
);
|
||||||
productName = suixinkan;
|
productName = suixinkan;
|
||||||
productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */;
|
productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */;
|
||||||
@ -181,6 +184,7 @@
|
|||||||
93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */,
|
93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */,
|
||||||
93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */,
|
B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */,
|
||||||
|
C3D4E5F22FFB84950033C307 /* XCRemoteSwiftPackageReference "lottie-spm" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 93FF17C62FFB84950033C304 /* Products */;
|
productRefGroup = 93FF17C62FFB84950033C304 /* Products */;
|
||||||
@ -532,6 +536,14 @@
|
|||||||
minimumVersion = 0.3.0;
|
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 */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@ -550,6 +562,11 @@
|
|||||||
package = B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */;
|
package = B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */;
|
||||||
productName = AlibabaCloudOSS;
|
productName = AlibabaCloudOSS;
|
||||||
};
|
};
|
||||||
|
C3D4E5F32FFB84950033C307 /* Lottie */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = C3D4E5F22FFB84950033C307 /* XCRemoteSwiftPackageReference "lottie-spm" */;
|
||||||
|
productName = Lottie;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 93FF17BD2FFB84950033C304 /* Project object */;
|
rootObject = 93FF17BD2FFB84950033C304 /* Project object */;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "bdf5586d6171744520b6e9c884c2dbe9a16818e8af708876240fda75c94cb2f0",
|
"originHash" : "cb8f20fce1c6d5182e1505245f7fa3cd864954e3b45b7397e3bc7cd403003882",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "alibabacloud-oss-swift-sdk-v2",
|
"identity" : "alibabacloud-oss-swift-sdk-v2",
|
||||||
@ -19,6 +19,15 @@
|
|||||||
"version" : "8.10.0"
|
"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",
|
"identity" : "snapkit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -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 能力。
|
/// 业务 ViewController 基类,提供统一的 UI 搭建流程与通用 UI 能力。
|
||||||
class BaseViewController: UIViewController {
|
class BaseViewController: UIViewController {
|
||||||
|
|
||||||
private var loadingOverlay: UIView?
|
|
||||||
private var loadingIndicator: UIActivityIndicatorView?
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@ -47,36 +44,14 @@ class BaseViewController: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func showLoading() {
|
func showLoading() {
|
||||||
if loadingOverlay == nil {
|
GlobalLoadingManager.shared.show()
|
||||||
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!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func hideLoading() {
|
func hideLoading() {
|
||||||
loadingIndicator?.stopAnimating()
|
GlobalLoadingManager.shared.hide()
|
||||||
loadingOverlay?.isHidden = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toast / Alert
|
// MARK: - Toast / Alert
|
||||||
@ -156,7 +131,7 @@ class BaseViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleSessionDidExpireNotification() {
|
@objc private func handleSessionDidExpireNotification() {
|
||||||
hideLoading()
|
GlobalLoadingManager.shared.hideAll()
|
||||||
onSessionDidExpire()
|
onSessionDidExpire()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,7 +117,6 @@ final class ProfileViewModel {
|
|||||||
realNameInfo = nil
|
realNameInfo = nil
|
||||||
bankCardInfo = nil
|
bankCardInfo = nil
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginEditing() {
|
func beginEditing() {
|
||||||
|
|||||||
@ -73,7 +73,6 @@ final class StatisticsViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
statistics = try await fetchSummary(range: range, api: api)
|
statistics = try await fetchSummary(range: range, api: api)
|
||||||
notifyStateChange()
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} 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 {
|
private func initializeHome() async {
|
||||||
showLoading()
|
showLoading()
|
||||||
defer { hideLoading() }
|
|
||||||
await viewModel.initialize(api: homeAPI)
|
await viewModel.initialize(api: homeAPI)
|
||||||
|
hideLoading()
|
||||||
|
|
||||||
applyViewModel()
|
applyViewModel()
|
||||||
await evaluateDialogsWithDelay()
|
await evaluateDialogsWithDelay()
|
||||||
await viewModel.refreshLocationInfo()
|
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() {
|
override func bindActions() {
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
self?.applyViewModel()
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
@ -103,7 +103,7 @@ final class ProfileViewController: BaseViewController {
|
|||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
Task { await reloadProfile(showLoading: false) }
|
Task { await reloadProfile(showGlobalLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private let headerContainer = UIView()
|
private let headerContainer = UIView()
|
||||||
@ -228,12 +228,6 @@ final class ProfileViewController: BaseViewController {
|
|||||||
value: viewModel.currentScenicName,
|
value: viewModel.currentScenicName,
|
||||||
badge: .scenic
|
badge: .scenic
|
||||||
)
|
)
|
||||||
|
|
||||||
if viewModel.isLoading, !refreshControl.isRefreshing {
|
|
||||||
showLoading()
|
|
||||||
} else {
|
|
||||||
hideLoading()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureWithdrawalRow() {
|
private func configureWithdrawalRow() {
|
||||||
@ -286,13 +280,13 @@ final class ProfileViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func refreshPulled() {
|
@objc private func refreshPulled() {
|
||||||
Task {
|
Task {
|
||||||
await reloadProfile(showLoading: false)
|
await reloadProfile(showGlobalLoading: false)
|
||||||
refreshControl.endRefreshing()
|
refreshControl.endRefreshing()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleAccountDidSwitch() {
|
@objc private func handleAccountDidSwitch() {
|
||||||
Task { await reloadProfile(showLoading: true) }
|
Task { await reloadProfile(showGlobalLoading: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func nicknameChanged() {
|
@objc private func nicknameChanged() {
|
||||||
@ -370,13 +364,19 @@ final class ProfileViewController: BaseViewController {
|
|||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadProfile(showLoading: Bool) async {
|
private func reloadProfile(showGlobalLoading: Bool) async {
|
||||||
if showLoading { self.showLoading() }
|
let shouldShowOverlay = showGlobalLoading && !refreshControl.isRefreshing
|
||||||
|
if shouldShowOverlay {
|
||||||
|
showLoading()
|
||||||
|
}
|
||||||
defer {
|
defer {
|
||||||
if showLoading { hideLoading() }
|
if shouldShowOverlay {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try await viewModel.reload(api: profileAPI)
|
try await viewModel.reload(api: profileAPI)
|
||||||
|
applyViewModel()
|
||||||
} catch {
|
} catch {
|
||||||
showToast(error.localizedDescription)
|
showToast(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,16 +91,13 @@ final class StatisticsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initializeStatistics() async {
|
private func initializeStatistics() async {
|
||||||
showLoading()
|
|
||||||
await viewModel.initStatistics(api: statisticsAPI)
|
await viewModel.initStatistics(api: statisticsAPI)
|
||||||
hideLoading()
|
applyViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectPeriod(_ period: String) {
|
private func selectPeriod(_ period: String) {
|
||||||
Task {
|
Task {
|
||||||
showLoading()
|
|
||||||
await viewModel.selectPeriod(period, api: statisticsAPI)
|
await viewModel.selectPeriod(period, api: statisticsAPI)
|
||||||
hideLoading()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
suixinkanTests/GlobalLoadingManagerTests.swift
Normal file
54
suixinkanTests/GlobalLoadingManagerTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user