SwiftUI 导航的三个阶段:从 NavigationStack 到深层链接

SwiftUI 导航的三个阶段:从 NavigationStack 到深层链接

年前接了一个需求:App 从外部 URL 打开文章详情页,需要直接跳到对应页面而不是先展示首页。

听起来简单,做起来踩了一圈坑。SwiftUI 的导航系统在这三年里变了好几次,NavigationLink、NavigationController、NavigationStack 混在一起,文档和博客说法不一。今天把完整路径讲清楚。

最早的 SwiftUI 导航只有 NavigationLink

NavigationView {
    List(items) { item in
        NavigationLink(item.title) {
            DetailView(item: item)
        }
    }
    .navigationTitle("列表")
}

这种方式的问题:

  • NavigationView 在 iPad 和 iPhone 上的布局行为不一样
  • 深层嵌套时,navigation bar 的状态管理混乱
  • 不支持 programmatic navigation(代码驱动跳转)

第二阶段:NavigationStack 的现代化

iOS 16 引入 NavigationStack,解决了大部分历史问题:

NavigationStack {
    List(items) { item in
        NavigationLink(value: item) {
            Text(item.title)
        }
    }
    .navigationDestination(for: Item.self) { item in
        DetailView(item: item)
    }
}

关键变化是数据和导航路径分离。不再用 destination view 本身作为导航目标,而是用 value。

Path 的力量

NavigationPath 是这代 API 的核心:

struct RootView: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            List(items) { item in
                NavigationLink(value: item) {
                    Text(item.title)
                }
            }
            .navigationDestination(for: Item.self) { item in
                DetailView(item: item)
            }
        }
    }
}

你可以在任何地方往 navigationPath 里追加值来触发跳转:

func navigateToDetail(_ item: Item) {
    navigationPath.append(item)
}

func navigateToSettings() {
    navigationPath.append(NavigationDestination.settings)
}

第三阶段:深层链接实战

回到开头的问题:外部 URL 打开 App,需要直接跳到文章详情。

URL Scheme 处理

首先在 AppDelegate 或 SceneDelegate 里接收 URL:

@main
struct MyApp: App {
    @StateObject private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ app: UIApplication,
        open url: URL,
        options: [UIApplication.OpenURLOptionsKey: Any] = [:]
    ) -> Bool {
        handleDeepLink(url)
        return true
    }
}

解析 URL 并同步到导航状态

关键在于:解析出目标页面后,如何驱动 NavigationStack 跳转?

@MainActor
final class AppState: ObservableObject {
    @Published var navigationPath = NavigationPath()
    @Published var pendingDeepLink: DeepLink?

    func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host else { return }

        switch host {
        case "article":
            if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {
                navigateToArticle(id: id)
            }
        case "profile":
            if let userId = components.queryItems?.first(where: { $0.name == "userId" })?.value {
                navigateToProfile(userId: userId)
            }
        default:
            break
        }
    }

    func navigateToArticle(id: String) {
        // 先查数据,再跳转
        Task {
            if let article = await fetchArticle(id: id) {
                navigationPath.append(article)
            }
        }
    }

    func navigateToProfile(userId: String) {
        navigationPath.append(ProfileDestination(userId: userId))
    }
}

完整的深层链接场景

假设 URL 是 myapp://article?id=12345

// 1. App 启动时恢复深层链接
struct MyApp: App {
    @StateObject private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
                .onOpenURL { url in
                    appState.handleDeepLink(url)
                }
        }
    }
}

// 2. 根视图读取 pendingDeepLink 并执行跳转
struct ContentView: View {
    @EnvironmentObject var appState: AppState

    var body: some View {
        TabView {
            HomeTab()
            ProfileTab()
        }
    }
}

struct HomeTab: View {
    @EnvironmentObject var appState: AppState

    var body: some View {
        NavigationStack(path: $appState.navigationPath) {
            ArticleListView()
                .navigationDestination(for: Article.self) { article in
                    ArticleDetailView(article: article)
                }
        }
    }
}

导航状态与 Tab 切换

这里有个容易踩的坑:TabView 里的 NavigationStack 如何管理各自的导航栈?

struct ContentView: View {
    @EnvironmentObject var appState: AppState

    var body: some View {
        TabView {
            HomeTab()
                .tabItem { Label("首页", systemImage: "house") }

            SearchTab()
                .tabItem { Label("搜索", systemImage: "magnifyingglass") }
        }
    }
}

每个 Tab 有自己独立的 NavigationStack,切换 Tab 时 navigationPath 不会自动重置。如果你需要切换 Tab 时清空导航栈:

struct HomeTab: View {
    @EnvironmentObject var appState: AppState
    @State private var previousTab: Tab = .home

    var body: some View {
        NavigationStack(path: $appState.navigationPath) {
            // ...
        }
        .onChange(of: appState.selectedTab) { oldTab, newTab in
            if newTab != .home && oldTab == .home {
                // 离开首页时重置导航栈
                appState.navigationPath = NavigationPath()
            }
            previousTab = newTab
        }
    }
}

导航栏的精细控制

有时候详情页需要隐藏导航栏,但子页面又要显示:

struct ArticleDetailView: View {
    let article: Article
    @Environment(\.navigationPath) private var navigationPath

    var body: some View {
        ScrollView {
            ArticleContent(article: article)
        }
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .principal) {
                Text(article.title)
                    .font(.headline)
            }
        }
    }
}

struct ArticleContent: View {
    let article: Article

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text(article.title)
                .font(.title)

            Text(article.body)
        }
        .padding()
    }
}

总结

SwiftUI 导航进化的核心思路是声明式替代命令式

  1. NavigationStack + navigationDestination 替代 NavigationLink 的嵌套写法
  2. NavigationPath 管理完整的导航栈,支持 programmatic navigation
  3. 深层链接通过 onOpenURL 接收 URL,解析后写入 NavigationPath
  4. Tab 间的导航栈隔离需要手动管理

理解了这个模型,就能优雅地处理各种导航场景,而不需要 hack 式的 DispatchQueue.main.async 或强行 popToRootViewController

最后更新 4/20/2026, 6:02:32 AM