Introduzione agli stati in SwiftUI

Come funzionano le View in SwiftUI

Abbiamo visto negli articoli precedenti che in SwiftUI una View è una struct conforme al protocollo View. Dunque, non eredita nessuna stored property perchè non c'è alcun padre; è allocata sullo stack ed il suo funzionamento è per valore e non per referenza (come avviene con le classi in UIKit).

Dietro le quinte, SwiftUI gestisce le View in una struttura dati altamente efficiente per il rendering a schermo dei contenuti. Per questo motivo è consigliabile utilizzare e creare componenti specializzati e riutilizzabiliDurante lo sviluppo, assicurano da Cupertino, non bisogna avere esitazioni ad estrarre sotto view che svolgono un compito specifico e creare per esse una struct apposita.


Insieme al suo body, una View in SwiftUI definisce le sue dipendenze.


Per quanto detto sopra, supponiamo di volere creare una View in SwiftUI specializzata nella creazione di un pulsante. Il nostro oggetto dovrà farsi carico semplicemente di gestire un pulsante: alla pressione, la sua etichetta dovrà cambiare riflettendo il nuovo stato

Quest'ultimo punto è quello dove la programmazione dichiarativa brilla: la conseguenza di un'azione come aggiornamento dell'interfaccia.

Il modificatore @State in SwiftUI

Il nostro pulsante, quindi, dovrà definire una dipendenza tra il suo stato e la sua rappresentazione a schermo. In SwiftUI questa dipendenza viene gestita automaticamente dal modificatore @State. Proviamo a fare subito un esempio così da capire meglio.


struct MyButton : View {
    @State private var toggleStatus:Bool = false
    var body: some View {
        Button(action: {
            self.toggleStatus = !self.toggleStatus
        }) {
            Text((toggleStatus == false) ? "Press Me" : "Unpress me")
        }
    }
}

@State lega la variabile toggleStatus al concetto di interfaccia: ogni qualvolta il suo valore cambierà, SwiftUI saprà che è necessario generare un nuovo body (e quindi un refresh della label ad esso associato).

SwiftUI View Data flow

Fig.1 - Semplice diagramma che illustra il flusso dati legato al codice sopra.

Togliendo @State, il compilatore si lamenterà che stiamo provando a cambiare un valore immutabile: non dimentichiamoci che siamo in presenza di una struct.

Ogni oggetto che viene renderizzato a schermo, infatti, è di per se immutabile per ragioni di prestazioni. In questo modo, SwiftUI sa cosa deve essere aggiornato e cosa può essere tralasciato. Aggiungendo un modificatore di tipo @State, stiamo esplicitamente dicendo a SwiftUI:

"Ehi!, attenzione! Guarda che qui c'è un oggetto che potrebbe cambiare il suo stato e quindi sarà necessario aggiornarne il contenuto."

Quando ci troviamo in presenza di una View con una variabile con attributo @State, quindi, SwiftUI definisce implicitamente una dipendenza tra questa variabile e la presentazione a schermo della View: ogni qualvolta la variabile cambierà di valore, SwiftUI saprà che la View deve essere ridisegnata usando il nuovo valore.

Per suggellare questo concetto e vedere quanto è differente dalla programmazione tradizionale, proviamo a fare un paritetico con UIKit:


import UIKit

class ViewController: UIViewController {
	@IBOulet var aButton: UIButton!
	var toggleStatus:Bool = false
	
	override func viewDidLoad() {
		super.viewDidLoad()
	}

	@IBAction func buttonDidPressed(sender: UIButton) {
		sender.setTitle((toggleStatus) ? "Unpress me" : "Press me", for: .normal)
		toggleStatus = !toggleStatus
	}
}

Chiaramente in UIKit tradizionale, il codice sopra non basta. Avremo creato preventivamente uno Storyboard con un UIViewController al suo interno ed  un pulsante UIButton. Dovremo, inoltre, allineare il pulsante con dei Constraints al centro e creare le corrette referenze @IBOutlet ed @IBAction per fare funzionare tutto il flusso.

Tralasciando l'evidente mole maggiore di codice ed azioni rispetto a SwiftUI, focalizziamoci sul nostro stato. Anche in questo caso, infatti, il nostro pulsante per poter cambiare la sua rappresentazione (etichetta) ha bisogno di discernere l'attuale stato in cui si trova. Notate come, nel caso della programmazione imperativa tradizionale, ci sono due grossi problemi concettuali:

  • toggleStatus è gestito (allocato, inizializzato e modificato) dal Controllore.
  • Non esiste un modo chiaro a livello di codice per separare variabili legate allo stato dell'interfaccia da variabili tradizionali: sono tutte uguali.
ViewController data flow in UIKit

Fig.2 - Un diagramma che illustra le relazioni ed il flusso del nostro oggetto Button in UIKit.

