NavigationStack e Sheet sono due tra i principali componenti di SwiftUI che ci permetteranno di poter navigare tra le View che compongono la nostra app. Infatti, se fino a questo momento abbiamo creato all in cui la logica era concentrata su singola view, oggi vedremo come dividere funzionalità in View diverse che possono essere attraversate utilizzando uno Stack o uno Sheet.

La differenza tra i due è data dalla tipologia d'animazione e relazione che queste view hanno tra loro:

  1. NavigationStack: Navigazione di tipo orizzontale dove nuove View vengono sovrapposte l'una sull'altra con un'animazione slide da destra verso sinistra. La nuova View eredita una barra di navigazione e l'utente potrà tornare indietro con uno swipe o cliccando sul bottone "back".

  2. Sheet: Una view viene presentata sopra l'origine con un'animazione slide che va da sotto verso sopra. In questa presentazione la nuova view occupa una porzione di schermo e la nuova view potrà essere chiusa con uno slide verso il basso.

Quando utilizzare l'una o l'altra?

Questo dipende esclusivamente dalla tipologia d'informazione e dalla relazioni che la nuova view ha con la view di partenza. Per esempio, Instagram utilizza una navigazione Stack quando selezioni un profilo, mentre i commenti vengono presentati in sheet:

Se volessimo definire una regola, potremmo dire che una NavigationStack è utile per presentare dati principali e che a loro volta potrebbero presentare dati accessori, mentre uno Sheet viene utilizzato per mostrare dati meno importanti o che servono ad arricchire una view principale.

Ma basta con la teoria, vediamo in pratica come utilizzarli.

NavigationStack in SwiftUI

Nella sua forma base, una NavigationStack si comporta da contenitore che aggiunge una barra di navigazione non appena un componente interno utilizza il modifier navigationTitle:

NavigationStack {
    VStack {
        
    }
    .navigationTitle("Home")
}

Il metodo più semplice per navigare all'interno della NavigationStack è quella di utilizzare il componente NavigationLink. Questo componente si comporta come un Button e può essere costruito in due modi:

  1. Specificando una destination view che verrà mostrata al tap.

  2. Specificando un value di type Hashable che potrà essere intercettato dalla stack, tramite il modifier navigationDestination, il quale mostrerà la view.

NavigationLink

Il componente NavigationLink può essere inizializzato passando un title ed una destination view:

NavigationLink("Nome Link") {
  // View da presentare e.g:
  Text("Hello")
}

Di base si comporta come un Button ma cambia stile in base al contesto in cui si trova. Per esempio, in una List la riga mostra un'icona sulla sinistra ed il testo sulla destra:

NavigationStack {
    List {
        NavigationLink("Menu 1") {
            Text("Hello 1")
        }
        
        NavigationLink("Menu 2") {
            Text("Hello 2")
        }
    }
    .navigationTitle("Home")
}

Ovviamente è impensabile di voler creare view complesse all'interno della closure, quindi ti consiglio di creare nuove view in un file diverso o, se sono abbastanza semplici di definire il loro contenuto in un una funzione/variabile.

NavigationDestination

Spesso capiterà di voler navigare in view che dipendono da dati. Per esempio, immaginiamo d'avere un'app di booking per una pizzeria ed immaginiamo d'avere una List che mostra le pizze. Al tap di ogni riga vogliamo che venga presentata una view di dettagli della pizza selezionata.

struct Pizza: Hashable, Identifiable {
    let id = UUID()
    let name: String
    let price: Decimal
}

In questi casi puoi passare un oggetto Hashable ad un NavigationLink:

let pizzas = [
    Pizza(name: "Margherita", price: 6.0),
    Pizza(name: "Porcini", price: 12.0),
    Pizza(name: "Marinara", price: 4)
]

// body
ForEach(pizzas) { pizza in
    NavigationLink(pizza.name, value: pizza)
}

Al tap il NavigationLink passerà il valore alla NavigationStack il quale grazie al modifier navigationDestination ti permetterà di catturarlo e definire la view da mostrare:

.navigationDestination(for: Pizza.self) { pizza in
    // View
}

Il parametro for: vuole come parametro il type da intercettare. Ti ricordo che un type si dichiara utilizzando la sintassi NomeType.self, nel nostro caso Pizza.self.

Il modifier navigationDestination va definito all'interno dello scope del NavigationStack, di solito lo si inserisce nel contenitore principale (e.g. List):

struct ContentView: View {
    
