SwiftUI NavigationStack iOS 26: Hướng Dẫn Toàn Diện Về Type-Safe Routing và Deep Linking

Hướng dẫn xây dựng hệ thống điều hướng production-ready trong SwiftUI iOS 26: NavigationStack, type-safe routes, deep linking qua Universal Links và state restoration với SceneStorage. Có code mẫu Router pattern đầy đủ.

SwiftUI NavigationStack iOS 26: Guide 2026

Thật lòng mà nói, hệ thống điều hướng (navigation) là xương sống của mọi ứng dụng iOS hiện đại — và nếu làm sai ngay từ đầu, bạn sẽ trả giá đắt khi app phình to. Với iOS 26 và Swift 6.2, Apple tiếp tục hoàn thiện NavigationStack — API thay thế NavigationView đã được giới thiệu từ iOS 16 — giúp việc xây dựng điều hướng có thể mở rộng, hỗ trợ deep link và khôi phục trạng thái trở nên tự nhiên hơn bao giờ hết.

Bài hướng dẫn này gom toàn bộ best practices năm 2026 cho NavigationStack: type-safe routing với enum, kiến trúc Router pattern, deep linking thông qua Universal Links và URL Schemes, cùng với cách triển khai state restoration bằng SceneStorage. Đây là những thứ mà tài liệu chính thức của Apple… nói thật, chưa trình bày đầy đủ ở một nơi.

Vì Sao Cần Hiểu Sâu Về NavigationStack Trong iOS 26

Trước iOS 16, lập trình viên SwiftUI buộc phải dùng NavigationView kết hợp với NavigationLink — một mô hình điều hướng dựa trên view. Cách tiếp cận này có ba vấn đề lớn:

  • Không hỗ trợ pop-to-root theo cách lập trình một cách dễ dàng.
  • Khó deep link, vì navigation state bị nhúng trực tiếp vào view tree.
  • Không thể serialize trạng thái điều hướng để khôi phục sau khi app bị system kill.

NavigationStack giải quyết cả ba bằng mô hình data-driven navigation: bạn quản lý một mảng các giá trị Hashable (gọi là path), và SwiftUI tự ánh xạ mảng đó thành ngăn xếp view. Trong iOS 26, mô hình này hoạt động liền mạch với Approachable Concurrency của Swift 6.2 và Observation framework, giảm đáng kể boilerplate (cuối cùng cũng đỡ phải gõ @StateObject khắp nơi).

Khi Nào Dùng NavigationStack vs NavigationSplitView

Apple đưa ra nguyên tắc khá đơn giản:

  • NavigationStack: dùng cho điều hướng đơn cột, theo phân cấp (push/pop). Phù hợp với iPhone và Apple Watch.
  • NavigationSplitView: dùng cho bố cục đa cột (sidebar — content — detail). Tự động co giãn về single-column trên iPhone trong compact size class.

Với hầu hết ứng dụng iPhone, NavigationStack là lựa chọn mặc định. Bạn vẫn có thể nhúng NavigationStack bên trong từng cột của NavigationSplitView để xây app universal cho iPad và Mac — và đây là cách mình thường dùng cho các project đa nền tảng.

Khái Niệm Cốt Lõi: Path-Based Navigation

NavigationStack có hai initializer chính:

// 1. Stateless — không kiểm soát path lập trình
NavigationStack {
    HomeView()
}

// 2. Path binding — full control, deep link, state restoration
NavigationStack(path: $router.path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            destinationView(for: route)
        }
}

Sự khác biệt then chốt? Chỉ initializer thứ hai cho phép bạn thao tác trên path bằng code (push, pop, pop-to-root) và serialize nó để khôi phục trạng thái. Nếu bạn nghiêm túc về điều hướng, hãy dùng cái thứ hai — luôn luôn.

Bước 1: Mô Hình Hóa Mọi Màn Hình Bằng Type-Safe Route Enum

Lỗi phổ biến nhất khi mới làm quen NavigationStack là dùng String hoặc Int làm path value. Cách này không có type safety và không scale được tới đâu cả. Quy tắc vàng: mỗi màn hình là một case trong enum, conform HashableCodable.

import SwiftUI

enum AppRoute: Hashable, Codable {
    case productList(category: String)
    case productDetail(id: UUID)
    case checkout(productID: UUID, variant: String)
    case orderConfirmation(orderID: UUID)
    case profileSettings
}

