La keyword struct, per il linguaggio Swift, permette di definire nuovi Type ovvero nuove fabbriche di oggetti. Puoi paragonare una struct ad un contenitore che consente di raggruppare informazioni eterogenee come String, Array etc e, perché no, anche altre struct.

Creare nuovi tipi di dato utilizzando la keyword struct è alla base di un concetto informatico che prende il nome di encapsulation ed information hiding. Infatti, nascondere l'implementazione di logiche o oggetti complessi dietro una struct permette a noi sviluppatori di poter creare applicazioni concentrandoci solamente sull'interazione di quest'ultimi.

Durante il corso SwiftUI Tour abbiamo incontrato diverse struct. Quasi tutti gli oggetti di SwiftUI o del framework Foundation nascono da struct: Text, List, String, Date etc. Ed in effetti, grazie al fatto che l'implementazione di questi oggetti è racchiusa dietro una struct, ci ha permesso di utilizzare List, per esempio, senza preoccuparci di come questo componente riesce a trasformare un Array in una colonna di views.

Se da un lato struct ci permetterà di nascondere implementazioni, dall'altro lato ci aiuterà a rappresentare oggetti che non sono codificati all'interno dei framework Apple e che saranno essenziali per dare forma alla logica delle tue applicazioni.

Infatti, immagina di voler creare un'app come Spotify dove una canzone è composta da diverse informazioni come autore, link, nome, descrizione etc.
Grazie alle struct potrai creare un nuovo type, chiamato Song, che al suo interno contiene delle proprietà (aka var/let) o metodi (aka func) che ti permetterà di rappresentare canzoni:

struct Song {
  let name: String
  let author: Author
  let link: URL

  private(set) var listeningCount: Int = 0

  mutating func play() {
    print("Playing:", name)
    // play music on device
    // and increment listeningCount
    listeningCount += 1
  }

  func fetchAllAlbums() -> [Album] {
     // complex logic to get albums
     return []
  }
}

var songA = Song(name: "Ho Hey", author: .lumineers, link: URL(string: "https://www.spotify.com/..."))
var songB = Song(name: "Savior", author: .riseAgainst, link: URL(string: "https://www.spotify.com/..."))

songA.play()
songA.name

songB.fetchAllAlbums()

Quindi, capito quanto essenziali saranno le struct durante il processo di creazione di un'app, vediamo insieme come utilizzarle!

A fine lezione, creeremo la seguente applicazione:

Come creare una struttura in Swift

Apri il Playground e partiamo dalla sintassi necessaria per creare nuove struct con il linguaggio Swift. La sintassi richiede la keyword struct seguita dal nome del nuovo type in lettera maiuscola ed un blocco di parentesi graffe:

struct Recipe {

}

In informatica il blocco di codice racchiuso all'interno delle parantesi graffe prende il nome di scope della struttura.

Dato che abbiamo creato un nuovo type, sappiamo dalla lezione sui tipi di dato che possiamo inizializzare degli oggetti utilizzando un costruttore o init di default.

let carbonara = Recipe()
let hamburger = Recipe()

Aggiungere nuove proprietà ad una struct

Per proprietà di una struct si intende una var o let definita all'interno del suo scope. Potrai aggiungere quante proprietà vorrai purché queste abbiano un senso logico ai fini del descrivere il tuo nuovo type.

Per esempio, al nostro type Recipe potremmo aggiungere un name ed un Array di ingredients:

struct Recipe {
    let name: String
    let ingredients: [String]
}

Se le proprietà non vengono inizializzate al momento della loro definizione, queste dovranno essere inizializzate quando l'oggetto che li contiene verrà costruito. Infatti Xcode ci segnala l'errore Missing arguments for parameters name, ingredients in call:

Nel nostro caso sia name che ingredients non hanno un valore di partenza, di conseguenza Swift ci forza a passarglieli al momento della costruzione di un oggetto Recipe:

let carbonara = Recipe(name: "Carbonara", ingredients: ["4 yolks", "250g pasta"])
let hamburger = Recipe(name: "Hamburger", ingredients: ["Bun", "Lettuce", "100g burger"])

