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 อย่างไร
Value-Based Routing: ออกแบบ Route enum แบบ Type-Safe
NavigationPath สำหรับ Programmatic Navigation
สร้าง Router Pattern ด้วย @Observable
Deep Linking ด้วย onOpenURL และ Universal Links
ใช้ NavigationStack ร่วมกับ TabView อย่างไรไม่ให้ path พันกัน
Codable NavigationPath: บันทึกและกู้คืนสถานะ
VoiceOver และ Accessibility ใน NavigationStack
ข้อผิดพลาดที่พบบ่อยและวิธีแก้
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 เป็นไปได้ในไฟล์เดียว
หมายเหตุ: NavigationSplitView คือ container คนละตัว ใช้สำหรับ layout แบบ sidebar/detail บน iPad และ Mac ไม่ใช่ตัวแทนของ NavigationStack ใน app ส่วนใหญ่คุณจะเจอ NavigationStack ซ้อนอยู่ในคอลัมน์ของ NavigationSplitView อีกที
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
}
}
สิ่งที่คุณได้จากรูปแบบนี้:
Compile-time safety: เพิ่มหน้าใหม่ = เพิ่ม case ใหม่ = compiler บังคับให้จัดการทุกจุดที่ switch route อยู่
Associated values ไม่ต้องพึ่ง global state: ID, handle, plan ติดไปกับ route ไม่ต้องมี selectedArticle เป็น @State ลอย ๆ อยู่ที่ root
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 สำหรับ Programmatic Navigation
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 เดียวเพราะแต่ละอันมีชีวิตของตัวเอง
เทคนิค: ถ้า path.count เท่ากับ 0 แปลว่าคุณอยู่ที่ root หน้าเดียว ใช้ property นี้เขียน derived state เช่น disable ปุ่ม "กลับหน้าหลัก" หรือซ่อน toolbar item ที่มีความหมายเฉพาะเมื่อลึกเข้าไปแล้ว
สร้าง 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 ขาดลอย
ระวัง: อย่าใส่ business logic ลงใน Router เพราะมันควรเก็บแค่ navigation state ถ้าคุณเริ่มมี method ชื่อ loginAndGoToDashboard ใน router แปลว่าคุณกำลังผสม concerns เตรียมแยก use case ออกไปเป็น service คนละไฟล์
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 แรกและทำให้พฤติกรรมไม่แน่นอน