SwiftUI NavigationStack ฉบับสมบูรณ์: Router, Deep Linking บน iOS 26 (2026)

สร้าง SwiftUI NavigationStack บน iOS 26 ด้วย Router Pattern, NavigationPath และ Deep Linking โค้ด Swift พร้อมใช้ รองรับ state restoration และ VoiceOver

คู่มือ SwiftUI NavigationStack iOS 26 (2026)

อัปเดตล่าสุด: 10 กรกฎาคม 2026

SwiftUI NavigationStack คือ container การนำทางแบบ value-driven ที่มาแทน NavigationView ตั้งแต่ iOS 16 และเป็นมาตรฐานเดียวสำหรับ iOS 26 โดยใช้ push stack ที่ผูกกับ NavigationPath หรืออาร์เรย์ของ route แบบ Hashable ทำให้ deep linking, programmatic navigation, state restoration และ Router pattern ทำได้แบบ type-safe ในไฟล์เดียว บทความนี้จะพาสร้าง Router ที่ push/pop ได้จากทุก view, parse deep link ด้วย onOpenURL, เก็บ path ผ่าน Codable และเช็คว่า VoiceOver อ่านโฟลว์ได้ถูกต้อง พร้อมโค้ดที่รันได้จริงบน Xcode 26 (ผมเจอบั๊กแปลก ๆ ตอนเปลี่ยนจาก NavigationView มาแล้วหลายรอบ บทความนี้เลยรวมสิ่งที่อยากบอกตัวเองในโปรเจ็กต์แรกไว้ให้หมด)

  • NavigationStack ทำงานแบบ value-driven คุณ push "ค่า" (route enum) ไม่ใช่ view และ navigationDestination(for:) จะแมปค่ากลับเป็นหน้าอัตโนมัติ ให้โครงสร้างที่ testable และ decouple ได้จริง
  • ใช้ NavigationPath เมื่อ stack ต้องเก็บ route หลายชนิด และใช้ [Route] เมื่อทุกหน้ามีชนิดเดียว โดย path รองรับ Codable เพื่อ state restoration ผ่าน SceneStorage
  • Router pattern ที่ใช้ @Observable จาก Observation framework (iOS 17+) ให้ push/pop/popToRoot ผ่าน environment โดยไม่ต้องส่ง binding ทะลุ view หลายชั้น
  • Deep linking เชื่อมผ่าน onOpenURL โดย parse URL เป็น route แล้วเซ็ต NavigationPath ในครั้งเดียว SwiftUI จะ animate การ push อัตโนมัติ
  • สำหรับ TabView ให้แต่ละแท็บมี NavigationPath ของตัวเอง อย่าใช้ path เดียวข้ามแท็บเพราะ state จะพันกันและ VoiceOver จะสับสน
  • Accessibility: NavigationStack จัดโฟกัส VoiceOver ให้อัตโนมัติเมื่อ push หน้าใหม่ ตรวจว่าทุกหน้ามี navigationTitle ที่มีความหมาย เพราะโรเตอร์ Headings ใช้เป็นจุด landmark

NavigationStack คืออะไร และต่างจาก NavigationView อย่างไร

โอเค เริ่มจากพื้นฐานก่อน NavigationStack คือ container สำหรับ push navigation ที่เปิดตัวใน iOS 16 และเข้ามาแทน NavigationView อย่างเป็นทางการ บน iOS 26 NavigationView ยัง compile ได้อยู่ แต่ Apple ประกาศ deprecated ตั้งแต่ WWDC 2022 แล้ว ถ้ายังใช้อยู่คือหนี้ทางเทคนิคที่ควรจ่ายก่อน iOS 27 มาถึง เพราะพฤติกรรมของ NavigationView บน iPad และการ backport ของ Liquid Glass ไม่การันตีอีกต่อไป

ความต่างที่สำคัญที่สุดไม่ใช่ชื่อ API แต่มันคือ model การนำทาง ทั้งหมด:

  • NavigationView (เดิม): View-driven โดยแต่ละ NavigationLink ผูกกับ destination view โดยตรง ทำให้ programmatic push ยาก, state restoration แทบไม่มี, และ deep linking ต้องพึ่ง hack แบบ @State boolean ต่อจอ
  • NavigationStack (ใหม่): Value-driven คุณสร้าง route ที่เป็น Hashable value ใส่ลงใน NavigationPath แล้ว navigationDestination(for:) จะ map value → view ที่ระดับ root ทำให้ push จากที่ไหนก็ได้ในแอปโดยไม่ต้องมี view ต้นทางเปิดอยู่

