优化冷启动流程:Launch Screen 对齐 Splash,仅阻塞 rolePermissions。
移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -30,27 +30,26 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
||||
|
||||
1. `suixinkanApp` 创建 `RootView`。
|
||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。
|
||||
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。
|
||||
5. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore`;Splash 以 bootstrap 完成为准,无固定最短展示时长。
|
||||
6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
||||
8. 无 token 时保持 `loggedOut`,Splash 结束后展示 `LoginView`。
|
||||
9. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||
11. 校验成功后进入 `loggedIn`,Splash 结束后展示 `MainTabsView`。
|
||||
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
13. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;Splash 即启动等待层。
|
||||
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||
3. iOS 系统 Launch Screen 展示品牌页(白底、Logo、文案)。
|
||||
4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏。
|
||||
5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||
6. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
||||
7. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
||||
8. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
9. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。
|
||||
10. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
|
||||
11. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
12. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
13. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
14. 冷启动 bootstrap 不使用 Lottie 全局 Loading;`.restoring` 期间展示 `SplashView`。
|
||||
15. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
|
||||
|
||||
## 初始化分层
|
||||
|
||||
| 阶段 | 职责 | 主要对象 |
|
||||
|------|------|----------|
|
||||
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` |
|
||||
| Splash | 品牌展示 + 登录态恢复 | `SplashView`、`SplashCoordinator`、`SessionBootstrapper` |
|
||||
| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView`、`SessionBootstrapper`、`AccountContextLoader` |
|
||||
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
|
||||
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ struct RootView: View {
|
||||
@State private var cooperationOrderAPI: CooperationOrderAPI
|
||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||
@State private var sessionBootstrapper: SessionBootstrapper
|
||||
@StateObject private var splashCoordinator = SplashCoordinator()
|
||||
@State private var isColdStartBootstrapPending = true
|
||||
@State private var appConfigAPI: AppConfigAPI
|
||||
|
||||
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
||||
@ -106,7 +106,7 @@ struct RootView: View {
|
||||
ZStack {
|
||||
rootContent
|
||||
|
||||
if !splashCoordinator.isFinished {
|
||||
if isColdStartBootstrapPending {
|
||||
SplashView()
|
||||
.transition(.opacity)
|
||||
.zIndex(1)
|
||||
@ -155,15 +155,16 @@ struct RootView: View {
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||
await splashCoordinator.start {
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
withAnimation {
|
||||
isColdStartBootstrapPending = false
|
||||
}
|
||||
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
|
||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||
@ -227,7 +228,7 @@ struct RootView: View {
|
||||
case .loggedOut:
|
||||
LoginView()
|
||||
case .restoring:
|
||||
Color(.systemBackground)
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loggedIn:
|
||||
MainTabsView()
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
//
|
||||
// SplashCoordinator.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 启动页时序协调器,并行执行冷启动 bootstrap,Splash 结束时机以 bootstrap 完成为准。
|
||||
final class SplashCoordinator: ObservableObject {
|
||||
@Published private(set) var isFinished = false
|
||||
|
||||
private let minimumDisplayDuration: TimeInterval
|
||||
private let skipsMinimumDuration: Bool
|
||||
|
||||
/// 创建启动页协调器;默认无最短展示时长,测试可注入自定义 delay。
|
||||
init(
|
||||
minimumDisplayDuration: TimeInterval = 0,
|
||||
skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests
|
||||
) {
|
||||
self.minimumDisplayDuration = minimumDisplayDuration
|
||||
self.skipsMinimumDuration = skipsMinimumDuration
|
||||
}
|
||||
|
||||
/// 并行执行 bootstrap 与可选最短展示时长,两者都完成后结束 Splash。
|
||||
func start(bootstrap: @escaping () async -> Void) async {
|
||||
guard !isFinished else { return }
|
||||
|
||||
async let minimumDelay: Void = waitForMinimumDisplayDuration()
|
||||
async let bootstrapWork: Void = bootstrap()
|
||||
|
||||
_ = await (minimumDelay, bootstrapWork)
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
/// 等待最短展示时长;UI Test 模式下直接返回。
|
||||
private func waitForMinimumDisplayDuration() async {
|
||||
guard !skipsMinimumDuration else { return }
|
||||
let nanoseconds = UInt64(max(minimumDisplayDuration, 0) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ protocol UserProfileServing {
|
||||
@MainActor
|
||||
/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。
|
||||
struct AccountContextLoader {
|
||||
/// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。
|
||||
/// 刷新完整账号上下文;登录成功等场景仍需要同步拉齐全部数据。
|
||||
func refresh(
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
@ -28,17 +28,66 @@ struct AccountContextLoader {
|
||||
cachedCurrentScenicId: Int? = nil,
|
||||
cachedCurrentStoreId: Int? = nil
|
||||
) async throws {
|
||||
async let userData = profileAPI.userInfo()
|
||||
async let roleData = accountContextAPI.rolePermissions()
|
||||
let (userInfo, rolePermissions) = try await (userData, roleData)
|
||||
try await restorePermissions(
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentRoleCode: cachedCurrentRoleCode,
|
||||
cachedLegacyRoleId: cachedLegacyRoleId,
|
||||
cachedCurrentScenicId: cachedCurrentScenicId,
|
||||
cachedCurrentStoreId: cachedCurrentStoreId
|
||||
)
|
||||
try await refreshSupplementalContext(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentScenicId: cachedCurrentScenicId,
|
||||
cachedCurrentStoreId: cachedCurrentStoreId
|
||||
)
|
||||
}
|
||||
|
||||
/// 冷启动阻塞阶段:仅恢复角色权限,确保首页菜单和权限控制可用。
|
||||
func restorePermissions(
|
||||
permissionContext: PermissionContext,
|
||||
accountContext: AccountContext,
|
||||
accountContextAPI: AccountContextServing,
|
||||
cachedCurrentRoleCode: String? = nil,
|
||||
cachedLegacyRoleId: Int? = nil,
|
||||
cachedCurrentScenicId: Int? = nil,
|
||||
cachedCurrentStoreId: Int? = nil
|
||||
) async throws {
|
||||
let rolePermissions = try await accountContextAPI.rolePermissions()
|
||||
permissionContext.replaceRolePermissions(
|
||||
rolePermissions,
|
||||
currentRoleCode: cachedCurrentRoleCode,
|
||||
cachedLegacyRoleId: cachedLegacyRoleId
|
||||
)
|
||||
|
||||
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
|
||||
let roleScenicScopes = permissionContext.currentRoleScenicScopes()
|
||||
guard accountContext.scenicScopes.isEmpty, !roleScenicScopes.isEmpty else { return }
|
||||
accountContext.replaceScopes(
|
||||
scenic: roleScenicScopes,
|
||||
stores: accountContext.storeScopes,
|
||||
currentScenicId: cachedCurrentScenicId,
|
||||
currentStoreId: cachedCurrentStoreId
|
||||
)
|
||||
}
|
||||
|
||||
/// 冷启动后台阶段:补齐用户资料、景区和门店上下文。
|
||||
func refreshSupplementalContext(
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
cachedCurrentScenicId: Int? = nil,
|
||||
cachedCurrentStoreId: Int? = nil
|
||||
) async throws {
|
||||
let userInfo = try await profileAPI.userInfo()
|
||||
let scenicResponse = await loadScenicList(
|
||||
accountContextAPI: accountContextAPI,
|
||||
rolePermissions: permissionContext.rolePermissions
|
||||
)
|
||||
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
|
||||
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
||||
let scenicScopes = scopedScenics(
|
||||
@ -53,8 +102,8 @@ struct AccountContextLoader {
|
||||
accountContext.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: storeScopes,
|
||||
currentScenicId: cachedCurrentScenicId,
|
||||
currentStoreId: cachedCurrentStoreId
|
||||
currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id,
|
||||
currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ final class SessionBootstrapper {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从本地缓存恢复登录态并请求服务端校验。
|
||||
/// 从本地缓存恢复登录态;阻塞等待 rolePermissions,其余数据后台刷新。
|
||||
func restore(
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
@ -51,32 +51,38 @@ final class SessionBootstrapper {
|
||||
|
||||
appSession.beginRestoring(token: token)
|
||||
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
|
||||
let loader = AccountContextLoader()
|
||||
|
||||
do {
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
try await loader.restorePermissions(
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContext: accountContext,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
|
||||
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
|
||||
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
||||
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
||||
)
|
||||
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
|
||||
appSession.markLoggedIn(token: token)
|
||||
refreshSupplementalContextInBackground(
|
||||
loader: loader,
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
toastCenter: toastCenter,
|
||||
cachedSnapshot: cachedSnapshot
|
||||
)
|
||||
} catch {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
appSession.logout()
|
||||
toastCenter.show("登录状态已失效,请重新登录")
|
||||
} else {
|
||||
appSession.markLoggedIn(token: token)
|
||||
toastCenter.show("网络异常,已使用本地登录状态")
|
||||
}
|
||||
handleBlockingRestoreFailure(
|
||||
error,
|
||||
token: token,
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +99,97 @@ final class SessionBootstrapper {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 后台补齐用户资料、景区和门店,不阻塞进入主界面。
|
||||
private func refreshSupplementalContextInBackground(
|
||||
loader: AccountContextLoader,
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
toastCenter: ToastCenter,
|
||||
cachedSnapshot: AccountSnapshot?
|
||||
) {
|
||||
Task {
|
||||
do {
|
||||
try await loader.refreshSupplementalContext(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
||||
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
||||
)
|
||||
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
|
||||
} catch {
|
||||
handleSupplementalRefreshFailure(
|
||||
error,
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理阻塞阶段恢复失败:鉴权失效登出,网络异常则降级为本地登录态。
|
||||
private func handleBlockingRestoreFailure(
|
||||
_ error: Error,
|
||||
token: String,
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
toastCenter: ToastCenter
|
||||
) {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
clearPersistedSession(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext
|
||||
)
|
||||
toastCenter.show("登录状态已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
appSession.markLoggedIn(token: token)
|
||||
toastCenter.show("网络异常,已使用本地登录状态")
|
||||
}
|
||||
|
||||
/// 处理后台补充刷新失败:鉴权失效登出,网络异常保留当前登录态。
|
||||
private func handleSupplementalRefreshFailure(
|
||||
_ error: Error,
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
toastCenter: ToastCenter
|
||||
) {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
clearPersistedSession(
|
||||
appSession: appSession,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext
|
||||
)
|
||||
toastCenter.show("登录状态已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
toastCenter.show("网络异常,已使用本地登录状态")
|
||||
}
|
||||
|
||||
/// 清空本地 token、快照和内存上下文。
|
||||
private func clearPersistedSession(
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext
|
||||
) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
appSession.logout()
|
||||
}
|
||||
|
||||
/// 保存服务端校验后的最新账号上下文。
|
||||
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
|
||||
let existing = snapshotStore.load()
|
||||
@ -109,5 +206,4 @@ final class SessionBootstrapper {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -2,32 +2,36 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范。
|
||||
Splash 模块负责冷启动品牌展示,与系统 Launch Screen 保持相同视觉,并在 bootstrap 完成前避免空白页或登录页闪屏。
|
||||
|
||||
该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制。
|
||||
冷启动恢复登录态由 `SessionBootstrapper` 负责;`SplashView` 仅覆盖 bootstrap 阻塞阶段,不做固定时长品牌停留。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `SplashView`:展示白底、180pt Logo、「随心瞰 / 商家版」文案。
|
||||
- `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`,bootstrap 完成后结束 Splash(无固定最短展示时长)。
|
||||
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`。
|
||||
- `SplashView`:冷启动 bootstrap 期间展示的品牌页,与 Launch Screen 布局一致。
|
||||
- `SplashTypography`:启动页标题/副标题字号与 UIKit 字体,供 `SplashView` 与 `Launch Screen.storyboard` 对齐。
|
||||
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源。
|
||||
- `SessionBootstrapper`:冷启动恢复登录态,阻塞等待 `rolePermissions`,其余数据后台刷新。
|
||||
- `AccountContextLoader.restorePermissions` / `refreshSupplementalContext`:拆分阻塞阶段与后台补充阶段。
|
||||
|
||||
## 展示规则
|
||||
|
||||
- 背景色:白色 `#FFFFFF`
|
||||
- Logo:`SplashLogo`,180×180 pt
|
||||
- 标题:`随心瞰`,40pt,bold + italic,`AppDesign.primary`
|
||||
- 副标题:`商家版`,20pt,bold,距底 32pt
|
||||
- 系统 Launch Screen:白底、180×180 Logo、「随心瞰 / 商家版」文案。
|
||||
- 应用内 `SplashView`:bootstrap 完成前覆盖根视图,避免 `.restoring` 空白或登录页闪现。
|
||||
- bootstrap 结束后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。
|
||||
- 无固定最短展示时长。
|
||||
|
||||
## 启动时序
|
||||
|
||||
1. iOS 系统 Launch Screen 展示白底 + Logo。
|
||||
2. `RootView` 挂载后立即显示 `SplashView`。
|
||||
3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。
|
||||
4. bootstrap 完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。
|
||||
5. 冷启动期间不显示 Lottie 全局 Loading。
|
||||
1. iOS 系统 Launch Screen 展示品牌页。
|
||||
2. `RootView` 挂载后立即显示 `SplashView` 覆盖层。
|
||||
3. `SessionBootstrapper.restore` 并行恢复登录态。
|
||||
4. 无 token:bootstrap 完成后进入 `LoginView`。
|
||||
5. 有 token:恢复 `AccountSnapshot` → 阻塞请求 `rolePermissions` → `markLoggedIn` → 进入 `MainTabsView`。
|
||||
6. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。
|
||||
7. 冷启动期间不显示 Lottie 全局 Loading。
|
||||
|
||||
## 与 Android 的差异
|
||||
|
||||
- Android 首启隐私弹窗在 Splash 上;iOS 仍在登录页勾选协议。
|
||||
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 仅展示 Logo,文案由 SwiftUI Splash 补齐。
|
||||
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 与应用内 `SplashView` 保持相同布局,但应用内 Splash 仅在 bootstrap 期间展示。
|
||||
|
||||
37
suixinkan/Features/Splash/SplashTypography.swift
Normal file
37
suixinkan/Features/Splash/SplashTypography.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// SplashTypography.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/30.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 启动页字体规范,供 `SplashView` 与 `Launch Screen.storyboard` 对齐。
|
||||
enum SplashTypography {
|
||||
/// 标题字号(「随心瞰」)
|
||||
static let titlePointSize: CGFloat = 40
|
||||
/// 副标题字号(「商家版」)
|
||||
static let subtitlePointSize: CGFloat = 20
|
||||
|
||||
/// 启动页标题 UIKit 字体:40pt system bold + italic。
|
||||
static var titleUIFont: UIFont {
|
||||
let base = UIFont.systemFont(ofSize: titlePointSize, weight: .bold)
|
||||
guard let descriptor = base.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]) else {
|
||||
return base
|
||||
}
|
||||
return UIFont(descriptor: descriptor, size: titlePointSize)
|
||||
}
|
||||
|
||||
/// 启动页副标题 UIKit 字体:20pt system bold。
|
||||
static var subtitleUIFont: UIFont {
|
||||
UIFont.systemFont(ofSize: subtitlePointSize, weight: .bold)
|
||||
}
|
||||
|
||||
/// 启动页标题 SwiftUI 字体。
|
||||
static var titleFont: Font { Font(titleUIFont) }
|
||||
|
||||
/// 启动页副标题 SwiftUI 字体。
|
||||
static var subtitleFont: Font { Font(subtitleUIFont) }
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 启动页视图,对齐 Android `activity_splash.xml` 的品牌展示布局。
|
||||
/// 启动页视图,在冷启动恢复登录态期间展示,与 Launch Screen 保持相同品牌布局。
|
||||
struct SplashView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -24,15 +24,14 @@ struct SplashView: View {
|
||||
.accessibilityIdentifier("splash.logo")
|
||||
|
||||
Text("随心瞰")
|
||||
.font(.system(size: 40, weight: .bold))
|
||||
.italic()
|
||||
.font(SplashTypography.titleFont)
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.accessibilityIdentifier("splash.title")
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("商家版")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.font(SplashTypography.subtitleFont)
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.bottom, 32)
|
||||
.accessibilityIdentifier("splash.subtitle")
|
||||
|
||||
@ -15,8 +15,50 @@
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" layoutMarginsRelativeArrangement="YES" axis="vertical" alignment="center" spacing="0" translatesAutoresizingMaskIntoConstraints="NO" id="Stk-Mn-001">
|
||||
<rect key="frame" x="0.0" y="59" width="393" height="759"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" verticalHuggingPriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Spc-Tp-001">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="200.5"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SplashLogo" translatesAutoresizingMaskIntoConstraints="NO" id="Img-Lg-001">
|
||||
<rect key="frame" x="106.5" y="200.5" width="180" height="180"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="180" id="Img-Wd-001"/>
|
||||
<constraint firstAttribute="height" constant="180" id="Img-Ht-001"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="随心瞰" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lbl-Tt-001">
|
||||
<rect key="frame" x="136.66666666666666" y="380.5" width="119.66666666666666" height="48"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="bold" pointSize="40"/>
|
||||
<color key="textColor" red="0.0" green="0.45098039215686275" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view contentMode="scaleToFill" verticalHuggingPriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Spc-Bt-001">
|
||||
<rect key="frame" x="0.0" y="428.5" width="393" height="200.5"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="商家版" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lbl-St-001">
|
||||
<rect key="frame" x="161.66666666666666" y="629" width="69.666666666666686" height="24"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="bold" pointSize="20"/>
|
||||
<color key="textColor" red="0.0" green="0.45098039215686275" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="32" right="0.0"/>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Stk-Mn-001" firstAttribute="top" secondItem="Bcu-3y-fUS" secondAttribute="top" id="Stk-Tp-001"/>
|
||||
<constraint firstItem="Stk-Mn-001" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="Stk-Ld-001"/>
|
||||
<constraint firstItem="Bcu-3y-fUS" firstAttribute="trailing" secondItem="Stk-Mn-001" secondAttribute="trailing" id="Stk-Tr-001"/>
|
||||
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="Stk-Mn-001" secondAttribute="bottom" id="Stk-Bt-001"/>
|
||||
<constraint firstItem="Spc-Tp-001" firstAttribute="height" secondItem="Spc-Bt-001" secondAttribute="height" id="Spc-Eq-001"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@ -24,4 +66,7 @@
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="SplashLogo" width="180" height="180"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
Reference in New Issue
Block a user