Appare evidente, inoltre, che la complessità di questo approccio cresce in maniera esponenziale al crescere del numero di oggetti e di stati.

Interface Complexity vs number of State

Fig.3 - Diagramma che illustra il crescere della complessità al crescere degli stati gestiti dall'interfaccia.

Come se non bastasse, iOS è un sistema operativo multithread. Questo significa che le richieste di aggiornamenti agli stati ed all'interfaccia possono arrivare da più punti del codice che lavorano in maniera indipendente tra loro. Non avere un modo chiaro a livello di codice per gestire questa evenienza, porta a delle inconsistenze - bugs - dell'interfaccia difficili da individuare e risolvere.


UIViewController MultiThread DataFlow

Fig.4 - Nel caso di un'applicazione multithread il confine tra chi aggiorna l'interfaccia e chi aggiorna lo stato non è chiaro.

La ricerca della verità

Ma come viene gestita questa dipendenza tra variabili ed interfaccia in SwiftUI? e cosa rende il sistema resistente agli aggiornamenti a differenza di un approccio imperativo tradizionale?

Quando SwiftUI elabora una View con una variabile di tipo @State, alloca in memoria uno spazio per questa variabile al posto della View. 

StateAllocationInSwiftUI

Fig.5 - Allocazione delle variabili di tipo @State in SwiftUI

Ecco perchè, aggiungendo l'attributo @State, riusciamo a modificare un oggetto altrimenti immutabile come una Struct: dietro lo quinte la variabile è allocata dal framework SwiftUI in una zona in lettura/scrittura.

In SwiftUI, ogni possibile stato di una View (offset di una scrollview, stato di un bottone, contenuto di uno stack, etc.) è gestito da questa idea altrimenti conosciuta come Sorgente di Verità o Source of TruthSeguendo questo concetto, è possibile classificare ogni variabile come sorgente di verità o valore derivato.

SourceOfTruthTable

Fig.6 - Diagramma che illustra la classificazioni delle variabili in SwiftUI.

Nel nostro esempio, toggleStatus è una sorgente di verità. Dallo schema, inoltre, vediamo subito che tutte le costanti sono anch'esse una sorgente di verità. 

Vedremo nei prossimi paragrafi che esiste un altro meccanismo per passare valori derivati in lettura e scrittura, denominato @Binding. Analizzeremo le differenze con @State e quando usare l'uno o l'altro in base alle circostanze.

Organizziamo il codice: estrazione delle view

Adesso che abbiamo chiaro come una View si aggiorna e ridisegna i contenuti al suo interno, proviamo a mettere insieme quanto appreso in relazione all'esempio dello scorso articolo. La scorsa volta, avevamo lasciato la nostra ContentView in questo stato:


import SwiftUI

struct ContentView : View {
    var body: some View {
        VStack {
            HStack {
                Image("photoThumb")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .clipShape(Circle())
                    .frame(width: 60, height: 60, alignment: .center)
                    .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0))
                VStack(alignment: .leading) {
                    Text("Mario Rossi").font(.title)
                    Text("Amministratore delegato").font(.subheadline).fontWeight(.thin)
                }
                Spacer()
            }
        }
        
    }
}

Chiaramente questo approccio non è molto elegante e neanche molto mantenibile. Il codice ha una tendenza ad annidarsi crescendo verso destra, in una cosidetta "piramide di doom".

All'inizio dell'articolo, avevamo accennato ad un meccanismo di estrazione delle sottoviste in maniera da rendere il codice scalabile e mantenibile. Vediamo cosa ci offre il nuovo ambiente Xcode11 e SwiftUI per risolvere questo problema.

Avendo cura di stare usando Xcode11, spostate il mouse sul tag HStack e tenendo premuto il stato CMD (⌘) fate click su di esso.

extractSubviewStep1

Fig.7a - Premere ⌘+Click sull'oggetto da estrarre nel codice.

ExtractSubviewStep2

Fig.7b - Xcode crea in automatico una nuova struct offrendoci la possibilità di cambiarne il nome.

Xcode crea in automatico una nuova struct, estrae il contenuto selezionato e lo utilizza per popolare il body del nuovo oggetto appena generato. Alla fine dell'operazione, ci offre anche la possibilità di cambiarne il nome: rinominiamo la View estratta in CustomTableViewCell.

La nuova versione di ContentView è diventata molto più leggibile e leggera, includendo soltanto un riferimento ad un nuovo oggetto. Volendo, potremmo spostare all'interno di un nuovo file il contenuto della CustomTableViewCell per meglio isolare il concetto di componente.

Passaggio di parametri

Non sarebbe interessante poter passare i giusti parametri alla nostra nuova CustomTableViewCell, così da poter generare una cella con contenuti dinamici rispetto a titolo, sottotitolo ed immagineProviamo a modificare la nostra nuova classe in questo senso:


struct CustomTableCell : View {
    var photoName:String
    var title:String
    var subtitle:String
    
    var body: some View {
        return HStack {
            Image(photoName)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .clipShape(Circle())
                .frame(width: 60, height: 60, alignment: .center)
                .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0))
            VStack(alignment: .leading) {
                Text(title).font(.title)
                Text(subtitle).font(.subheadline).fontWeight(.thin)
            }
            Spacer()
        }
    }
}

Adesso la nostra CustomTableCell può essere istanziata con dei parametri che ne definiscono in maniera unica il contenuto.


struct ContentView : View {
    var body: some View {
        VStack {
            CustomTableCell(photoName: "photoThumb", 
                                title: "Gianluca Verdi", 
                             subtitle: "Consigliere")
        }
        
    }
}

E se volessimo spingerci oltre e creare, ad esempio, un pulsante che cambia il contenuto del campo sottotitolo? 


Il modello di un oggetto e SwiftUI

Il modello legato ad un oggetto View gioca un ruolo fondamentale nel paradigma dichiarativo di SwiftUI. Ogni oggetto, tipicamente, ha associato un modello che, insieme allo stato, identifica completamente la visualizzazione grafica dell'interfaccia.

Ad esempio, potremmo associare alla nostra CustomTableViewCell un modello di questo tipo:


struct Person {
    var photoName:String
    var title:String
    var subtitle:String
}

La nostra View, di conseguenza, dovrà essere adeguata per sfruttare il nuovo modello che abbiamo appena creato:


struct CustomTableViewCell : View {
    var model: Person
    
    var body: some View {
        return HStack {
            Image(model.photoName)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .clipShape(Circle())
                .frame(width: 60, height: 60, alignment: .center)
                .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0))
                VStack(alignment: .leading) {
                    Text(model.title).font(.title)
                    Text(model.subtitle).font(.subheadline).fontWeight(.thin)
                }
                Spacer()
            }
        }
    }

Infine, la ContentView del nostro applicativo dovrà rispecchiare questo cambiamento per potere disegnare correttamente la nostra CustomTableViewCell.


struct ContentView : View {
    @State private var model = Person(photoName: "photoThumb", 
                               title: "Mario Rossi", 
                            subtitle: "Amministratore delegato")
    
    var body: some View {
        VStack {
            CustomTableViewCell(model: model)
            Button(action: {
                self.model.subtitle = "Consigliere"
            }) {
                Text("Change subtitle")
                .foregroundColor(Color.white)
                .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
                .background(Color.blue)
                .cornerRadius(8)
            }
        }
    }
}

Questa volta la variabile model ha davanti l'attributo @State e private. In questo caso, infatti, il proprietario del modello è l'oggetto ContentView. Il modello poi viene passato per copia - ricordiamoci che stiamo lavorando con strutture e non con classi - alla CustomTableViewCell che, a sua volta, lo utilizza per valorizzare i campi corrispondenti dei suoi oggetti: Image e Text.

Il pulsante, ha associata un'azione di aggiornamento del modello che ne cambia il sottotitolo. Visto che la ContentView ha aggiunto l'attributo @State al suo modello, il cambiamento di quest'ultimo fa scattare l'aggiornamento del suo body che, a sua volta rigenera una nuova CustomTableViewCell con il nuovo contenuto.

DataFlowSwiftUIPassingArgs

Fig.8 - Gerarchia del flusso con aggiornamento di stato degli oggetti.

Avrete notato che ho aggiunto l'attributo private prima della dichiarazione della variabile di stato model. Questo perchè, come abbiamo spiegato sopra, le variabili marcate come @State, appartengono esclusivamente alla View che le dichiara. E' buona regola, dunque, rinforzare questo concetto marcandole come private.

Vedremo nel prossimo articolo che SwiftUI espone altri modi per gestire il passaggio di parametri quando ci troviamo a dialogare con situazioni come quella illustrata sopra. In questo caso, infatti, sebbene sintatticamente e funzionalmente corretto, il nostro flusso soffre di un piccolo problema: la nostra CustomTableViewCell utilizza una sua copia del modello che a sua volta è esposto, allocato e gestito dalla ContentView con una variabile privata. SwiftUI offre delle astrazioni molto simili all'attributo @State per dialogare con queste evenienze e permettere al modello di essere propagato correttamente dove necessario.

Sebbene il progetto continuerà a compilare senza problemi, purtroppo noteremo che all'atto pratico il pulsante non cambierà più il contenuto del campo subtitle. 

@State lavora con tipi per valore, mentre le classi sono dei tipi per riferimento: cambiando il valore di una classe marcata con l'attributo @State, il suo cambiamento interno non fa scattare l'aggiornamento del body.

Scarica il progetto di questo articolo

Prossimo articolo: ObjectBinding e Stato

© Copyright 2019 Sofapps - All Rights Reserved - VAT 05197020877