โครงสร้างพื้นฐานสั้นที่สุดที่คุณควรจำ:

import SwiftUI

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

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    RouteResolver.view(for: route)
                }
        }
    }
}

ทั้งแอปสามารถ push view ใหม่ได้โดยการเพิ่มค่าเข้า path ไม่ต้องมี NavigationLink เป็นตัวกลาง นี่คือจุดเปลี่ยนที่ทำให้ Router pattern, deep link และ state restoration เป็นไปได้ในไฟล์เดียว

Value-Based Routing: ออกแบบ Route enum แบบ Type-Safe

กุญแจสำคัญของ NavigationStack คือการ ไม่ push view เรา push value แทน เพราะฉะนั้นเราต้องมี type ที่นิยาม "ทุกหน้าที่ปลายทาง push ไปได้" ให้ครบก่อน วิธีที่ผมทำมาหลายโปรเจ็กต์คือใช้ enum เดียวชื่อ Route ที่ conform Hashable และ Codable:

enum Route: Hashable, Codable {
    case articleDetail(id: UUID)
    case authorProfile(handle: String)
    case settings
    case subscription(plan: Plan)

    enum Plan: String, Hashable, Codable {
        case monthly, yearly, lifetime
    }
}

สิ่งที่คุณได้จากรูปแบบนี้:

  1. Compile-time safety: เพิ่มหน้าใหม่ = เพิ่ม case ใหม่ = compiler บังคับให้จัดการทุกจุดที่ switch route อยู่
  2. Associated values ไม่ต้องพึ่ง global state: ID, handle, plan ติดไปกับ route ไม่ต้องมี selectedArticle เป็น @State ลอย ๆ อยู่ที่ root
  3. Codable ฟรีเมื่อทุก case ใช้ type ที่ Codable อยู่แล้ว เตรียมพร้อมสำหรับ state restoration ทันที (ดูหัวข้อ Codable ด้านล่าง)

ผม resolve ค่าให้เป็น view ด้วยฟังก์ชัน @ViewBuilder เดียว เพื่อให้แก้จุดเดียวเมื่อ shape ของหน้าเปลี่ยน:

enum RouteResolver {
    @ViewBuilder
    static func view(for route: Route) -> some View {
        switch route {
        case .articleDetail(let id):
            ArticleDetailView(articleID: id)
        case .authorProfile(let handle):
            AuthorProfileView(handle: handle)
        case .settings:
            SettingsView()
        case .subscription(let plan):
            SubscriptionView(plan: plan)
        }
    }
}

ในทุก view ที่อยากพาไปหน้าถัดไป คุณเพียงใช้ NavigationLink(value:):

NavigationLink(value: Route.articleDetail(id: article.id)) {
    ArticleRow(article: article)
}

SwiftUI จะจับ tap, ต่อกับ navigationDestination(for:) ที่ระดับ root และ push อัตโนมัติ คุณไม่ต้องใส่ destination view ที่ตำแหน่ง link อีกต่อไป จุดนี้ทำให้ list row และ card ที่ reuse ข้ามฟีเจอร์ไม่ต้องรู้ปลายทางของตัวเอง

NavigationPath คือ collection แบบ type-erased ที่เก็บลำดับ route ทั้งหมดของ stack ปัจจุบัน คุณ push โดยเรียก append(_:) และ pop โดย removeLast(_:) ซึ่ง SwiftUI จะ diff กับ path เดิมและ animate การเปลี่ยนแปลงให้อัตโนมัติ ตัวอย่างการนำทางแบบ programmatic:

@State private var path = NavigationPath()

// push
path.append(Route.articleDetail(id: article.id))

// pop หนึ่งหน้า
path.removeLast()

// pop ทุกหน้ากลับ root
path.removeLast(path.count)

ถ้าทุกปลายทางในแอปคุณคือ Route เดียว ผมแนะนำให้ใช้ [Route] ตรง ๆ แทน NavigationPath:

@State private var path: [Route] = []

NavigationStack(path: $path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            RouteResolver.view(for: route)
        }
}

ข้อดี: iterate/inspect ได้ตรง, ไม่ต้อง encode/decode ระหว่างการเข้าถึง เหมาะกับแอปเล็กและกลาง ส่วน NavigationPath จะได้เปรียบเมื่อคุณต้อง push หลาย route type ปนกัน เช่น มีทั้ง Route.article, Route.podcast และ Route.event ที่ไม่ควรยัดลง enum เดียวเพราะแต่ละอันมีชีวิตของตัวเอง