    private let pizzas = [
        Pizza(name: "Margherita", price: 6.0),
        Pizza(name: "Porcini", price: 12.0),
        Pizza(name: "Marinara", price: 4)
    ]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(pizzas) { pizza in
                    NavigationLink(pizza.name, value: pizza)
                }
            }
            .navigationTitle("Home")
            .navigationDestination(for: Pizza.self) { pizza in
                List {
                    HStack {
                        Text("Price")
                        Spacer()
                        Text(pizza.price, format: .currency(code: "EUR"))
                    }
                }
                .navigationTitle(pizza.name)
            }
        }
    }
}

Nota come la List all'interno del navigationDestination utilizza il modifier navigationTitle per modificare il titolo della bar.

Regole del NavigationStack

Il modifier navigationDestination deve sempre trovarsi all'interno di un NavigationStack . Questo è necessario perché il modifier deve sapere quale NavigationStack utilizzare per presentare la nuova view.

Le view presentate, a loro volta, non dovranno nuovamente specificare un NavigationStack come contenitore principale dato che questo verrà automaticamente dedotto dal contesto in cui si trovano.

Di conseguenza, se spostiamo la view definita all'interno del navigationDestination(for: Pizza.self) all'interno di un nuovo componente ti basterà definire il suo aspetto senza lo Stack, e nel caso in cui volessi testarla in combinazione con uno stack, dovrai aggiungerlo all'interno della Preview:

struct PizzaDetailsView: View {
    
    let pizza: Pizza
    
    var body: some View {
        List {
            HStack {
                Text("Price")
                Spacer()
                Text(pizza.price, format: .currency(code: "EUR"))
            }
        }
        .navigationTitle(pizza.name)
    }
}

#Preview {
    NavigationStack {
        PizzaDetailsView(pizza: Pizza(name: "Margherita", price: 4.99))
    }
} 

Quindi, ricordati che se una view viene presentata all'interno di un NavigationStack il suo body non dovrà definire nuovamente il componente stack.

NavigationStack: presentare una view programmaticamente

Spesso capiterà di voler presentare una view programmaticamente come risultato di un'operazione. In questi casi potrai creare uno state NavigationPath, ovvero una sorta di lista di oggetti presentati, e passare quest'ultimo come binding del NavigationStack.

@State private var path = NavigationPath()

NavigationStack(path: $path) {

}

Appendere un nuovo elemento al NavigationPath invocherà uno dei vari navigationDestination associati al type dell'oggetto aggiunto al path.

Per esempio, ipotizziamo di voler aggiungere una riga alla lista che al tap presenta una random pizza:

Button("Random", action: showRandomPizza)

private func showRandomPizza() {
  guard let randomPizza = pizzas.randomElement() else { return }
  path.append(randomPizza)
}

Toolbar

Il modifier toolbar permette di aggiungere elementi interattivi all'interno della barra di navigazione del NavigationStack. Per esempio, possiamo spostare il Button all'interno del modifier toolbar e questo verrà automaticamente posizione nell'angolo superiore destro (su iPhone):

toolbar {
  Button("Random", action: showRandomPizza)
}

Nel caso in cui volessi modificare la disposizioni di questi elementi potrai wrapparli all'interno di un ToolbarItem il quale permette di specificare il placement o posizioni all'interno della barra:

toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button("Leading button") {
            
        }
    }
    ToolbarItem(placement: .primaryAction) {
        Button("Random", action: showRandomPizza)
    }
}

Sheet in SwiftUI

Uno sheet può essere presentato utilizzando un interruttore booleano o un optional item. In baso al valore SwiftUI presenterà o nasconderà lo sheet:

@State private var isSheetOpen = false

.sheet(isPresented: $isSheetOpen, content: {
  Text("Hello, world!")
  Button("Close Sheet") {
    isSheetOpen.toggle()
  }
})