Vì sao cần Hashable? Vì NavigationPath chỉ chấp nhận giá trị Hashable. Vì sao cần Codable? Để có thể lưu path vào SceneStorage phục vụ state restoration — và chúng ta sẽ làm điều này ở phần cuối, đừng lo.

Bước 2: Xây Dựng Router Pattern Với @Observable

iOS 17 trở lên đã có macro @Observable thay thế cho ObservableObject. Trong Swift 6.2 với Approachable Concurrency, mọi class @Observable đều mặc định @MainActor, nên ở các project mới bạn không cần thêm annotation thủ công.

import SwiftUI

@Observable
final class AppRouter {
    var path: [AppRoute] = []

    func push(_ route: AppRoute) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeAll()
    }

    func replace(with routes: [AppRoute]) {
        path = routes
    }
}

Inject router vào view tree bằng environment:

@main
struct ShopApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
        }
    }
}

struct RootView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        @Bindable var router = router
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    DestinationFactory.view(for: route)
                }
        }
    }
}

Để ý cú pháp @Bindable var router = router — đây là cách bind property của một @Observable object trong iOS 17+. Khác hoàn toàn với @StateObject trước đây, và lần đầu nhìn thấy thì hơi lạ mắt thật.

Bước 3: Destination Factory — Tách View Resolution Khỏi Logic

Đặt toàn bộ logic ánh xạ Route sang View trong một factory riêng giúp dễ test và dễ mở rộng:

enum DestinationFactory {
    @ViewBuilder
    static func view(for route: AppRoute) -> some View {
        switch route {
        case .productList(let category):
            ProductListView(category: category)
        case .productDetail(let id):
            ProductDetailView(productID: id)
        case .checkout(let productID, let variant):
            CheckoutView(productID: productID, variant: variant)
        case .orderConfirmation(let orderID):
            OrderConfirmationView(orderID: orderID)
        case .profileSettings:
            ProfileSettingsView()
        }
    }
}

Khi cần thêm màn hình mới, bạn chỉ chỉnh enum và factory — không phải đụng tới các view khác trong stack. Đơn giản vậy thôi.

Bước 4: Đăng Ký Universal Links Trong Info.plist Và Apple App Site Association

Universal Links là cơ chế chính thức của Apple để mở app từ một URL HTTPS. Có hai bước cấu hình, không thể bỏ qua bước nào:

4.1. Bật Associated Domains Capability

Trong Xcode, mở target → Signing & Capabilities → thêm Associated Domains, sau đó nhập:

applinks:shop.example.com

4.2. Đặt File apple-app-site-association Lên Server

File phải được phục vụ tại https://shop.example.com/.well-known/apple-app-site-association với Content-Type: application/json (lưu ý: không có đuôi .json ở tên file):

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAM12345.com.example.shop"],
        "components": [
          { "/": "/products/*" },
          { "/": "/orders/*" }
        ]
      }
    ]
  }
}

Bước 5: Parse URL Thành AppRoute

Để deep link hoạt động, bạn phải dịch URL nhận được thành một mảng AppRoute. Tách logic này ra khỏi router để có thể unit test độc lập:

struct URLRouter {
    func routes(for url: URL) -> [AppRoute]? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return nil
        }
        let path = components.path.split(separator: "/").map(String.init)

        switch path.first {
        case "products":
            if path.count == 1 {
                let category = components.queryItems?
                    .first(where: { $0.name == "category" })?.value ?? "all"
                return [.productList(category: category)]
            }
            if path.count == 2, let id = UUID(uuidString: path[1]) {
                return [
                    .productList(category: "all"),
                    .productDetail(id: id)
                ]
            }
            return nil

        case "orders":
            if path.count == 2, let id = UUID(uuidString: path[1]) {
                return [.orderConfirmation(orderID: id)]
            }
            return nil

        default:
            return nil
        }
    }
}

Để ý cách hàm trả về một mảng routes, không phải một route đơn lẻ. Khi user mở link /products/UUID, ta đẩy hai cấp vào stack (list rồi detail) — để khi họ bấm back, app trở về danh sách sản phẩm thay vì màn hình rỗng. Một chi tiết nhỏ nhưng tạo nên trải nghiệm "đúng kiểu iOS".

Bước 6: Bắt Sự Kiện onOpenURL Và onContinueUserActivity

SwiftUI cung cấp hai modifier để nhận URL từ hệ thống. Cài cả hai để app xử lý được Universal Links lẫn Custom URL Scheme:

struct RootView: View {
    @Environment(AppRouter.self) private var router
    private let urlRouter = URLRouter()