สร้าง Router Pattern ด้วย @Observable

การถือ @State path ที่ root แล้วส่ง binding ลงไปทุกชั้นเป็นวิธีที่พอใช้ได้ในตอนต้น แต่พอมี view 5–6 ชั้นลึกและมีปุ่มหลายที่ที่ต้อง navigate การส่ง binding เริ่มเลอะ (ผมเคยลากผ่าน 4 ชั้นแล้วเจอ compile error ที่งงมาก) ทางที่คลีนกว่าคือทำ Router เป็น class ที่ conform @Observable จาก Observation framework (iOS 17+) แล้ว inject ผ่าน @Environment:

import SwiftUI
import Observation

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

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

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

    func popToRoot() {
        path.removeAll()
    }

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

เชื่อมกับ view tree ที่ root ครั้งเดียว:

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

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        RouteResolver.view(for: route)
                    }
            }
            .environment(router)
        }
    }
}

ใน view ลึก ๆ เข้าถึง router ด้วย @Environment:

struct ArticleDetailView: View {
    @Environment(AppRouter.self) private var router
    let articleID: UUID

    var body: some View {
        Button("อ่านบทความถัดไป") {
            router.push(.articleDetail(id: nextArticleID))
        }
    }
}

สิ่งที่ pattern นี้ปลดล็อกให้คุณ: unit test router โดยไม่ต้องเปิด view เลย (มันคือ class ธรรมดา), reuse ข้าม feature module ได้, และเมื่อ deep link เข้ามา คุณเพียง router.replace(with: [...]) ครั้งเดียว ระบบจะ animate การ push ทุกหน้าที่ควรอยู่ในลำดับให้อัตโนมัติ จริง ๆ แล้วผมเคยลอง Coordinator แบบ UIKit-style ใน SwiftUI มาแล้วหลายครั้ง ไม่คุ้มค่าเลย Router class เดียวชนะเรื่อง readability ขาดลอย

Deep Linking ด้วย onOpenURL และ Universal Links

Deep linking ใน SwiftUI ยุคใหม่ทำงานผ่าน .onOpenURL { url in ... } ที่ผูกกับ view ระดับ root ครับ Modifier นี้ครอบคลุมทั้ง custom URL scheme (swiftcrafted://) และ Universal Links (https://swiftcrafted.dev/...) ที่ประกาศไว้ใน apple-app-site-association

Pattern ที่ผมใช้: แยก parser ออกจาก router เพื่อ test แบบ pure function ได้

enum DeepLinkParser {
    static func routes(from url: URL) -> [Route]? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return nil
        }

        switch components.path {
        case "/article":
            guard let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
                  let id = UUID(uuidString: idString) else { return nil }
            return [.articleDetail(id: id)]

        case "/author":
            guard let handle = components.queryItems?.first(where: { $0.name == "handle" })?.value else {
                return nil
            }
            return [.authorProfile(handle: handle)]

        case "/settings/subscription":
            let planRaw = components.queryItems?.first(where: { $0.name == "plan" })?.value ?? "monthly"
            let plan = Route.Plan(rawValue: planRaw) ?? .monthly
            return [.settings, .subscription(plan: plan)]

        default:
            return nil
        }
    }
}

สังเกตว่า /settings/subscription return route สองอัน เพราะเราอยาก push SettingsView ไว้ใต้ SubscriptionView ให้ผู้ใช้กดปุ่ม back แล้วเจอหน้า settings ตามลำดับที่คาดเดาได้ นี่คือประโยชน์ที่ value-driven navigation ให้ ในขณะที่ NavigationView ทำไม่ได้

เชื่อมกับ router:

NavigationStack(path: $router.path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            RouteResolver.view(for: route)
        }
}
.onOpenURL { url in
    if let routes = DeepLinkParser.routes(from: url) {
        router.replace(with: routes)
    }
}

สำหรับ Universal Links ให้เพิ่ม Associated Domains capability และประกาศไฟล์ apple-app-site-association ที่ root โดเมนตามที่ระบุใน เอกสาร Universal Links ของ Apple ส่วน custom scheme ต้องเพิ่ม URL Types ใน Info.plist ทั้งสองแบบใช้ onOpenURL ตัวเดียวกันฝั่ง SwiftUI

