重构全局 Toast 为自动消失的顶部横幅
采用全宽主色横幅居中展示文案,2.2 秒后自动隐藏,并补充定时器重置行为的测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -38,15 +38,11 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
939AC7982FE3F832004B22E4 /* suixinkan */ = {
|
939AC7982FE3F832004B22E4 /* suixinkan */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkan;
|
path = suixinkan;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -259,10 +255,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||||
|
|||||||
@ -19,7 +19,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||||
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
||||||
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
||||||
- `ToastCenter`:保存当前全局 Toast 文案。
|
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
|
||||||
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
|
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
|
||||||
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
||||||
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
|
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
|
||||||
@ -55,6 +55,12 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
- `AppSession` 切换为 `loggedOut`。
|
- `AppSession` 切换为 `loggedOut`。
|
||||||
- 保留上次手机号、协议状态等非敏感偏好。
|
- 保留上次手机号、协议状态等非敏感偏好。
|
||||||
|
|
||||||
|
## Toast
|
||||||
|
|
||||||
|
全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。
|
||||||
|
|
||||||
|
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
|
||||||
|
|
||||||
## 导航规则
|
## 导航规则
|
||||||
|
|
||||||
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
|
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
|
||||||
|
|||||||
@ -13,16 +13,54 @@ import SwiftUI
|
|||||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||||
final class ToastCenter {
|
final class ToastCenter {
|
||||||
fileprivate var message: String?
|
fileprivate var message: String?
|
||||||
|
@ObservationIgnored private let autoDismissNanoseconds: UInt64
|
||||||
|
@ObservationIgnored private var dismissTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var displayToken: UInt64 = 0
|
||||||
|
|
||||||
|
/// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。
|
||||||
|
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
|
||||||
|
self.autoDismissNanoseconds = autoDismissNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
/// 显示一条全局 Toast 文案。
|
/// 显示一条全局 Toast 文案。
|
||||||
func show(_ message: String) {
|
func show(_ message: String) {
|
||||||
|
displayToken &+= 1
|
||||||
self.message = message
|
self.message = message
|
||||||
|
scheduleAutoDismiss(token: displayToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除当前 Toast 文案。
|
/// 清除当前 Toast 文案。
|
||||||
func dismiss() {
|
func dismiss() {
|
||||||
|
displayToken &+= 1
|
||||||
|
dismissTask?.cancel()
|
||||||
|
dismissTask = nil
|
||||||
message = nil
|
message = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 为当前 Toast 安排自动隐藏任务,并避免旧任务误清除新 Toast。
|
||||||
|
private func scheduleAutoDismiss(token: UInt64) {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
let delay = autoDismissNanoseconds
|
||||||
|
dismissTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: delay)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self, self.displayToken == token else { return }
|
||||||
|
self.message = nil
|
||||||
|
self.dismissTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// 测试专用快照,业务代码不应读取 Toast 展示状态。
|
||||||
|
var snapshotForTests: ToastSnapshot {
|
||||||
|
ToastSnapshot(message: message)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。
|
/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。
|
||||||
@ -32,35 +70,44 @@ private struct GlobalToastOverlay: ViewModifier {
|
|||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if let message = toastCenter.message {
|
GeometryReader { proxy in
|
||||||
HStack(spacing: 10) {
|
if let message = toastCenter.message {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
toastBanner(message, topInset: proxy.safeAreaInsets.top)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
Text(message)
|
|
||||||
.font(.system(size: 13, weight: .medium))
|
|
||||||
.lineLimit(2)
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
Button("关闭") {
|
|
||||||
toastCenter.dismiss()
|
|
||||||
}
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
}
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.18), value: toastCenter.message)
|
.animation(.easeInOut(duration: 0.18), value: toastCenter.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建顶部全宽 Toast 横幅。
|
||||||
|
private func toastBanner(_ message: String, topInset: CGFloat) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.top, topInset + AppMetrics.Spacing.small)
|
||||||
|
.padding(.bottom, AppMetrics.Spacing.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.background(AppDesign.primary.ignoresSafeArea(edges: .top))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。
|
||||||
|
struct ToastSnapshot: Equatable {
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// 给任意根视图追加全局 Toast 展示能力。
|
/// 给任意根视图追加全局 Toast 展示能力。
|
||||||
func globalToastOverlay() -> some View {
|
func globalToastOverlay() -> some View {
|
||||||
|
|||||||
@ -75,6 +75,12 @@ Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并
|
|||||||
|
|
||||||
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
|
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
|
||||||
|
|
||||||
|
### 全局 Toast
|
||||||
|
|
||||||
|
`ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。
|
||||||
|
|
||||||
|
Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。
|
||||||
|
|
||||||
## Upload
|
## Upload
|
||||||
|
|
||||||
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
||||||
|
|||||||
60
suixinkanTests/ToastCenterTests.swift
Normal file
60
suixinkanTests/ToastCenterTests.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// ToastCenterTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 全局 Toast 状态中心测试,覆盖自动隐藏和连续展示行为。
|
||||||
|
final class ToastCenterTests: XCTestCase {
|
||||||
|
/// 测试 show 会立即写入当前 Toast 文案。
|
||||||
|
func testShowStoresMessage() {
|
||||||
|
let center = ToastCenter(autoDismissNanoseconds: 100_000_000)
|
||||||
|
|
||||||
|
center.show("保存成功")
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "保存成功"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 Toast 到达自动隐藏时间后会清空文案。
|
||||||
|
func testToastAutoDismissesAfterDelay() async throws {
|
||||||
|
let center = ToastCenter(autoDismissNanoseconds: 60_000_000)
|
||||||
|
|
||||||
|
center.show("保存成功")
|
||||||
|
try await Task.sleep(nanoseconds: 90_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试连续 show 会覆盖旧文案,并重新计算自动隐藏时间。
|
||||||
|
func testRepeatedShowResetsAutoDismissTimer() async throws {
|
||||||
|
let center = ToastCenter(autoDismissNanoseconds: 100_000_000)
|
||||||
|
|
||||||
|
center.show("第一条")
|
||||||
|
try await Task.sleep(nanoseconds: 70_000_000)
|
||||||
|
center.show("第二条")
|
||||||
|
try await Task.sleep(nanoseconds: 60_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "第二条"))
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 70_000_000)
|
||||||
|
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试手动 dismiss 会立即清空文案,并取消当前自动隐藏任务。
|
||||||
|
func testDismissClearsMessageImmediately() async throws {
|
||||||
|
let center = ToastCenter(autoDismissNanoseconds: 100_000_000)
|
||||||
|
|
||||||
|
center.show("保存成功")
|
||||||
|
center.dismiss()
|
||||||
|
|
||||||
|
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 130_000_000)
|
||||||
|
XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user