NavigationStack στο SwiftUI: Type-Safe Navigation με NavigationPath (2026)

Πρακτικός οδηγός για type-safe navigation στο SwiftUI με NavigationStack και NavigationPath: deep linking, pop-to-root, state restoration και accessibility patterns για iOS 26 με έτοιμα παραδείγματα κώδικα.

NavigationStack SwiftUI: Type-Safe Οδηγός (2026)

Ενημερώθηκε: 17 Ιουνίου 2026

Το NavigationStack είναι το σύγχρονο, type-safe API πλοήγησης του SwiftUI που αντικατέστησε το deprecated NavigationView από το iOS 16 και αποτελεί το προτεινόμενο εργαλείο στο iOS 18 και iOS 26. Δηλώνετε τους προορισμούς με τον modifier navigationDestination(for:) και διαχειρίζεστε το stack είτε δηλωτικά μέσω ενός NavigationPath binding, είτε επιτρέποντας στις τιμές που τοποθετούνται σε NavigationLink να οδηγούν τη μετάβαση. Σε αυτόν τον οδηγό θα δούμε type-safe routes, deep linking, programmatic pop-to-root, διατήρηση κατάστασης και τη συμπεριφορά VoiceOver που πρέπει να ελέγχετε από την πρώτη μέρα.

  • Το NavigationStack αντικαθιστά πλήρως το NavigationView· χρησιμοποιείτε το σε όλα τα έργα iOS 16+.
  • Type-safe routing γίνεται με navigationDestination(for: Route.self) και ενούμενα enum για όλες τις διαδρομές της εφαρμογής.
  • Το NavigationPath δέχεται ετερογενείς Hashable τιμές και υποστηρίζει Codable για state restoration.
  • Pop-to-root γίνεται με path.removeLast(path.count) ή path = NavigationPath().
  • Το VoiceOver μετακινεί αυτόματα την εστίαση στο πρώτο accessible στοιχείο του νέου view· ελέγξτε το με @AccessibilityFocusState.
  • Deep linking γίνεται φορτώνοντας τα segments του URL σε NavigationPath πριν αποδοθεί η ιεραρχία.

Τι είναι το NavigationStack και γιατί αντικατέστησε το NavigationView

Το NavigationView ήταν το αρχικό API πλοήγησης του SwiftUI από το 2019 και είχε δύο διαρκή προβλήματα: η συμπεριφορά του ήταν διφορούμενη (single-column vs multi-column ανάλογα με τη συσκευή) και δεν υπήρχε καθαρός τρόπος να ελέγξετε programmatically τη στοίβα. Το NavigationStack, που παρουσιάστηκε στο WWDC 2022 και σταθεροποιήθηκε στο iOS 16, λύνει και τα δύο. Είναι ένα καθαρό LIFO stack: push με NavigationLink(value:), pop με back button ή με μετατροπή του path. Δηλώνετε τους τύπους των τιμών που μπορούν να ωθηθούν και ο compiler ελέγχει τη συμβατότητα.

Στο iOS 26 το NavigationView εξακολουθεί να μεταγλωττίζεται αλλά εμφανίζει deprecation warning και χάνει νέες δυνατότητες όπως οι Liquid Glass transitions. Στην πράξη: αν ξεκινάτε νέο project, μη χρησιμοποιείτε καθόλου NavigationView. Αν συντηρείτε παλιό κώδικα, η μετάβαση είναι μηχανική για single-column ροές και προαπαιτεί απόφαση για multi-column (όπου χρησιμοποιείτε πλέον NavigationSplitView).

Έχω δει αρκετές βάσεις κώδικα όπου ομάδες ανακάτευαν τα δύο API «για ευελιξία». Δεν είναι ευελιξία· είναι τεχνικό χρέος που σπάει συμπεριφορά back button σε iPad. Διαλέξτε ένα και προχωρήστε.

Type-safe routes με enum και navigationDestination

Η πιο καθαρή αρχιτεκτονική στο NavigationStack είναι ένα enum Route που περιγράφει κάθε δυνατή «οθόνη προορισμού» της εφαρμογής. Το enum πρέπει να συμμορφώνεται με τα Hashable και (ιδανικά) Codable, ώστε να μπορεί να μπει στο NavigationPath και να αποθηκευτεί για state restoration.

import SwiftUI

enum AppRoute: Hashable, Codable {
    case productDetail(id: UUID)
    case category(slug: String)
    case checkout
    case orderConfirmation(orderId: String)
}

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

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .productDetail(let id):
                        ProductDetailView(id: id)
                    case .category(let slug):
                        CategoryView(slug: slug)
                    case .checkout:
                        CheckoutView()
                    case .orderConfirmation(let orderId):
                        OrderConfirmationView(orderId: orderId)
                    }
                }
        }
    }
}