ใช้ NavigationStack ร่วมกับ TabView อย่างไรไม่ให้ path พันกัน

ข้อผิดพลาดที่เห็นบ่อยที่สุดคือการใช้ NavigationPath ก้อนเดียวข้ามหลายแท็บ ทำให้เมื่อผู้ใช้กดสลับแท็บแล้วกลับมา stack เดิมหาย, VoiceOver ประกาศ heading ผิดหน้า, และ deep link ยิงไป tab ผิด (ผมเจอบั๊กนี้ครั้งแรกใน PR รีวิวเมื่อสองปีก่อน ใช้เวลาไล่นานเกินคาด) ทางที่ถูกต้องคือให้แต่ละแท็บมี NavigationPath หรือ [Route] เป็นของตัวเอง โดย Router class เดียวถือ path หลายอันได้:

@Observable
@MainActor
final class AppRouter {
    var homePath: [Route] = []
    var searchPath: [SearchRoute] = []
    var profilePath: [ProfileRoute] = []
    var selectedTab: Tab = .home

    enum Tab: Hashable {
        case home, search, profile
    }
}

โครงสร้าง TabView:

TabView(selection: $router.selectedTab) {
    NavigationStack(path: $router.homePath) {
        HomeView()
            .navigationDestination(for: Route.self) { RouteResolver.view(for: $0) }
    }
    .tabItem { Label("หน้าหลัก", systemImage: "house") }
    .tag(AppRouter.Tab.home)

    NavigationStack(path: $router.searchPath) {
        SearchView()
            .navigationDestination(for: SearchRoute.self) { SearchResolver.view(for: $0) }
    }
    .tabItem { Label("ค้นหา", systemImage: "magnifyingglass") }
    .tag(AppRouter.Tab.search)

    NavigationStack(path: $router.profilePath) {
        ProfileView()
            .navigationDestination(for: ProfileRoute.self) { ProfileResolver.view(for: $0) }
    }
    .tabItem { Label("โปรไฟล์", systemImage: "person") }
    .tag(AppRouter.Tab.profile)
}

เมื่อ deep link มา ให้ตั้ง selectedTab ก่อน แล้วค่อยเซ็ต path ของแท็บนั้น ลำดับสำคัญมาก:

.onOpenURL { url in
    guard let intent = DeepLinkParser.intent(from: url) else { return }
    router.selectedTab = intent.tab
    switch intent.tab {
    case .home:    router.homePath = intent.routes
    case .search:  router.searchPath = intent.searchRoutes
    case .profile: router.profilePath = intent.profileRoutes
    }
}

เคล็ดลับที่หลายคนพลาด: iOS 18+ มี pattern สำหรับ TabView โมเดลใหม่แบบ Tab API ที่ประกาศได้ตรงกว่าเดิม แต่หลักการเดียวกันครับ path ต้อง scope ต่อแท็บเสมอ

Codable NavigationPath: บันทึกและกู้คืนสถานะ

เมื่อ Route ของคุณ conform Codable ทั้ง NavigationPath และ [Route] จะ encode/decode ได้ตรง ๆ ทำให้ state restoration ทำได้ในไม่กี่บรรทัด วิธีที่ Apple แนะนำคือใช้ SceneStorage ซึ่งจะเก็บ per-window และกู้กลับเองเมื่อผู้ใช้ swipe แอปทิ้งแล้วเปิดใหม่:

struct RootView: View {
    @State private var router = AppRouter()
    @SceneStorage("homePath") private var storedHome: Data?

    var body: some View {
        NavigationStack(path: $router.homePath) {
            HomeView()
                .navigationDestination(for: Route.self) { RouteResolver.view(for: $0) }
        }
        .task {
            if let data = storedHome,
               let routes = try? JSONDecoder().decode([Route].self, from: data) {
                router.homePath = routes
            }
        }
        .onChange(of: router.homePath) { _, newValue in
            storedHome = try? JSONEncoder().encode(newValue)
        }
    }
}

สำหรับ NavigationPath ใช้ CodableRepresentation:

if let representation = navigationPath.codable {
    let data = try JSONEncoder().encode(representation)
}

// decode กลับ
let representation = try JSONDecoder().decode(
    NavigationPath.CodableRepresentation.self,
    from: data
)
navigationPath = NavigationPath(representation)