Anche se sheet(isPresented risolve la maggior parte degli use case di uno sheet, in alcuni contesti potrebbe esserti utile utilizzare sheet(item.
Per esempio, se hai uno sheet che gestisce l'aggiunta di un nuovo elemento T, potremmo utilizzare un @State var newT: T? per gestire la presentazione dello sheet.

Ipotizziamo di voler aggiungere una nuova Pizza, trasformiamo il let pizzas in @State e creiamo uno State var newPizza: Pizza? da associare allo sheet(item: $newPizza) {}. Infine, usiamo il topBarLeading button per presentare lo sheet.
Questo verrà attivato non appena assegneremo un nuovo oggetto a newPizza:

// state
@State private var newPizza: Pizza?

// sheet
sheet(item: $newPizza, content: { _ in
    Form {
        TextField("Name", text: Binding(
            get: { newPizza?.name ?? "" },
            set: { newPizza?.name = $0 })
        )
        
        TextField(
            value: Binding(
                get: { newPizza?.price ?? 0 },
                set: { newPizza?.price = $0 }
            ),
            format: .number,
            label: {
                Text("Price")
            }
        )
        
        Button {
            guard let newPizza else { return }
            pizzas.append(newPizza)
            
            self.newPizza = nil
        } label: {
            Text("Add Pizza")
        }
        .disabled(
            newPizza?.name.isEmpty == true
            || newPizza?.price ?? 0 <= 0
        )
        
    }
})

// toolbar
ToolbarItem(placement: .topBarLeading) {
    Button("Add") {
        newPizza = Pizza(name: "", price: 0)
    }
}

Environment dismiss

Quando hai a che fare con sheet di view complesse ti ritroverai a creare un nuovo type che rappresenta quella view.
In questi casi, per chiudere lo sheet puoi passare il binding al bool o oggetto che lo controlla o sfruttare una proprietà Environment che ti permette di invocare la funzionalità di chiusura con facilità.

Un Environment è una collezione di valori o funzionalità condivise all'interno dell'app. Gli EnvironmentValue si accedono utilizzando la sintassi KeyPath. Per esempio, l'environment @Environment(\.dismiss) var dismiss ci permette di far riferimento alla funzione dismiss che, per l'appunto, ci permette di chiudere la view programmaticamente.

struct EditPizzaView: View {
    
    @Environment(\.dismiss) private var dismiss
    
    let onSave: (Pizza) -> ()
    
    @State private var pizza = Pizza(name: "", price: 0)
    
    var body: some View {
        NavigationStack {
            Form {
                TextField("Name", text: $pizza.name)
                
                TextField(
                    value: $pizza.price,
                    format: .number,
                    label: {
                        Text("Price")
                    }
                )
            }
            .navigationTitle("New Pizza")
            .toolbar {
                Button {
                    onSave(pizza)
                    dismiss()
                } label: {
                    Text("Add Pizza")
                }
                .disabled(
                    pizza.name.isEmpty == true
                    || pizza.price <= 0
                )
            }
        }
    }
}

#Preview {
    EditPizzaView { pizza in
        
    }
}

Nota come ho invocato la funzione dismiss subito dopo l'invocazione della closure onSave. Ho anche modificato la view, aggiungendo un NavigationStack e reso questa indipendente dal Optional<Pizza>.

Adesso, possiamo cambiare lo sheet da sheet(item: in sheet(isPresented ed utilizzare la closure onSave per salvare la nuova pizza all'interno dell'array:

struct ContentView: View {
    
    @State private var pizzas = [
        Pizza(name: "Margherita", price: 6.0),
        Pizza(name: "Porcini", price: 12.0),
        Pizza(name: "Marinara", price: 4)
    ]
    
    @State private var path = NavigationPath()
    @State private var addPizza: Bool = false
    
    var body: some View {
        NavigationStack(path: $path) {
            List {
                ForEach(pizzas) { pizza in
                    NavigationLink(pizza.name, value: pizza)
                }
            }
            .sheet(isPresented: $addPizza, content: {
                EditPizzaView { pizza in
                    pizzas.append(pizza)
                }
            })
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("Add") {
                        addPizza.toggle()
                    }
                }
                ToolbarItem(placement: .primaryAction) {
                    Button("Random", action: showRandomPizza)
                }
            }
            .navigationTitle("Home")
            .navigationDestination(for: Pizza.self) { pizza in
                PizzaDetailsView(pizza: pizza)
            }
        }
    }
    
    private func showRandomPizza() {
        guard let randomPizza = pizzas.randomElement() else { return }
        path.append(randomPizza)
    }
}

#Preview {
    ContentView()
} 

PresentationDetents

Nel caso in cui la view presentata dallo sheet non richiede di essere visualizzata a tutto schermo, puoi utilizzare il modifier presentationDetents per modificare la sua altezza. Di default il valore è large ma puoi definirne di diversi come medium o anche una frazione ben specifica:

sheet(isPresented: $addPizza, content: {
    EditPizzaView { pizza in
        pizzas.append(pizza)
    }
    .presentationDetents([.medium, .fraction(0.3)])
})

In questo caso la view utilizza il valore franction(0.3) dato che l'altezza dei componenti interni è minore del 30% della view totale. Altrimenti, avrebbe utilizzato medium o eventualmente ripiegato sul large ovvero il valore di default.

Conclusione

In questa lezione abbiamo visto come utilizzare i componenti NavigationStack e sheet per creare navigazione all'interno delle nostre applicazioni. Ricordati che non esiste una regola specifica su quando utilizzare l'uno o l'altro perché questi dipendono dall'organizzazione logica dei tuoi dati e view.

➡️ Link progetto completo qui.

Ad ogni modo, nel caso in cui volessi approfondire concetti di UX puoi far sempre riferimento alle Human Interface Design guidelines Apple.

Buona programmazione!