Το push γίνεται με δύο τρόπους που είναι πλέον τα standards του 2026:

// Δηλωτικό push μέσω NavigationLink
NavigationLink("Δες το προϊόν", value: AppRoute.productDetail(id: product.id))

// Programmatic push μέσω του path
Button("Συνέχεια στο checkout") {
    path.append(AppRoute.checkout)
}

Αν προτιμάτε διαφορετικό enum ανά feature module, το navigationDestination(for:) δέχεται πολλαπλούς modifiers, ένα ανά τύπο. Ο compiler θα σας υποχρεώσει να καλύψετε όλες τις περιπτώσεις, που είναι ακριβώς αυτό που θέλετε από type-safe routing.

Έχετε δύο επιλογές για το state binding του stack: ένα [Route] (typed array) ή ένα NavigationPath (type-erased). Η σύγκριση δεν είναι θεωρητική. Επηρεάζει την αρχιτεκτονική σας από την πρώτη στιγμή.

ΧαρακτηριστικόNavigationPath[Route] (typed array)
Τύποι τιμώνΕτερογενείς (κάθε Hashable)Ομοιογενείς (ένας τύπος)
Codable serializationΝαι, μέσω CodableRepresentationΝαι, αν Route είναι Codable
Compile-time έλεγχοςΠιο χαλαρός (Any Hashable)Αυστηρός
Pop-to-rootpath = NavigationPath()path = []
Inspect συγκεκριμένου τύπουΔύσκολοΕύκολο (filter/map)
Ιδανικό γιαApps με πολλαπλά route enums ή plugin featuresApps με ένα κεντρικό Route enum

Η οδηγία μου: αν έχετε ένα κυρίαρχο AppRoute enum, χρησιμοποιήστε @State var path: [AppRoute] = []. Είναι πιο γρήγορο, πιο type-safe και αφήνει το Swift compiler να σας πει αν ξεχάσατε μια περίπτωση. Κρατήστε το NavigationPath για περιπτώσεις όπου διαφορετικά modules συνεισφέρουν τύπους (π.χ. ένα SDK που εκθέτει τα δικά του route values).

Programmatic navigation και pop-to-root

Η πιο συχνή ερώτηση που μου κάνουν είναι «πώς κάνω pop-to-root από την οθόνη επιβεβαίωσης παραγγελίας;». Με NavigationStack η απάντηση είναι μία γραμμή, αρκεί να έχετε πρόσβαση στο path:

struct OrderConfirmationView: View {
    @Binding var path: NavigationPath
    let orderId: String

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: "checkmark.seal.fill")
                .font(.system(size: 64))
                .foregroundStyle(.green)
            Text("Η παραγγελία #\(orderId) καταχωρήθηκε")
            Button("Πίσω στην αρχική") {
                path.removeLast(path.count)
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .navigationTitle("Επιβεβαίωση")
    }
}

Για να αποφύγετε το «threading» του binding σε κάθε child view, βάλτε το path σε ένα @Observable router object και ενέσετέ το με .environment(). Είναι το pattern που χρησιμοποιώ σε production:

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

    func push(_ route: AppRoute) { path.append(route) }
    func pop() { _ = path.popLast() }
    func popToRoot() { path.removeAll() }
    func replace(with route: AppRoute) {
        path = [route]
    }
}

struct RootView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    destination(for: route)
                }
        }
        .environment(router)
    }
}

Διαβάστε επίσης τον πλήρη οδηγό για το @Observable macro ώστε να καταλάβετε γιατί το Router εδώ ενεργοποιεί observation σε επίπεδο property και όχι ολόκληρου του object.

Πώς γίνεται deep linking με NavigationStack

Deep linking σημαίνει: κάποιος ανοίγει ένα URL όπως swiftcrafted://product/42 και η εφαρμογή πρέπει να φτάσει απευθείας στη σχετική οθόνη με σωστή στοίβα πίσω. Με NavigationStack ο μηχανισμός είναι απλός. Φορτώνετε τα segments στο path πριν εμφανιστεί η ιεραρχία.

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

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(router)
                .onOpenURL { url in
                    handle(url: url)
                }
        }
    }

    private func handle(url: URL) {
        guard url.scheme == "swiftcrafted" else { return }
        switch url.host {
        case "product":
            if let idString = url.pathComponents.dropFirst().first,
               let id = UUID(uuidString: idString) {
                router.path = [.productDetail(id: id)]
            }
        case "checkout":
            router.path = [.checkout]
        default:
            break
        }
    }
}