    var body: some View {
        @Bindable var router = router
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    DestinationFactory.view(for: route)
                }
        }
        .onOpenURL { url in
            handle(url: url)
        }
        .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
            if let url = activity.webpageURL {
                handle(url: url)
            }
        }
    }

    private func handle(url: URL) {
        guard let routes = urlRouter.routes(for: url) else { return }
        router.replace(with: routes)
    }
}

Bước 7: State Restoration Với SceneStorage

Khi hệ thống kill app do thiếu RAM (chuyện rất bình thường trên iPhone đời cũ), bạn nên khôi phục đúng màn hình user đang xem. SceneStorage là property wrapper lưu dữ liệu gắn với scene, sống lâu hơn process:

struct RootView: View {
    @Environment(AppRouter.self) private var router
    @SceneStorage("appNavigationPath") private var encodedPath: Data?

    var body: some View {
        @Bindable var router = router
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    DestinationFactory.view(for: route)
                }
        }
        .onAppear {
            if let data = encodedPath,
               let routes = try? JSONDecoder().decode([AppRoute].self, from: data) {
                router.path = routes
            }
        }
        .onChange(of: router.path) { _, newPath in
            encodedPath = try? JSONEncoder().encode(newPath)
        }
    }
}

AppRoute đã conform Codable ở Bước 1, encode/decode trở nên cực kỳ đơn giản. Mỗi tab trong app có thể dùng key SceneStorage riêng để lưu path độc lập — đây là chi tiết nhỏ nhưng tạo nên cảm giác "app sống động".

Bước 8: Một NavigationStack Cho Mỗi Tab — Đừng Lồng Stack

Với TabView, mỗi tab phải có NavigationStack và Router riêng. Lồng NavigationStack bên trong nhau, hoặc share path giữa nhiều tab, là anti-pattern kinh điển — nó gây ra hành vi không thể đoán trước, kiểu lỗi "không reproduce được" mà bạn ghét nhất:

struct MainTabView: View {
    @State private var shopRouter = AppRouter()
    @State private var profileRouter = AppRouter()

    var body: some View {
        TabView {
            Tab("Shop", systemImage: "bag") {
                NavigationStack(path: $shopRouter.path) {
                    HomeView()
                        .navigationDestination(for: AppRoute.self) { route in
                            DestinationFactory.view(for: route)
                        }
                }
                .environment(shopRouter)
            }
            Tab("Profile", systemImage: "person") {
                NavigationStack(path: $profileRouter.path) {
                    ProfileView()
                        .navigationDestination(for: AppRoute.self) { route in
                            DestinationFactory.view(for: route)
                        }
                }
                .environment(profileRouter)
            }
        }
    }
}

Để ý cú pháp Tab mới của iOS 18+/26 thay cho .tabItem. Sạch hơn hẳn.

Bước 9: Test Deep Link Bằng Xcode 26 Và SwiftUI Preview

Bạn có thể test deep link mà không cần triển khai lên server thật — đây là điểm hay tiết kiệm cả ngày debug:

9.1. Dùng xcrun simctl

xcrun simctl openurl booted "https://shop.example.com/products/A1B2C3D4-E5F6-7890-1234-567890ABCDEF"

9.2. Dùng Preview với router preset

#Preview("Deep link to product detail") {
    let router = AppRouter()
    router.path = [
        .productList(category: "shoes"),
        .productDetail(id: UUID())
    ]
    return RootView()
        .environment(router)
}

Cách này cho phép visualize bất kỳ deep link nào ngay trong canvas — không cần build & run, không cần đợi simulator boot. Cá nhân mình thấy đây là cách hiệu quả nhất khi cần kiểm tra một loạt URL pattern liên tiếp.

Bước 10: NavigationPath Khi Routes Khác Loại

Nếu app của bạn thực sự cần đẩy nhiều kiểu dữ liệu khác nhau vào cùng một stack (ví dụ: cả Product lẫn User), dùng NavigationPath thay vì mảng cụ thể:

@Observable
final class HeterogeneousRouter {
    var path = NavigationPath()

    func push<T: Hashable>(_ value: T) {
        path.append(value)
    }
}

Trong hầu hết trường hợp thực tế, một enum AppRoute duy nhất với nhiều case là cách rõ ràng và dễ bảo trì hơn. Chỉ chuyển sang NavigationPath khi bạn thực sự cần điều hướng đa kiểu — nếu không, bạn đang đánh đổi type safety lấy sự "linh hoạt" mà bạn chẳng bao giờ dùng đến.

