将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
191 lines
5.7 KiB
Swift
191 lines
5.7 KiB
Swift
//
|
||
// ToastCenter.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/18.
|
||
//
|
||
|
||
import Combine
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
@MainActor
|
||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||
final class ToastCenter: ObservableObject {
|
||
@Published fileprivate var message: String?
|
||
private let autoDismissNanoseconds: UInt64
|
||
@Published private var dismissTask: Task<Void, Never>?
|
||
@Published private var displayToken: UInt64 = 0
|
||
|
||
/// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。
|
||
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
|
||
self.autoDismissNanoseconds = autoDismissNanoseconds
|
||
}
|
||
|
||
/// 显示一条全局 Toast 文案。
|
||
func show(_ message: String) {
|
||
displayToken &+= 1
|
||
self.message = message
|
||
scheduleAutoDismiss(token: displayToken)
|
||
}
|
||
|
||
/// 清除当前 Toast 文案。
|
||
func dismiss() {
|
||
displayToken &+= 1
|
||
dismissTask?.cancel()
|
||
dismissTask = 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 独立窗口宿主,确保提示浮在 sheet / fullScreenCover 等 present 层之上。
|
||
@MainActor
|
||
private final class ToastWindowPresenter {
|
||
static let shared = ToastWindowPresenter()
|
||
|
||
private var toastWindow: UIWindow?
|
||
|
||
/// 在独立 UIWindow 中展示 Toast。
|
||
func show(message: String) {
|
||
guard let windowScene = resolveWindowScene() else { return }
|
||
|
||
let window: UIWindow
|
||
if let existing = toastWindow, existing.windowScene === windowScene {
|
||
window = existing
|
||
} else {
|
||
let newWindow = PassthroughWindow(windowScene: windowScene)
|
||
newWindow.windowLevel = .alert + 1
|
||
newWindow.backgroundColor = .clear
|
||
toastWindow = newWindow
|
||
window = newWindow
|
||
}
|
||
|
||
let hosting = UIHostingController(
|
||
rootView: ToastBannerHostView(message: message)
|
||
)
|
||
hosting.view.backgroundColor = .clear
|
||
window.rootViewController = hosting
|
||
window.isHidden = false
|
||
}
|
||
|
||
/// 隐藏 Toast 窗口。
|
||
func hide() {
|
||
toastWindow?.isHidden = true
|
||
toastWindow?.rootViewController = nil
|
||
}
|
||
|
||
/// 取当前前台 window scene,供独立 Toast 窗口挂载。
|
||
private func resolveWindowScene() -> UIWindowScene? {
|
||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||
return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
|
||
}
|
||
}
|
||
|
||
/// 透传触摸事件的 Toast 窗口,避免挡住下层弹窗交互。
|
||
private final class PassthroughWindow: UIWindow {
|
||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||
guard let view = super.hitTest(point, with: event) else { return nil }
|
||
return view === rootViewController?.view ? nil : view
|
||
}
|
||
}
|
||
|
||
/// Toast 全屏居中容器。
|
||
private struct ToastBannerHostView: View {
|
||
let message: String
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
ToastBannerContent(message: message)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
/// 屏幕中央的黑色半透明圆角 Toast 卡片。
|
||
private struct ToastBannerContent: View {
|
||
let message: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
|
||
Text(message)
|
||
.font(.system(size: 16, weight: .medium))
|
||
.lineLimit(3)
|
||
.multilineTextAlignment(.leading)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 14)
|
||
.background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
|
||
.padding(.horizontal, 40)
|
||
.accessibilityIdentifier("global.toast")
|
||
}
|
||
}
|
||
|
||
/// 全局 Toast 展示桥接层,监听 ToastCenter 并驱动独立窗口。
|
||
private struct GlobalToastOverlay: ViewModifier {
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.onAppear {
|
||
syncToastWindow(with: toastCenter.message)
|
||
}
|
||
.onChange(of: toastCenter.message) { message in
|
||
syncToastWindow(with: message)
|
||
}
|
||
}
|
||
|
||
/// 根据当前文案显示或隐藏 Toast 窗口。
|
||
private func syncToastWindow(with message: String?) {
|
||
if let message {
|
||
ToastWindowPresenter.shared.show(message: message)
|
||
} else {
|
||
ToastWindowPresenter.shared.hide()
|
||
}
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。
|
||
struct ToastSnapshot: Equatable {
|
||
let message: String?
|
||
}
|
||
#endif
|
||
|
||
extension View {
|
||
/// 给任意根视图追加全局 Toast 展示能力。
|
||
func globalToastOverlay() -> some View {
|
||
modifier(GlobalToastOverlay())
|
||
}
|
||
}
|