ข้อควรระวังใหญ่ (จุดที่ผมเคยพลาดสมัยชิพ v1.2): ถ้าคุณเปลี่ยน shape ของ Route เช่นเปลี่ยนชนิด associated value route ที่ encode ไว้เดิมจะ decode fail ควรใส่ version ลงใน struct wrapper แล้ว migrate หรือ discard เงียบ ๆ เมื่อ version ไม่ตรง อย่าให้แอป crash เพราะ path เก่า

VoiceOver และ Accessibility ใน NavigationStack

NavigationStack ทำงานร่วมกับ VoiceOver ได้ดีมากถ้าคุณให้ข้อมูลที่มันต้องการ สิ่งที่ผมเช็คทุกครั้งก่อน merge:

  • ทุกหน้ามี .navigationTitle ที่มีความหมาย VoiceOver อ่านชื่อหน้าเป็นสิ่งแรกเมื่อ push, และ Headings rotor ใช้เป็น landmark ให้ผู้ใช้กระโดดกลับได้
  • ปุ่ม back เป็น default อย่าเปลี่ยน label เว้นแต่จำเป็น เพราะ VoiceOver คุ้นกับคำว่า "Back, Button" และการ swipe left-down เพื่อ pop
  • ประกาศการเปลี่ยนหน้าเมื่อ push แบบ programmatic ถ้า user tap ปุ่มแล้ว router push ไปโดยไม่มีปฏิสัมพันธ์ทางสายตาระหว่างทาง ให้ใช้ AccessibilityNotification.PageScrolled หรือ post UIAccessibility announcement เพื่อยืนยันสิ่งที่เกิด
  • Focus management: ใช้ @AccessibilityFocusState ชี้ element ที่ควรถูกอ่านต่อเมื่อหน้าเปิด เช่น field แรกในฟอร์ม
struct SubscriptionView: View {
    @AccessibilityFocusState private var focusedField: Field?
    enum Field { case plan, cta }

    var body: some View {
        VStack {
            Text("เลือกแผน")
                .accessibilityFocused($focusedField, equals: .plan)
            // ...
        }
        .navigationTitle("สมัครสมาชิก")
        .onAppear { focusedField = .plan }
    }
}

อีกจุดที่มักถูกลืม: NavigationLink(value:) จะประกาศเป็น "Button" กับ VoiceOver โดยดีฟอลต์ ถ้าแถวรายการมี metadata มาก (ผู้เขียน, วันที่, จำนวนคอมเมนต์) ให้ combine ทุกอย่างเป็น label เดียวด้วย .accessibilityElement(children: .combine) เพื่อให้ผู้ใช้ VoiceOver ไม่ต้อง swipe หลายครั้งกว่าจะรู้ว่าจะกดหรือไม่ ต้องลอง flow ของแอปคุณจริงด้วย VoiceOver เปิดที่ Settings → Accessibility → VoiceOver แล้วเดินหน้าถอยหลังใน stack ดู ถ้ารู้สึกอึดอัด แปลว่าผู้ใช้ที่ใช้จริงจะรู้สึกอึดอัดกว่านั้นอีกครับ

สำหรับข้อกำหนด detail ทั้งหมด อ่านต่อได้ใน คู่มือ SwiftUI Liquid Glass สำหรับ iOS 26 ที่พูดถึงการทำ contrast และ Reduce Transparency ให้ครบด้วย

ข้อผิดพลาดที่พบบ่อยและวิธีแก้

ตารางเปรียบเทียบ pattern ที่ผมเห็นซ้ำในการ review PR:

Anti-patternปัญหาที่เกิดวิธีที่ถูก
NavigationLink(destination: View) โดยตรง ไม่สามารถ push programmatic ได้, deep link ต้องเขียน hack แยก ใช้ NavigationLink(value:) คู่กับ navigationDestination(for:) ที่ root
ประกาศ navigationDestination ใน child ที่อาจถูก pop SwiftUI warn ที่ console, deep link ไป route นั้นแล้วไม่ไปไหน ประกาศทุก navigationDestination ที่ root ของ NavigationStack
ใช้ @State boolean คุมการ push หลายที่ Boolean หลายตัวพันกัน, state restoration ทำไม่ได้ Router class เดียวที่ถือ [Route]
Path เดียวข้ามทุกแท็บใน TabView สลับแท็บแล้ว stack หาย, VoiceOver ประกาศ heading ผิด Path แยกต่อแท็บ, deep link ตั้ง selectedTab ก่อน
Route enum ไม่ conform Hashable คอมไพล์ไม่ผ่านเมื่อใช้กับ navigationDestination(for:) ทุก associated value ต้อง Hashable และเพิ่ม Codable ถ้าอยากทำ state restoration