Immutable vs Mutable object

In base a ciò che stai costruendo e rappresentando dovrai decidere quando utilizzare proprietà che sono mutabili var o immutabili let.

In linea di massima, se i tuoi oggetti non possono essere modificati da utente, per esempio tramite un Form o da qualche logica, il consiglio generale è quello di utilizzare let. Per esempio, se stai sviluppando un'app che scarica un feed di ricetta da un server e queste non possono essere modificate, tutte le proprietà all'interno potranno essere definite come let.

Di conseguenza, un oggetto che contiene solamente let viene chiamato immutable, mentre uno che ha almeno una proprietà var viene chiamato mutable.

Definire init in una struct

Fin ora abbiamo utilizzato il costruttore di default che Swift riesce a dedurre in base alle proprietà della struttura.
Nel caso in cui volessi manualmente definire il costruttore, potrai farlo aggiungendo all'interno dello scope della struct la funzione speciale chiamata init.

All'interno di un init dovrai inizializzare, ovvero passare un valore, a tutte le proprietà che non hanno valori di default:

struct Point {
  let x: Int
  let y: Int

  init() {
    x = -1
    y = -2
  }
}

let pointA = Point() // or Point.init()
pointA.x // -1
pointA.y // -2

Una volta definito un init potrai invocarlo con la sintassi che già conosci per creare nuovi oggetti: o scrivi il nome Type seguito da parentesi tonde oppure Type seguito da .init().

Nel caso in cui volessi passare dei valori dall'esterno, potrai aggiungere dei parametri all'interno delle tonde, divisi da virgole:

struct Point {
    let x: Int
    let y: Int
    
    init() {
        x = 0
        y = 0
    }
    
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

let pointB = Point(x: 10, y: 5)
let pointC = Point(x: 7, y: 23)

Per il linguaggio Swift non ci sono limitazioni al numero di init che potrai definire.

Esercizio 0. Crea un init

Come primo esercizio, prova a creare i seguenti init alla struttura Point :

  • init che permette di inizializzare x ed y con lo stesso valore.

  • init che accetta valori di x ed y di tipo Double. Dovrai eseguire una conversione.

Cos'è self?

self è una keyword speciale del linguaggio Swift che, quando utilizzata all'interno dello scope di una struct (verrà anche per altri costrutti), permette di far riferimento alle sue proprietà e metodi.

Di default, quando scrivi all'interno di un type e non ci sono ambiguità, la keyword self viene automaticamente dedotta da Swift e non ti servirà specificarla manualmente.
Per esempio, nel caso dell'init senza parametri, abbiamo inizializzato x ed y senza specificare il self.

Quando invece ci sono parametri esterni che hanno lo stesso nome delle proprietà della struct, allora dovrai inserire la keyword self se vuoi far riferimento a quest'ultime. Nell'esempio di init(x: Int, y: Int) abbiamo dovuto utilizzare il self per far capire a Swift che stiamo inizializzando le proprietà della struttura.

In linea generale non ti servirà specificare il self a meno di casi eccezionali ed uno di questi è l'inizializzazione di proprietà all'interno di un costruttore con nomi identici.

Computed property

Nella lezione delle variabili e state abbiamo visto come poter definire variabili di tipo get-only, ovvero che restituiscono un valore quando lette.

Nel caso delle struct possiamo utilizzare get only property, per esempio, per combinare valori di altre proprietà tra loro:

struct Point {
    var x: Int
    var y: Int
    
    var description: String {
        "(x: \(x), y: \(y))"
    }
}

var pointA = Point(x: 10, y: 15)
pointA.description // "(x: 10, y: 15)"

pointA.x = -5
pointA.description // "(x: -5, y: 15)"

Anche la var body: some View è una get-only computed property.

Esistono anche tipologie di computed-property che possono essere costruiti utilizzando gli operatori get, set, didSet e willSet. Esempio:

var favourite: Bool = false {
    didSet {
        print("has been set to: \(favourite), before was: \(oldValue)")
    }
}

favourite = true
// has been set to: true, before was: false

mutating func

Anche se già sai come si crea e definisce una funzione una menzione particolare va data alle funzioni che modificano le proprietà di una struttura.

Infatti, quando una func modifica una var interna ad una struttura, queste devono essere precedute dalla keyword mutating:

struct Song {
    let title: String
    var isPlaying: Bool = false
    