Παρατηρήστε ότι αντικαθιστούμε ολόκληρο το path αντί να κάνουμε append. Αυτό εξασφαλίζει ότι η οθόνη ξεκινά «καθαρή» όταν φτάνει από URL, αντί να συσσωρεύεται πίσω στην προηγούμενη πλοήγηση του χρήστη.

State restoration με Codable NavigationPath

Όταν ο χρήστης κλείνει την εφαρμογή και επιστρέφει αργότερα, το iOS μπορεί να επαναφέρει την ακριβή θέση πλοήγησης, αν το γράψετε εσείς. Το NavigationPath εκθέτει το property codable που επιστρέφει ένα CodableRepresentation εφόσον όλοι οι τύποι μέσα του είναι Codable.

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

    func save() {
        guard let representation = path.codable else { return }
        let data = try? JSONEncoder().encode(representation)
        UserDefaults.standard.set(data, forKey: "nav.path")
    }

    func restore() {
        guard
            let data = UserDefaults.standard.data(forKey: "nav.path"),
            let representation = try? JSONDecoder().decode(
                NavigationPath.CodableRepresentation.self, from: data)
        else { return }
        path = NavigationPath(representation)
    }
}

Καλέστε το save() στο scenePhase == .background και το restore() στο onAppear του root view. Σύμφωνα με το Apple Developer Documentation, η σειριοποίηση αποτυγχάνει αθόρυβα αν κάποιος τύπος δεν είναι Codable· βεβαιωθείτε ότι το enum σας συμμορφώνεται.

TabView + NavigationStack: το σωστό nesting

Σε εφαρμογές με tabs το σωστό pattern είναι ένα NavigationStack ανά tab, όχι ένα γύρω από το TabView. Έτσι κάθε tab διατηρεί ανεξάρτητη στοίβα και ο χρήστης μπορεί να εναλλάσσεται χωρίς να χάνει το βάθος.

struct MainTabView: View {
    @State private var homePath: [AppRoute] = []
    @State private var searchPath: [AppRoute] = []
    @State private var cartPath: [AppRoute] = []

    var body: some View {
        TabView {
            NavigationStack(path: $homePath) {
                HomeView()
                    .navigationDestination(for: AppRoute.self) { route in
                        destination(for: route)
                    }
            }
            .tabItem { Label("Αρχική", systemImage: "house") }

            NavigationStack(path: $searchPath) {
                SearchView()
                    .navigationDestination(for: AppRoute.self) { route in
                        destination(for: route)
                    }
            }
            .tabItem { Label("Αναζήτηση", systemImage: "magnifyingglass") }

            NavigationStack(path: $cartPath) {
                CartView()
                    .navigationDestination(for: AppRoute.self) { route in
                        destination(for: route)
                    }
            }
            .tabItem { Label("Καλάθι", systemImage: "cart") }
        }
    }
}

Για iPad πολλαπλών στηλών χρησιμοποιήστε NavigationSplitView αντί για NavigationStack στο root. Μπορείτε να συνδυάσετε τα δύο, ενώνοντας NavigationSplitView για master/detail και NavigationStack μέσα στη detail στήλη. Αν χτίζετε για iOS 26 και θέλετε ομαλές μεταβάσεις, διαβάστε τον οδηγό για το Liquid Glass στο SwiftUI, καθώς οι transitions του NavigationStack υιοθετούν αυτόματα το νέο υλικό όταν τρέχουν σε iOS 26.

Προσβασιμότητα και VoiceOver focus

Όταν το NavigationStack κάνει push, το VoiceOver μετακινεί την εστίαση στο πρώτο accessible στοιχείο του νέου view (συνήθως στο back button). Αυτό είναι λάθος για περιεχομενοκεντρικές οθόνες. Ένας χρήστης που μόλις πάτησε σε «Δες προϊόν» θέλει να ακούσει πρώτα τον τίτλο του προϊόντος, όχι «Πίσω, κουμπί».

Χρησιμοποιήστε @AccessibilityFocusState για να μετακινήσετε το focus εσείς:

struct ProductDetailView: View {
    let id: UUID
    @AccessibilityFocusState private var titleFocused: Bool

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 12) {
                Text(product.name)
                    .font(.largeTitle.bold())
                    .accessibilityAddTraits(.isHeader)
                    .accessibilityFocused($titleFocused)
                Text(product.description)
                Text("€\(product.price, format: .number.precision(.fractionLength(2)))")
                    .font(.title2)
            }
            .padding()
        }
        .onAppear {
            // Δίνουμε στο view ένα run loop tick να εγκατασταθεί
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                titleFocused = true
            }
        }
    }
}

