本章覆盖 iOS 17 的完整导航体系:NavigationStack(栈式导航)、TabView(Tab导航)、Sheet/FullScreenCover(弹出导航)、NavigationSplitView(分栏导航),以及 Deep Link 和 Universal Links 深链接处理。
6.1 NavigationStack - 栈式导航
NavigationStack 是 iOS 16+ 推荐的导航方式,支持程序化控制整个导航栈。
swift
// App 根导航结构
struct AppRootView: View {
// NavigationPath 管理导航栈状态(可序列化、可程序化控制)
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
HomeView()
// 注册路由目标(类型安全)
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
}
.navigationDestination(for: User.self) { user in
UserProfileView(user: user)
}
.navigationDestination(for: AppRoute.self) { route in
AppRouteView(route: route)
}
}
.environment(\.navigationPath, $navigationPath) // 暴露给子视图
}
}
// 路由枚举(将路由集中管理)
enum AppRoute: Hashable {
case settings
case notifications
case search(String)
case articleDetail(String)
case userProfile(String)
}
// 在任意视图中使用 NavigationLink
struct HomeView: View {
let articles: [Article] = Article.samples
var body: some View {
List(articles) { article in
// 声明式导航:点击自动 push
NavigationLink(value: article) {
ArticleRowView(article: article)
}
}
.navigationTitle("首页")
.navigationBarTitleDisplayMode(.large)
}
}
// 程序化导航(从 ViewModel 或按钮触发跳转)
struct NavigationProgrammaticDemo: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 20) {
Button("跳转到设置") {
path.append(AppRoute.settings) // push
}
Button("跳转到搜索") {
path.append(AppRoute.search("Swift"))
}
Button("直达二级页面") {
// 一次 push 多层(跳过中间层)
path.append(AppRoute.settings)
path.append(AppRoute.notifications)
}
Button("回到根页面") {
path.removeLast(path.count) // pop to root
}
}
.navigationTitle("导航演示")
.navigationDestination(for: AppRoute.self) { route in
AppRouteView(route: route)
}
}
}
}
NavigationBar 定制
swift
struct CustomNavBarDemo: View {
var body: some View {
List { /* 内容 */ }
.navigationTitle("自定义导航栏")
.navigationBarTitleDisplayMode(.inline) // .large / .inline / .automatic
// 工具栏按钮
.toolbar {
// 左侧按钮
ToolbarItem(placement: .topBarLeading) {
Button("取消") { }
}
// 右侧单按钮
ToolbarItem(placement: .topBarTrailing) {
Button { } label: {
Image(systemName: "plus")
}
}
// 右侧多按钮(菜单)
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("按时间排序") { }
Button("按热度排序") { }
Divider()
Button("筛选", role: .destructive) { }
} label: {
Image(systemName: "ellipsis.circle")
}
}
// 底部工具栏
ToolbarItem(placement: .bottomBar) {
HStack {
Button("分享") { }
Spacer()
Button("下载") { }
}
}
}
// 搜索栏
.searchable(text: .constant(""), placement: .navigationBarDrawer,
prompt: "搜索文章")
// 隐藏返回按钮
.navigationBarBackButtonHidden(true)
// 自定义返回按钮
.toolbar {
ToolbarItem(placement: .topBarLeading) {
BackButton()
}
}
}
}
6.2 TabView - Tab 导航
swift
struct MainTabView: View {
@State private var selectedTab: Tab = .home
enum Tab: String, CaseIterable {
case home = "首页"
case explore = "发现"
case publish = "发布"
case message = "消息"
case profile = "我的"
var icon: String {
switch self {
case .home: "house.fill"
case .explore: "safari.fill"
case .publish: "plus.circle.fill"
case .message: "message.fill"
case .profile: "person.fill"
}
}
}
var body: some View {
TabView(selection: $selectedTab) {
ForEach(Tab.allCases, id: \.self) { tab in
NavigationStack {
tabContent(for: tab)
}
.tabItem {
Label(tab.rawValue, systemImage: tab.icon)
}
.tag(tab)
// 角标
.badge(badgeCount(for: tab))
}
}
.tint(.blue) // 选中颜色
}
@ViewBuilder
func tabContent(for tab: Tab) -> some View {
switch tab {
case .home: HomeView()
case .explore: ExploreView()
case .publish: PublishView()
case .message: MessageView()
case .profile: ProfileView()
}
}
func badgeCount(for tab: Tab) -> Int {
switch tab {
case .message: return 5
default: return 0
}
}
}
// 自定义 Tab 外观(iOS 18 新 API,iOS 17 需 UITabBar 定制)
struct StyledTabView: View {
var body: some View {
TabView {
Tab("首页", systemImage: "house") { HomeView() }
Tab("消息", systemImage: "message") { MessageView() }
.badge(3)
Tab("我的", systemImage: "person") { ProfileView() }
}
.tabViewStyle(.automatic)
}
}
6.3 Sheet 与弹出导航
swift
struct SheetNavigationDemo: View {
@State private var showAddSheet = false
@State private var showLoginFullScreen = false
@State private var selectedArticle: Article?
var body: some View {
VStack(spacing: 20) {
// ① Sheet - 支持多段高度(iOS 16+)
Button("添加内容(Sheet)") { showAddSheet = true }
.sheet(isPresented: $showAddSheet) {
AddContentView()
.presentationDetents([
.height(200), // 固定高度
.fraction(0.5), // 屏幕 50%
.medium, // 约 50%
.large // 全屏
])
.presentationDragIndicator(.visible)
.presentationCornerRadius(24)
// 不拦截后面的视图交互
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
// ② Sheet with item(有关联数据时弹出)
Button("查看文章") { selectedArticle = Article.samples.first }
.sheet(item: $selectedArticle) { article in
ArticleReaderView(article: article)
.presentationDetents([.large])
}
// ③ FullScreenCover - 全屏弹出
Button("登录(全屏)") { showLoginFullScreen = true }
.fullScreenCover(isPresented: $showLoginFullScreen) {
LoginView(onSuccess: { showLoginFullScreen = false })
}
}
}
}
// Sheet 内部视图关闭自己
struct AddContentView: View {
@Environment(\.dismiss) var dismiss // 获取 dismiss 动作
@State private var title = ""
var body: some View {
NavigationStack {
Form {
TextField("标题", text: $title)
}
.navigationTitle("新建内容")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("完成") {
save()
dismiss()
}
.disabled(title.isEmpty)
}
}
}
}
func save() { /* 保存逻辑 */ }
}
6.4 NavigationSplitView - iPad/Mac 分栏
swift
// 三栏布局(iPad / macOS)
struct SplitViewLayout: View {
@State private var selectedCategory: Category?
@State private var selectedArticle: Article?
@State private var columnVisibility = NavigationSplitViewVisibility.automatic
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
// 侧边栏(左):分类列表
List(Category.allCases, selection: $selectedCategory) { category in
Label(category.name, systemImage: category.icon)
}
.navigationTitle("分类")
} content: {
// 内容栏(中):文章列表
if let category = selectedCategory {
ArticleListColumn(category: category, selection: $selectedArticle)
} else {
ContentUnavailableView("选择分类", systemImage: "sidebar.left")
}
} detail: {
// 详情栏(右):文章内容
if let article = selectedArticle {
ArticleDetailView(article: article)
} else {
ContentUnavailableView("选择文章", systemImage: "doc.text")
}
}
.navigationSplitViewStyle(.balanced)
}
}
6.5 Deep Link 深链接
swift
// Info.plist 配置 URL Scheme
// CFBundleURLSchemes: ["iosDemo"]
@main
struct iOSDemosApp: App {
@State private var appRouter = AppRouter()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appRouter)
// 处理 URL Scheme 深链接
.onOpenURL { url in
appRouter.handle(url: url)
}
// 处理 Universal Links(需要 Associated Domains 配置)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL {
appRouter.handle(url: url)
}
}
}
}
}
// 路由处理器
@Observable
class AppRouter {
var navigationPath = NavigationPath()
var presentedSheet: AppRoute?
func handle(url: URL) {
// iosDemo://article/123
// iosDemo://user/profile/456
// https://yourdomain.com/article/123
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
let pathComponents = components.path
.components(separatedBy: "/")
.filter { !$0.isEmpty }
switch pathComponents.first {
case "article":
if let id = pathComponents.dropFirst().first {
navigationPath.append(AppRoute.articleDetail(id))
}
case "user", "profile":
if let userId = pathComponents.last {
navigationPath.append(AppRoute.userProfile(userId))
}
case "settings":
navigationPath.append(AppRoute.settings)
default:
break
}
}
func navigate(to route: AppRoute) {
navigationPath.append(route)
}
func popToRoot() {
navigationPath.removeLast(navigationPath.count)
}
}
章节总结
| 导航类型 | API | 适用场景 |
|---|---|---|
| 栈式导航 | NavigationStack |
层级页面(列表→详情) |
| 程序化控制 | NavigationPath |
ViewModel 触发导航、深链接 |
| Tab 导航 | TabView |
顶级功能区切换 |
| 底部弹出 | .sheet + presentationDetents |
半屏/全屏内容弹出 |
| 全屏弹出 | .fullScreenCover |
登录、引导页 |
| 分栏导航 | NavigationSplitView |
iPad/Mac 自适应布局 |
| 深链接 | .onOpenURL |
外部唤起 App 特定页面 |
Demo 说明
| 文件 | 演示内容 |
|---|---|
NavigationStackDemo.swift |
程序化导航、NavigationPath |
TabViewDemo.swift |
主 Tab 结构 + 角标 |
SheetNavigationDemo.swift |
Sheet 多段高度 + FullScreenCover |
SplitViewDemo.swift |
iPad 三栏分栏布局 |
DeepLinkDemo.swift |
URL Scheme 深链接处理 |
📎 扩展内容补充
来源:第六章_主题与国际化.md
本章概述:掌握 iOS 的 Dark Mode 适配、自定义颜色系统、动态颜色、以及多语言国际化方案(String Catalog)。
6.1 Dark Mode 与主题系统
概念讲解
动态颜色适配
swift
// 在 Assets.xcassets 中定义自适应颜色(Any/Dark 两套)
// 代码中使用:
Color("AppPrimary") // 自动根据深浅色切换
Color("BackgroundColor")
// 系统语义颜色(推荐,自动适配 Dark Mode)
Color.primary // 主要文本色
Color.secondary // 次要文本色
Color.background // 背景色(白/黑)
Color(.systemGroupedBackground)
Color(.label) // 标签色
Color(.systemBlue) // 系统蓝
// 读取当前模式
struct ThemeAwareView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack {
Text("当前模式:\(colorScheme == .dark ? "深色" : "浅色")")
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ? Color(.systemGray5) : .white)
.shadow(
color: colorScheme == .dark ? .clear : .black.opacity(0.1),
radius: 8
)
}
}
}
自定义主题系统
swift
// 定义 App 主题(类比 Flutter 的 ThemeData)
struct AppTheme {
let primaryColor: Color
let secondaryColor: Color
let backgroundColor: Color
let surfaceColor: Color
let textPrimary: Color
let textSecondary: Color
let cornerRadius: CGFloat
let shadowRadius: CGFloat
static let light = AppTheme(
primaryColor: Color(hex: "#007AFF"),
secondaryColor: Color(hex: "#5AC8FA"),
backgroundColor: Color(.systemBackground),
surfaceColor: .white,
textPrimary: Color(.label),
textSecondary: Color(.secondaryLabel),
cornerRadius: 12,
shadowRadius: 8
)
static let dark = AppTheme(
primaryColor: Color(hex: "#0A84FF"),
secondaryColor: Color(hex: "#64D2FF"),
backgroundColor: Color(.systemBackground),
surfaceColor: Color(.systemGray6),
textPrimary: Color(.label),
textSecondary: Color(.secondaryLabel),
cornerRadius: 12,
shadowRadius: 0
)
}
// 通过 EnvironmentKey 注入主题
struct AppThemeKey: EnvironmentKey {
static let defaultValue = AppTheme.light
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}
// 在 App 根部注入
@main
struct iOSDemosApp: App {
@Environment(\.colorScheme) var colorScheme
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme,
colorScheme == .dark ? AppTheme.dark : AppTheme.light)
}
}
}
// 子视图使用主题
struct ThemedButton: View {
@Environment(\.appTheme) var theme
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(theme.primaryColor)
.cornerRadius(theme.cornerRadius)
}
}
}
颜色扩展
swift
extension Color {
// 十六进制颜色
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3:
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8:
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
6.2 国际化(多语言)
概念讲解
iOS 17 推荐使用 String Catalog(.xcstrings 文件),比传统 Localizable.strings 更易维护。
String Catalog 使用方式
swift
// 在代码中直接写中文,Xcode 自动提取到 .xcstrings
Text("欢迎使用") // 自动国际化的键
Text("items_count \(count)", tableName: nil, bundle: nil, comment: "")
// 使用带参数的本地化字符串
Text("welcome_user \(username)")
// 对应 String Catalog 中:
// "welcome_user %@" -> 中文:"欢迎,%@!"
// -> 英文:"Welcome, %@!"
传统 Localizable.strings 方式
swift
// zh-Hans.lproj/Localizable.strings
// "home_title" = "首页";
// "settings_title" = "设置";
// "welcome_user %@" = "欢迎,%@!";
// en.lproj/Localizable.strings
// "home_title" = "Home";
// "settings_title" = "Settings";
// "welcome_user %@" = "Welcome, %@!";
// 代码中使用
Text(String(localized: "home_title"))
Text("home_title") // SwiftUI Text 自动查找本地化
// 带参数
let message = String(localized: "welcome_user \(username)")
Text(message)
动态切换语言
swift
@Observable
class LocalizationManager {
var currentLanguage: String = Locale.current.language.languageCode?.identifier ?? "zh"
private static let supportedLanguages = ["zh-Hans", "en", "ja"]
var supportedLanguages: [Language] = [
Language(code: "zh-Hans", displayName: "简体中文"),
Language(code: "en", displayName: "English"),
Language(code: "ja", displayName: "日本語")
]
func setLanguage(_ code: String) {
currentLanguage = code
UserDefaults.standard.set([code], forKey: "AppleLanguages")
// 需要重启 App 生效(或使用第三方库实现即时切换)
}
}
struct LanguageSettingsView: View {
@Environment(LocalizationManager.self) var locManager
var body: some View {
List(locManager.supportedLanguages, id: \.code) { language in
HStack {
Text(language.displayName)
Spacer()
if locManager.currentLanguage == language.code {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
.contentShape(Rectangle())
.onTapGesture {
locManager.setLanguage(language.code)
}
}
.navigationTitle("语言设置")
}
}
日期、数字、货币格式化
swift
struct FormattingDemo: View {
let date = Date()
let price = 1234.56
let count = 1000000
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// 日期格式化(自动根据语言环境格式化)
Text(date, format: .dateTime.year().month().day())
Text(date, format: .dateTime.hour().minute())
Text(date.formatted(.relative(presentation: .named))) // "2天前"
// 数字格式化
Text(count, format: .number) // 1,000,000
Text(price, format: .number.precision(.fractionLength(2)))
// 货币格式化
Text(price, format: .currency(code: "CNY")) // ¥1,234.56
Text(price, format: .currency(code: "USD")) // $1,234.56
// 文件大小
Text(1024 * 1024, format: .byteCount(style: .file)) // 1 MB
}
}
}
章节总结
| 知识点 | 关键点 | 重要程度 |
|---|---|---|
| Dark Mode 适配 | 使用系统语义颜色 / Assets 动态颜色 | ⭐⭐⭐⭐⭐ |
| 自定义主题 | EnvironmentKey 注入 AppTheme | ⭐⭐⭐⭐ |
| String Catalog | iOS 17 推荐方案 | ⭐⭐⭐⭐ |
| 格式化 | 日期/数字/货币自动随语言变化 | ⭐⭐⭐⭐ |
Demo 说明
| Demo 文件 | 演示内容 |
|---|---|
DarkModeDemo.swift |
Dark Mode 切换 + 自定义主题系统 |
LocalizationDemo.swift |
String Catalog + 动态语言切换 |