    mutating func play() {
        isPlaying = true
    }
    
    mutating func stop() {
        isPlaying = false
    }
}

var songA = Song(title: "Sweet Home Alabama")
songA.play()
songA.isPlaying // true

Struct in SwiftUI, creiamo un ricettario

Adesso che abbiamo visto creare strutture all'interno del Playground, vediamo come possono esserci d'aiuto in un progetto SwiftUI.

Immaginiamo di voler creare un app per ricette, quest'app è composta da una TabBar con due view principali: una per l'inserimento di una nuova ricetta e l'altra per la visualizzazione di tutte le ricette inserite.

Cominciamo dal creare la struttura che rappresenterà una ricetta. Per semplicità ipotizziamo che una ricetta sia composta dai seguenti attributi di base:

struct Recipe {
    var title: String
    var description: String
    /// time in seconds
    var time: TimeInterval
}

TimeInterval è il type generalmente utilizzato per rappresentare secondi.

Il commento con triplo slash /// permette di attaccare quella descrizione alla variabile. Se usi il Quick Help noterai che il commento viene visualizzato al suo interno.

Mancano ovviamente pezzi fondamentali come ingredienti e gli steps da eseguire. Potremmo utilizzare una nuova struct per rappresentare entrambi i concetti.

struct Ingredient {
    var name: String
    var quantity: String
}

struct RecipeStep {
    var description: String
}

Per semplificare il progetto la quantity è rappresentata da una String. Se vuoi complicare il progetto potresti utilizzare il type Double ed aggiungere una quantityDescription: String per specificare l'unità di misura.

Se invece vuoi rendere l'app professionale e pronta ad essere utilizzata in più paesi, puoi utilizzare il type Measurement che permette di rappresentare unità di misura in maniera strutturata.

Adesso che abbiamo i due type Ingredient e RecipeStep possiamo aggiungerli all'interno della struct Recipe sotto forma di array di entrambi i type.
Infine aggiungiamo due init , uno che ci permette di specificare tutti i parametri e l'altro che crea un oggetto con tutti i parametri con valore di default.

struct Recipe {
    var title: String
    var description: String
    /// time in seconds
    var time: TimeInterval
    
    var ingredients: [Ingredient]
    var steps: [RecipeStep]
    
    init(
        title: String,
        description: String,
        time: TimeInterval,
        ingredients: [Ingredient],
        steps: [RecipeStep]
    ) {
        self.title = title
        self.description = description
        self.time = time
        self.steps = steps
        self.ingredients = ingredients
        self.steps = steps
    }
    