Επίσης, τα NavigationLink(value:) κληρονομούν τα accessibility traits του label τους. Αν το label είναι κάρτα προϊόντος, ομαδοποιήστε τα στοιχεία με .accessibilityElement(children: .combine) και προσθέστε .accessibilityHint("Άνοιγμα λεπτομερειών προϊόντος") ώστε ο χρήστης VoiceOver να ξέρει τι θα συμβεί πριν διπλοπατήσει.

Στο iOS 26 το σύστημα προσθέτει νέο modifier .navigationAccessibilityFocus(.contentFirst) που μετακινεί αυτόματα την εστίαση στο πρώτο header του view αντί στο back button. Είναι ασφαλές να το ενεργοποιείτε σε όλες τις οθόνες περιεχομένου.

Συνηθισμένα προβλήματα και πώς να τα διορθώσετε

Από εμπειρία σε review code, τέσσερα bugs επανέρχονται:

  1. «Το navigationDestination δεν τριγγάρει.» Συμβαίνει όταν τοποθετείτε τον modifier μέσα σε child view αντί στο root του stack. Ο modifier πρέπει να είναι σε ancestor του NavigationLink, αλλά εντός του NavigationStack.
  2. «Διπλό push σε γρήγορο tap.» Αν χρησιμοποιείτε programmatic push σε button action, ελέγξτε αν το route υπάρχει ήδη: guard !router.path.contains(where: { $0 == route }) else { return }.
  3. «Το πίσω από swipe σβήνει state.» Σιγουρευτείτε ότι τα view-specific @State δηλώνονται στο view και όχι σε parent· διαφορετικά το SwiftUI τα recreate όταν το stack αλλάξει.
  4. «Crash σε pop-to-root με animation.» Σε iOS 17 υπήρχε bug όταν removeLast γινόταν μέσα σε withAnimation· διορθώθηκε στο iOS 18.0.1. Σε παλιότερες εκδόσεις τυλίξτε σε Task { @MainActor in ... }.

Για βαθύτερη κατανόηση του πώς το @State και η ταυτότητα view αλληλεπιδρούν με το stack, ο οδηγός για Swift Concurrency και actors καλύπτει το γιατί όλες οι state mutations σε router πρέπει να γίνονται στο MainActor. Επίσης, το επίσημο reference του NavigationStack στο developer.apple.com ενημερώνεται με κάθε beta, οπότε αξίζει να το έχετε bookmarked.

Συχνές ερωτήσεις

Ποια είναι η διαφορά μεταξύ NavigationStack και NavigationView;

Το NavigationView είναι deprecated από το iOS 16. Το NavigationStack προσφέρει type-safe push μέσω navigationDestination(for:), programmatic έλεγχο της στοίβας μέσω path binding και σαφή single-column συμπεριφορά. Για multi-column layouts χρησιμοποιείτε πλέον NavigationSplitView.

Πώς κάνω pop-to-root στο NavigationStack;

Καθαρίστε το path binding. Αν είναι NavigationPath γράψτε path = NavigationPath() ή path.removeLast(path.count). Αν είναι typed array γράψτε path.removeAll(). Και τα δύο εκτελούν animated pop πίσω στο root.

Γιατί δεν δουλεύει το navigationDestination μου;

Στις 9 από τις 10 περιπτώσεις ο modifier έχει τοποθετηθεί σε child view εκτός του NavigationStack ή ο τύπος που περνάτε στο NavigationLink(value:) δεν ταιριάζει ακριβώς με τον τύπο στο navigationDestination(for:). Βεβαιωθείτε ότι ο modifier είναι μέσα στο stack και ότι ο τύπος είναι ίδιος (όχι π.χ. Int έναντι Int?).

Μπορώ να αποθηκεύσω την τρέχουσα στοίβα πλοήγησης;

Ναι. Το NavigationPath εκθέτει το codable property που επιστρέφει CodableRepresentation σειριοποιήσιμη με JSONEncoder. Όλα τα route values μέσα στο path πρέπει να συμμορφώνονται με Codable. Αποθηκεύστε σε UserDefaults ή FileManager στο background phase.

Πώς κάνω deep linking με NavigationStack;

Χρησιμοποιήστε .onOpenURL στο root Scene για να λάβετε το URL. Αναλύστε τα segments και αντικαταστήστε το path με τις αντίστοιχες route τιμές (π.χ. router.path = [.productDetail(id: parsedId)]). Αυτό φτάνει στην οθόνη με σωστό back stack αυτόματα.

Ava Thompson
Σχετικά με τον Συγγραφέα Ava Thompson

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