NavigationStack e Sheet in SwiftUI
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:
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".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:
Specificando una
destination
view che verrà mostrata al tap.Specificando un
value
di typeHashable
che potrà essere intercettato dalla stack, tramite il modifiernavigationDestination
, 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 sintassiNomeType.self
, nel nostro casoPizza.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 delnavigationDestination
utilizza il modifiernavigationTitle
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!