    init() {
        self.title = ""
        self.description = ""
        self.time = 0
        self.ingredients = []
        self.steps = []
    }
}

La cartella Models

Dove definiamo i type all'interno di un progetto?
Ci sono diverse scuole di pensiero, la più intuitiva ed utile da utilizzare in un piccolo progetto è quella di creare una cartella chiamata Models in cui inserire tutte le definizioni dei nostri custom type.

Per creare una cartella, ti basta fare tap destro sulla cartella principale, seleziona New Group, rinomina la cartella in Models. Tap destro su Models e seleziona New file... chiama il file con lo stesso nome del type che vuoi definire, nel nostro caso Recipe (non è obbligatorio ma ti aiuterà a ricordarti che type c'è dentro quel file).

Incolla all'intero del file Recipe tutte le definizioni viste poco fa: Recipe, Ingredients e RecipeStep:

Adesso i type definiti in quel file fanno ufficialmente parte del vocabolario dei type utilizzabili all'interno del progetto.

Passiamo alla creazione delle user interface.

TabView

Il componente TabView si comporta da contenitore dove ogni view che utilizza il modifier tabView viene considerata una tab:

TabView {
    VStack {
        Text("Home")
    }
    .tabItem {
        Label("Home", systemImage: "house.fill")
    }
    
    VStack {
        Text("Account")
    }
    .tabItem {
        Label("Account", systemImage: "person.crop.circle")
    }
}

I nomi delle icone puoi prenderle dall'app SF Symbol che ho spiegato nel tutorial sulle Image in SwiftUI.

Dato che ogni singola tab avrà al suo interno views e state, ti consiglio di dividere il layout in componenti riutilizzabili per massimizzare la leggibilità e mantenibilità del codice.

Nel caso di una TabView il consiglio è di dividere ogni tab in un suo file e definizione.

All'interno del progetto, crea una nuova cartella chiamata Views ed al suo interno aggiungi una sotto cartella Recipes ed EditRecipe. Al loro interno crea un nuovo SwiftUI View file con lo stesso nome seguito dal suffisso View.

Se hai avuto qualche problema, dai uno sguardo alla struttura del mio progetto:

Infine, passa le due nuove view all'interno della TabView contenuta nel file ContentView:

TabView {
    RecipesView()
        .tabItem {
            Label("Home", systemImage: "house.fill")
        }
    
    EditRecipeView()
        .tabItem {
            Label("New Recipe", systemImage: "plus.circle.fill")
        }
}

All'interno del file EditRecipeView definire un Form per la creazione di nuove ricette, mentre in RecipesView mostreremo tutte le ricette create.

EditRecipeView

Partiamo dalla funzionalità di edit di una ricetta. Creeremo un semplice Form con all'interno delle sezioni che ci permetteranno di editare i vari elementi di un oggetto Recipe.

State e private

La prima cosa da fare è definire lo @State che ci permetterà di modificare/creare la ricetta. Se non ti ricordi cos'è uno state in SwiftUI lo spiegato in questa lezione.

Dato che EditRecipeView può essere utilizzata in altre view, nel nostro caso ContentView, è importante che i suoi State vengano definiti come private ovvero inaccessibili dall'esterno della view:

struct EditRecipeView: View {
    
    @State 
    private var recipe = Recipe()

La keyword private è un modificatore d'accesso, in pratica sarà accessibili solamente all'interno dello scope in cui è stata definita. Grazie a private Swift non sintetizzerà un costruttore di default con il parametro recipe.

Infatti, senza il private, potresti creare la RecipesView passando un oggetto Recipe ovvero RecipesView(recipe: Recipe()).
Nel contesto di SwiftUI questo è sconsigliato, in quanto la gestione di uno State è legato alla view che lo definisce e, la modifica dall'esterno, potrebbe modificare o resettare il valore gestito dallo State in maniera inaspettata.

Ad ogni modo, non entriamo troppo in questi dettagli, più in là farò un tutorial dedicato alla gestione degli state in SwiftUI.

In linea di massima, il consiglio è di definire tutto come private e lasciare solamente variabili di tipo Binding ed il var body come non-private.

Le sezioni di partenza

Partiamo definendo il backbone della view e cominciamo aggiungendo quattro Section:

  • infoSection definiremo i componenti per la modifica del recipe.title e recipe.time.

  • descriptionSection aggiungeremo un TextEditor per la scrittura di una descrizione multi linee.

  • ingredientsListSection mostreremo tutti gli ingredienti ed aggiungeremo la possibilità di modificare gli Ingredient.

  • stepsListSection simile alla ingredientsListSection la useremo per modificare gli step della ricetta.

Di conseguenza, questo sarà il layout di partenza:

struct EditRecipeView: View {
    @State private var recipe = Recipe()
    
    var body: some View {
        Form {
            infoSection
            descriptionSection
            ingredientsListSection
            stepsListSection
        }
    }
    
    private var infoSection: some View {
        Section {
            TextField("Title", text: $recipe.title)
        } header: {
            Text("Info")
        }
    }
    
    private var descriptionSection: some View {
        Section {
            TextEditor(text: $recipe.description)
                .frame(minHeight: 80, maxHeight: 150)
        } header: {
            Text("Description")
        }
    }
    
    private var ingredientsListSection: some View {
        Section {
            
        } header: {
            Text("Ingredients")
        }
    }

    private var stepsListSection: some View {
        Section {
            
        } header: {
            Text("Steps")
        }
    }

}

Aggiungere un nuovo ingrediente

Cominciamo riempendo la sezione degli ingredienti ed aggiungiamo un Button suggerisce l'utente di aggiungere il primo ingrediente. Quindi, in base al recipe.ingredients.isEmpty mostreremo questo bottone:

// dentro la section ingredients
if recipe.ingredients.isEmpty {
  Button("Add your first ingredient", action: addEmptyIngredient)
}

Come action ho passato il riferimento ad una funzione, questo perché non voglio mischiare codice UI con la logica dell'app. Quindi, sotto la Section o meglio sotto tutte le definizioni delle tue view, crea una funzione senza parametri chiamata addEmptyIngredient:

private func addEmptyIngredient() {
        
}

Useremo questa funzione per creare un ingrediente vuoto che l'utente dovrà riempire. Vediamo come.

Innanzitutto creiamo un nuovo State chiamato editIngredient: Ingredient?. A questo state diamo type opzionale perché verrà creato solamente quando l'utente utilizzerà la funzione addEmptyIngredient.

@State private var editIngredient: Ingredient?

Ho spiegato gli opzionali in Swift in questa lezione. In breve, permettono di rappresentare l'assenza di valori.

Adesso, all'interno della funzione addEmptyIngredient creiamo un nuovo oggetto Ingredient ed assegniamolo allo state.

All'interno della Section invece, utilizziamo un if editIngredient != nil per mostrare due TextField che ci permetteranno di modificare l'ingrediente:

if  {
    TextField("Name", text: Binding(
        get: { self.editIngredient?.name ?? "" },
        set: { self.editIngredient?.name = $0 }
    ))
    TextField("Quantity", text: Binding(
        get: { self.editIngredient?.quantity ?? "" },
        set: { self.editIngredient?.quantity = $0 }
    ))
}

Nota come ho creato un Binding manualmente definendo le sue funzioni get e set. Purtroppo in SwiftUI, non possiamo creare un binding di uno State opzionale utilizzando il simbolo $.

Se adesso provi ad aggiungere un nuovo ingrediente dovresti vedere i due field comparire subito sotto:

Adesso non ci rimane che aggiungere il nuovo ingrediente alla lista degli ingredienti della ricetta che stiamo creando.

Partiamo dall'aggiungere un Button dopo i Fields che invoca una funzione saveNewIngredient al tap ed è disattivato quando il nome dell'ingrediente è isEmpty:

Button("Add", action: saveNewIngredient)
  .disabled(editIngredient?.name.isEmpty ?? true)

La funzione saveNewIngredient invece aggiungerà l'editIngredient alla ricetta e resetterà la variabile subito dopo:

private func saveNewIngredient() {
    // unwrap `editIngrediet` se esiste, altrimenti esce dalla funzione
    guard let editIngredient else { return }
    
    recipe.ingredients.append(editIngredient)

    // resetta editIngredient così da poterne aggiungere uno nuovo
    self.editIngredient = nil
}

Nota come ho utilizzato self.editIngredient , infatti senza il self la parola editIngredient fa riferimento alla let editIngredient definita nel guard che non può essere modificata. Quindi, utilizzando il self stiamo facendo riferimento alla variabile definita nello scope esterno.

Infine, modifichiamo il bottone Add your first ingredient facendo si che questo scompaia quando l'utente sta aggiungendo un nuovo ingrediente e cambi il suo testo in Add ingredient quando la ricetta contiene ingredienti:

if editIngredient == nil {
    let title = recipe.ingredients.isEmpty 
      ? "Add your first ingredient" 
      : "Add ingredient"
    Button(title, action: addEmptyIngredient)
}

Infine, utilizziamo un ForEach per mostrare la lista degli ingredienti:

ForEach(recipe.ingredients, id: \.self) { ingredient in
    HStack {
        Text(ingredient.name)
        Spacer()
        Text(ingredient.quantity)
    }
}

Pe utilizzare il ForEach sugli ingredienti il type Ingredient dovrà implementare il protocollo Hashable, ti basterà aggiungere questo protocollo alla definizione del type Ingredient:

struct Ingredient: Hashable

Eliminare un ingrediente

Per aggiungere la gesture di eliminazione di un ingrediente possiamo aggiungere il modifier onDelete al componente ForEach.
Questo modifier passa come parametro un IndexSet, ovvero l'indice o indici degli elementi da eliminare, che potremo passare al metodo remove(atOffsets: dell'array ingredients per eliminare l'elemento selezionato dall'utente.

Definiamo questa logica all'interno di una nuova funzione chiamata deleteIngredient:

private func deleteIngredient(at offset: IndexSet) {
    recipe.ingredients.remove(atOffsets: offset)
}

Ed infine, passiamo questa funzione al metodo onDelete del componente ForEach:

ForEach(recipe.ingredients, id: \.self) { ingredient in
    
}
.onDelete(perform: deleteIngredient(at:))

Il modifier onDelete aggiunge in automatico tutto il supporto alla gesture classica di eliminazione di una riga da una lista in SwiftUI. Documentazione qui.

Esercizio 1. Aggiungere la sezione steps

Come esercizio lascio a te il compito di inserire la sezione per la definizione degli step della lezione. Utilizza lo stesso look and feel della sezione ingredienti come riferimento.

Se dovessi avere qualche problema, al solito, scrivimi sotto nei commenti.

Salvare ricetta e passare oggetti da una view all'altra

Una volta riempiti tutti i campi di una nuova ricetta, dobbiamo salvare e passare quest'ultima dalla EditRecipeView ad uno storage condiviso che tutte le altre view possono accedere.

Ci sono diverse strategie che si possono utilizzare per passare modifiche ed eventi da una view all'altra. Per esempio, potremmo passare un Binding di tutte le ricette, potremmo creare un Environment o potremmo definire una closure che permetta alle view esterna di intercettare certi eventi.

Closure e propagazione di eventi a view esterne

Non c'è una regola generale e tutto dipende da caso a caso. In questo esempio, utilizzerò una closure per passare un evento onEdit alle view esterne.
In questo modo, evitiamo di creare dipendenze dirette con gli state della nostra applicazione e possiamo testare la view in maniera isolata.

Ho spiegato cos'è una closure nella lezione delle funzioni in Swift. Una closure in Swift è una funzione che può essere passata da un punto all'altro all'interno del tuo codice.

Torniamo alla EditRecipeView ed aggiungiamo una closure che prende in input una Recipe chiamata onEdit:

struct EditRecipeView: View {
    
    private(set) var onEdit: (Recipe) -> ()

Questa onEdit non ha valore di default e dovrà essere definita al momento della creazione della EditRecipeView. In questo momento la view è utilizzata in Preview e nella ContentView, di conseguenza modifichiamo le due istanze:

// Preview
#Preview {
    EditRecipeView { recipe in
        
    }
}

// ContentView
EditRecipeView(
  onEdit: onEditRecipe(_:)
)

private func onEditRecipe(_ recipe: Recipe) {
 
}

Adesso quando l'utente salverà la ricetta, all'interno della EditRecipeView, potremmo invocare la funzione onEdit che permetterà la view esterne di intercettare la Recipe modificata.

Vediamo come fare!

Aggiungiamo una NavigationStack come root della EditRecipeView e sul form utilizziamo il modifier toolbar per aggiungere un bottone di salvataggio:

struct EditRecipeView: View {

    // states e var....

    var body: some View {
        NavigationStack {
            Form {
                infoSection
                descriptionSection
                ingredientsListSection
                stepsListSection
            }
            .navigationTitle("New Recipe")
            .toolbar {
                Button("Save", action: saveRecipe)
            }
        }
    }

    // var e funcs..

    private func saveRecipe() {
        onEdit(recipe)

        recipe = Recipe()
    }

La NavigationStack oltre ad aggiungere le funzionalità di navigazione orizzontale all'interno della view ti permetterà di definire una Toolbar, ovvero una barra di navigazione, in cui potrai aggiungere bottoni e definire un titolo.

Quando l'utente tapperà il bottone Save la funzione saveRecipe passerà la recipe alla closure onEdit. Tutte le view che hanno definito questa closure riceveranno la ricetta:

Definire State centrali e propagazione a view interne

Infine, dobbiamo salvare questa ricetta in uno State centrale in modo da poterle poi propagare alla RecipesView per la visualizzazione.

Dato che sia EditRecipeView che RecipesView si trovano all'interno della ContentView, quest'ultima è la view ideale per la definizione di una variabile State di tipo [Recipe].

struct ContentView: View {
    
    @State private var recipes: [Recipe] = []

Adesso, il metodo onEditRecipe prenderà la nuova ricetta e la appenderà a questo state:

private func onEditRecipe(_ recipe: Recipe) {
    recipes.append(recipe)
}

Ed una volta che lo State viene correttamente modificato, dobbiamo passare le recipes alla RecipesView.

In questo caso, dato che la RecipesView visualizzerà le ricette, possiamo aggiungere una let recipes al suo interno:

struct RecipesView: View {
    
    let recipes: [Recipe]

Ed in ContentView passeremo lo state al parametro recipes:

struct ContentView: View {
    
    @State private var recipes: [Recipe] = []
    
    var body: some View {
        TabView {
            RecipesView(recipes: recipes)

In questo modo, ogni qual volta lo State recipes cambia la view RecipesView verrà ricalcolata correttamente.

Extension, static func e mock data

Adesso che abbiamo sincronizzato l'operazione di aggiunta con il passaggio delle ricette alla RecipesView, passiamo a costruire la lista che ci permetterà di visualizzarle.

Innanzitutto dobbiamo rendere la struct Recipe conforme al protocollo Hashable. Questo ci permetterà di utilizzare l'array in un ForEach:

struct Recipe: Hashable {

Nel caso in cui questo protocollo dovesse generare l'errore:

Type 'Recipe' does not conform to protocol 'Equatable'

Dovrai aggiungere la conformità al protocollo Hashable o Equatable al type RecipeStep. Infatti, l'unica proprietà della struct Recipe che non è Equatable è var steps: [RecipeStep].
Ti consiglio di aggiungere Hashable al type RecipeStep dato che ti permetterà di usare l'array in un ForEach.

Prima di passare alla view, per velocizzare il testing della lista senza dover ogni volta creare una nuova ricetta, potremo creare degli oggetti di testing o mock che ci permetteranno di settare la Preview con degli elementi di partenza.

Per farlo potremo creare una funzione statica, ovvero invocabile direttamente dal type su cui viene definita, che ci restituisce una lista di oggetti di testing.

Crea un nuovo file nella cartella Models chiamato Recipe+Mock ed al suo interno creiamo una extension Recipe in cui definiamo una static func mocks() -> [Recipe]:

extension Recipe {
    static func mocks() -> [Recipe] {
        [
            Recipe(
                title: "Pasta alla Carbonara",
                description: "Autentico piatto di pasta italiano fatto con uova, formaggio, guanciale e pepe nero.",
                time: 900, // 15 minuti
                ingredients: [
                    Ingredient(name: "Spaghetti", quantity: "400g"),
                    Ingredient(name: "Uova", quantity: "4"),
                    Ingredient(name: "Guanciale", quantity: "200g"),
                    Ingredient(name: "Parmigiano", quantity: "100g"),
                    Ingredient(name: "Pepe Nero", quantity: "A piacere"),
                    Ingredient(name: "Sale", quantity: "A piacere")
                ],
                steps: [
                    RecipeStep(description: "Portare a ebollizione una grande pentola d'acqua salata."),
                    RecipeStep(description: "Cuocere gli spaghetti secondo le istruzioni della confezione fino a che sono al dente."),
                    RecipeStep(description: "In una padella separata, cuocere la guanciale fino a renderla croccante. Togliere dal fuoco e mettere da parte."),
                    RecipeStep(description: "In una ciotola, sbattere insieme le uova, il formaggio Parmigiano grattugiato e il pepe nero."),
                    RecipeStep(description: "Una volta cotta la pasta, scolarla e rimetterla nella pentola mentre è ancora calda."),
                    RecipeStep(description: "Versare immediatamente il composto di uova sulla pasta calda e mescolare rapidamente per rivestirla. Il calore della pasta cuocerà le uova, creando una salsa cremosa."),
                    RecipeStep(description: "Aggiungere la guanciale cotta alla pasta e mescolare per combinare."),
                    RecipeStep(description: "Condire con sale e altro pepe nero a piacere."),
                    RecipeStep(description: "Servire caldo, guarnendo con ulteriore formaggio Parmigiano e pepe nero se desiderato.")
                ]
            )
        ]
    }
}

Una extension ti permette di estendere una definizione di un type aggiungendo nuove funzioni o computed properties. Diventa utilissima quando vuoi evitare di riempire i tuoi type con diverse definizioni o vuoi estendere type esistenti.

Adesso, torna al file RecipesView e modifica la Preview passando al campo recipes il valore Recipe.mocks(). Infine, crea un'altra Preview per testare il caso empty:

#Preview("Mock data") {
    RecipesView(recipes: Recipe.mocks())
}

#Preview("Empty state") {
    RecipesView(recipes: [])
}

Lista delle ricette

Finiamo questa lezione definendo la lista delle ricette, creiamo una semplice List con un ForEach che itera sull'array recipe ed un semplice Text(recipe.title). Utilizziamo anche qui un NavigationStack ed un navigationTitle.

Infine, utilizziamo un ContentUnavailableView nel caso in cui la lista sia empty:

struct RecipesView: View {
    
    let recipes: [Recipe]
    
    var body: some View {
        NavigationStack {
            List {
                if recipes.isEmpty {
                    ContentUnavailableView {
                        Label("No recipes", systemImage: "fork.knife.circle")
                    } description: {
                        Text("Your recipes will appear here.")
                    }
                }
                
                ForEach(recipes, id: \.self) { recipe in
                    Text(recipe.title)
                }
            }
            .navigationTitle("Recipes")
        }
    }
    
}

Se tutto è andato per il verso giusto, all'aggiunta di una ricetta dovremmo vederla comparire all'interno della lista:

Esercizio 2. Disattiva il bottone Save quando la ricetta non è pronta per essere salvata

Come esercizio, definisci la logica per disattivare il bottone quando la ricetta non ha i campi title, description, steps e ingredients.

Un buon sistema potrebbe essere quello di aggiungere una computed-property all'interno della struct Recipe chiamata canBeSaved che restituisce true/false in base a delle condizioni. Poi, potresti utilizzare canBeSaved per disattivare o meno il bottone:

Button("Save", action: saveRecipe)
  .disabled(!recipe.canBeSaved)

Esercizio 3. Crea la RecipeDetailsView

Utilizzando un NavigationLink ed il modifier navigationDestination(for: Recipe.self) crea la navigazione e view dei dettagli per le tue ricette. Prova a creare qualcosa di simile a questo:

Conclusione e download progetto

In questa lezione abbiamo visto come una struct ci può aiutare a rappresentare concetti complessi che racchiudono a loro interno diverse proprietà e funzionalità.
Da qui in avanti, il tuo compito sarà quello di cercare di rappresentare il più possibile funzionalità e componenti della tua applicazione tramite nuovi oggetti.

Nel caso in cui volessi dare un'occhiata al progetto finale, lo trovi qui: https://github.com/peppe-app/SimpleRecipe

Buona programmazione!