อีกเรื่องที่ทำเสียเวลา debug บ่อย: animation ของการ push จะดูกระตุกเมื่อคุณเปลี่ยน path ใน onAppear ของ root เพราะ SwiftUI ยังไม่ได้ commit initial render ให้ push หลัง view stable แล้ว วิธีที่ผมใช้คือใส่ใน .task แทน มันจะรอ transaction แรกจบก่อน

ถ้าคุณมาจากโลก NavigationView เก่า อาจอยากอ่านเสริมเรื่อง concurrency ที่กระทบ Router (โดยเฉพาะ @MainActor isolation) ได้ใน คู่มือ Swift 6.2 Approachable Concurrency เพราะ Router ที่ mutate path ต้องอยู่บน main actor เท่านั้น ไม่งั้น Swift 6 จะฟ้อง data race ที่คอมไพล์ตรง ๆ

เอกสารอ้างอิงระดับ Apple ที่ควรบุ๊คมาร์คไว้: NavigationStack reference และ Bringing robust navigation structure to your SwiftUI app ทั้งสองอันเป็น source of truth สำหรับพฤติกรรมที่ต่างระหว่าง iOS 16/17/18 และ 26

คำถามที่พบบ่อย

NavigationLink ถูก deprecate แล้วหรือยังบน iOS 26?

ยัง NavigationLink(value:) ยังเป็นวิธีที่แนะนำและ Apple ยังคงพัฒนาต่อ ตัวที่ถูก deprecated คือ NavigationLink(destination:) รูปแบบเก่าที่ผูก view โดยตรงกับ link (ยังใช้ได้แต่เตือน) และ NavigationView container เก่า บน iOS 26 คุณควรใช้ NavigationStack คู่กับ NavigationLink(value:) เท่านั้น

ต่างกันอย่างไรระหว่างใช้ NavigationPath กับใช้ [Route] เป็น path?

[Route] คือ typed array ทำงานเร็วกว่า, inspect ตรง ๆ ได้, และเหมาะเมื่อทุก destination เป็น type เดียว ส่วน NavigationPath เป็น type-erased ใช้เก็บ route หลายชนิดปนกันได้ แต่ต้อง encode/decode เมื่อจะ inspect content ทั้ง 2 แบบ conform state restoration ได้ถ้า element เป็น Codable

Deep link ยิงเข้ามาแต่ push ไม่ทำงาน ต้อง debug อย่างไร?

เช็ค 3 จุด: (1) onOpenURL ถูกเรียกจริงไหม โดยวางไว้ที่ root view ไม่ใช่ child ที่ยังไม่ mount, (2) navigationDestination(for:) ประกาศ type ตรงกับ route ที่ append ไปหรือไม่ เพราะ mismatch จะเงียบไม่มี warn, และ (3) ถ้าใช้ TabView ตรวจว่า selectedTab ถูกเปลี่ยนก่อนที่จะเซ็ต path ของแท็บนั้น มิฉะนั้น push จะไปตกที่ path ของแท็บที่ยังไม่ active

Router แบบ @Observable ต้องอยู่บน main actor เสมอหรือไม่?

ใช่ครับ เพราะทุกครั้งที่ mutate path จะ trigger view update ซึ่ง SwiftUI คาดว่าเกิดบน main actor ถ้าคุณเรียก router.push(...) จาก background task, Swift 6 concurrency checker จะฟ้อง data race แนะนำใส่ @MainActor ที่ class เลย แล้ว call site ที่ไม่ใช่ main จะถูกบังคับให้ await โดย compiler เอง

ต้องประกาศ navigationDestination(for:) หลายอันสำหรับหลาย type ได้ไหม?

ได้ครับ chain modifier ได้เต็มที่ เช่น .navigationDestination(for: Route.self) {...}.navigationDestination(for: DetailRoute.self) {...} SwiftUI จะเลือก closure ตาม type ของ value ที่ push อัตโนมัติ ประโยชน์คือแยก concern ของแต่ละ feature module ได้ แต่ระวังอย่าประกาศ type เดียวกันซ้ำ 2 ครั้ง เพราะ closure หลังจะ shadow closure แรกและทำให้พฤติกรรมไม่แน่นอน

Ava Thompson
เกี่ยวกับผู้เขียน Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.