Anti-Patterns Cần Tránh

Anti-patternBest practice
Lồng nhiều NavigationStackMột NavigationStack cho mỗi tab
Lưu trạng thái điều hướng trong ViewModelTách riêng vào Router dedicated
Dùng String làm path valueType-safe enum + Hashable + Codable
Quên .navigationDestinationĐăng ký mọi route ở cấp NavigationStack
Bỏ qua state restorationEncode path vào SceneStorage

Tích Hợp Với Swift 6.2 Approachable Concurrency

Với cấu hình Default Actor Isolation: MainActor mặc định trong Xcode 26, bạn không cần annotate @MainActor trên class Router nữa. Nếu deep link kích hoạt một lệnh gọi mạng để fetch dữ liệu trước khi push, dùng @concurrent để tách rõ phần chạy trên background:

@concurrent
func fetchProduct(id: UUID) async throws -> Product {
    let (data, _) = try await URLSession.shared.data(from: productURL(id))
    return try JSONDecoder().decode(Product.self, from: data)
}

Tách rạch ròi giữa code chạy trên main actor (UI, router) và code chạy concurrent (network, parsing) sẽ giúp bạn tránh data race ngay từ giai đoạn compile — và đó là một trong những lý do mình thật sự thích Swift 6.2.

FAQ — Câu Hỏi Thường Gặp

NavigationStack có thay thế hoàn toàn NavigationView không?

Có. Từ iOS 16, NavigationView đã được deprecated với cảnh báo soft, và Apple khuyến nghị mọi project mới chuyển sang NavigationStack hoặc NavigationSplitView. Trong iOS 26, NavigationView vẫn còn hoạt động vì lý do tương thích, nhưng không nhận tính năng mới — và các bug đã biết sẽ không được sửa. Nói cách khác: đừng dùng nó cho code mới.

Sự khác biệt giữa Universal Links và Custom URL Scheme là gì?

Universal Links dùng URL HTTPS thông thường (https://shop.example.com/...) và yêu cầu file apple-app-site-association trên server. Apple ưu tiên cách này vì an toàn — không app nào khác có thể giả mạo domain. Custom URL Scheme (như myapp://product/123) đơn giản hơn nhưng không bảo mật và dễ bị app khác hijack. Cho production, hãy dùng Universal Links.

Làm sao để pop về root khi user nhấn vào tab đang active lần thứ hai?

Dùng onChange(of:) của tab selection: nếu user nhấn lại đúng tab đang active, gọi router.popToRoot(). Lưu ý cần lưu cả previous selection để phân biệt giữa chuyển tab và nhấn cùng tab — nếu không, bạn sẽ vô tình pop khi user vừa mới chuyển sang tab đó.

NavigationLink với value vẫn dùng được trong NavigationStack chứ?

Có, và đây là cách được khuyến nghị: NavigationLink(value: AppRoute.productDetail(id: id)) { Text("Xem chi tiết") }. Cách này push giá trị vào path của NavigationStack và tự động kích hoạt navigationDestination tương ứng. Tránh dùng NavigationLink với inline destination view, vì kiểu này không thể trigger lập trình và không hỗ trợ state restoration.

Có thể test URL routing bằng Unit Test không?

Hoàn toàn được, và đây chính là lý do tách URLRouter ra khỏi AppRouter. Với Swift Testing (Swift 6.2), bạn viết test thuần (pure function) cho URLRouter().routes(for:) — không cần SwiftUI host, nên chạy cực nhanh và dễ tích hợp vào CI.

Tổng Kết

NavigationStack trong iOS 26 không chỉ là API mới — nó là một kiến trúc giúp bạn xây app có thể mở rộng, deep-linkable và khôi phục được trạng thái. Bốn nguyên tắc cần khắc cốt ghi tâm:

  1. Mọi route là enum Hashable + Codable — không bao giờ là String.
  2. Tách Router, URLRouter và Destination Factory — mỗi thành phần một trách nhiệm.
  3. Một NavigationStack cho mỗi tab — không lồng, không share path.
  4. SceneStorage là bạn của bạn — tận dụng để khôi phục đúng màn hình sau khi app bị kill.

Triển khai đúng kiến trúc này một lần, bạn sẽ có nền tảng điều hướng đủ vững để phát triển ứng dụng hàng triệu user mà không phải refactor lại mỗi khi thêm tính năng mới. Và tin mình đi — refactor navigation giữa chừng là một trong những việc đau đầu nhất.

Về Tác Giả Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.