Documenti di Didattica
Documenti di Professioni
Documenti di Cultura
Vincenzo Giacchina
© Apogeo - IF - Idee editoriali Feltrinelli s.r.l.
Socio Unico Giangiacomo Feltrinelli Editore s.r.l.
Seguici su Twitter
NOTA
Analizzare e confrontare le performance tra nativo e ibrido significa
analizzare anche le performance native divise per linguaggi differenti.
Uso della CPU, GPU, Memory, FPS e consumo della batteria sono alla
base di un benchmark completo. Alcuni benchmark vedono Flutter più
performante di altre alternative ibride ed estremamente competitivo
con applicazioni native.
Nativo e ibrido
Nel caso in cui alcuni di noi si stiano chiedendo quale
effettivamente sia la differenza tra app native e app ibride,
apriamo una brevissima parentesi, in attesa di un
approfondimento nei capitoli successivi. Le app native si
interfacciano direttamente con l’ambiente che le ospita (sistema
operativo) e vengono sviluppate con un software (IDE)
specifico.
Le app ibride, invece, in genere necessitano di uno strato
intermedio, che introduce un calo delle performance e di
conseguenza dei compromessi che introducono problematiche
legate alla user experience e in contesti specifici anche delle
vere e proprie limitazioni.
Possiamo semplicemente immaginare un concetto molto
banale. L’app nativa consente allo sviluppatore di “dialogare”
con il sistema operativo mobile attraverso un sdk, che consente
in tutto e per tutto la gestione del dispositivo. L’approccio ibrido
impone l’utilizzo di un framework di terze parti, che consentirà di
intermediare le funzionalità offerte dal sistema operativo, o
meglio quelle che il framework implementa.
Negli anni ogni framework nato per lo sviluppo di applicazioni
mobili ibride si è prefissato lo scopo di sviluppare un software
che consentisse, con un unico ambiente di sviluppo, di creare
un’applicazione mobile per più sistemi operativi. La sfida si è
sempre caratterizzata sull’implementazione, che risulta essere
una vera e propria firma del framework.
Figura I.2 Differenza sommaria tra app native ed ibride.
Punti di forza
Flutter, rispetto a tutte le soluzioni oggi presenti nel mercato,
riesce a essere particolarmente performante. Rispetto a
competitor che utilizzano uno strato intermedio di
comunicazione con il sistema operativo, Flutter compila
direttamente l’applicativo in linguaggio macchina e questo
dialoga direttamente con il sistema operativo e i vari
componenti. Un approccio di questo tipo rende efficiente
l’applicativo e le sue performance sono molto vicine a quelle che
avremmo con uno sviluppo nativo. Flutter, a oggi, è sicuramente
l’alternativa più performante.
Benchmark
Riporto i dati di un articolo molto ben fatto di una società
ucraina (inVeritaSoft), che ha realizzato diversi benchmark per
sistema operativo, linguaggio e casi d’uso differenti.
NOTA
Di seguito, gli URL dei benchmark: https://inveritasoft.com/blog/flutter-
vs-react-native-vs-native-deep-performance-comparison,
https://inveritasoft.com/blog/flutter-vs-native-vs-react-native-examining-
performance
vs-react-native/ o https://www.orientsoftware.com/blog/flutter-vs-
react-native-performance/ si raggiunge ugualemente la stessa
conclusione.
Adoption
Flutter è nato a metà del 2017 e, nonostante si basi su un
linguaggio non largamente diffuso come Dart, ha fatto molti
passi avanti in termini di adozione, soprattutto negli ultimi
due/tre anni. Nel survey di stackoverflow del 2022 si può
osservare quanto sia diffuso. Rispetto ai due anni precedenti, la
curva di adozione è notevole: un aumento del 5% di progetti che
usano Flutter (Figura I.3 e Figura I.4).
Google Trends può venirci in aiuto nel capire quanto
effettivamente la curva di interesse su Flutter, dagli addetti ai
lavori e non solo, sia aumentata nel tempo, tanto da dividersi il
mercato con quella che a oggi era la reale alternativa allo
sviluppo di applicazioni ibride.
Figura I.5 Google Trends mostra quanto Flutter (blu) partito da 0 nel 2007 nel
giro di pochi anni abbia invertito il trend rispetto a React Native (rosso).
I revisori
Ralf Mureddu è Software Engineer specializzato in sicurezza
applicativa con esperienza più che decennale in ambito
finanziario.
Carlo Zappatini è Software Engineer specializzato in
applicazioni mobile e front-end con un passato da
programmatore embedded in ambito automotive.
Il codice
Tutto il codice sorgente presente in questo libro è disponibile
all’indirizzo https://bit.ly/apo-saf e su GitHub all’indirizzo
https://github.com/vgflutter/flutter_apogeo.
Ringraziamenti
Il ringraziamento principale va ai lettori, che spero con tutto il
cuore siano soddisfatti. Non è la mia prima esperienza in questo
campo, tutt’altro, e ogni lavoro rende il prossimo sempre più
sfidante. Alzare l’asticella nella speranza di far sempre meglio è
l’obiettivo che mi sono prefissato. Nonostante l’ultimo anno non
sia stato particolarmente felice, ho portato avanti il lavoro con il
massimo impegno e non ho rimpianti: ho fatto del mio meglio.
Quindi, grazie a voi per aver dedicato tempo e attenzione al mio
lavoro.
Un altro grazie ai miei fedelissimi: a mia moglie per la
pazienza e alla coppia Ralf Mureddu e Carlo Zappatini per il
lavoro di revisione che hanno condotto cercando di complicarmi
la vita il più possibile. Grazie davvero.
Infine, un saluto a tutti i colleghi di Cornèr Banca SA.
Nonostante la quotidianità lavorativa sia in antitesi con il lavoro
da autore, un ambiente di lavoro stimolante come quello che mi
trovo a vivere giornalmente aiuta non poco a mantenere alto il
morale e la voglia di fare.
Detto ciò, credo fermamente che Flutter diventerà il
riferimento principale nel mondo del cross-platform
development, e mi auguro che riguardo il tema Flutter questo
sia solo un arrivederci.
Contattatemi pure via Linkedin
https://www.linkedin.com/in/vincenzo-giacchina-1550538b o via mail
Installazione
NOTA
I possessori macOS con processori Apple Silicon, per esempio M1,
necessitano di una dipendenza aggiuntiva (/usr/sbin/softwareupdate --
install-rosetta --agree-to-license), oltre a una versione di SDK Flutter
dedicata.
NOTA
Con il comando export impostiamo il path solo all’interno dell’istanza
della console. Per rendere permanente il path, possiamo inserire il
path assoluto nel file /etc/paths.
NOTA
Per Linux, nel caso in cui l’utente utilizzi bash, saprà che è sufficiente
aggiungere il path di Flutter nel file: $HOME/.bashrc.
Flutter doctor
Terminata l’installazione, proseguiamo con la configurazione
dell’ambiente di sviluppo. Il passo successivo si basa sull’uso
del tool doctor che consente di verificare a che punto si trova la
nostra installazione.
# flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.7.1, on macOS 12.3.1 21E258 darwin-x64,
locale it-CH)
[X] Android toolchain - develop for Android devices
X Unable to locate Android SDK.
Install Android Studio from:
https://developer.android.com/studio/index.html
On first launch it will assist you in installing the Android SDK
components.
(or visit https://flutter.dev/docs/get-
started/install/macos#android-setup
for detailed instructions).
If the Android SDK has been installed to a custom location, set
ANDROID_SDK_ROOT to that location.
You may also want to add it to your PATH environment variable.
Android
Come anticipato, Flutter ci notifica la mancanza della
toolchain Android.
NOTA
Toolchain è un nsieme di software che lavorano in sinergia per lo
sviluppo del software.
NOTA
Per utilizzare il device fisico Android durante il processo di sviluppo è
necessario procedere all’installazione del driver Google USB,
reperibile a questo indirizzo:
https://developer.android.com/studio/run/win-usb.
iOS
Come ben sappiamo, e se così non fosse potreste
tranquillamente immaginarlo dalle politiche Apple, per
sviluppare app per iOS è necessario avere un Mac, proprio
perchè il corrispettivo di Android Studio per iOS è Xcode, e
quest’ultimo è disponibile solo per Apple.
Procediamo all’installazione di Xcode tramite Apple Store
(https://developer.apple.com/xcode/).
Una volta ottenuto l’IDE, procedere avviandolo per accettare
la licenza e l’installazione delle dipendenze, che verrà fatta dal
software automaticamente. Insieme a Xcode sarà necessario
installare anche CocoaPods.
CocoaPods (https://cocoapods.org/) consente di gestire le
dipendenze; nel caso di Flutter potrebbe tornare utile nel caso si
usino plugin che necessitano di dipendenze.
# sudo gem install cocoapods
Emulazione
La connessione di un device mobile, o la sua emulazione, è
parte fondamentale nella preparazione dell’ambiente di
sviluppo; infatti, doctor lo segnala inequivocabilmente:
[!] Connected device
! No devices available
ADV
È comunque possibile creare e gestire i device virtuali tramite
il tool emulator offerto da Android SDK, installato
precedentemente. Se il path dell’SDK Android vi fosse sfuggito
durante il wizard di installazione di Android Studio, è comunque
consultabile eseguendo Android Studio e selezionando l’SDK
Manager nella lista delle Preferences (Figura 1.5).
Tramite la GUI di Android Studio è possibile lanciare
l’interfaccia Android Virtual Device Manager. Di default
dovremmo già trovare un device; infatti, avviandolo tramite
l’icona di start e procedendo a lanciare flutter doctor, noteremo
che adesso il device viene rilevato con successo.
[✓] Connected device (1 available)
Simulator
L’installazione di Xcode si porta con sé il tool simulator.
L’emulatore di Xcode è facilmente eseguibile una volta creato il
nostro progetto all’interno di Xcode. In questo caso, opteremo
per eseguire direttamente il simulatore, fuori lo scope di Xcode,
proprio per verificare che tutto l’ambiente è disponibile.
NOTA
La differenza tra emulatore e simulatore è che il primo si prefigge lo
scopo di emulare anche le risorse hardware, a differenza dei simulatori
che simulano esclusivamente il sistema operativo. Pertanto risulta
sempre fondamentale, anche in fase di sviluppo, il supporto del device
fisico per effettuare i nostri test.
Hello world
All done!
In order to run your application, type:
$ cd hello_world
$ flutter run
$ cd hello_world/
$ flutter run
Multiple devices found:
macOS (desktop) • macos • darwin-x64 • macOS 12.3.1 21E258 darwin-
x64
Chrome (web) • chrome • web-javascript • Google Chrome 107.0.5304.110
[1]: macOS (macos)
[2]: Chrome (chrome)
Please choose one (To quit, press "q/Q"): 1
Launching lib/main.dart on macOS in debug mode...
Building macOS application...
Syncing files to device macOS... 418ms
Desktop
L’output appena prodotto mostra inizialmente la creazione del
progetto tramite il comando create. L’output, in questo caso,
mostra l’esecuzione di un sottocomando flutter pub get, che è il
comando per la gestione (download) delle dipendenze. pub.dev
(https://pub.dev/) è il package manager di Dart, usato in Flutter
per gestire le dipendenze.
Il secondo step è l’esecuzione dell’applicazione hello_world,
un progetto di esempio utile per approcciare la nostra prima
applicazione in Flutter. Il comando run ci chiede se emulare
l’applicativo su un browser o sul desktop, due scelte che di
default non necessitano di nessuna configurazione particolare.
In questo caso, procediamo con la prima (Desktop), come in
Figura 2.1.
Figura 2.1 Flutter in emulazione sul desktop.
NOTA
Con il termine single-stepping si intende una funzionalità di debug
dove lo sviluppatore esegue debug del codice linea per linea istruendo
il debugger a fermarsi esattamente nella linea di codice desiderata
tramite un breakpoint.
Browser
Procediamo eseguendo nuovamente il run e avviando
l’esecuzione sul browser.
$ flutter run
Multiple devices found:
macOS (desktop) • macos • darwin-x64 • macOS 12.3.1 21E258 darwin-
x64
Chrome (web) • chrome • web-javascript • Google Chrome 107.0.5304.110
[1]: macOS (macos)
[2]: Chrome (chrome)
Please choose one (To quit, press "q/Q"): 2
Launching lib/main.dart on Chrome in debug mode...
Waiting for connection from debug service on Chrome... 25.1s
This app is linked to the debug service:
ws://127.0.0.1:60458/jXWpO1eDMlk=/ws
Debug service listening on ws://127.0.0.1:60458/jXWpO1eDMlk=/ws
Device
Proseguiamo con l’esecuzione dell’applicazione,
assicurandoci di avere un emulatore Android attivo, o iOS se
preferite.
Android
Nel mio caso, possiamo usare direttamente da linea di
comando per l’emulatore Android, piuttosto che l’IDE.
Per farlo abbiamo due modi: utilizzare lo strumento emulator
di Android SDK oppure direttamente Flutter.
$ ./Library/Android/sdk/tools/emulator -list-avds
Pixel_API_33
$ ./Library/Android/sdk/tools/emulator -avd Pixel_API_33
o
$ flutter emulators
2 available emulators:
You can find more information on managing emulators at the links below:
https://developer.android.com/studio/run/managing-avds
https://developer.android.com/studio/command-line/avdmanager
$ flutter emulators --launch Pixel_API_33
Using hardware rendering with device sdk gphone64 x86 64. If you notice
graphics artifacts, consider enabling software rendering with "--enable-
software-rendering".
Launching lib/main.dart on sdk gphone64 x86 64 in debug mode...
Checking the license for package Android SDK Build-Tools 30.0.3 in
/Users/vgiacchina/Library/Android/sdk/licenses
License for package Android SDK Build-Tools 30.0.3 accepted.
Preparing "Install Android SDK Build-Tools 30.0.3 (revision: 30.0.3)".
"Install Android SDK Build-Tools 30.0.3 (revision: 30.0.3)" ready.
Installing Android SDK Build-Tools 30.0.3 in
/Users/vgiacchina/Library/Android/sdk/build-tools/30.0.3
"Install Android SDK Build-Tools 30.0.3 (revision: 30.0.3)" complete.
"Install Android SDK Build-Tools 30.0.3 (revision: 30.0.3)" finished.
Checking the license for package Android SDK Platform 31 in
/Users/vgiacchina/Library/Android/sdk/licenses
License for package Android SDK Platform 31 accepted.
Preparing "Install Android SDK Platform 31 (revision: 1)".
"Install Android SDK Platform 31 (revision: 1)" ready.
Installing Android SDK Platform 31 in
/Users/vgiacchina/Library/Android/sdk/platforms/android-31
"Install Android SDK Platform 31 (revision: 1)" complete.
"Install Android SDK Platform 31 (revision: 1)" finished.
Running Gradle task 'assembleDebug'... 228.9s
✓ Built build/app/outputs/flutter-apk/app-debug.apk.
Installing build/app/outputs/flutter-apk/app.apk... 9.1s
Syncing files to device sdk gphone64 x86 64... 990ms
o
$ flutter emulators --launch apple_ios_simulator
Hello World
È arrivato il momento di buttare un occhio dentro al progetto
hello_world. Facciamolo direttamente da VS Code.
Il primo passo è quello di modificare il file lib/main.dart,
sostituendo la stringa You have pushed the button this many times:
le dipendenze specifiche
web: Directory destinata alla piattaforma web - app per
Browser
.idea: Directory che contiene i metadata del workspace per
l’editor IntelliJ IDEA
.gitignore: File che indica a GIT che file o directory ignorare
NOTA
GIT è un software per il controllo di versione distribuito (https://git-
scm.com/).
Build
Il comando flutter run esegue una build in modalità debug.
Per gli amanti delle definizioni, sperando di non averlo dato per
scontato, con il termine build si intende il processo che
trasforma il codice sorgente in un artefatto che potrà essere
eseguito.
Debug
La modalità debug consente di velocizzare lo sviluppo, grazie
a hot reload, e di conseguenza anche l’esecuzione sul device
fisico o virtuale. Inoltre, vengono forniti in questa modalità tutti
gli strumenti di debug, come devtools.
In questa modalità sarà possibile ispezionare il layout,
esaminare nel dettaglio tutti i componenti che compongono la
UI, analizzare codice, memoria, profilazione e tutti gli strumenti
necessari agli sviluppatori.
Release
La modalità release, flutter run –release, consente di effettuare
una build destinata a una release, ovvero alla distribuzione
finale dell’applicazione. In questa modalità la build sarà
ottimizzata al massimo garantendo anche la dimensione più
piccola possibile dell’eseguibile. Tutti gli strumenti di debug non
saranno forniti.
Inoltre, per le applicazioni destinate al web, l’applicazione
verrà minificato.
NOTA
La minificazione è un processo di compressione del codice sorgente.
Viene usata per il web per ridurre le dimensioni dei sorgenti, in genere
JS e CSS.
Profile
La modalità profile, flutter run –profile, a differenza di quella
di debug, è dedicata alla profilazione, ovvero la possibilità di
misurare le performance dell’applicazione; infatti, sarà possibile
attivarla solo su device fisici, non virtuali.
DevTools saranno comunque presenti in questa modalità e
potranno essere visionati in dettaglio sul tab Performance.
In conclusione, dopo aver parlato di build, diventa essenziale,
prima ancora di procedere con lo studio di Flutter, introdurre
Dart, il linguaggio di sviluppo di Flutter!
Capitolo 3
Dart in teoria
Aspetti peculiari
Le potenzialità del linguaggio Dart sono state
specificatamente create per sopperire a quello che JavaScript
non può offrire; mi riferisco alle sue caratteristiche intrinseche.
La storia vuole che la prima prova scritta dell’esistenza di
Dart, al tempo solo teorizzato, venga ritrovata in una mail
invitata da Mark S. Miller a una mailing list interna a Google e
poi trapelata. La mail incriminata parlava di quanto emerso
durante un summit aziendale che vedeva tra i vari partecipanti
diversi team di sviluppo, ovvero la necessità di contribuire allo
sviluppo di un nuovo linguaggio (chiamato inizialmente Dash).
L’obiettivo era sostituire JavaScript, garantendo prestazioni
migliori nell’utilizzo in progetti di dimensioni medio-grandi.
La necessità di un nuovo linguaggio partiva dal presupposto
che (riporto testualmente una frase significativa): “Javascript
has fundamental flaws that cannot be fixed merely by evolving
the language”. Da questo presupposto nasce in Google la
scommessa dello sviluppo di questo nuovo linguaggio.
Uno degli aspetti peculiari di Dart è la sua compilazione che
può essere eseguita in modalità differenti. Inizialmente, il
progetto prevedeva lo sviluppo di una Dart VM (Virtual Machine)
e una sua integrazione in Chrome, per intenderci un
corrispettivo di V8 (JavaScript engine). Nel 2015 questo
progetto viene ufficialmente abbandonato e viene data priorità
all’integrazione tramite una feature del compilatore per generare
codice JavaScript affinché si possano far girare, di fatto,
applicazioni scritte in Dart nei browser.
NOTA
La Virtual Machine in ambienti di sviluppo è un software che fornisce
un ambiente di esecuzione in cui eseguire codice ad alto livello.
# dart
Global options:
-h, --help Print this usage information.
-v, --verbose Show additional command output.
--version Print the Dart SDK version.
--enable-analytics Enable analytics.
--disable-analytics Disable analytics.
Available commands:
analyze Analyze Dart code in a directory.
compile Compile Dart to various formats.
create Create a new Dart project.
devtools Open DevTools (optionally connecting to an existing
application).
doc Generate API documentation for Dart projects.
fix Apply automated fixes to Dart source code.
format Idiomatically format Dart source code.
migrate Perform null safety migration on a project.
pub Work with packages.
run Run a Dart program.
test Run tests for a project.
Esecuzione
In Dart abbiamo due possibilità di eseguire il codice: from
source, ovvero direttamente dal sorgente, e tramite snapshot. È
importante chiarire questo aspetto prima di procedere al
dettaglio della compilazione. Non sarà facile, ma ci proveremo:
mettetevi d’impegno.
From-source
L’esecuzione partendo dal codice sorgente avviene tramite
quella che viene chiamata compilazione JIT.
JIT
La Virtual Machine esegue codice Dart compilandolo just-in-
time. La compilazione JIT viene eseguita appena prima
dell’esecuzione del codice. Una spiegazione molto spicciola è
quella che la VM non compilerà tutto il codice dell’applicativo
alla sua esecuzione, ma si “limiterà” a compilare metodi e
funzioni che dovranno essere “appena” eseguite. Inoltre, questo
processo muta costantemente durante il runtime
dell’applicazione poiché il compilatore JIT implementa delle
particolari feature, tra le quali l’Adaptive optimization, che
consente di migliorare e ottimizzare le prestazioni durante il
runtime.
La compilazione JIT si pone a metà strada tra un approccio
interpretato e una compilazione “statica”. Un linguaggio
interpretato, a differenza di un software compilato in JIT, viene
eseguito del tutto a runtime, senza che venga, appunto,
compilato ed eseguito in codice macchina.
La compilazione JIT si avvale di un layer intermedio, chiamato
kernel. Questo è necessario in quanto la compilazione just-in-
time analizza innanzitutto il codice sorgente, creando un layer
intermedio in fase di compilazione. Successivamente, il runtime
della Dart VM, che riceve in pasto il file kernel, mantiene un link
a esso, per accedere a tutti i riferimenti che verranno
successivamente portati in memoria sulla Dart VM
(deserializzazione), nel momento in cui la sua esecuzione lo
necessita.
La compilazione JIT ha questa caratteristica: grazie al suo
runtime può accedere ai suoi dati di profilazione e ottimizzare il
più possibile i processi gestiti dal compilatore in fase di
esecuzione.
Figura 3.1 IL–intermediate language.
NOTA
Per codice macchina si intende un binario, direttamente eseguibile dal
device.
Snapshot
Snapshot è un’altra strada per eseguire il codice Dart.
Proseguiamo provando a capire che cosa si intende con questo
termine nel mondo Dart.
Il termine snapshot in italiano si traduce come “instantanea”.
Abbiamo appena visto che il kernel AST viene caricato dalla
Dart VM e consente di allocare nella memoria della Dart VM gli
oggetti in riferimento alle classi e le librerie contenuti nel kernel
AST. La gestione della memoria da parte della VM è delegata a
un componente chiamato Garbage Collector, che a sua volta è
contenuto in quello che viene definito runtime.
Nel caso di uno snapshot, al contrario di quanto appena visto,
il compilatore effettua una copia dell’heap (memoria Dart VM),
con un elenco di oggetti e altre informazioni, consentendo così
un avvio molto più veloce in quanto non è necessaria, come
nella compilazione JIT, l’analisi del codice.
Se il tema focale è l’esecuzione di un applicativo Dart, lo
snapshot è, a differenza di quanto visto nel precedente
paragrafo, ciò che consente la distribuzione dell’applicativo.
Infatti, l’uso di una snapshot è necessario nel caso in cui
dovessimo prepararci alla sua distribuzione.
JIT
Nel caso di una snapshot JIT, rispetto ad avere il sorgente,
l’applicativo può essere avviato partendo da un binario. La
snapshot JIT consente di eseguire il codice più velocemente,
rispetto che partendo dal sorgente, in quanto contiene già tutte
le classi e funzioni compilate necessarie all’esecuzione
dell’applicativo. La snapshot JIT è dipendente dall’architettura;
ciò non consente di distribuire la snapshot su un’architettura
differente rispetto a quella che ha creato la snapshot.
NOTA
In informatica, nella programmazione orientata agli oggetti,
deserializzare un oggetto significa ricostruirlo a partire dalla sua
rappresentazione binaria, ottenuta da un file o da un canale di rete. Il
processo inverso si chiama serializzazione e trasforma un oggetto
nella sua rappresentazione binaria che, successivamente, può essere
salvata su una memoria di massa o si può trasmettere su un canale di
rete (fonte Wikipedia).
$ dart compile jit-snapshot example.dart
Compiling example.dart to jit-snapshot file example.jit.
Info: Compiling with sound null safety
Hello World
$ dart run example.jit
Hello World
NOTA
Da notare l’esecuzione dell’applicativo in fase di compilazione JIT.
Ecco perché si fa riferimento al fatto che la snapshot JIT contenga
porzioni di codice già compilate.
AoT
La compilazione JIT non è l’unica che offre il linguaggio Dart.
La compilazione AoT, a differenza di quella JIT, viene
precompilata del tutto. Il codice viene compilato in nativo dopo
l’analisi TFA (vedi Figura 3.3).
L’ulteriore step definito come Type Flow Analysis (TFA)
consente un’analisi statica del codice kernel AST con il fine
(consentitemi la superficialità) di eliminare le porzioni di codice
che non verranno utilizzate, implementando importanti
ottimizzazioni in fase di compilazione.
Il file binario AoT sarà sicuramente più “leggero” rispetto a
quello JIT, proprio perchè non è necessario includere altro oltre
il codice nativo. L’avvio di un applicativo compilato in AoT è
nettamente più veloce di quello JIT, proprio perché JIT, come
visto, si occupa di deserializzare le classi direttamente a
runtime, mentre in AoT questo processo è già eseguito in fase
di compilazione.
NOTA
Un aspetto peculiare è che JIT offre comunque prestazioni di picco più
elevate. Questo è possibile perché, ciclo su ciclo, il compilatore JIT,
grazie al profiling e alle informazioni sull’esecuzione del processo, è in
grado di ottimizzare il codice.
Self-contained executables
Questa compilazione genera un eseguibile per Windows,
macOS, o Linux. Il file in questione, per esempio un exe per
Windows, contiene, oltre al codice sorgente dell’applicativo, le
sue dipendenze e un ridotto runtime di Dart per gestire memoria
e garbage collector, insomma tutto il necessario per eseguire
l’applicazione. In gergo possiamo immaginare un self-contained
executables come un compilato AoT con un piccolo runtime. Il
termine runtime possiamo immaginarlo come una versione
minimale della Dart VM.
$ dart compile exe example.dart
Info: Compiling with sound null safety
Generated: /Users/vgiacchina/work/dart/example.exe
$ ./example.exe
Hello World
Dart JS
L’SDK Dart fornisce la possibilità di eseguire un’applicazione
Dart all’interno del browser. Inizialmente questo processo era
gestito da un tool separato, dart2js. Successivamente, questa
feature è stata direttamente integrata all’interno del tool dart,
tramite il comando dart compile js. Il codice Dart verrà appunto
trasformato in codice js, con la possibilità di scegliere diversi
livelli di ottimizzazione.
Mobile
Chiarito i concetti di base che ruotano intorno all’esecuzione
del codice Dart, negli step successivi eseguiremo del codice
all’interno dei nostri dispositivi mobili. La compilazione vera e
propria in codice macchina verrà gestita dal framework Flutter,
poiché verrà compilato il nostro codice Dart, oltre che l’SDK
Dart, ma anche del codice nativo per generare di fatto il nostro
apk o file ipa, rispettivamente Android e iOS.
Release
La compilazione per un progetto di release viene effettuata da
Flutter in AoT in libreria nativa, per esempio ARM. La libreria
conterrà sia il nostro codice Dart che l’SDK Dart. Per entrambe
le architetture, a loro volta, l’engine di Flutter verrà integrato
tramite Android NDK o LLVM per iOS in un progetto per creare
rispettivamente i file apk per Android e ipa per iOS.
Troverete i dettagli sulla compilazione AoT di Flutter al
seguente indirizzo:
https://github.com/flutter/flutter/wiki/Flutter-engine-operation-in-AOT-
Mode
Development
Il processo di sviluppo (debug mode), invece, viene eseguito
direttamente dalla Dart VM. Flutter rimane in attesa di
un’eventuale modifica del codice sorgente durante la sua
esecuzione; questa viene rilevata, viene generato un file kernel
e questo viene inviato al dispositivo mobile, che lo elabora e
riproduce sulla UI i relativi cambiamenti.
Il processo che abbiamo descritto è quello che viene definito
come snapshot kernel, che abbiamo già introdotto in
precedenza.
Concludendo questa parte, che ammetto essere piuttosto
ostica, possiamo immaginare che, per la parte di sviluppo,
l’applicativo porti con sé il compilatore JIT, gli strumenti di debug
e il runtime, composto dal garbage collector. Invece, per la parte
di release l’applicazione conterrà solo il runtime; proprio per
questo motivo vi è questa differenza tra le dimensioni di due
applicativi compilati nei due differenti modi.
Flutter
Avendo appreso la teoria che si cela dietro l’uso di Dart in
Flutter, proseguiamo con una breve introduzione di quella che è
l’architettura di alto livello di Flutter. Fatto ciò, prometto che
inizieremo a vedere del codice.
Architettura
L’architettura Flutter è composta da 3 strutture che devono
essere viste “verticalmente”:
Framework;
Engine;
Embedder.
Engine
Il layer Engine è lo strato intermedio, il cuore C++ di Flutter.
Questo strato è definito engine proprio perché compone il
motore di Flutter. L’engine lavora a runtime e fornisce tutte le
API di basso livello: le interfacce per la rete e per l’I/O, le
animazioni, l’architettura per i plugin e tutta la toolchain Dart per
la compilazione e l’esecuzione.
Embedder
L’embedder è il punto di contatto tra Flutter e il sistema
operativo; questo layer sarà specifico per piattaforma.
L’embedder è scritto in Java e C++ per Android, Obejct-C/C++
per iOS e macOS e infine C++ per Linux e Windows.
La coerenza della UI in ambienti cross-platform è un punto di
forza di Flutter ed è gestita esattamente in questo layer. Il layer
Embedder dialoga con le API offerte del sistema operativo per il
calcolo della superficie del rendering. Oltre a questo l’embedder
è responsabile della gestione degli eventi (Event Loop), la
gestione dei thread, l’accessibilità, insomma tutta l’interazione
reale dell’applicativo con il device.
NOTA
Flutter è uno strumento così potente da rendere possibile la creazione
di un embedded specifico (layer embedder) che consentirebbe di
eseguire un’applicazione Flutter su una nuova piattaforma
(https://github.com/flutter/flutter/wiki/Custom-Flutter-Engine-
Embedders).
Reactive
Flutter può essere definito “reattivo”, poiché la sua architettura
consente questa particolarità. Affronteremo nel capitolo
successivo i widget, scopriremo che cosa sono e come
svilupparli. I widget compongono la nostra UI e una UI reattiva
consente di ragionare per componenti e porzioni specifiche della
user interface che compone il layout di un’applicazione. Questo
approccio garantisce un’efficienza migliore in termini di
progettazione e prestazioni.
Come vedremo, Flutter manterrà separata la logica di
business e i relativi dati dell’interfaccia dell’applicazione; questo
grazie al concetto di widget e poi di albero, che verrà
approfondito più avanti. Il widget è un contenitore di una
configurazione, la descrizione di un componente immutabile, e
Flutter riflette le modifiche sulla UI nel modo più puntuale
possibile.
Ciò che vedremo si basa sull’uso di componenti (widget), che
definiremo con una configurazione, in una porzione specifica
(rapporto padre-figlio) e Flutter si occuperà di utilizzare e
riutilizzare i widget a runtime, ottimizzando il più possibile i
processi di building e rendering.
Il nostro codice non gestirà realmente la modifica
dell’interfaccia, ma piuttosto notificheremo un cambiamento che,
grazie a un mapping tra “widget” e la sua reale implementazione
all’interno dell’engine di Flutter, consentirà all’interfaccia di
essere modificata da Flutter stesso nel modo più efficiente
possibile.
Questo è il concetto di reactive user interface.
Non preoccupatevi, tutto prenderà maggiore chiarezza una
volta approcciato lo sviluppo e il ciclo di vita di un’applicazione
Flutter.
Capitolo 4
Linguaggio
Aspetti peculiari
Gli aspetti peculiari di Dart, che vedremo in questo capitolo,
possono essere riassunti in queste feature.
Dart è un linguaggio open source.
Dart è un linguaggio platform independent.
Dart è un linguaggio compilato.
Dart supporta la programmazione concorrente.
Dart è object oriented ed “everything’s an object”.
Dart è fortemente tipizzato, ma allo stesso tempo può
gestire la tipizzazione dinamica. Inoltre Dart supporta i tipi
generici.
Dart supporta il null safety. In poche parole puoi gestire se
una variabile può o non può contenere un tipo null, ed
eventualmente anche gestirne un’eccezione.
Dart supporta top-level declaration
Dart è un linguaggio con una curva di apprendimento molto
interessante. È un linguaggio semplice da imparare; pertanto
non perdiamoci in chiacchiere.
Variabili
Le variabili sono contenitori di dati. I dati in Dart possono
essere di diversi tipi (lo vedremo nel prossimo paragrafo). La
dichiarazione delle variabili può contenere o meno la definizione
del tipo di dato che la variabile conterrà, ma per garantire un
approccio type-safe è assolutamente consigliato che venga
fatto. L’approccio type-safe esclude la possibilità di incappare in
un tipo di dato non gestito. Non preoccupatevi, l’installazione
dell’estensione Dart, effettuata in VS Code qualche capitolo fa,
vi garantisce in real time la notifica di errori e warning e vi
supporta nella gestione dei tipi.
keyword <tipo_di_dato> nome;
var
void main() {
var a = 'pippo';
}
dynamic
L’uso della keyword dynamic introduce la possibilità di cambiare
la tipologia di dato durante l’esecuzione. Il controllo del tipo
dynamic verrà così demandato direttamente alla fase di
esecuzione (runtime). Superfluo dire che, là dove è possibile, è
meglio evitare l’uso di dynamic, in quanto è sempre bene sapere
in anticipo con che tipologia di dato andremo a lavorare.
void main() {
dynamic a = 'pippo';
a = 3;
}
const
La keyword in questione definisce un dato immutabile. Viene
definita in fase di compilazione e dopo la sua dichiarazione non
sarà possibile sovrascrivere il dato in questione.
void main() {
const a = 'pippo';
}
final
Il concetto di dato immutabile vale anche per final, con la
differenza che questo dato può essere dichiarato anche in fase
di esecuzione, oltre che in compilazione.
late
La keyword late introduce il concetto di Lazy initializing.
Quando una variabile late viene dichiarata, questa viene
inizializzata solo quando verrà realmente usata.
late String a;
void main() {
a = 'pippo';
print(a);
null safety
Dart ha il concetto di null safety. Questa feature garantisce
che non vi è la possibilità di accedere a una variabile che può
contenere null. Per questo motivo tutte le variabili sono not-
nullable di default.
void main() {
String a;
print(a);
}
È bene tenere a mente che ogni qual volta che si dichiara una
variabile senza assegnare un valore (inizializzazione), il suo
valore di default sarà null.
Tipi di dati
I principali tipi di dati che possono essere definiti in Dart sono:
int, double, String, bool, List, Set, Map.
Numeri
Il tipo di dato Numbers può contenere due tipologie di
Numbers: gli int e i double. Gli int (Integer, intero in italiano)
contengono numeri interi e i double i numeri in virgola mobile.
void main() {
int num1 = 2;
double num2 = 1.5;
var num3 = 1.5;
print(num1 + num2 + num3);
}
NOTA
Le keyword sono “parole” riservate al linguaggio che non possono
essere utilizzate in altro modo dallo sviluppatore. var è un esempio.
Debug
Un aspetto che ci consente di utilizzare gli strumenti di debug
offerti da Dart è l’uso dei breakpoint. Il breakpoint consente di
stoppare l’esecuzione del codice, durante le fasi di sviluppo, per
analizzarla; per esempio: analizzare variabili locali, parametri,
funzioni, condizioni, oggetti ecc. È uno strumento
incredibilmente utile a uno sviluppatore e vi accompagnerà
costantemente nel processo di sviluppo. VS Code può eseguire
un file .dart in modalità debug e, se avremo posizionato un
breakpoint, basterà attivare “un punto di interruzione” all’inizio
della riga che si vuole stoppare. Una volta interrotta
l’esecuzione, fermandosi con il cursore sulla riga in oggetto è
possibile accedere ai dettagli del runtime. Ad applicazione
interrotta viene mostrato un menu che consente diverse opzioni,
tra le quali quella di riprendere il flusso, la possibilità di
proseguire riga per riga (F10), oltre a quella di ispezionare
anche il corpo della funzione presente nel flusso (F11).
Assert
Assert è una keyword che consente di facilitare il debug del
Stringhe
Il tipo di dato Stringa è definibile con la keyword String.
void main() {
var a = "Hello";
String b = "World";
print('$a $b');
print(a + ' ' + b);
}
$ dart example_3.dart
Hello World
Hello World
NOTA
In Dart i commenti si possono esplicitare con i due caratteri // per la
singola riga o tutto quello che si trova tra i caratteri /* e */. In questo
modo il compilatore ignorerà del tutto quanto scritto come commento.
Booleani
Il dato booleano accetta solo due costanti: true e false.
void main() {
bool equal;
equal = 1==1;
print(equal);
}
$ dart example_4.dart
true
Liste
Le liste, meglio conosciute ai più anziani come array,
contengono una lista ordinata di oggetti (ricordiamoci che ogni
cosa è un oggetto in Dart).
void main() {
List num = [1, 2];
var words = ['uno', 'due'];
print('${num[0]} ${words[1]}');
}
$ dart example_5.dart
1 due
$ dart example_6.dart
[1, 2, 3]
print(a);
print(a['uno']);
a = Map();
a['tre'] = 3;
a['quattro'] = 4;
print(a);
print(a['tre']);
}
Set
I set sono una lista non ordinata di elementi. Possono essere
utilizzati per memorizzare una lista univoca di input.
void main() {
var list = {'uno', 'due', 'tre', 'tre', 'quattro'};
print(list);
}
$ dart example_8.dart
{uno, due, tre, quattro}
Generici
I generici sono una forma di definizione del dato che consente
di scrivere codice più sicuro. Prendiamo per esempio la
dichiarazione:
List a = [1, 2];
Funzioni
Le funzioni sono dei costrutti che racchiudono delle istruzioni
per svolgere un determinato compito.
void main() {
String text = helloworld("Hello", "World");
print(text);
}
String helloworld(String str1, String str2){
return '$str1 $str2';
}
$ dart example_10.dart
Hello World
main
La funzione a cui dare risalto, seguendo tutti gli esempi finora
trattati, è certamente la funzione main(). La funzione main è
indispensabile per qualsiasi applicazione scritta in Dart poiché è
il suo punto di ingresso, il nostro “entry point”.
È possibile passare dei parametri alla funzione main, utile per
esempio nello sviluppo di un batch o uno script da console.
void main(List a) {
print(a);
}
String helloworld() {
return "Hello World";
}
$ dart example_12.dart
Hello World
Scope
Lo scope è la definizione della visibilità di una variabile
all’interno di una porzione di codice. Dart è un linguaggio
definito “lessicale” (lexical language) e la visibilità di una
variabile è facilmente ricavabile osservando se questa è definita
tra un blocco di parentesi graffe.
Un esempio banale, ma efficace.
main() {
String main = "Main scope";
void child() {
String levelOne = 'Second scope';
void subchild() {
String levelTwo = Third scope';
print('$main');
print('$levelOne');
print('$levelTwo');
}
subchild();
}
child();
}
$ dart example_14.dart
Main scope
Second scope
Third scope
Lo scope della variabile main è riferita a tutto il blocco che vive
all’interno della funzione main, infatti questa è visibile anche dalla
funzione subchild. Diversamente la funzione levelTwo vive solo
all’interno della funzione subchild. Se provassimo a inserire un
print('$levelTwo'); fuori dalla funzione subchild, il compilatore ci
avvertirebbe con un: Error: Undefined name 'levelTwo'.
Closure
Il termine closure indica una funzione che mantiene accesso
al suo scope anche se richiamata fuori dal suo contesto.
Function addOne() {
int count = 1;
return () {
count = count + 1;
print(count);
};
}
void main() {
f = addOne();
f();
f();
}
$ dart example_15.dart
2
3
funzione.
La funzione interna a addOne accede tramite scope alla
variabile count e si premura, a ogni esecuzione, di aumentare
count di uno. Nonostante l’esecuzione di addOne termini, la
funzione interna mantiene la sua referenza alla variabile count e
questo ci consente di incrementare il suo valore anche al di fuori
di addOne. Questo approccio è definito come lexical closure.
Arrow syntax
Arrow syntax è una sintassi compatta molto usata. In pratica
consente di utilizzare il costrutto => per le funzioni che sono
composte da una sola istruzione, e ciò consente di risparmiare
qualche linea di codice. Facciamo un esempio.
void main() {
final int a = sum(1, 1);
print(a);
}
Classi
La classe è un concetto fondamentale della programmazione
orientata agli oggetti. Come ripetuto molte volte, “everything is
an object”: ogni oggetto ha un suo modello che lo definisce.
Questo modello è una classe. L’oggetto altro non è che l’istanza
di una classe.
class Book {
String? title;
int pages = 0;
bool read = false;
static String catalog = "Marketing";
void main(){
var o = new Book("Dart", 200);
o.read = true;
o.desc();
o.changeCatalog("Development");
print(Book.catalog);
}
Costruttore
Il costruttore è una funzione speciale che viene eseguita in
fase di inizializzazione della classe. Nel nostro caso il
costruttore Book ci consente di valorizzare i membri tramite
parametri passati al costruttore. Il costruttore non è mandatorio
e non ha nessun valore di ritorno.
Book(String title, int pages){
this.title = title;
this.pages = pages;
}
print(b1.title);
print(b2.title);
}
class Book {
String? title;
Book() {
print("undefined book");
}
Book.withTitle(String title) {
this.title = title;
}
}
$ dart example_17.dart
undefined book
null
Dart
This
Un’altra novità introdotta dal sorgente appena mostrato è
l’operatore this.
Book(String title, int pages){
this.title = title;
this.pages = pages;
}
Static
Nell’esempio introdotto con il paragrafo Class abbiamo anche
introdotto la keyword static. Le variabili definite static vengono
referenziate a livello di classe, infatti è possibile accedervi
direttamente dalla classe e non dall’oggetto; di conseguenza
tutte le istanze di Book faranno riferimento unico a questo
“catalog”.
Getter/Setter
Get e Set sono metodi speciali che consentono di applicare
della logica nel leggere o scrivere dati all’interno della classe.
Nell’esempio fornito possiamo vedere come applicare una
logica nella fase di scrittura del membro title.
void main() {
Book b = new Book();
b.book_title = "Flutter";
print(b.book_title);
}
class Book {
String? title;
String? get book_title {
return title;
}
Ereditarietà
L’ereditarietà è un concetto portante della programmazione a
oggetti. Per ereditarietà si intende la possibilità di definire una
classe ereditando tutti gli attributi di una classe madre. Nella
fattispecie, Dart supporta l’ereditarietà singola, ovvero non può
ereditare caratteristiche da più classi.
void main() {
DevBook b = new DevBook();
b.catalog();
b.devCatalog();
}
class Book {
void catalog() {
print('Book catalog');
}
}
void main() {
DevBook b = new DevBook();
}
class Book {
void catalog() {
print('Book catalog');
}
Book(){
print("Book costructor!");
}
}
$ dart example_19.dart
Book costructor!
DevBook costructor!
class Book {
String? title;
void catalog() {
print('Book catalog');
}
Book(String title){
this.title = title;
print("Book constructor with title $title");
}
}
Override
Se volessimo sovrascrivere il metodo della classe madre
dovremmo usare l’annotation override. Le annotazioni sono
strumenti che consentono di informare il compilatore di una
peculiarità. Nel nostro caso l’uso dell’annotation @override,
appena prima di un metodo, informa il compilatore (e lo
sviluppatore, migliorando così anche la manutenibilità del
codice) che stiamo sovrascrivendo il metodo della classe
madre. È possibile in Dart usare @override per metodi, membri e
getter/setter.
void main() {
DevBook b = new DevBook();
b.catalog();
}
class Book {
void catalog() {
print('Book catalog');
}
}
$ dart example_19.dart
new Book catalog
Interfacce
Le interfacce definiscono il modello di una classe in maniera
astratta, descrivono i metodi della classe e i membri (escluso il
costruttore). L’interfaccia conterrà solo la definizione del
metodo, poi il corpo del metodo sarà implementato nella classe
che utilizza l’interfaccia.
Il suo uso è tipico della programmazione a oggetti ed è
presente in moltissimi linguaggi. In Dart le interfacce sono
implicite, ovvero nascono direttamente quando definiamo la
nostra classe. Al momento in cui creiamo una classe, dietro le
quinte possiamo immaginare la creazione speculare della sua
interfaccia.
Proviamo a chiarire il concetto con un esempio.
class Book {
String? title;
void catalog() {
print('Book catalog');
}
}
void main() {}
Abstract
Le classi abstract vengono usate per rendere più manutenibile
ed efficiente la struttura del codice. Questo concetto è tradotto,
nel linguaggio della programmazione a oggetti, come
“astrazione”. In altre parole si astraggono delle classi, che
rappresentano funzionalità specifiche prive di implementazione,
con il fine di frammentare il design del progetto in parti più
piccole e circoscritte. Immaginiamo di avere una classe già in
uso che vogliamo riscrivere, ma che rimanga “compatibile” con il
resto del codice. Se avessimo una classe che astrae i metodi,
utilizzeremmo quella per “rifattorizzare” la classe e questa
rimarrebbe “compatibile”.
abstract class Book {
void catalog();
}
b.catalog();
m.catalog();
}
$ dart example_21.dart
DevBook catalog
MathBook catalog
NOTA
Il refactoring o rifattorizzazione è una pratica dell’ingegneria del
software che prevede una modifica importante alla struttura del codice
senza però modificarne il comportamento.
Mixin
Come già anticipato poco sopra, Dart non ha l’ereditarietà
multipla. Per intenderci, Dart potrebbe ereditare i metodi di una
classe provando a estendere le classi a cascata. Facciamo un
esempio banale:
class Test {
var t1 = 0;
}
class Test1 extends Test {
var t2 = 1;
}
class Test2 extends Test1{
var t3 = 2;
}
void main(){
Test2 t = Test2();
print('${t.t1} ${t.t2} ${t.t3}');
}
$ dart example_22.dart
0 1 2
mixin MathBook {
void mathCatalog() {
print("MathBook catalog");
}
}
mixin BioBook {
void bioCatalog() {
print("BioBook catalog");
}
}
void main() {
DevBook b = DevBook();
b.catalog();
b.bioCatalog();
b.devCatalog();
b.mathCatalog();
}
$ dart example_23.dart
Book catalog
BioBook catalog
DevBook catalog
MathBook catalog
Istruzioni di controllo
Come in qualsiasi linguaggio conosciuto dall’uomo, i principali
“control instruction” sono presenti anche in Dart. Vediamo
superficialmente come vengono approcciati.
If/else rappresentano le istruzioni condizionali
if(1==0) {
//
} else if(1==2) {
//
} else {
//
}
do {
print(a); // 4
} while (a>4);
Il costrutto switch-case si basa su un confronto e una serie di
blocchi che ne identificano la scelta.
int a = 4;
switch(a){
case 3:
print('3');
break;
case 4:
print('4'); //blocco eseguito
break;
default:
print('-1');
}
Eccezioni
La gestione delle eccezioni è una pratica comune in tutti i
linguaggi di programmazione. Le eccezioni sono eventi anomali
che alterano il flusso del programma e Dart offre il costrutto
try/catch per gestirle.
IOException
DeferredLoadException
FormatException
IsolateSpawnException
Timeout
$ dart example_24.dart
Exception:IntegerDivisionByZeroException
void main() {
try {
int result = 20 ~/ 0;
print('result:$result');
} catch (e) {
print('Exception:$e');
} finally {
print('finally');
}
}
$ dart example_25.dart
Exception:IntegerDivisionByZeroException
finally
Moduli
Il codice Dart può essere modularizzato. L’approccio alla
programmazione modulare ci consente di strutturare il codice
con maggiore attenzione, rendendo così le nostre applicazioni
più manutenibili. In Dart questo concetto è ancor di più
esasperato in quanto “every Dart app is a library”. In genere
chiamiamo modulo una routine che, “importata” nel nostro
codice, introduce una nuova feature; la libreria può essere
immaginata come una raccolta di moduli.
Le librerie in Dart si importano con l’uso della keyword import e
possono essere importate come librerie “core” di Dart, facendo
direttamente uso del prefisso dart: o tramite il prefisso package:,
se effettivamente stiamo importando un package, oppure
puntando direttamente al file Dart solo con il suo path.
NOTA
Un package in Dart è una libreria, o anche un tool, che può essere
distribuito grazie al suo package manager.
import 'dart:math';
void main() {
print(Random().nextInt(6));
}
$ dart example_26.dart
5
$ dart example_26.dart
2
void main() {
helloworld();
}
$ dart example_27.dart
Hello World
Iterable
Gli iterable sono collezioni di valori a cui è possibile accedere
in modo sequenziale. Gli iterable sono alla base di altre strutture
dati, per esempio List o Map. La loro struttura è simile al tipo di
dato List, ma con la differenza che iterable non consente l’uso di
un indice tramite []. Iterable implementa anche metodi che
consentono di interagire con gli elementi, come first, last, e
elementAt. Il primo metodo consente di accedere al primo
elemento dell’Iterable, last l’ultimo e infine elementAt accede
tramite un indice.
Facciamo un esempio.
void main() {
Iterable<int> i = [0, 1, 2];
print(i.first);
print(i.last);
print(i.elementAt(0));
for (var n in i) {
print(n);
}
}
Operazioni asincrone
Dart offre la possibilità di eseguire, e di conseguenza gestire,
il risultato di operazioni asincrone. Le operazioni asincrone sono
routine che possono essere gestite parallelamente al flusso di
esecuzione del codice. Possiamo immaginare una casistica in
cui una funzione dovrà recuperare dei dati dalla lettura di un file,
piuttosto che da una chiamata HTTP, e allo stesso momento
lasciare libero l’utente di proseguire nell’uso dell’applicativo fino
alla notifica dell’avvenuta ricezione del dato.
In Dart, l’asincronicità è gestita fondamentalmente attraverso
due strumenti: Future e Stream.
Procediamo con un esempio piuttosto immediato.
void main() {
Future.delayed(Duration(seconds: 2), () {
print('2');
});
print('1');
}
$ dart example_28.dart
1
2
Future
Il costruttore Future è istanziabile con una funzione, nella quale
potremo gestire le nostre routine asincrone.
Future<String> f = Future(() {
return 'Hello World';
});
Then
L’istanza Future ci offre il metodo then per accedere al risultato
dell’operazione asincrona, che corrisponde all’esito della Future.
void main() {
helloWorld().then((value) {
print('Future value: $value');
});
}
Future<String> helloWorld() {
return Future<String>.delayed(Duration(seconds: 2), () {
return 'Hello World';
});
}
$ dart example_29.dart
Future value: Hello World
Async/Await
Rispetto a quanto visto, un’altra possibilità è l’uso delle
keyword: async/await. La differenza rispetto all’uso del metodo
then consiste nel fatto che con async/await il flusso del codice
viene interrotto fino al completamento della Future.
void main() async {
String value = await helloWorld();
print('Future value: $value');
}
Future<String> helloWorld() {
return Future<String>.delayed(Duration(seconds: 2), () {
return 'Hello World';
});
}
Errore
La gestione degli errori dovrà essere gestita tramite il
costrutto try/catch
void main() async {
try {
String value = await helloWorld();
print('Future value: $value');
} catch (e) {
print('Future error: $e');
}
}
Future<String> helloWorld() {
return Future<String>.delayed(Duration(seconds: 2), () {
throw 'Error';
});
}
$ dart example_31.dart
Future error: Error
void main() {
helloWorld().then((value) {
print('Future value: $value');
}, onError: (e) {
print(e);
});
}
Future<String> helloWorld() {
return Future<String>.delayed(Duration(seconds: 2), () {
throw 'Error';
});
}
$ dart example_32.dart
Error
void main() {
helloWorld().then((value) {
print('Future value: $value');
}).catchError((e) {
print('$e');
});
}
Future<String> helloWorld() {
return Future<String>.delayed(Duration(seconds: 2), () {
throw 'Error';
});
}
$ dart example_33.dart
Error
whenComplete
Esattamente come l’uso del metodo finally nel costrutto
try/catch, whenComplete esegue lo stesso compito.
Indipendentemente dal successo o dal fallimento della Future,
whenComplete sarà comunque eseguita.
helloWorld().then((value) {
print('Future value: $value');
}).catchError((e) {
print('$e');
}).whenComplete(() {
print('complete!');
});
}
$ dart example_34.dart
Future value: Hello World
complete!
Stream
La classe stream consente di gestire un flusso di eventi
asincroni. La gestione della comunicazione è piuttosto semplice.
L’emittente emette un dato e questo verrà ricevuto se ci sarà un
listener in ascolto.
Facciamo subito un esempio pratico.
import 'dart:async';
Stream helloWorld() {
return Stream.periodic(const Duration(seconds: 1), (index) {
return 'Hello World';
});
}
void main() {
Stream h = helloWorld();
h.listen((event) {
print('Stream value $event');
});
Stream s = singleHelloWorld();
s.listen((event) {
print('Stream single value $event');
});
}
void main() {
Stream s = incrementValue(0);
s.listen((event) {
print('Stream value $event');
});
}
StreamController
La classe StreamController è per definizione un controller di
Stream. StreamController rende meno ostica la gestione dei flussi:
consente di gestirne i dati ed eventuali errori. È anche possibile
accedere con facilità ad altre informazioni relative allo Stream,
per esempio i sottoscrittori.
import 'dart:async';
void main() {
int a = 0;
final streamController = StreamController<int>();
Timer.periodic(Duration(seconds: 1), (timer) {
if (a > 3) {
streamController.close();
timer.cancel();
return;
}
streamController.add(a++);
});
streamController.stream.listen(
(event) {
print(event);
},
onDone: () => print('Done'),
onError: (error) => print(error),
);
}
$ dart example_35.dart
0
1
2
3
Done
NOTA
La classe StreamSubscription definisce una sottoscrizione al flusso
Stream, ed è esattamente l’istanza ritornata dal metodo listen.
Il primo passo è quello di importare la libreria async, per
supportare la classe streamController. La classe in questione
consente di creare uno stream e gestire il suo flusso. In questo
esempio faccio uso di Timer.periodic che consente di invocare
una callback ogni secondo.
All’interno del timer inviamo, a ogni invocazione della
callback, un dato (a). Facciamo anche in modo di chiudere lo
streaming dopo 3 secondi. La chiusura dello streaming è
definita dal metodo close.
Dall’altra parte del tubo, resteremo in attesa del dato tramite il
metodo listen dell’oggetto stream a cui potremo accedere grazie
al controller. Il metodo listen gestisce tre callback differenti. La
prima viene invocata per accedere al dato ricevuto dallo stream.
A seguire troviamo: onDone e onError. In ordine, la prima callback
scatta quando lo stream viene chiuso tramite il metodo close, il
secondo quando il dato è inviato tramite il metodo addError.
Broadcast
L’esempio precedente ha una tipologia di stream 1:1. Ciò
vorrebbe dire che, se avessimo provato a metterci in ascolto
due volte sullo stesso Stream, avremmo ottenuto un errore.
Esiste la possibilità di inviare un dato in broadcast. Il termine
broadcast sta a significare che l’emissione dello stesso dato
verrà inviato a più destinatari.
Procediamo con un esempio modificando il precedente.
import 'dart:async';
void main() {
int a = 0;
final streamController = StreamController<int>.broadcast();
Timer.periodic(Duration(seconds: 1), (timer) {
if (a > 3) {
streamController.close();
timer.cancel();
return;
}
streamController.add(a++);
});
streamController.stream.listen(
(event) {
print('listen One: $event');
},
onDone: () => print('listen One:Done'),
onError: (error) => print(error),
);
streamController.stream.listen(
(event) {
print('listen Two: $event');
},
onDone: () => print('liste Two:Done'),
onError: (error) => print(error),
);
}
$ dart example_36.dart
listen One: 0
listen Two: 0
listen One: 1
listen Two: 1
listen One: 2
listen Two: 2
listen One: 3
listen Two: 3
listen One:Done
liste Two:Done
await for
Esiste un’alternativa all’uso di listen per l’elaborazione dei dati
all’interno di uno Stream. Questa alternativa è l’uso del costrutto
await for.
print('stream closed');
}
$ dart example_37.dart
0
1
2
3
stream closed
Widget
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
@override
State<MyHomePage> createState() => _MyHomePageState();
}
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
MyApp widget
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
Super initializer
La definizione di questo costrutto potrebbe confondervi in
quanto nel capitolo di Dart non abbiamo trovato una sintassi di
questo tipo. Questa sintassi è definita “super initializer” ed è una
abbreviazione introdotta dalla versione 2.17 di Dart.
La sintassi const MyApp({super.key}) è un’abbreviazione di:
const MyApp({Key key}) : super(key: key)
Build
Infine, proseguiamo con l’override di build. Build è un metodo
(StatelessWidget class) che si occupa di definire l’oggetto
Widget. In questo caso definiamo il widget MaterialApp con le
chiavi title, theme, primarySwatch e home, rispettivamente il
titolo, il tema complessivo dell’app definito tramite la classe
ThemeData, il materialColor primarySwatch e il widget
MyHomePage.
NOTA
Il tema viene definito per tutto l’applicativo tramite la key theme in
MaterialApp; ciò non toglie che questo comportamento possa essere
ridefinito per una porzione di albero specifica usando il widget Theme,
che avvolgerà il widget a cui si vuole configurare un tema differente.
Material design
Il Material design è un design creato da Google che dal 2015
ha iniziato a caratterizzare tutte le principali applicazioni del
parco Google, fino a introdurre, con Android 12 (parliamo del
2021), un nuovo rifacimento del Material design che caratterizza
tutto il sistema operativo Android. Nonostante Google abbia
creato il Material design parecchi anni fa per offrire una
coerenza di UI e UX in tutto il parco Google, app e web, solo di
recente è stato fatto un ulteriore e fondamentale passo avanti
verso un’evoluzione che mette al centro di tutto la
personalizzazione dell’esperienza utente, ovvero il Material You,
chiamato anche Material 3.
Flutter ha già iniziato a migrare i widget verso Material 3 e la
sua introduzione in Flutter è già gradualmente in atto, proprio
per essere il più indolore possibile. A oggi è possibile, dove
concesso, forzare l’uso del nuovo design con la property
useMaterial3 presente in ThemeData. Nei nostri sorgenti non ne
faremo uso, ma vi invito a usare questa property proprio per
vedere le differenze attraverso i widget.
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Widget
Flutter offre widget per coprire ogni aspetto che la vostra app
vorrà offrire. Negli esempi che vedremo, come già affrontato in
hello_world, troveremo widget che estendono due tipologie di
classi: Stateless e Stateful. Oltre a queste categorie, ci sono
anche widget legati strettamente al layout che sono detti widget
invisibili, semplicemente perché non renderizzano elementi
grafici tangibili. In Figura 5.1, nell’esempio precedente, Column
è un widget di questo tipo.
StatelessWidget
I widget che non modificano il loro stato perché rimangono
statici nel corso dell’esecuzione dell’app sono definiti stateless.
import 'package:flutter/material.dart';
void main() {
runApp(
const Center(
child: Text(
'Hello World',
textDirection: TextDirection.rtl
),
),
);
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return const Center(
child: Text(
'Hello World',
textDirection: TextDirection.ltr
),
);
}
}
L’esempio appena creato fa uso del metodo BuildContext, che
è un aspetto fondamentale dello sviluppo di applicazioni in
Flutter.
BuildContext
Il metodo build ha come argomento BuildContext. Il metodo
build crea un nuovo nodo all’interno del widget tree e allo stesso
tempo BuildContext inietta nella funzione builder ciò che viene
chiamato context. Il context può essere immaginato come un
ponte di accesso dal widget al widget tree.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.amber,
child: const Text("Hello World", textDirection: TextDirection.ltr)
);
}
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primaryColor: Colors.lightBlue[800]),
home: Scaffold(
body: Center(
child: Text(
"Hello World",
textDirection: TextDirection.ltr,
style: TextStyle(color: Theme.of(context).primaryColor)
)
)
)
);
}
}
StatefulWidget
L’interattività in un’applicazione è un aspetto di vitale
importanza. Rispetto ai widget stateless, che sono statici, i
widget Stateful sono dinamici e consentono di gestire i
cambiamenti dell’interfaccia che possono avvenire, per
esempio, sia per l’interazione dell’utente con la User Interface
che per eventi asincroni. Il widget in sé stesso rimane
comunque immutabile; ciò che invece cambia è lo stato
associato al widget.
Il widget gestisce la sua dinamicità attraverso l’uso degli stati.
Prendiamo come esempio la classe MyHomePage del solito
hello_world:
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
State
Infine l’override è l’aspetto peculiare dei widget stateful. Il
corrispettivo del metodo build per questa tipologia di widget è il
metodo createState. Questo metodo crea uno stato mutabile, a
differenza del widget, a cui viene associata l’istanza del widget.
In genere ogni nuova istanza del widget genera un nuovo
stato. Esistono delle possibilità che vi consentono di condividere
lo stato tra più istanze tramite una special key, ma lo vedremo
qualche capitolo più avanti.
Alla creazione dello stato viene associata la classe
_MyHomePageState che si occuperà di gestire lo stato.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Lifecycle
Il ciclo di vita dei widget è differente tra widget stateless o
stateful. Il lifecycle di un widget stateless è piuttosto semplice
(vedi Figura 5.3): abbiamo la classe e il suo processo di build
che restituisce un widget.
Il widget stateful ha un lifecycle più complesso. La gestione
dello stato ha un suo ciclo di vita ben definito. Proviamo ad
analizzare insieme la Figura 4. Il widget, ha il suo costruttore
che definisce un nuovo stato. La creazione di un nuovo stato
mette in campo un processo ben definito. Il primo passo è la
valorizzazione di mounted, una property della casse State. Se la
property è settata a true, il widget risulterà “montato”, ovvero
presente nel widget tree. Il passo successivo è l’esecuzione del
metodo initState, questo viene eseguito appena dopo la
creazione dello state. initState può essere utilizzato, ad
esempio, per gestire eventi attraverso dei listener, vedremo più
avanti qualche caso d’uso.
initState verrà eseguito una sola volta, dopo la creazione del
widget. Proseguendo didChangeDependencies verrà eseguito
quando cambia una dipendenza del widget, ad esempio un
widget genitore.
Figura 5.3 Lifecycle stateless widget.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
@override
State<MyHomePage> createState() {
debugPrint('Parent createState!');
return _MyHomePageState();
}
}
void _incrementCounter() {
setState(() {
debugPrint('Parent setState!');
_counter++;
});
}
@override
void initState() {
debugPrint('Parent initState!');
super.initState();
}
@override
void didChangeDependencies() {
debugPrint('Parent didChangeDependencies!');
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
debugPrint('Parent build!');
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
ElevatedButton(
style: ElevatedButton.styleFrom(textStyle: const
TextStyle(fontSize: 12)),
onPressed: _incrementCounter,
child: const Text('Increment'),
),
const ChildHomePage(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () =>
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const
NewPage())),
tooltip: 'Increment',
child: const Text('New'),
),
);
}
}
@override
void didUpdateWidget(ChildHomePage oldWidget) {
debugPrint('ChildHomePage didUpdateWidget!');
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(10),
child: Text('I\'m a child')
);
}
}
@override
void deactivate() {
debugPrint('NewPage deactivate!');
super.deactivate();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('New Page'),
),
body: const Center(
child: Text('This is the new page')),
);
}
}
NOTA
Il widget Padding consente di inserire il padding, ovvero dello spazio
vuoto tra il widget ed il suo bordo.
createState
Il metodo createState è invocato una singola volta per la
creazione di uno State. Potrebbe essere invocata più volte se lo
stesso widget è presente più volte nel nostro widget tree. Al
contrario, un widget potrebbe essere eliminato dal widget tree e
ricreato successivamente.
Nell’esempio che abbiamo appena fornito, possiamo vedere
che il metodo createState è invocato una volta per il widget
MyHomePage e N volte per il widget NewPage. Senza entrare
adesso nel dettaglio del widget Navigator, possiamo pensarlo
come uno strumento che ci consente di navigare verso altre
pagine che compongono la nostra applicazione. In questo il
widget verrà creato e rimosso definitivamente dall’albero ogni
volta.
NOTA
Come potete notare VS Code, tramite un warning, sconsiglia di
introdurre logica applicativa nel metodo createState. È sempre buona
norma seguire i consigli che Dart fornisce tramite l’analisi del codice
che effettua. La configurazione dell’analisi può essere cambiata
personalizzando la configurazione definita nel file analysis_options.yaml.
initState
Il metodo initState viene invocato esattamente dopo il metodo
createState, appena il widget viene inserito nel widget tree. La
logica segue quella di createState: N volte verrà creato un
oggetto State, N volte verrà invocato questo metodo. L’override
di initState, (ciò significa voler applicare una logica in questo
punto del lifecycle) tornerebbe utile per esempio per
sottoscrivere un listener. Se questo gergo vi suona nuovo,
possiamo immaginare il listener come un gestore di eventi. Il
metodo initState nella vita dell’istanza del widget è invocato una
sola volta, a differenza di build, e questa è una differenza
importante per talune implementazioni.
@override
void initState() {
debugPrint('Parent initState!'); // logica da introdurre
super.initState();
}
didChangeDependencies
Il metodo didChangeDependencies viene invocato subito
dopo il metodo initState. Oltre a questa invocazione,
didChangeDependencies consente di intercettare modifiche a
una dipendenza del widget. Il metodo didChangeDependencies
è usato per fare quelle operazioni che al rebuild del widget
sarebbe troppo dispendioso da ripetere N volte. In poche parole,
cambiando una dipendenza il widget verrà comunque
ricompilato, ma didChangeDependencies identifica comunque
una modifica a una dipendenza. Il fatto che comunque il widget
verrebbe ugualmente ricompilato rende questo metodo non così
usato.
NOTA
La dipendenza a cui facciamo riferimento può essere immaginata
come un legame tra oggetti differenti. Nel caso di Flutter quindi, un
widget che può accedere a risorse condivise con altri widget, per
esempio un Provider (lo introdurremo più avanti).
build
Il metodo build viene eseguito dopo il metodo initState o dopo
didUpdateWidget. Inoltre un widget viene ricompilato quando viene
didUpdateWidget
Il metodo didUpdateWidget viene eseguito quando le
proprietà di un widget parent cambiano. Esattamente come
nell’esempio fornito, possiamo vedere che il metodo
didUpdateWidget dello stato _ChildHomePage viene invocato
quando lo stato del widget genitore MyHomePage effettua un
cambiamento sulla property _counter e di conseguenza esegue
un setState per aggiornare il widget.
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
debugPrint('Parent setState!');
_counter++;
});
}
…
@override
void didUpdateWidget(ChildHomePage oldWidget) {
debugPrint('ChildHomePage didUpdateWidget!');
super.didUpdateWidget(oldWidget);
}
dispose
Il metodo dispose viene invocato quando un widget viene
eliminato dal widget tree. Nell’esempio fornito, quando torniamo
nella “Home” il framework invocherà il suddetto metodo. È utile
posizionarsi in questo punto per poter liberare le risorse.
@override
void dispose() {
debugPrint('NewPage disposed!');
super.dispose();
}
deactivate/activate
Il metodo deactivate viene invocato prima di dispose, ma, a
differenza di dispose che è “definitivo”, deactivate può essere
usato, insieme ad activate, in situazioni in cui il framework
piuttosto che eliminare il widget dall’albero, lo rimuove per
reinserirlo in un nuovo punto dell’albero. Lo stesso principio si
applica al metodo activate.
Come già anticipato, questo tema sarà più chiaro quanto il
concetto di key verrà approfondito.
Widget Base
Lo scopo di questa sezione è introdurre un numero ristretto di
widget per poter iniziare a creare delle piccole applicazioni per
giocare con il widget tree. Nonostante io mantenga un approccio
“discorsivo”, tengo molto che venga compresa la logica del
framework, e spero che il messaggio venga compreso il più
possibile. Nonostante si possano realizzare intere applicazioni
senza aver appreso a fondo le nozioni legate al funzionamento
interno del framework, vi garantisco che nel medio lungo
periodo risulta davvero utile.
I widget base che ci aiuteranno ad approcciare le nostre app
sono certamente quelli che consentono di strutturare il nostro
layout e sperimentare creando delle semplici viste.
Text
Abbiamo già introdotto il widget Text negli esempi forniti.
Innanzitutto, prendiamo la buona abitudine, nel momento in cui
approcciamo un nuovo widget, di prendere in esame il suo
costrutto, e facciamo sempre riferimento alla documentazione
ufficiale (https://api.flutter.dev/flutter/widgets/Text-class.html). È
possibile visionare il costrutto e una breve descrizione anche
tramite l’IDE, posizionandosi con il cursore sul nome della
classe.
Text(String data, {Key? key, TextStyle? style, StrutStyle? strutStyle,
TextAlign? textAlign, TextDirection? textDirection, Locale? locale,
bool? softWrap, TextOverflow? overflow, double? textScaleFactor, int?
maxLines, String? semanticsLabel, TextWidthBasis? textWidthBasis,
TextHeightBehavior? textHeightBehavior, Color? selectionColor})
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
scale.
NOTA
Rispetto ad altri esempi già visti, dove è stato necessario definire una
textDirection per il widget Text, in questo non non se ne fa uso poiché
la textDirection rtl è già una proprietà di default definita da
MaterialApp.
Row
Il widget Row consente di allineare i figli orizzontalmente. Il
widget Row spesso ricorre al widget Expanded per, appunto
espandere, il testo (e di conseguenza la riga) per tutto lo spazio
disponibile.
Row({Key? key, MainAxisAlignment mainAxisAlignment =
MainAxisAlignment.start, MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection, VerticalDirection verticalDirection =
VerticalDirection.down, TextBaseline? textBaseline, List<Widget>
children = const <Widget>[]})
Column
Le colonne in Flutter vengono gestite tramite la classe
Column. Il widget Column allinea verticalmente i figli.
Column( {Key? key, MainAxisAlignment mainAxisAlignment =
MainAxisAlignment.start, MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection, VerticalDirection verticalDirection =
VerticalDirection.down, TextBaseline? textBaseline, List<Widget>
children = const <Widget>[]} )
Container
Il widget Container crea uno spazio in cui può essere
posizionato un figlio. Possiamo immaginarlo come un
contenitore. Il container può applicare un margine al di fuori
dello spazio del container e un padding tra il bordo del container
e il figlio.
Container({Key? key, AlignmentGeometry? alignment, dgeInsetsGeometry?
padding, Color? color, Decoration? decoration, Decoration?
foregroundDecoration, double? width, double? height, BoxConstraints?
constraints, EdgeInsetsGeometry? margin, Matrix4? transform,
AlignmentGeometry? transformAlignment, Widget? child, Clip clipBehavior
= Clip.none})
child: Container(
margin: const EdgeInsets.all(5),
width: 200.0,
height: 200.0,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle
),
),
Stack
Il widget Stack consente di definire uno spazio in cui i figli
vengono sovrapposti. Possiamo immaginarlo, per chi ha
dimestichezza con il web, con una posizione “absolute” in CSS.
Ovvero i figli, di default, verranno posizionati partendo dal pixel
dell’angolo in alto a sinistra del widget Stack.
Stack({Key? key, AlignmentGeometry alignment =
AlignmentDirectional.topStart, TextDirection? textDirection, StackFit
fit = StackFit.loose, Clip clipBehavior = Clip.hardEdge, List<Widget>
children = const <Widget>[]})
Facciamo un esempio.
body: Center(
child: Stack(
children: [
Container(
width: 250,
height: 250,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle
),
), //Container
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Theme.of(context).canvasColor,
shape: BoxShape.circle
),
), //Container
Container(
height: 25,
width: 25,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle
),
), //Container
], //<Widget>[]
),
),
Routing
Navigator
Il widget Navigator consente di muoversi tra le schermate
dell’app utilizzando un approccio “navigation history stacks”.
Navigator gestisce la navigazione approciandola come fosse
una pila. Il concetto di pila dovrebbe essere familiare ai più. Se
non vi è chiaro, potete immaginare una gestione di questo tipo:
una volta avviata l’app, la schermata home è il primo elemento
in cima alla pila; se l’utente si muoverà tra le schermate,
Navigator gestirà dinamicamente l’inserimento o la rimozione
dell’elemento in cima alla pila, mostrando sempre la schermata
che corrisponde all’elemento in cima.
Facciamo un esempio.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
@override
State<MyHomePage> createState() => _MyHomePageState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text('Home page'),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () =>
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const
NewPage())),
child: const Text('New'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Second Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text('Second page'),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () =>
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const
NewPage())),
child: const Text('New'),
),
);
}
}
),
),
NOTA
L’uso dei generici in Dart porta con sé la convenzione della definizione
dei tipi generici, che per i Future è definita come T.
RouteSettings? settings,
NOTA
I costrutti possono usare la chiave required per obbligare l’uso del
parametro, piuttosto che il carattere ? per esplicitarlo come opzionale o
anche definire un parametro con un valore di default tramite il carattere
=.
Named Routes
Le rotte definite con un “nome”, consentono una navigazione
alternativa a quanto appena visto. Questa tipologia di
navigazione si rende preferibile quando si hanno un buon
numero di pagine. L’uso di questa tipologia di rotte consente
accedere a una determinata rotta tramite un nome. La
differenza, rispetto a un routing imperativo, è che in questo caso
è necessario definire una mappa che associ i nomi delle rotte ai
relativi widget.
Modifichiamo la classe MyApp per gestire la navigazione con i
named routes.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => const MyHomePage(title: 'Flutter Demo Home
Page'),
'/second': (context) => const NewPage(),
},
);
}
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => const MyHomePage(title: 'Flutter Demo Home
Page'),
'/second': (context) => const NewPage(),
},
);
}
}
@override
State<MyHomePage> createState() => _MyHomePageState();
}
@override
State<NewPage> createState() => _NewPageState();
}
return Scaffold(
appBar: AppBar(
title: const Text('Second Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(arguments['widgetTitle']),
],
),
),
);
}
}
Navigator 2.0
La navigazione, nell’accezione del Navigator 2.0, è usata in
contesti in cui l’app interagisce con deep links o ancora meglio
necessita di una navigazione maggiormente basata sugli URL.
Questa tipologia di navigazione si basa sulla classe Router di
Flutter, intorno a cui è stato costruito un package chiamato
go_router.
NOTA
Il deep link è una caratteristica che consente di interagire con l’app
tramite un collegamento esterno.
NOTA
Potete notare che, dopo l’installazione di go_router, Flutter aggiunge
questa dipendenze automaticamente nei file pubspec.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
theme: ThemeData(
primarySwatch: Colors.blue,
),
routerConfig: _router,
);
}
}
@override
State<MyHomePage> createState() => _MyHomePageState();
}
o
context.go('/second'),
Homepage
Possiamo fare uso delle property initialLocation quando
configuriamo l’istanza di GoRouter. La property initialLocation
consente di indicare un path di default diverso da /.
final GoRouter _router = GoRouter(
initialLocation: '/second',
routes: <RouteBase>[
GoRoute(
…
Location
Potremmo avere la necessità di sapere in quale rotta si trova
un determinato widget.
In questo caso possiamo fare uso dell’operatore of, offerto da
GoRouter.
Modifichiamo lo stato _NewPageState per accedere alla property
location.
Sub-Routes
La configurazione appena usata fa uso delle sub-routes. La
definizione della rotta second, se notate bene, appartiene al
path definito con /. L’uso di una rotta child (chiamata anche
così) riproduce il comportamento di Navigator.push (mi riferisco
al back button). Per comprendere meglio il concetto di sub-
routes proviamo a definire diversamente le due rotte, creandole
allo stesso livello.
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const MyHomePage(title: 'Flutter GoRoute Demo');
}
),
GoRoute(
path: '/third',
builder: (BuildContext context, GoRouterState state) {
return const ThirdPage();
}
),
],
o
/user?userId="pippo"
Param property
Nel primo caso, ipotizziamo una URL che mostra la
schermata di dettaglio di uno user:
/user/pippo
NOTA
L’uso dell’operatore !, dopo una variabile, ci consente di esplicitare al
compilatore che la variabile in questione è “not nullable”, in altre parole
la variabile non sarà mai null.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.user),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
Text('User page'),
],
),
),
);
}
}
Named Router
GoRouter gestisce, così come visto nel paragrafo Navigator,
anche le named routes.
GoRoute(
name: 'user',
path: 'user/:userId',
builder: (BuildContext context, GoRouterState state) {
return NewPage(user: state.params['userId']!);
},
),
Redirect
GoRouter ci consente di gestire il redirect: questa particolare
feature è attivabile tramite una property dell’istanza GoRouter.
Immaginiamo lo scenario in cui sia possibile accedere a una
specifica schermata solo se prima si è effettuato un login.
Ancora più semplicemente, possiamo creare un esempio in cui,
per accedere a una rotta, è necessario navigarne prima un’altra.
Modifichiamo la rotta /third, gestendo la property redirect.
GoRoute(
path: '/third',
builder: (BuildContext context, GoRouterState state) {
return const ThirdPage();
},
redirect: (context, state) {
debugPrint('isLogged: ${loginInfo.isLogged}');
if (loginInfo.isLogged){
return "/third";
}
return "/";
},
),
Deep linking
Il deep linking è una feature che consente di accedere a una
rotta interna direttamente tramite un URL. Per quanto sul web
sia piuttosto semplice, per i dispositivi mobili è necessaria una
configurazione specifica per Android ed iOS.
Android
Per android è necessario modificare il file
AndroidManifest.xml in android/app/src.
Aggiungere questa porzione di configurazione all’interno
dell’activity principale.
<meta-data android:name="flutter_deeplinking_enabled"
android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="exampledeeplinking.com" />
<data android:scheme="https" android:host="exampledeeplinking.com" />
</intent-filter>
iOS
Per iOS il file da modificare è Info.plist, contenuto in ios/Runner.
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>exampledeeplinking.com</string>
<key>CFBundleURLSchemes</key>
<array>
<string>customscheme</string>
</array>
</dict>
</array>
Material
Il design Material è alla base di una serie di widget figli del
widget MaterialApp. Il suo stile è supportato nativamente da
Android, ma è ottimizzato anche per i sistemi iOS. Possiamo
sviluppare la nostra applicazione usando il Material design
anche su iOS senza preoccuparci di dover necessariamente
usare lo stile Apple (Cupertino).
MaterialApp
Tutto parte dal widget MaterialApp, che abbiamo finora
incontrato numerose volte.
const MaterialApp(
{Key? key,
GlobalKey<NavigatorState>? navigatorKey,
GlobalKey<ScaffoldMessengerState>? scaffoldMessengerKey,
Widget? home,
Map<String, WidgetBuilder> routes = const <String, WidgetBuilder>{},
String? initialRoute,
RouteFactory? onGenerateRoute,
InitialRouteListFactory? onGenerateInitialRoutes,
RouteFactory? onUnknownRoute,
List<NavigatorObserver> navigatorObservers = const <NavigatorObserver>
[],
TransitionBuilder? builder,
String title = ‘’,
GenerateAppTitle? onGenerateTitle,
Color? color,
ThemeData? theme,
ThemeData? darkTheme,
ThemeData? highContrastTheme,
ThemeData? highContrastDarkTheme,
ThemeMode? themeMode = ThemeMode.system,
Duration themeAnimationDuration = kThemeAnimationDuration,
Curve themeAnimationCurve = Curves.linear,
Locale? locale,
Iterable<LocalizationsDelegate>? localizationsDelegates,
LocaleListResolutionCallbackLocaleResolutionCallback?
localeResolutionCallback,
Iterable<Locale> supportedLocales = const <Locale>[Locale(‘en’, ‘US’)],
bool debugShowMaterialGrid = false,
bool showPerformanceOverlay = false,
bool checkerboardRasterCacheImages = false,
bool checkerboardOffscreenLayers = false,
bool showSemanticsDebugger = false,
bool debugShowCheckedModeBanner = true,
Map<, Intent>? shortcuts,
Map<Type, Action<Intent>>? actions,
String? restorationScopeId,
ScrollBehavior? scrollBehavior,
bool useInheritedMediaQuery = false}
“generate”.
onUnknownRoute: usato per gestire gli errori di routing. Per
esempio, se una rotta non è presente possiamo dirottare
l’utente su una pagina dedicata.
navigatorObservers: istanza di RouteObserver che consente di
il title.
color: colore primario mostrato dal sistema operativo
NOTA
Dark mode è un’impostazione offerta dal sistema operativo che
consente di ottenere dalle applicazioni (là dove possibile) delle
schermate più scure per migliorare la lettura notturna e un
conseguente risparmio energetico.
light o dark.
themeAnimationDuration: durata dell’animazione per il cambio di
tema.
themeAnimationCurve: tipologia di Curve di animazione. Flutter
offre diverse animazioni che cambiano la velocità durante la
loro esecuzione e vengono definite Curve.
locale: identificativo della lingua usata dall’utente e viene
accessibilità.
debugShowCheckedModeBanner: viene mostrato un banner se l’app
è in debug mode.
shortcuts: mappa per le scorciatoie da tastiera che è
NOTA
I delegati sono elementi di design del software. Immaginiamo un
delegato come un oggetto che viene in aiuto di un altro oggetto per
svolgere un determinato compito.
NOTA
La classe MediaQuery consente di catturare le informazioni relative al
layout e allo screen, tra le quali il sizing del device, l’orientamento,
pixel ratio e molte altre. Il suo uso si basa sul metodo of:
MediaQuery.of(context).
NOTA
La state restoration è un processo che in Flutter viene gestito di
default. Nella fattispecie può capitare che un’app, che viene
posizionata in background, dopo un lasso di tempo definito dal sistema
operativo venga di fatto uccisa e venga liberata la memoria allocata
dall’app. Flutter offre degli strumenti per personalizzare questo
comportamento al suo restore.
Scaffold
Il widget Scaffold è il widget che fornisce la struttura di base
per il Material design. La sua traduzione italiana aiuta
sicuramente a esprimere in maniera più efficace il concetto:
impalcatura. Il widget in questione implementa una serie di
widget che si innestano allo Scaffold tramite specifiche
properties.
Scaffold({Key? key, PreferredSizeWidget? appBar, Widget? body, Widget?
floatingActionButton, FloatingActionButtonLocation?
floatingActionButtonLocation, FloatingActionButtonAnimator?
floatingActionButtonAnimator, Lis<>? persistentFooterButtons,
persistentFooterAlignment = AlignmentDirectional.centerEnd, ? drawer, ?
onDrawerChanged, ? endDrawer, ? onEndDrawerChanged, ?
bottomNavigationBar, ? bottomSheet, ? backgroundColor, ?
resizeToAvoidBottomInset, primary = true, drawerDragStartBehavior =
DragStartBehavior.start, extendBody = false, extendBodyBehindAppBar =
false, ? drawerScrimColor, ? drawerEdgeDragWidth,
drawerEnableOpenDragGesture = true, endDrawerEnableOpenDragGesture =
true, ? restorationId})
appbar
Finora, abbiamo visto parecchie volte il widget appbar. È
certamente il primo widget di un’app di Material design che
compone il nostro widget tree. L’appbar, come suggerisce il
nome, è una barra, posizionata in alto sullo schermo con
un’altezza fissa, che consente all’utente di interagire con
pulsanti, quindi con altri widget.
AppBar({Key? key, Widget? leading, bool automaticallyImplyLeading =
true, Widget? title, List<Widget>? actions, Widget? flexibleSpace,
PreferredSizeWidget? bottom, double? elevation, double?
scrolledUnderElevation, ScrollNotificationPredicate
notificationPredicate = defaultScrollNotificationPredicate, Color?
shadowColor, Color? surfaceTintColor, ShapeBorder? shape, Color?
backgroundColor, Color? foregroundColor, @Deprecated('This property is
no longer used, please use systemOverlayStyle instead. ' 'This feature
was deprecated after v2.4.0-0.0.pre.') Brightness? brightness,
IconThemeData? iconTheme, IconThemeData? actionsIconTheme,
@Deprecated('This property is no longer used, please use
toolbarTextStyle and titleTextStyle instead. ' 'This feature was
deprecated after v2.4.0-0.0.pre.') TextTheme? textTheme, bool primary =
true, bool? centerTitle, bool excludeHeaderSemantics = false, double?
titleSpacing, double toolbarOpacity = 1.0, double bottomOpacity = 1.0,
double? toolbarHeight, double? leadingWidth, @Deprecated('This property
is obsolete and is false by default. ' 'This feature was deprecated
after v2.4.0-0.0.pre.') bool? backwardsCompatibility, TextStyle?
toolbarTextStyle, TextStyle? titleTextStyle, SystemUiOverlayStyle?
systemOverlayStyle})
body
La property body di Scaffold crea la struttura per ospitare il
contenuto della nostra app. Il body è appena posizionato al di
sotto dell’appBar. Il body viene ridimensionato all’apertura della
keyboard. È possibile non applicare nessun resize se
impostiamo la property resizeToAvoidBottomInset di Scaffold a false.
body: const Center(child: Text('Hello World')),
floatingActionButton
floatingActionButton è un bottone con un bordo circolare,
sovrapposto al body poiché configurato come “fluttuante”. Float
significa esattamente questo; possiamo immaginarlo come un
elemento che, nonostante il body sia scrollabile, rimarrà fisso
nella sua posizione originale.
floatingActionButton: FloatingActionButton(
onPressed: () => debugPrint('pressed!'),
child: const Icon(Icons.add),
),
bottomNavigationBar
bottomNavigationBar è un widget ancorato al bottom dell’app
per consentire una navigazione veloce tra pochi elementi. La
barra di navigazione può interagire con il tap dell’utente
sull’icona, tramite la property onTap.
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Contact',
),
],
currentIndex: _selectedItem,
selectedItemColor: Colors.blue,
onTap: (int index) {setState(() => _selectedItem = index);}
),
NOTA
Con il termine tap, sui device mobili, si intende quello che nei
computer tramite mouse viene definito clic.
drawer
Il drawer è un widget posto al lato del body dello Scaffold e
compare come un menu laterale che si apre all’interazione con
l’utente. La tipologia di interazione a cui si fa riferimento è quella
tramite button o tramite gesture.
NOTA
Il termine gesture è usato per indicare una interazione che l’utente
crea con la sua app tramite l’uso di uno o più dita. Approfondiremo il
concetto in Flutter in un capitolo dedicato.
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Scaffold example',
debugShowCheckedModeBanner: false,
home: MyStatefulWidget(),
);
}
}
@override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
@override
Widget build(BuildContext context) {
final ButtonStyle style = TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
);
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(25),
),
),
actions: <Widget>[
TextButton(
style: style,
onPressed: () => debugPrint('logout!'),
child: const Text('Logout'),
),
IconButton(
icon: const Icon(Icons.add_alert),
onPressed: () => debugPrint('alert!'),
),
],
),
body: const Center(child: Text('Hello World')),
floatingActionButton: FloatingActionButton(
onPressed: () => debugPrint('pressed!'),
child: const Icon(Icons.add),
),
drawer: Drawer(
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
Navigator.pop(context);
});
},
)),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Contact',
),
],
currentIndex: _selectedItem,
selectedItemColor: Colors.blue,
onTap: (int index) {
setState(() => _selectedItem = index);
}),
);
}
}
Figura 7.1 scaffold_example (1); scaffold_example (2); scaffold_example
(3).
SliverAppBar
Il widget SliverAppBar è un’evoluzione dell’appBar. Offre
diverse features in più, ma possiamo principalmente
immaginarla come un’appbar con la possibilità di fluttuare,
contraendosi o espandendosi durante la fase di scroll.
SliverAppBar è un’implementazione piuttosto differente di
un’appBar che, come avevamo detto, ha un’altezza fissa.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(
expandedHeight: 150,
flexibleSpace: FlexibleSpaceBar(
title: Text('Bar'),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, int index) {
return Container(
height: 100,
margin: const EdgeInsets.all(10),
padding: const EdgeInsets.all(3.0),
decoration: const BoxDecoration(
color: Color.fromRGBO(200, 200, 200, 0.2),
border: Border(
top: BorderSide(color: Colors.grey),
bottom: BorderSide(color: Colors.grey),
left: BorderSide(color: Colors.grey),
right: BorderSide(color: Colors.grey),
),
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child:
Text('$index', style: const
TextStyle(fontWeight: FontWeight.bold)),
)
],
)
);
},
childCount: 20,
),
),
],
),
);
}
Figura 7.2 SliverAppBar.
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Scaffold example',
debugShowCheckedModeBanner: false,
home: MyStatefulWidget(),
);
}
}
@override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
bottom: TabBar(
controller: _tabController,
tabs: const <Widget>[
Tab(
icon: Icon(Icons.home),
),
Tab(
icon: Icon(Icons.contact_page),
)
],
),
),
body: TabBarView(
controller: _tabController,
children: const <Widget>[
Center(
child: Text("Home page"),
),
Center(
child: Text("Contact page"),
),
],
),
);
}
}
TickerProviderStateMixin.
L’uso del ticker vsync consente di gestire con grande
efficienza il rendering di un nuovo fotogramma,
intercettando così la modifica del layout introdotto dalla
gestione dei tab.
NOTA
Pensiamo al ticker come a uno speciale timer che viene invocato
periodicamente per ogni nuovo frame renderizzato. Affronteremo
il tema nel dettaglio successivamente.
Dialog
Le dialog sono dei pop up che notificano un evento a un
utente. In genere sono alert, ma in alcuni casi possono
anche consentire una scelta.
AlertDialog
Un classico esempio che realizzeremo con il widget
AlertDialog è un alert che vedremo comparire quando
proviamo a cancellare qualcosa di importante.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Center(
child: IconButton(
icon: const Icon(Icons.delete_forever),
iconSize: 52,
onPressed: () => showDialog<String>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Alert'),
content: const Text('Are you sure?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, 'No'),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, 'Yes'),
child: const Text('Yes'),
),
],
),
),
),
),
);
}
Figura 7.6 IconButton per aprire la dialog (2).
SimpleDialog
Il widget SimpleDialog è molto simile ad AlertDialog.
Rispetto a quest’ultimo, può essere pensato per un utilizzo
più generico e meno immediato. L’esempio che
presenteremo riproduce la stessa logica del precedente
sorgente.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Hide the debug banner
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const HomePage(),
);
}
}
const HomePage({super.key});
@override
State<StatefulWidget> createState() {
return _HomePageState();
}
}
String? result;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Center(
child: Column(
children: [
IconButton(
icon: const Icon(Icons.delete_forever),
iconSize: 52,
onPressed: () => showMyAlertDialog(context),
),
Text(result==null ? '':'Result $result')
],
)
)
);
}
showMyAlertDialog(BuildContext context) {
return showDialog(
context: context,
builder: (_) {
return SimpleDialog(
title: const Text('Are you sure?'),
children: [
SimpleDialogOption(
child: const Text('Yes'),
onPressed: () {
setState(() {
result = 'Option 1';
});
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: const Text('No'),
onPressed: () {
setState(() {
result = 'Option 2';
});
Navigator.of(context).pop();
},
)
],
);
},
);
}
}
Figura 7.8 SimpleDialog.
false.
Panel
I panel sono elementi meno rigidi rispetto ai dialog. Di
norma vengono utilizzati per generare un’interazione con
l’utente. Una scelta o una gesture che genera
un’interazione sono elementi di UI molto comuni.
Procediamo con alcuni esempi.
BottomSheet
Il widget bottomSheet è un panel che viene mostrato
nella parte inferiore dello schermo. Spesso si usa per
generare un’interazione con l’utente, per esempio un social
bottom.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Builder(
builder: (context) =>
Center(
child: ElevatedButton(
child: const Text('showBottomSheet'),
onPressed: () {
Scaffold.of(context).showBottomSheet<void>(
(BuildContext context) {
return Container(
height: 150,
color: Colors.blue,
child:Column(
children: <Widget>[
TextButton(
style: TextButton.styleFrom(fixedSize:
const Size.fromHeight(50)),
child: Row(children: const [
Icon(Icons.share, color: Colors.white,),
SizedBox(width: 10),
Text('Share', style: TextStyle(color:
Colors.white),),
]),
onPressed: ()=>{},
),
TextButton(
style: TextButton.styleFrom(fixedSize:
const Size.fromHeight(50)),
child: Row(children: const [
Icon(Icons.logout, color:
Colors.white,),
SizedBox(width: 10),
Text('Logout', style: TextStyle(color:
Colors.white),),
]),
onPressed: ()=>{},
),
TextButton(
style: TextButton.styleFrom(fixedSize:
const Size.fromHeight(50)),
child: Row(children: const [
Icon(Icons.logout, color:
Colors.white,),
SizedBox(width: 10),
Text('Close showBottomSheet', style:
TextStyle(color: Colors.white),),
]),
onPressed: ()=>{Navigator.pop(context)},
),
],
)
);
},
);
},
),
)
)
ExpansionPanel
Il widget expansionPanel consente di creare schede
estendibili (conosciute anche con il termine accordion). In
genere, un expansionPanel offre la possibilità di espandere
e contrarre un elemento tramite un bottone. In questo modo
è possibile accedere a un contenuto di dettaglio relativo al
panel.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Hide the debug banner
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const HomePage(),
);
}
}
class Item {
String name;
String description;
bool isExpanded;
Item({
required this.name,
required this.description,
this.isExpanded = false,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const PanelList()
);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
ExpansionPanelList(
expansionCallback: (int index, bool isExpanded) {
setState(() {
_items[index].isExpanded = !isExpanded;
});
},
children: _items.map((Item item) {
debugPrint('${item.isExpanded}');
return ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded)
{
return ListTile(
title: Text(item.name)
);
},
body: Padding(padding: const EdgeInsets.all(16.0),
child: Text(item.description)),
isExpanded: item.isExpanded,
);
}).toList()
)
]
);
}
}
NOTA
Il widget ListTile è usato per mostrare un contenuto di solito
composto da un testo e un’icona con un’altezza fissa.
NOTA
ll metodo toList trasforma una mappa in una lista e in Flutter è
spesso usato per iterare una mappa e creare una lista di widget,
associata chiaramente a un widget tramite il parametro children.
SnackBar
Il widget snackBar in genere è usato per trasmettere un
messaggio all’utente a fronte di un’azione. Il messaggio
viene mostrato nel bottom.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Builder(
builder: (context) =>
Center(
child:
TextButton(onPressed: () {
final snackBar = SnackBar(
content: const Text('snackBar is opened!'),
action: SnackBarAction(
label: 'Close',
onPressed: () {
// Some code to undo the change.
},
),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}, child: const Text('open snackBar'))
)
)
);
}
NOTA
Vi invito a provare a rimuovere il widget Builder (builder key)
presente nel widget Scaffold. Ciò che noterete, procedendo con
questa modifica, è che Scaffold non sarà presente nel context,
pertanto è necessario procedere con un nuovo builder per
arricchire il context del widget Scaffold.
Visualizzazione dati
Flutter offre un set di widget piuttosto ampio per
organizzare al meglio la visualizzazione dei dati.
Card
Le card sono elementi di UI che mostrano contenuti e
azioni relativi allo stesso topic. Le card vengono create
tramite la classe Card, e nel nostro esempio conterrà una
colonna con ListTile e una riga. L’esempio creato si avvicina
molto all’utilizzo che di solito si fa di questa tipologia di
widget.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Card(
margin: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
leading: const CircleAvatar(foregroundImage:
AssetImage('assets/images/cat.jpg')),
title: const Text.rich(
TextSpan(
text: 'Username: ', // default text style
children: <TextSpan>[
TextSpan(text: 'Walter', style:
TextStyle(fontWeight: FontWeight.bold)),
],
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Lingua: Italiano', textAlign:
TextAlign.left),
Text('Iscrizione: Novembre 2008', textAlign:
TextAlign.left),
]
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
TextButton(
child: const Text('BACHECA'),
onPressed: () {},
),
],
),
],
),
),
);
}
Assets
CircleAvatar è di solito usato per mostrare l’immagine di
profilo di uno user; al suo interno facciamo uso del widget
AssetImage che consente di visualizzare un’immagine
presente nella nostra directory di assets.
La directory assets di norma viene creata nella root del
progetto e deve essere indicata nel file pubspec.yaml, insieme
a tutti i file che dovremo usare nell’applicazione. Il file
pubspec.yaml conterrà già un esempio commentato. Nel
Chip
Il widget Chip è un piccolo elemento che mostra una
breve informazione e, in alcuni casi, può anche richiedere
un’interazione. Gli esempi più comuni sono l’uso di filtri di
ricerca, piuttosto che un elenco di tag.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Chip(
avatar: const CircleAvatar(foregroundImage:
AssetImage('assets/images/cat.jpg'), child: Text('AB')),
label: const Text('Messages'),
onDeleted: () {},
)
);
}
Figura 7.16 Chip.
DataTable
Il widget DataTable ci aiuta a organizzare i dati in una
tabella, composta chiaramente da righe e colonne.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Pound for pound'),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DataTable(
columns: const <DataColumn>[
DataColumn(
label: Expanded(
child: Text(
'Player',
),
),
),
DataColumn(
label: Expanded(
child: Text('Ranking'),
),
),
],
rows: const <DataRow>[
DataRow(
cells: <DataCell>[
DataCell(Text('Vincenzo')),
DataCell(Text('1')),
],
),
DataRow(
cells: <DataCell>[
DataCell(Text('Ralf')),
DataCell(Text('2')),
],
),
DataRow(
cells: <DataCell>[
DataCell(Text('Nicolas')),
DataCell(Text('3')),
],
),
],
)
]
)
);
}
GridView
Il widget GridView è un griglia scrollabile. È generalmente
utilizzata per mostrare i dati in una forma tabellare.
Vedremo più avanti un approfondimento importante.
GridView è un widget molto utilizzato nella costruzione di
layout che necessitano la visualizzazione di più dati.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Grid'),
),
body: GridView.count(
padding: const EdgeInsets.all(5),
crossAxisSpacing: 5,
mainAxisSpacing: 5,
crossAxisCount: 2,
children: <Widget>[
Container(
padding: const EdgeInsets.all(4),
color: Colors.brown,
child: const Text('First...', style: TextStyle(color:
Colors.white)),
),
Container(
padding: const EdgeInsets.all(4),
color: Colors.deepPurple,
child: const Text('Second...', style: TextStyle(color:
Colors.white)),
),
Container(
padding: const EdgeInsets.all(4),
child: const Text('Third...'),
),
Container(
padding: const EdgeInsets.all(4),
color: Colors.orange,
child: const Text('Fourth...'),
),
Container(
padding: const EdgeInsets.all(4),
color: Colors.black,
child: const Text('Fifth...', style: TextStyle(color:
Colors.white)),
),
Container(
padding: const EdgeInsets.all(4),
color: Colors.red,
child: const Text('Sixth...', style: TextStyle(color:
Colors.white)),
),
],
)
);
}
Figura 7.18 GridView.
Tooltip
Il widget tooltip è utile per mostrare all’utente una precisa
informazione. Questo avviene “tappando” a lungo il
componente o passando il mouse sopra l’elemento (non
dimentichiamoci che Flutter è usato anche per la creazione
di applicazioni web).
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Pound for pound'),
),
body: const Tooltip(
message: 'I am a Tooltip',
child: Text('Hover over the text to show a tooltip.'),
)
);
}
Stepper
Il widget Stepper visualizza lo step in cui l’utente si trova.
Nel caso, per esempio, di una registrazione o di una
compilazione di dati a step, un widget di questo tipo torna
estremamente utile.
class StepperPage extends StatefulWidget {
const StepperPage({super.key});
@override
State<StepperPage> createState() => _StepperPageState();
}
Bottoni
Flutter offre un set di bottoni di diversi design e con
diverse user experience.
DropdownMenu
Il widget DropdownMenu è utile per mostrare e dare
l’opportunità all’utente di scegliere un bottone da un elenco.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Hide the debug banner
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('DropdownMenu'),
),
body: const ButtonsPage()
)
);
}
}
@override
State<ButtonsPage> createState() => _ButtonsPageState();
}
@override
Widget build(BuildContext context) {
return Center(
child: DropdownButton<String>(
value: dropdownValue,
icon: const Icon(Icons.arrow_downward),
onChanged: (String? value) {
setState(() {
dropdownValue = value!;
});
},
items: items
)
);
}
}
DropdownMenuItem.
PopupMenuButton
Il widget PopupMenuButton visualizza un menu
all’interno di un piccolo popup.
class PopupMenuPage extends StatefulWidget {
const PopupMenuPage({super.key});
@override
State<PopupMenuPage> createState() => _PopupMenuPageState();
}
@override
Widget build(BuildContext context) {
return Center(
child: PopupMenuButton<int>(
onSelected: (value) {},
itemBuilder: (BuildContext context) => <PopupMenuEntry<int>>
[
const PopupMenuItem<int>(
value: 1,
child: Text('Option 1'),
),
const PopupMenuItem<int>(
value: 2,
child: Text('Option 2'),
),
const PopupMenuItem<int>(
value: 3,
child: Text('Option 3'),
),
],
),
);
}
}
floatingActionButton, ElevatedButton
,IconButton,OutlinedButton,TextButton
floatingActionButton è un bottone “fluttuante”;
ElevatedButton è un bottone in rilievo; IconButton è un
bottone a icona; infine OutlinedButton differisce dal widget
TextButton per il fatto che il primo ha un bordo ed è di
conseguenza in rilievo.
Racchiudiamo i bottoni all’interno di un unico esempio.
Listato 7.6 main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Buttons example'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Add your onPressed code here!
},
child: const Icon(Icons.event_available_sharp),
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const SizedBox(height: 30),
ElevatedButton(
onPressed: () {},
child: const Text('Enabled'),
),
IconButton(
iconSize: 62,
color: Colors.blue,
icon: const Icon(Icons.event_available_sharp),
onPressed: () {},
),
OutlinedButton(
onPressed: () {},
child: const Text('Enabled'),
),
TextButton(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 18),
),
onPressed: () {},
child: const Text('Enabled'),
),
],
),
)));
}
}
Figura 7.25 Buttons.
Input
In questo paragrafo prendiamo in esempio i principali
widget da utilizzare per gestire gli input. I widget di questo
tipo sono fondamentali per gestire la user experience.
L’esempio a seguire implementa i seguenti widget: Radio,
CheckBox, Slider, Switch, TextField, TextButton e
DateTime.
Radio
Il widget Radio consente di scegliere un’“opzione” tra un
gruppo di possibilità. Il gruppo viene definito tramite la key
groupValue. Radio implementa una callback tramite la key
onChanged.
CheckBox
Il widget Checkbox consente di gestire un booleano. Il
valore del widget CheckBox può essere true o false e
questo viene settato tramite la key value. Anche questo
widget implementa una callback tramite la key onChanged.
Slider
Il widget Slider consente di selezionare un valore tra un
range più ampio. La key value consente di selezionare il
valore di default, mentre max e divisions compongono
rispettivamente un range con un valore massimo e gli step
di incremento definiti da divisions. Nell’ esempio con un
max 100 e un divisions 20, verrà creato uno slider con 5
possibilità: 0, 20, 40, 60 ,80 e 100.
Switch
Il widget in questione consente di definire uno stato:
on/off tramite un componente chiamato Switch.
Esattamente come il widget CheckBox, viene acceso o
spento tramite la key value e anche questo widget gestisce
la callback onChanged.
TextField
Il widget TextField consente di inserire dei dati
interagendo con la keyboard del device. Nell’esempio
proposto, simuliamo l’inserimento di un username e una
password. In questo caso la differenza tra i due input viene
gestita dall’oscuramento della password, feature gestita
dalla key obscureText. La chiave decoration invece consente di
definire l’aspetto dell’input box e questo viene fatto tramite
la classe InputDecoration, che consente di implementare
diverse feature; nel nostro caso la usiamo per inserire un
bordo e una label.
DatePicker
Il date picker è un componente fondamentale nella
gestione delle date. Normalmente viene usato per
selezionare una data o un range di date.
Il componente può essere mostrato tramite la funzione
showDatePicker e viene gestito tramite Future.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Hide the debug banner
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Input test'),
),
body: const InputPage()));
}
}
@override
State<InputPage> createState() => _InputPageState();
}
@override
Widget build(BuildContext context) {
return Column(children: [
ListTile(
title: const Text('Accept'),
leading: Radio(
value: true,
groupValue: radioChecked,
onChanged: (value) {
setState(() {
radioChecked = value;
});
},
),
),
ListTile(
title: const Text('Decline'),
leading: Radio(
value: false,
groupValue: radioChecked,
onChanged: (value) {
setState(() {
radioChecked = value;
});
},
),
),
ListTile(
title: const Text('Accept'),
leading: Checkbox(
checkColor: Colors.white,
value: isChecked,
onChanged: (bool? value) {
setState(() {
isChecked = value!;
});
},
),
),
Slider(
value: _currentSliderValue,
max: 100,
divisions: 5,
label: _currentSliderValue.round().toString(),
onChanged: (double value) {
setState(() {
_currentSliderValue = value;
});
},
),
Switch(
value: switchChecked,
onChanged: (bool value) {
setState(() {
switchChecked = value;
});
},
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: const [
TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Username',
),
),
SizedBox(height: 10),
TextField(
obscureText: true,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Password',
),
)
],
)),
TextButton(
onPressed: () async {
DateTime? pickedDate = await showDatePicker(
context: context,
initialEntryMode: DatePickerEntryMode.calendarOnly,
initialDate: DateTime(2021),
firstDate: DateTime(2021),
lastDate: DateTime(2023),
);
setState(() {
date =
'${pickedDate?.day.toString()}-${pickedDate?.month.toString()}-${pi
ckedDate?.year.toString()}';
});
},
child: Text(date ?? 'Select date'),
),
]);
}
}
Figura 7.26 Inputs.
Figura 7.27 DatePicker. Nel widget viene mostrato il risultato del
DatePicker.
Capitolo 8
Scrollable Widget
techniques-behavior.
Scroll
I widget di questa categoria aiuteranno lo sviluppatore a
capire come gestire la fase di scroll. L’uso di widget che siano
scrollabili è pressoché un’attività mandatoria per creare layout
mobili.
Negli esempi introdotti fino a qui, avrete certamente notato
che cosa accade quando viene superata l’altezza o la larghezza
dello screen o di un widget che delimita la sua dimensione.
Facciamo un esempio.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Overflow'),
),
body: const SizePage()));
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
debugPrint('size width: ${size.width} height: ${size.height}');
return Container(
color: Colors.red,
height: size.height / 3,
width: size.width / 3,
child: Column(children: <Widget>[
SizedBox(height: size.height / 3),
const Text('Overflow!!')
]));
}
}
SingleChildScrollView
Ammetto che questo esempio è un pò al limite, ma credo che
per chi viene dallo sviluppo web l’approccio in questione possa
tornare utile.
Come rendiamo un widget scrollable?
Nel nostro caso, possiamo iniziare ad approcciare
l’argomento usando il widget SingleChildScrollView.
class SizePage extends StatelessWidget {
const SizePage({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
debugPrint('size width: ${size.width} height: ${size.height}');
return Container(
color: Colors.red,
height: size.height / 3,
width: size.width / 3,
child: SingleChildScrollView(
child: Column(children: <Widget>[
SizedBox(height: size.height / 3),
const Text('Overflow!!')
])));
}
}
ListView, GridView
I widget ListView e GridView (già presentati nel capitolo
precedente) sono probabilmente gli strumenti più semplici e
immediati da utilizzare quando abbiamo una lista di items da
mostrare in un contenitore scrollabile.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('ListView'),
),
body: const ListViewPage()));
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return ListView(
children: <Widget>[
Container(
decoration: BoxDecoration(border: Border.all()),
height: size.height / 3,
child: const Center(child: Text('Entry A')),
),
Container(
decoration: BoxDecoration(border: Border.all()),
height: size.height / 3,
child: const Center(child: Text('Entry B')),
),
Container(
decoration: BoxDecoration(border: Border.all()),
height: size.height / 3,
child: const Center(child: Text('Entry C')),
),
],
);
}
}
@override
Widget build(BuildContext context) {
return ListView.separated(
separatorBuilder: (context, index) => const Divider(
color: Colors.black,
),
itemCount: 20,
itemBuilder: (context, index) => SizedBox(
height: 200,
child: Center(child: Text('Entry $index')),
));
}
GridView
Possiamo pensare a GridView come a un’evoluzione di
ListView. GridView, grazie alla property crossAxisCount, consente
di definire una griglia e gestire la disposizione dei figli in righe
separate se questi superano il limite definito in crossAxisCount.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: Scaffold(
appBar: AppBar(
title: const Text('Grid'),
),
body: GridView.count(
crossAxisCount: 1,
children: <Widget>[
Container(
decoration: BoxDecoration(border: Border.all()),
child: const Text('First...')),
Container(
decoration: BoxDecoration(border: Border.all()),
child: const Text('Second...')),
Container(
decoration: BoxDecoration(border: Border.all()),
child: const Text('Third...'),
),
],
)));
}
}
PageView
Il widget PageView consente di gestire all’interno di un unico
widget più pagine alle quali si può accedere tramite gesture.
Esattamente come l’uso delle TabBar, è necessaria la presenza
di un controller per coordinare l’accesso alle differenti pagine.
La pagina selezionata dall’utente sarà quella mostrata dalla
view.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('PageView'),
),
body: const PageViewExample()));
}
}
@override
Widget build(BuildContext context) {
final PageController controller = PageController();
return PageView(
scrollDirection: Axis.horizontal,
controller: controller,
children: const <Widget>[
Center(
child: Text('Page One'),
),
Center(
child: Text('Page Two'),
),
Center(
child: Text('Page Three'),
),
],
);
}
}
Oltre a una serie di parametri che rendono il widget
personalizzabile, scrollDirection, su tutti, consente di definire il
comportamento di accesso alle pagine tramite una gesture
orizzontale piuttosto che verticale.
CustomScrollView
Il widget CustomScrollView è apparso già nel capitolo
precedente, utilizzato nell’esempio fornito per la SliverAppBar.
CustomScrollView fornisce un supporto per realizzare
elementi scrollabili con effetti che si basano sugli sliver. L’effetto
di scrolling è realizzabile attraverso l’uso di tre differenti slivers:
SliverAppBar, SliverList, e SliverGrid. Gli slivers devono essere
void main() {
runApp(const MyApp());
}
@override
State<CustomScrollViewPage> createState() =>
_CustomScrollViewPageState();
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const DraggableScrollable()));
}
}
@override
State<DraggableScrollable> createState() =>
_DraggableScrollableState();
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(body: NestedScrollViewPage()));
}
}
@override
State<NestedScrollViewPage> createState() =>
_NestedScrollViewPageState();
}
@override
void initState() {
super.initState();
_tabController = TabController(vsync: this, length: 3);
}
@override
Widget build(BuildContext context) {
return NestedScrollView(
headerSliverBuilder: (BuildContext context, bool
innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
pinned: true,
snap: true,
floating: true,
bottom: TabBar(
tabs: const [
Tab(text: 'Tab 1'),
Tab(text: 'Tab 2'),
Tab(text: 'Tab 3'),
],
controller: _tabController,
)),
];
},
body: TabBarView(
controller: _tabController,
children: <Widget>[
Center(
child: ListView.builder(
itemCount: 25,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Tile ${index + 1}'),
);
},
),
),
const Center(
child: Text('Hello World'),
),
const Center(
child: Text('Hello World'),
),
],
));
}
}
NotificationListener
Il widget NotificationListener serve a catturare gli eventi
generati dalle operazioni di scroll. Modifichiamo l’esempio
precedente, utilizzando il widget NotificationListener per
“wrappare” il widget scrollable.
children: <Widget>[
Center(
child: NotificationListener<ScrollNotification>(
onNotification: (scrollState) {
debugPrint('scrollState: $scrollState');
return false;
},
child: ListView.builder(
itemCount: 25,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Tile ${index + 1}'),
);
},
)),
),
…
Scrollbar
Il widget Scrollbar nasce per modificare il comportamento
di default dello scroll di Flutter. Quando un utente
interagisce con un widget scrollabile, lo fa tramite una barra
di scorrimento che scompare appena dopo la conclusione
della fase di scroll.
Questo comportamento, come altri aspetti legati alla
barra dello scroll, può essere modificato tramite il widget
Scrollbar.
return Scrollbar(
thumbVisibility: true,
thickness: 15,
child: ListView.builder(
itemCount: 25,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Tile ${index + 1}'),
);
},
),
);
ScrollController
Abbiamo già utilizzato, negli esempi pregressi, il
controller ScrollController. Spendiamo due parole per
descrivere effettivamente che cosa sia. La classe
ScrollController è usata dai widget scrollable per gestire lo
scrolling di ogni singolo widget. Un controller può essere
usato per gestire più widget. Nel caso in cui, invece,
utilizzassimo un controller per leggere lo stato dell’offset di
scorrimento, questo deve essere specifico per il singolo
widget; infatti, il widget ScrollController altro non è che un
listener. Ciò che precedentemente abbiamo fatto con
ScrollNotification può essere fatto anche tramite il
controller.
Nell’esempio in cui abbiamo fatto uso del widget
DraggableScrollableSheet, abbiamo necessariamente
dovuto impostare uno ScrollController per il widget
ListView, anche se, come visto in altri casi, non è
mandatorio. In quel caso è necessario poiché, usando due
widget scrollabili, è necessario impostare il controller in
quanto si potrebbe incappare in errori di gestione dello
scroll.
NOTA
Un esercizio potrebbe essere quello di rimuovere il controller
nell’esempio del widget DraggableScrollableSheet per rendersi
conto di quanto sia fondamentale la comunicazione tra widget
innestati che necessitano di un controller per comunicare.
void _onScrollEvent() {
debugPrint("Scroll event: ${_controller.position}");
}
@override
void initState() {
_controller = ScrollController();
_controller.addListener(_onScrollEvent);
super.initState();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _controller,
itemCount: 25,
itemBuilder: (BuildContext context, int index) {
return ListTile(
title: Text('Tile ${index + 1}'),
);
},
);
}
ReorderableListView
Il widget ReorderableListView consente, oltre a garantire
lo scrolling delle informazioni contenute nel widget
ReorderableListView, il drag and drop degli elementi in
modo da poterne modificare l’ordinamento. Procediamo con
un esempio.
Listato 8.8 main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const ReorderableListPage()));
}
}
@override
State<ReorderableListPage> createState() =>
_ReorderableListPageState();
}
@override
Widget build(BuildContext context) {
return ReorderableListView(
onReorder: _reorderInt,
children: <Widget>[
for (int index = 0; index < _items.length; index += 1)
Padding(
key: Key('$index'),
padding: const EdgeInsets.all(16.0),
child: ListTile(
key: Key('$index'),
shape: RoundedRectangleBorder(
side: const BorderSide(color: Colors.black, width:
1),
borderRadius: BorderRadius.circular(5),
),
title: Text('Tile ${_items[index]}'),
),
),
],
);
}
}
Layout Widget
Vincoli
Prima di procedere all’analisi di widget per widget, vorrei
soffermarmi sulla gestione dei vincoli in Flutter, ciò è importante
che venga appreso e fatto suo.
I vincoli in Flutter sono 4 proprietà: altezza massima e
minima, larghezza massima e minima. I vincoli in questione
vengono comunicati al widget figlio e il figlio comunicherà al
padre la sua grandezza e altezza nell’assoluto rispetto dei
vincoli. Infine, il genitore decide la posizione del figlio nell’asse x
e y.
Partendo da questa regola, possiamo confermare che il
widget figlio non può scegliere la sua dimensione, se non entro i
vincoli, e la sua posizione in autonomia. La complicazione
avviene proprio sulla base che i vincoli vengono ereditati e ciò
significa che per calcolare la precisa dimensione di ogni widget
è necessario consultare tutto l’albero dei widget.
Là dove il widget esprime un size preciso, deve rispettare i
vincoli e allo stesso tempo il genitore deve aver pensato già a
un allineamento poiché, se l’allineamento non fosse definito, la
volontà del widget figlio potrebbe essere ignorata; se vi
ricordate, è quanto già preso in esame per il widget Container.
Quando definiamo una grandezza specifica, almeno nei
widget che lo consentono, di fatto stiamo utilizzando quello che
viene definito tight constraint, ovvero un vincolo stretto. Se
ispezioniamo il widget Container, vedremmo che quando
definiamo un altezza o una larghezza, Flutter crea un widget
figlio chiamato ConstrainedBox proprio per imporre al figlio il
vincolo che corrisponde essere l’altezza o la larghezza definita
in Container. Faremo un approfondimento su questo tema
qualche capitolo più avanti.
Rimane comunque a carico del widget figlio definire la propria
dimensione e questa logica può variare da widget a widget,
pertanto è fondamentale consultare la documentazione ufficiale
per comprendere a fondo la specifica implementativa. In genere
i comportamenti sono raggruppabili in tre categorie: i widget che
si espandono il più possibile, quelli che hanno una dimensione
particolare di default e quelli che ereditano la grandezza dai figli.
Rimane comunque valido quanto appena detto, anche perchè i
widget che possono utilizzare una logica di default, questa
potrebbe cambiare in base alla sua configurazione.
Vi invito a utilizzare un’applicazione creata dal team Flutter
con lo specifico scopo di far comprendere al meglio la gestione
dei vincoli, è reperibile all’indirizzo:
https://github.com/marcglasberg/flutter_layout_article.
Container
Abbiamo già trattato il widget Container nel Capitolo 5, ed è
certamente il capostipite dei widget layout.
Il suo costrutto è:
Container(
{Key? key,
AlignmentGeometry? alignment,
EdgeInsetsGeometry? padding,
Color? color,
Decoration? decoration,
Decoration? foregroundDecoration,
double? width,
double? height,
BoxConstraints? constraints,
EdgeInsetsGeometry? margin,
Matrix4? transform,
AlignmentGeometry? transformAlignment,
Widget? child,
Clip clipBehavior = Clip.none}
)
piuttosto che
color: Colors.lightBlue,
e non solo.
BoxDecoration
La property decoration è utilizzata per decorare il container e
questo viene fatto tramite la classe Decoration. Finora, abbiamo
utilizzato BoxDecoration per applicare un bordo.
Per esempio: decoration: BoxDecoration(border: Border.all()),
BoxDecoration può far uso di queste properties: color, image,
border, borderRadius, gradient, backgroundBlendMode e shape.
Color, come per la properties del widget Container, definisce
un colore di background, image consente di applicare un’
immagine come background, border e borderRadius gestiscono i
bordi e l’arrotondamento degli angoli, Gradient gestisce un
gradiente, backgroundBlendMode consente di applicare un filtro al
background o al gradiente e infine shape consente di applicare
una particolare forma (rettangolare o circolare).
NOTA
Con il termine gradiente si intende una trasformazione graduale tra
due o più colori.
decoration: BoxDecoration(
image: const DecorationImage(
image: NetworkImage(
'https://www.apogeonline.com/contrib/uploads/9788850334063_quarta.jpg'),
fit: BoxFit.fill,
),
border: Border.all(color: Colors.black),
borderRadius: const BorderRadius.all(Radius.circular(12)),
boxShadow: const [
BoxShadow(
color: Colors.black,
offset: Offset(8.0, 8.0),
blurRadius: 10.0,
spreadRadius: 1.0,
), //BoxShadow
],
shape: BoxShape.rectangle),
NOTA
L’anti aliasing è una tecnica che consente di migliorare un frame
arrotondando o colmando un difetto grafico tramite pixel aggiuntivi.
Sizedbox
Il widget Sizedbox è molto simile al widget Container. A
differenza di Container, non è possibile gestire, per esempio,
colori o bordi. Il widget Sizedbox è sicuramente meno pesante
da gestire da parte del framework e il fatto che abbia un
costrutto di tipo const consente una maggiore performance in
fase di runtime, in quanto Flutter non dovrà ricompilarlo.
return const SizedBox(
width: 200, height: 200, child: Text('Hello World'));
Center
Abbiamo già usato decine di volte il widget Center, ma non è
stato mai introdotto come si deve. Il widget Center è usato per
allineare il figlio al centro dello spazio disponibile. Se non ha le
property heightFactor e widthFactor, di default si espanderà per
catturare tutto lo spazio disponibile.
return Center(
child: Container(
decoration:
BoxDecoration(border: Border.all()),
child:
const Text('Hello World')));
Inspector
Non dimentichiamoci che Flutter offre un potente inspector,
che VS Code implementa direttamente e mostra durante il run
dell’applicazione.
In Figura 9.4 possiamo vedere l’inspector che mostra il layout
del nostro widget tree. È intuibile che 812px e 375px siano le
dimensioni dello screen del device. Selezionando nell’albero il
widget Center vediamo direttamente il dettaglio del widget: in
questo caso il sizing corrisponde a 706x375px. La differenza tra
l’altezza di 812 e quella di 706, che corrisponde al widget
Center, è esattamente l’altezza del widget AppBar.
Per avere una corrispondenza visiva tra il widget selezionato
nell’inspector e il nostro simulatore o il device fisico, possiamo
attivare la feature Select Widget Mode, come in Figura 9.5.
È possibile delimitare la grandezza del widget Center grazie
alle proprietà height e width factor. Procediamo con un esempio
per vedere come funziona.
return Center(
heightFactor: 2,
widthFactor: 2,
child: Container(
height: 150,
width: 150,
decoration: BoxDecoration(border: Border.all()),
child: const Text('Hello World')));
Padding
Il padding, che ricordiamo essere lo spazio vuoto presente tra
il contenuto e il suo confine, è definibile in Flutter tramite il
widget Padding.
return Padding(
padding: const EdgeInsets.fromLTRB(50, 50, 0, 0),
child: Container(
decoration: BoxDecoration(border: Border.all()),
child: const Padding(
padding: EdgeInsets.all(14), child: Text('Hello
World'))));
Align
Il widget Align segue la stessa logica del widget Center. È
usato quando vogliamo allineare un widget figlio non
esattamente al centro, come visto per l’omonimo widget. Il
widget Align consente, tramite la property alignment, di usare la
classe Alignment, come già mostrato nell’esempio legato al
container.
return Container(
height: 150,
decoration: BoxDecoration(border: Border.all()),
child: const Padding(
padding: EdgeInsets.all(14),
child: Align(
alignment: Alignment.centerRight, child: Text('Hello
World'))));
AspectRatio
L’aspect ratio è la descrizione del rapporto tra larghezza e
altezza. L’esempio più classico di AspectRatio che può venire in
mente è il rapporto 16:9. Il widget in questione può definire la
grandezza del figlio esprimendo un rapporto.
return Container(
alignment: Alignment.centerRight,
height: 250.0,
width: 300.0,
decoration: BoxDecoration(border: Border.all()),
child: Padding(
padding: const EdgeInsets.all(14),
child: AspectRatio(
aspectRatio: 62 / 9,
child: Container(
decoration: BoxDecoration(border: Border.all())))));
Figura 9.8 AspectRatio.
Baseline
Il widget Baseline consente di posizionare un figlio usando
come logica la base del widget figlio. Procediamo con un
esempio per chiarire il concetto.
return Center(
child: Container(
height: 200.0,
width: 200.0,
decoration: BoxDecoration(border: Border.all()),
child: Baseline(
baseline: 0,
baselineType: TextBaseline.alphabetic,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(border: Border.all())))));
ConstrainedBox
Il widget ConstrainedBox consente di impostare vincoli di
grandezza e altezza a un figlio. In un esempio in cui abbiamo
due container con entrambi i sizing definiti, se avvolgessimo il
Container figlio con un widget ContrainedBox, quest’ultimo ne
sarebbe senz’altro influenzato. Facciamo un esempio.
return Container(
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
width: 250,
height: 250,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
maxHeight: 150,
),
child: (Container(
width: 200,
height: 200,
decoration: BoxDecoration(border: Border.all()),
child: const Text('Hello World')))));
UnconstrainedBox e LimitedBox
Il widget LimitedBox ha una logica molto simile a
ConstrainedBox, con la differenza che quest’ultimo funziona
solo quando il genitore non ha nessun vincolo. Per poter
riprodurre quanto fatto per il widget precedente, è necessario
avvolgere LimitedBox con un widget di tipo UnconstrainedBox.
return Container(
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
width: 250,
height: 250,
child: UnconstrainedBox(
child: LimitedBox(
maxWidth: 150,
maxHeight: 150,
child: (Container(
width: 200,
height: 200,
decoration: BoxDecoration(border: Border.all()),
child: const Text('Hello World'))))));
Transform
Il widget Transform consente di trasformare il widget prima
della sua fase di rendering.
La classe Transform può essere utilizzata direttamente con il
suo costruttore dove, grazie alla sua property transform, è
possibile usare la classe Matrix4. La suddetta classe consente
di applicare una trasformazione delle dimensioni: lunghezza,
larghezza e profondità. La classe Transform può essere
utilizzata, forse ancor più semplicemente, utilizzando i metodi
rotate, scale e translate.
CustomSingleChildLayout
Il widget CustomSingleChildLayout crea un delegato per
gestire la posizione ed eventuali vincoli del widget. Può anche
essere usato per determinare il sizing del parent, di
conseguenza accedere ai vincoli.
Questo è il suo costrutto:
CustomSingleChildLayout({
Key key,
@required SingleChildLayoutDelegate delegate,
Widget child,
});
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const ExamplePage()));
}
}
@override
Widget build(BuildContext context) {
double parentMargin = 12;
return Container(
margin: EdgeInsets.all(parentMargin),
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
width: 250,
height: 250,
child: CustomSingleChildLayout(
delegate: _MySingleChildLayoutDelegate(parentMargin),
child: Container(
decoration: BoxDecoration(border: Border.all()),
),
));
}
}
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints(
maxWidth: constraints.maxWidth / 2,
maxHeight: constraints.maxHeight / 2,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
return Offset(parentMargin, parentMargin);
}
@override
bool shouldRelayout(_MySingleChildLayoutDelegate oldDelegate) {
return parentMargin != oldDelegate.parentMargin;
}
}
Figura 9.11 CustomSingleChildLayout.
Expanded
Il widget Expanded, di cui abbiamo fatto già uso nei primissimi
capitoli, costringe un child dei widget Row, Column o Flex a
espandersi fino a utilizzare tutto lo spazio disponibile.
return Row(children: <Widget>[
Container(
decoration: BoxDecoration(border: Border.all()),
padding: const EdgeInsets.all(12),
child: const Text('1'),
),
Expanded(
child: Container(
decoration: BoxDecoration(border: Border.all()),
padding: const EdgeInsets.all(12),
child: const Text('2'),
)),
Container(
decoration: BoxDecoration(border: Border.all()),
padding: const EdgeInsets.all(12),
child: const Text('3'),
),
]);
Figura 9.12 Expanded.
FittedBox
FittedBox è un widget che consente di adattare le dimensioni
di un figlio a quelle del genitore. L’esempio proposto segue la
stessa logica dello “scale” che abbiamo già affrontato con il
widget Transform.scale. In questo caso lo “scale” e il
posizionamento sono gestiti autonomamente dal widget
FittedBox.
Container(
decoration: BoxDecoration(
border: Border.all(),
),
width: 180,
height: 30,
child: FittedBox(child: Text('Hello World')),
);
Offstage
Il widget Offstage consente di nascondere all’utente un widget
senza che questo venga effettivamente rimosso dal widget tree.
return Column(
children: [
Offstage(
offstage: _hidden,
child: const Text('Hello World'),
),
ElevatedButton(
onPressed: () {
setState(() {
_hidden = !_hidden;
});
},
child: const Text('Offstage'),
),
],
);
NOTA
Attenzione all’uso del widget Offstage, poiché sarà comunque
presente nell’albero; ciò vuol dire che, anche se non sarà visibile,
consumerà comunque le risorse occupate (per esempio i listener
continueranno a essere attivi).
OverflowBox
Il widget OverflowBox consente di gestire l’overflow di un
figlio. Nel caso in cui avessimo due container annidati, ma il
figlio fosse più grande del genitore, il figlio risulterebbe grande al
massimo quanto il genitore. Per gestire l’eccedenza di spazio
può essere usato il widget in questione.
Center(
child: Container(
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
width: 250,
height: 250,
child: OverflowBox(
maxHeight: 350,
maxWidth: 350,
child: Container(
width: 350,
height: 350,
decoration: BoxDecoration(border: Border.all()),
child: const Text('Hello World')))));
NOTA
Attenzione alla gestione dell’overflow, poiché si deve tenere conto
degli elementi vicini. In questo caso, se rimuoviamo il widget Center, il
Container figlio va sicuramente in collisione con l’appBar.
NOTA
I widget OverflowBox e SizedOverfloxBox sono molto simili. La
differenza sta nel fatto che SizedOverfloxBox consente al figlio di
“traboccare” il genitore, mentre OverflowBox impone una grandezza
massima per il traboccamento.
IntrinsicHeight e IntrinsicWidth
I widget IntrinsicHeight e IntrinsicWidth lavorano allo stesso
modo. Entrambi andrebbero usati quando ci troviamo nella
condizione in cui un figlio può espandersi per tutta la grandezza
del genitore e vogliamo limitare questo comportamento alla sua
grandezza intrinseca; ciò sta a significare limitare la dimensione
alla grandezza naturale del figlio. Procediamo con un esempio
chiarificatore.
IntrinsicWidth(
child: Row(children: [
Expanded(
child: Container(
decoration: BoxDecoration(border: Border.all()),
child: const Text('Hello World')))
]));
Multi-child
I widget multi-child sono widget che hanno più figli. Ne
abbiamo già utilizzati diversi e molte volte, ora vediamo di
approfondire l’argomento.
Row
Il widget Row è stato già presentato tra i widget di base
qualche capitolo fa. Vale la pena entrare nel dettaglio del
costruttore e vedere le possibili configurazioni.
Row(
{Key? key,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline? textBaseline,
List<Widget> children = const <Widget>[]}
)
spaceAround e spaceEvenly.
start ed end consentono di allineare i figli il più vicino possibile
all’inizio o alla fine della riga. Un aspetto importante è che per
utilizzare questo valore è necessario che venga definito un
TextDirection. In alcuni esempi precedenti lo abbiamo utilizzato,
in altri no: questo dipende dall’uso di MaterialApp, che di default
offre una configurazione left-to-right, e, se non lo si usa, è
necessario esplicitarlo. Center consente invece di allineare i figli
al centro della riga.
SpaceBetween consente di distribuire lo spazio libero
Column
Esattamente come Row esiste per la gestione delle righe,
Column esiste per la gestione delle colonne. Vediamo il suo
costruttore.
Column(
{Key? key,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline? textBaseline,
List<Widget> children = const <Widget>[]}
Flex
Flex è un widget che consente di utilizzare Column o Row
attraverso l’uso di uno specifico parametro: direction. Potete
utilizzarlo come fosse Row, con la property direction settata con
Axis.horizontal, o con Axis.vertical per Column.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Container(
margin: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(),
),
child: Flex(
direction: Axis.vertical,
mainAxisAlignment: MainAxisAlignment.start,
children: const [
Text('1'),
Text('2'),
Text('3'),
])),
);
}
Stack
Abbiamo già introdotto il widget Stack. Esso consente una
sovrapposizione di widget. Vediamo nel dettaglio il costruttore.
Stack(
{Key? key,
AlignmentGeometry alignment = AlignmentDirectional.topStart,
TextDirection? textDirection,
StackFit fit = StackFit.loose,
Clip clipBehavior = Clip.hardEdge,
Listlt;Widget> children = const lt;Widget>[]}
)
IndexedStack
Il widget IndexedStack è una pila, che mostra esclusivamente
un figlio tramite l’uso di un indice.
Listato 9.2 main.dart
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const IndexedStackPage()));
}
}
@override
State<IndexedStackPage> createState() => _IndexedStackPageState();
}
class _IndexedStackPageState extends State<IndexedStackPage> {
int index = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IndexedStack(
index: index,
children: [
Container(
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
child: const Text(
'0',
style: TextStyle(fontSize: 100),
),
),
Container(
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
child: const Text('1', style: TextStyle(fontSize: 100)),
),
Container(
decoration: BoxDecoration(border: Border.all()),
alignment: Alignment.center,
child: const Text('2', style: TextStyle(fontSize: 100)),
)
],
),
ElevatedButton(
onPressed: () {
setState(() {
index = Random().nextInt(3);
});
},
child: const Text('Change'),
)
],
);
}
}
Table
Il widget Table consente la creazione di una tabella. La tabella
è composta da una lista di figli, che corrispondono alle righe;
questa, a sua volta, conterrà una lista di celle. Facciamo un
esempio.
return Padding(
padding: const EdgeInsets.all(16.0),
child: Table(
children: const <TableRow>[
TableRow(
children: <Widget>[
TableCell(child: Text('1')),
TableCell(child: Text('Luca')),
TableCell(child: Text('Roma')),
],
),
TableRow(
children: <Widget>[
TableCell(child: Text('2')),
TableCell(child: Text('Simone')),
TableCell(child: Text('Milano')),
],
),
TableRow(
children: <Widget>[
TableCell(child: Text('3')),
TableCell(child: Text('Alessandro')),
TableCell(child: Text('Catania')),
],
),
],
));
Wrap
Il widget Wrap consente di allineare i figli orizzontalmente o
verticalmente senza preoccuparsi necessariamente delle
problematiche di overflow. I figli verranno posizionati sempre
adiacenti agli altri figli del widget Wrap; se viene meno lo spazio,
il child verrà posizionato su una nuova riga o colonna.
return Wrap(direction: Axis.horizontal, children: <Widget>[
Container(
width: 250,
height: 100,
decoration: BoxDecoration(border: Border.all()),
),
Container(
width: 100,
height: 100,
decoration: BoxDecoration(border: Border.all()),
),
Container(
width: 250,
height: 100,
decoration: BoxDecoration(border: Border.all()),
),
]);
CustomMultiChildLayout
Il widget CustomMultiChildLayout, a differenza del widget
CustomSingleChildLayout, delega la creazione di una lista di
figli. Prendiamo spunto dall’esempio precedente e procediamo
con un nuovo esempio.
Listato 9.3 main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const ExamplePage()));
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
decoration: BoxDecoration(border: Border.all()),
height: 150,
width: 150,
child: CustomMultiChildLayout(
delegate: _MyMultiChildLayoutDelegate(),
children: [
LayoutId(
id: 1,
child: Container(
decoration: BoxDecoration(border: Border.all()),
child: const Center(child: Text('1')),
),
),
LayoutId(
id: 2,
child: Container(
decoration: BoxDecoration(border: Border.all()),
child: const Center(child: Text('2')),
)),
],
)));
}
}
class _MyMultiChildLayoutDelegate extends MultiChildLayoutDelegate {
_MyMultiChildLayoutDelegate();
@override
void performLayout(Size size) {
layoutChild(
1, BoxConstraints(maxWidth: size.width, maxHeight: size.height /
2));
positionChild(1, const Offset(0, 0));
layoutChild(
2, BoxConstraints(maxWidth: size.width, maxHeight: size.height /
2));
@override
bool shouldRelayout(_MyMultiChildLayoutDelegate oldDelegate) {
return false;
}
}
LayoutBuilder
Il widget LayoutBuilder consente di creare un albero di widget
che può interagire con i vincoli. Il widget in questione verrà
aggiornato quando i vincoli del genitore cambieranno.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Center(child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints)
{
return customRow(
maxHeight: constraints.maxHeight,
maxWidth: constraints.maxWidth);
})),
));
}
}
NOTA
A differenza del widget Builder, il widget LayoutBuilder viene eseguito
in fase di rendering; infatti, abbiamo lì la possibilità di disporre dei
vincoli del genitore.
BoxConstraints
La classe BoxConstraints definisce dei vincoli legati al sizing
che il parent impone al proprio figlio. Il costruttore di
BoxConstraints è così definito:
const BoxConstraints(
{double minWidth = 0.0,
double maxWidth = double.infinity,
double minHeight = 0.0,
double maxHeight = double.infinity}
)
Flow
Il widget Flow consente di creare e posizionare dei figli tramite
l’uso di uno speciale delegato. Il widget Flow è usato spesso per
la creazione di menu dinamici. Nell’esempio proposto creiamo
un menu a cascata.
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const FlowMenu(),
),
);
}
}
@override
State<FlowMenu> createState() => _FlowMenuState();
}
@override
void initState() {
super.initState();
menuAnimation = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Flow(
delegate: FlowMenuDelegate(menuAnimation: menuAnimation),
children: menuItems
.map<Widget>((IconData icon) => flowMenuItem(icon))
.toList(),
));
}
}
@override
bool shouldRepaint(FlowMenuDelegate oldDelegate) {
return menuAnimation != oldDelegate.menuAnimation;
}
@override
void paintChildren(FlowPaintingContext context) {
double y = 0.0;
for (int i = 0; i < context.childCount; ++i) {
y = context.getChildSize(i)!.width * i;
context.paintChild(
i,
transform: Matrix4.translationValues(
0,
y * menuAnimation.value,
0,
),
);
}
}
}
un’animazione fluida.
Il metodo shouldRepaint indica a Flutter di ridisegnare i figli nel
caso in cui l’istanza del controllore sia cambiata durante
l’esecuzione dell’applicativo. Nel nostro caso questo metodo
ritornerà sempre false,e non influenzerà il comportamento.
Il metodi getConstraintsForChild, getSize e shouldRelayout sono utili
rispettivamente a catturare i vincoli del genitore, indicare la
grandezza del container che ospiterà i figli (Flow) e infine
ridisporre i figli a fronte di un cambio di layout.
Flow è un widget che fa uso di un’animazione, ed è giunto il
momento di affrontare nel dettaglio questo tema molto
interessante. Il prossimo capitolo ha come argomento proprio le
animazioni in Flutter.
Animazioni
Controller
AnimationController è il cuore delle animazioni in Flutter. Il
controller deve essere gestito all’interno dello state del nostro
widget, e verrà utilizzato per controllare l’animazione.
Prendiamo per esempio questa porzione di sorgente.
class _AnimationPageState extends State<AnimationPage>
with SingleTickerProviderStateMixin {
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync:
this);
}
Il primo passo è quello di comprendere l’uso del mixin
SingleTickerProviderStateMixin. La suddetta classe è necessaria
Ticker
Il ticker in Flutter è uno strumento che ci consente di
comunicare con il framework nelle fasi in cui viene renderizzato
un nuovo frame. Flutter renderizza i suoi frame alla velocità di
60fps (sui device che lo supportano anche a 120fps). Quando
qualcosa nel layout della nostra app cambia è necessario
generare un nuovo frame. Il cambiamento nella nostra
interfaccia può essere di qualsiasi tipo; nel nostro caso
immaginiamo che la causa scatenante sia una banale
animazione.
NOTA
fps è l’acronimo di frames per second, in italiano fotogrammi per
secondo. Questa unità di misura indica quanti fotogrammi il nostro
device può renderizzare in un solo secondo: più alto sarà questo
valore più fluida sarà l’animazione.
@override
void initState() {
super.initState();
_ticker = createTicker((elapsed) {
debugPrint('elapsed ${elapsed.inSeconds}');
});
_ticker.start();
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const AnimationPage()));
}
}
@override
State<AnimationPage> createState() => _AnimationPageState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (BuildContext _, child) {
debugPrint('AnimationController: ${_controller.value}');
return Opacity(
opacity: _controller.value,
child: const Text("Hello World"),
);
},
));
}
}
Tipologie
Le animazioni a cui possiamo attingere in Flutter sono
essenzialmente di due tipi. Il primo è basato sulla simulazione
della fisica e il secondo è di tipo lineare.
Un esempio banale di animazione basata sulla fisica potrebbe
essere quella che simula l’effetto della gravità o di oscillazione.
L’uso di queste animazioni può rendere davvero accattivanti le
applicazioni. Questa tipologia di animazione non richiede di
essere maghi della fisica o di generare calcoli complessi, poiché
Flutter offre una serie di strumenti per semplificare lo sviluppo di
queste animazioni. Un esempio piuttosto confortante al
momento può essere dato dall’uso della classe
GravitySimulation. Quest’ultima, per esempio, può generare
un’animazione che ha un’accelerazione di 100 pixel per
secondo, che parte dal pixel 0 fino al pixel 500 con una velocità
iniziale di 0 pixel per secondo.
GravitySimulation(
100.0, // accelerazione
0.0, // inizio
500.0, // fine
0.0, // velocità iniziale
);
Physics
Procediamo con un esempio che fa uso della fisica.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: const AnimationExample()));
}
}
@override
State<AnimationExample> createState() => _AnimationExampleState();
}
@override
void initState() {
super.initState();
simulation = GravitySimulation(
100.0, // acceleration
0.0, // starting point
500.0, // end point
0.0, // starting velocity
);
controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
return Stack(children: [
Positioned(
left: 50,
top: controller.value,
height: 50,
width: 50,
child: Container(
decoration: BoxDecoration(border: Border.all()),
),
),
]);
}
}
Tween
Le animazioni definibili come Tween hanno un approccio
lineare. Non fanno altro che tradurre i valori 0.0-1.0
dell’AnimationController in un range più ampio. Hanno un
inizio e una fine definiti per una durata precisa. Inoltre è
anche possibile introdurre una curva affinchè l’animazione
non sia “del tutto” lineare.
class AnimationExample extends StatefulWidget {
const AnimationExample({super.key});
@override
State<AnimationExample> createState() => _AnimationExampleState();
}
@override
void initState() {
super.initState();
controller =
AnimationController(vsync: this, duration: const
Duration(seconds: 3));
animation = Tween<double>(begin: 0, end: 150).animate(controller);
animation.addListener(() {
setState(() {});
});
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
controller.forward();
}
@override
Widget build(BuildContext context) {
return Stack(children: [
Positioned(
left: animation.value,
top: 10,
height: 50,
width: 50,
child: Container(
decoration: BoxDecoration(border: Border.all()),
),
),
]);
}
}
Questo esempio, che si basa su un semplice movimento, è
realizzato grazie a un remapping dei valori generati
dall’AnimationController. Il range 0-1, a cui abbiamo fatto
riferimento qualche esempio fa, viene rimappato con un range
0-150. L’animazione, eseguita dal metodo animate di Tween,
genererà a ogni rendering un valore (animation.value) che verrà
espresso con valori intermedi più distanti l’uno dall’altro
rispetto al range 0-1, ma scandito dallo stesso numero di fps.
Figura 10.3 Tween (start ed end).
Curve
Abbiamo accennato al concetto di curva. L’uso della
curva, in Flutter, ci consente di controllare la velocità
dell’animazione in una porzione specifica di tempo, ovvero
in una fase dell’esecuzione. In Figura 10.4 viene mostrato
l’uso della curva easeInQuint e come questa esegua
un’accelerazione esponenziale nella parte finale
dell’animazione.
È possibile usare le curve tramite la classe
CurvedAnimation. Quest’ultima userà Interval per definire
l’intervallo dell’animazione rispetto alla sua durata e la
tipologia di animazione. Flutter offre tante implementazioni
di curve; è possibile reperire la lista qui:
https://api.flutter.dev/flutter/animation/Curves-class.html.
@override
void initState() {
super.initState();
_controller =
AnimationController(vsync: this, duration: const
Duration(seconds: 6));
_controller.repeat(reverse: true);
animation = Tween(
begin: 0.0,
end: 500.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(
0.0,
1.0,
curve: Curves.easeInQuint,
),
),
);
}
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (BuildContext _, child) {
return Stack(children: [
Positioned(
left: 0,
top: animation.value,
height: 50,
width: 50,
child: Container(
decoration: BoxDecoration(border: Border.all()),
),
),
]);
},
));
}
}
Gesture
GestureDetector
Il widget GestureDetector consente di rilevare le gesture
utilizzate dell’utente. Il suo funzionamento si basa sull’avvolgere
il widget che interagirà con le gesture.
Il costrutto del widget in oggetto è costituito da un’infinità di
callback associati a diverse tipologie di gesture.
GestureDetector(
{Key? key,
Widget? child,
GestureTapDownCallback? onTapDown,
GestureTapUpCallback? onTapUp,
GestureTapCallback? onTap,
GestureTapCancelCallback? onTapCancel,
GestureTapCallback? onSecondaryTap,
GestureTapDownCallback? onSecondaryTapDown,
GestureTapUpCallback? onSecondaryTapUp,
GestureTapCancelCallback? onSecondaryTapCancel,
GestureTapDownCallback? onTertiaryTapDown,
GestureTapUpCallback? onTertiaryTapUp,
GestureTapCancelCallback? onTertiaryTapCancel,
GestureTapDownCallback? onDoubleTapDown,
GestureTapCallback? onDoubleTap,
GestureTapCancelCallback? onDoubleTapCancel,
GestureLongPressDownCallback? onLongPressDown,
GestureLongPressCancelCallback? onLongPressCancel,
GestureLongPressCallback? onLongPress,
GestureLongPressStartCallback? onLongPressStart,
GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate,
GestureLongPressUpCallback? onLongPressUp,
GestureLongPressEndCallback? onLongPressEnd,
GestureLongPressDownCallback? onSecondaryLongPressDown,
GestureLongPressCancelCallback? onSecondaryLongPressCancel,
GestureLongPressCallback? onSecondaryLongPress,
GestureLongPressStartCallback? onSecondaryLongPressStart,
GestureLongPressMoveUpdateCallback? onSecondaryLongPressMoveUpdate,
GestureLongPressUpCallback? onSecondaryLongPressUp,
GestureLongPressEndCallback? onSecondaryLongPressEnd,
GestureLongPressDownCallback? onTertiaryLongPressDown,
GestureLongPressCancelCallback? onTertiaryLongPressCancel,
GestureLongPressCallback? onTertiaryLongPress,
GestureLongPressStartCallback? onTertiaryLongPressStart,
GestureLongPressMoveUpdateCallback? onTertiaryLongPressMoveUpdate,
GestureLongPressUpCallback? onTertiaryLongPressUp,
GestureLongPressEndCallback? onTertiaryLongPressEnd,
GestureDragDownCallback? onVerticalDragDown,
GestureDragStartCallback? onVerticalDragStart,
GestureDragUpdateCallback? onVerticalDragUpdate,
GestureDragEndCallback? onVerticalDragEnd,
GestureDragCancelCallback? onVerticalDragCancel,
GestureDragDownCallback? onHorizontalDragDown,
GestureDragStartCallback? onHorizontalDragStart,
GestureDragUpdateCallback? onHorizontalDragUpdate,
GestureDragEndCallback? onHorizontalDragEnd,
GestureDragCancelCallback? onHorizontalDragCancel,
GestureForcePressStartCallback? onForcePressStart,
GestureForcePressPeakCallback? onForcePressPeak,
GestureForcePressUpdateCallback? onForcePressUpdate,
GestureForcePressEndCallback? onForcePressEnd,
GestureDragDownCallback? onPanDown,
GestureDragStartCallback? onPanStart,
GestureDragUpdateCallback? onPanUpdate,
GestureDragEndCallback? onPanEnd,
GestureDragCancelCallback? onPanCancel,
GestureScaleStartCallback? onScaleStart,
GestureScaleUpdateCallback? onScaleUpdate,
GestureScaleEndCallback? onScaleEnd,
HitTestBehavior? behavior,
bool excludeFromSemantics = false,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
Set<PointerDeviceKind>? supportedDevices}
)
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Example',
home: GestureWidget(),
);
}
}
class GestureWidget extends StatefulWidget {
const GestureWidget({super.key});
@override
State<GestureWidget> createState() => _GestureWidgetState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
onTap: () {
setState(() {
_tapped = !_tapped;
});
},
child: Center(
child: Container(
decoration: BoxDecoration(border: Border.all()),
padding: const EdgeInsets.all(12),
child: Text('tap event: $_tapped'),
),
)),
);
}
}
Advanced Gesture
Flutter offre un widget che consente di creare nuove gesture
custom. Il widget in questione è RawGestureDetector. Le
potenzialità offerte da questo widget consentono non solo di
creare gesture personalizzate che possono interagire con la UI,
ma anche combinarle tra loro.
Creiamo un esempio con RawGestureDetector che riproduce
il comportamento dell’esempio precedente. Lo scopo
dell’esempio è capire l’approccio legato all’uso di una factory.
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Example',
home: GestureWidget(),
);
}
}
@override
State<GestureWidget> createState() => _GestureWidgetState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: RawGestureDetector(
gestures: {
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(), //constructor
(TapGestureRecognizer instance) {
instance.onTap = () {
setState(() {
_tapped = !_tapped;
});
};
},
)
},
child: Center(
child: Container(
decoration: BoxDecoration(border: Border.all()),
padding: const EdgeInsets.all(12),
child: Text('tap event: $_tapped'),
),
)),
);
}
}
InkWell
InkWell è un widget Material design molto simile a
GestureDetector ma, a differenza di questo, implementa una
“ripple animation” che viene scatenata appena invocata la
callback configurata. Inoltre, InkWell implementa meno gesture
rispetto a GestureDetector; per esempio, non implementa eventi
di tipo drag*.
Listato 11.3 main.dart
import 'package:flutter/material.dart';
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Example',
home: InkWellWidget(),
);
}
}
@override
State<InkWellWidget> createState() => _InkWellWidgetState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: InkWell(
onTap: () {
setState(() {
_tapped = false;
});
},
onLongPress: () {
setState(() {
_tapped = true;
});
},
child: Container(
decoration: BoxDecoration(border: Border.all()),
padding: const EdgeInsets.all(8.0),
child: Text('onTap or LongPress: $_tapped'),
),
)),
);
}
}
Esattamente come per il widget GestureDetector, InkWell
definisce una serie di callback per gestire un set di gesture.
L’animazione definita come “ripple” si espande in maniera
concentrica dal punto esatto del tocco verso tutto lo spazio
occupato da InkWell.
NOTA
InkWell, rispetto a GestureDetector, consente di intercettare le gesture
anche nello spazio di padding.
Capitolo 12
Demo
All done!
In order to run your application, type:
$ cd demo_meteo
$ flutter run
API Platform
Flutter offre un’infinità di plugin che consentono, tra le tante
possibilità, di accedere alle API offerte dal sistema operativo.
NOTA
Il plugin differisce da un package perché ha al suo interno del codice
nativo che consente il dialogo con il platform di riferimento.
Geolocation
Nella nostra app vogliamo offrire la feature di
geolocalizzazione dell’utente. Geolocalizzare significa accedere
al GPS tracker del device, ovvero interagire con le API del
sistema operativo per catturare la posizione dell’utente.
La scelta più immediata è quella di utilizzare uno specifico
plugin, creato dalla community, che di fatto è diventato lo
standard per l’accesso alla geolocalizzazione.
Il plugin in questione è Geolocator
(https://pub.dev/packages/geolocator). Nella sezione score è
possibile vedere quanto effettivamente sia apprezzato e
utilizzato il plugin in questione.
NOTA
Ogni package presente in pub.dev viene catalogato con un sistema di
score. Lo score è composto da un numero di like, dei punti dati in
relazione alla qualità e manutenibilità del codice e, infine, un valore di
popolarità. Questi valori devono essere presi in considerazione per
valutare l’adozione del package.
Permission
Nel momento in cui si usano le primitive offerte dal device per
ricavare, per esempio, informazioni relative alla posizione, è
necessario configurare l’applicativo per richiedere l’esplicito
permesso dell’utente.
Per questa ragione è necessario definire nella configurazione
dell’app, in relazione ai permessi, che cosa effettivamente l’app
richieda di essere abilitata a fare. Per l’accesso al GPS è
necessario definire la specifica permission e questa strada è
differente a dipendenza del sistema operativo.
Per Android, per esempio, è necessario modificare il file
AndroidManifest.xml, aggiungendo, allo stesso livello di
<manifest xmlns:android="http://schemas.android.com/apk/res/android"..
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
/>
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application..
https://developer.apple.com/documentation/corelocation/handling_lo
cation_updates_in_the_background
if (permission == LocationPermission.deniedForever) {
return Future.error(
'Location permissions are permanently denied, we cannot request
permissions.');
}
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
@override
State<MyHomePage> createState() => _MyHomePageState();
}
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Your position:',
),
Text(_position ?? "waiting.."),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: savePosition,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
if (permission == LocationPermission.deniedForever) {
return Future.error(
'Location permissions are permanently denied, we cannot request
permissions.');
}
NOTA
Sia il simulatore Android che iOS consentono di gestire la
localizzazione ed è possibile, inoltre, configurare una posizione
custom, navigando le impostazioni del simulatore. In Android, è
possibile farlo utilizzando la barra di impostazioni del device virtuale e
accedendo al menu location; in iOS, dal menu Simulator (Debug ->
Location -> Custom Location).
Figura 12.4 permission (iOS e Android).
Figura 12.5 Nuova posizione.
Custom Icons
Nel caso in cui le icone offerte da Flutter non dovessero
soddisfare le nostre esigenze, è possibile comunque utilizzarne
altre. Fra varie possibilità, sicuramente il primo step è cercare
qualcosa che possa fare al caso nostro dal sito FlutterIcon
(https://www.fluttericon.com/). FlutterIcon è un sito che consente di
generare icone partendo da file SVG e anche di accedere a un
vasto catalogo per attingere alle icone create da terzi.
Al caso nostro, esiste nel catalogo un set di icone a tema
meteo: Meteocons. Procediamo al download; una volta
terminato e scompattato il file zip, è necessario indicare l’uso del
“nuovo” font nel file pubspec.yaml, per informare Flutter della
presenza di un nuovo font.
Modifichiamo il file aggiungendo la configurazione relativa al
font; inoltre, dobbiamo indicare il path in cui abbiamo
posizionato il file MyFlutterApp.ttf, in questo caso la directory
fonts.
flutter:
…
fonts:
- family: MyFlutterApp
fonts:
- asset: fonts/MyFlutterApp.ttf
Http
Credo sia pressoché impossibile immaginare una qualsiasi
applicazione che non faccia uso del networking. Il package più
usato per implementare richieste http è sicuramente l’omonimo
http (https://pub.dev/packages/http).
Procediamo innanzitutto all’installazione. Al solito:
$ flutter pub add http
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to load weather');
}
}
Struttura
Il design che vorremmo applicare nella nostra demo_meteo
sarebbe composto da una bottomNavigation Bar, in cui sarà
possibile scegliere due differenti view: in una, accedere al
meteo corrente, nell’altra, accedere alle previsioni meteo dei
sette giorni successivi.
Procederemo strutturando il nostro codice sorgente, per
intenderci ciò che è contenuto all’inteno della directory lib, in più
file. Rispetto a quanto visto finora, dove tutto il nostro sorgente
era contenuto nel file main.dart, qui faremo un passo avanti.
Strutturare il codice sorgente in Flutter, così come in altri
framework, è di vitale importanza, poiché UI e la relativa logica
sono contenuti nello stesso codice sorgente e, quando la nostra
applicazione si espande, o ancor più semplicemente più
sviluppatori dovranno collaborare allo sviluppo del codice, si
rende necessario che il codice sia ben strutturato. La
manutenibilità è alla base di questo concetto e un codice
organizzato migliora di molto la sua leggibilità e aiuta non poco
nella risoluzione di eventuali bug o refactoring.
Il primo passo per strutturare un’app Flutter con un buon
livello di leggibilità è partire dall’assunzione che il file main.dart
dovrà contenere meno logica possibile.
main.dart
Il file main.dart verrà modificato per contenere meno codice
possibile. Rispetto a quanto offerto dalla demo di Flutter, lo
modificheremo per contenere solo il widget stateless root,
MaterialApp, nel nostro caso.
import 'package:flutter/material.dart';
import './screens/home_screen.dart';
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Demo Meteo',
theme: ThemeData(
primarySwatch: Colors.blueGrey,
),
home: const MyHomePage(title: 'Demo Meteo'),
);
}
}
screens/home_screen.dart
Il file in questione corrisponde al widget home di MaterialApp.
@override
State<MyHomePage> createState() => _MyHomePageState();
}
@override
void initState() {
super.initState();
_position = determinePosition();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: FutureBuilder<Position>(
future: _position,
builder: (BuildContext context, AsyncSnapshot<dynamic>
snapshot) {
if (snapshot.hasData) {
return _onWidgetTapped(snapshot.data);
} else {
return const Text('waiting location..');
}
})),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(MyFlutterApp.sun),
label: 'Weather',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_month),
label: 'Days',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.amber[800],
onTap: _onItemTapped,
),
);
}
}
In questo file, creiamo il widget MyHomePage che è la parte
principale del nostro widget tree. Il widget in questione è uno
stateful, in quanto dovrà gestire i dati relativi alla
geolocalizzazione, e implementa anche una
NavigationBottomBar per gestire le due view. La prima
(Weather) mostra il widget CurrentWeatherView, l’altra (Days) il
widget ForecastWeatherView. Un aspetto a cui dare importanza
è sicuramente il widget FutureBuilder, che ci aiuta a integrare
nel nostro layout dati di tipo Future.
FutureBuilder
FutureBuilder consente di procedere alla build di un widget
che ha come dipendenza un dato asincrono. È importante
ricordare che la funzione build di un widget è chiamata N volte
per N situazioni diverse, cosa ben diversa, per esempio, per il
metodo initState. È cosa buona e giusta risolvere la Future, per
esempio dentro il metodo initState, e utilizzare il valore di ritorno
della Future all’interno del widget FutureBuilder.
Nel nostro esempio il widget FutureBuilder è usato per gestire
la chiamata asincrona per ottenere la posizione. Avremmo
potuto ottenere il nostro scopo anche invocando direttamente la
funzione:
child: FutureBuilder<Position>(
future: determinePosition(),
builder: (BuildContext context, AsyncSnapshot<dynamic>
snapshot) {
services/get_position.dart
Listato 12.4 get_position.dart
import 'package:geolocator/geolocator.dart';
if (permission == LocationPermission.deniedForever) {
return Future.error(
'Location permissions are permanently denied, we cannot request
permissions.');
}
StreamBuilder
StreamBuilder è un widget che, una volta introdotto e
compreso il funzionamento di FutureBuild, si applica allo stesso
modo per l’oggetto Stream.
Il widget StreamBuilder ascolta i cambiamenti dell’oggetto
Stream e, quando questi avvengono, Flutter esegue la build del
widget. Il comportamento è identico a quello di FutureBuilder. La
differenza intrinseca è che da uno Stream ci si aspetta che il
numero di eventi asincroni sia per definizione maggiore di
Future e di conseguenza la gestione degli stati è leggermente
più complessa.
Un esercizio utile potrebbe essere quello di provare a
sostituire FutureBuilder con StreamBuilder, modificando di
conseguenza il sorgente services/get_position.dart, per poi
spingersi a inviare la posizione ogni N secondi. Sarebbe
sicuramente un buon caso di studio.
screens/current_screen.dart
Come abbiamo visto nel file home_screen, è necessario definire i
due widget che corrispondono alle due tab. In questo caso,
procediamo definendo la tab Weather, che è la tab di default.
@override
State<CurrentWeatherView> createState() => _CurrentWeatherViewState();
}
@override
void initState() {
super.initState();
_futureCurrentWeather = fetchCurrentWeather(
latitude: widget.location.latitude,
longitude: widget.location.longitude);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<dynamic>(
future: _futureCurrentWeather,
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
if (snapshot.hasData) {
return Card(
child: Column(mainAxisSize: MainAxisSize.min, children:
<Widget>[
ListTile(
title: Icon(
IconData(snapshot.data.codeIcon, fontFamily:
'MyFlutterApp'),
size: 62),
subtitle: Column(children: [
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Temperature: '),
Text(
'${snapshot.data.temperature}°',
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.bold),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Wind speed: '),
Text(
'${snapshot.data.windspeed} Km/h',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Wind direction: '),
Text(
'${snapshot.data.winddirection}',
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
Text('Time: ${snapshot.data.time}'),
Text('Location: ${widget.location}'),
]),
)
]));
} else if (snapshot.hasError) {
return const Text('Error');
}
return const CircularProgressIndicator();
},
);
}
}
services/open_meteo.dart
Il file open_meteo implementa due funzioni per gestire le due
request ognuno per le due differenti tab.
Future<Weather> fetchCurrentWeather(
{required double latitude, required double longitude}) async {
Uri url = Uri.parse(
'https://api.open-meteo.com/v1/forecast?
latitude=$latitude&longitude=$longitude¤t_weather=true');
final response = await http.get(url);
if (response.statusCode == 200) {
return Weather.fromJson(jsonDecode(response.body)
['current_weather']);
} else {
throw Exception('Failed to load weather');
}
}
Future<List<Weather>> fetchForecastWeather(
{required double latitude, required double longitude}) async {
List<Weather> w = [];
Uri url = Uri.parse(
'https://api.open-meteo.com/v1/forecast?
latitude=$latitude&longitude=$longitude&daily=temperature_2m_max,tempera
ture_2m_min,weathercode,winddirection_10m_dominant,windspeed_10m_max&tim
ezone=GMT');
final response = await http.get(url);
if (response.statusCode == 200) {
var j = jsonDecode(response.body);
for (int i = 0; i < j['daily']['time'].length; i++) {
w.add(Weather(
time: j['daily']['time'][i],
code: j['daily']['weathercode'][i],
codeIcon: Weather.iconFromCode(j['daily']['weathercode'][i]),
temperatureMin: j['daily']['temperature_2m_min'][i],
temperatureMax: j['daily']['temperature_2m_max'][i],
windspeed: j['daily']['windspeed_10m_max'][i],
winddirection: Weather.convertDirection(
j['daily']['winddirection_10m_dominant'][i].toDouble())));
}
return w;
} else {
throw Exception('Failed to load items');
}
}
Modello dati
Il data model è uno degli aspetti cardini della
programmazione. L’uso che viene fatto di un data model, in
genere, è quello di validare e trasformare un dato acquisito
tramite la modellazione di un nuovo tipo di dato, nel nostro caso
weather.
La classe Weather mappa e consente di creare strutture dati
affinché possano essere “portate”, attraverso un modello, sulla
view, in modo strutturato e sicuro. Questo concetto è
sostanzialmente quello che viene definito come design pattern
MVC. Model, view e controller. Nel caso dell’app demo meteo, il
model è definito dal file model/weather.dart, la view dal file
contenuto nella directory screen e, se avessimo definito una
logica di manipolazione dei dati, avremmo potuto creare un file
separato dove sviluppare le business logic. Nel nostro esempio,
abbiamo solo una logica che riguarda la visualizzazione dei dati;
il nostro pattern non è propriamente un MVC, in quanto la logica
legata al dato è contenuta nello stesso file che definisce la view.
NOTA
Il package mvc_pattern (https://pub.dev/packages/mvc_pattern)
consente di implementare con estrema facilità un pattern MVC. La
scelta è soggettiva, ma consiglio la visione del codice di esempio per
rendersi immediatamente conto di come è realizzata una
implementazione di questo tipo.
models/weather.dart
Il file models/weather.dart implementa il modello dati
protagonista dell’applicazione.
const Weather({
required this.code,
this.codeIcon,
this.temperatureMin,
this.temperatureMax,
this.temperature,
required this.windspeed,
required this.winddirection,
required this.time,
});
if ((code >= 61 && code <= 67 || code >= 80 && code <= 82)) {
return 0xe813;
}
if ((code >= 71 && code <= 77 || code >= 85 && code <= 86)) {
return 0xe829;
}
if (code >= 61 && code <= 67) {
return 0xe813;
}
if (code >= 95 && code <= 99) {
return 0xe823;
}
throw 'Invalid code';
}
}
Factory
La factory è un costrutto speciale che, rispetto a un costrutto
classico, aggiunge nuove possibilità. Diversamente dal costrutto
classico che si “limita” a restituire un nuova istanza di una
classe, l’uso di factory consente non solo la possibilità di
istanziare una nuova classe, ma anche di restituire un’istanza
già esistente (cache), piuttosto che restituire un sottotipo di
classe; insomma, l’uso di factory introduce nuove features.
Nel sorgente models/weather, utilizziamo il costrutto per
istanziare una nuova classe, ma è anche possibile utilizzare
fromType per istanziare una nuova classe direttamente tramite un
class Weather {
Weather();
factory Weather.fromType(String type) {
if (type == 'weather') return Weather();
if (type == 'station') return Station();
throw "error factory";
}
}
void main() {
print(Weather(1).hashCode);
print(Weather(1).hashCode);
}
NOTA
Hashcode è una property che accompagna ogni oggetto e lo identifica
univocamente.
factory Weather() {
return _instance;
}
}
void main() {
print(Weather().hashCode);
print(Weather().hashCode);
}
screens/forecast_screen.dart
La view che si basa sul widget ForecastWeatherView
implementa una logica leggermente diversa dalla view
precedente.
@override
State<ForecastWeatherView> createState() =>
_ForecastWeatherViewState();
}
@override
void initState() {
super.initState();
futureForecastWeather = fetchForecastWeather(
latitude: widget.location.latitude,
longitude: widget.location.longitude);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<dynamic>(
future: futureForecastWeather,
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
if (snapshot.hasData) {
debugPrint('Future result: ${snapshot.data}');
return ListView(
padding: const EdgeInsets.all(8),
children: snapshot.data
.map<Widget>((Weather i) => Card(
child:
Column(mainAxisSize: MainAxisSize.max,
children: [
ListTile(
title: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
IconData(i.codeIcon ?? i.code,
fontFamily: 'MyFlutterApp'),
size: 62),
Text(i.time,
style: const TextStyle(
color: Color.fromRGBO(0, 0, 0,
0.8),
fontSize: 12,
fontWeight: FontWeight.bold))
]),
subtitle: Column(children: [
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Text('Temperature min: '),
Text(
'${i.temperatureMin}°',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold),
),
const Text(' max: '),
Text(
'${i.temperatureMax}°',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold),
),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Text('Wind speed: '),
Text(
'${i.windspeed} Km/h',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold),
),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const Text('Wind direction: '),
Text(
'${i.winddirection} Km/h',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold),
),
],
),
Text('Location: ${widget.location}'),
]),
)
])))
.toList());
} else if (snapshot.hasError) {
return const Text('Error');
}
return const CircularProgressIndicator();
},
);
}
}
Release
Se volessimo testare sul device fisico una build più vicina a
quella che vorremmo distribuire, senza alcun dubbio dobbiamo
procedere a una build di release. La build di release è
caratterizzata da una dimensione contenuta poiché vengono
eseguite una serie di ottimizzazioni e non porta con sé tutti gli
strumenti di debug a cui ci siamo appoggiati in fase di sviluppo.
È possibile procedere tramite il comando Flutter.
Procediamo con una build per Android.
$ flutter run –release
Using hardware rendering with device sdk gphone64 x86 64. If you notice
graphics artifacts, consider enabling software rendering with "--enable-
software-rendering".
Launching lib/main.dart on sdk gphone64 x86 64 in release mode...
Running Gradle task 'assembleRelease'... 2'109ms
✓ Built build/app/outputs/flutter-apk/app-release.apk (7.5MB).
iOS
Eseguire una build su un device fisico iOS è più ostico del
corrispettivo in Android. Seguiamo brevemente gli step da
seguire:
verificare il device, non appena collegato al desktop;
aprire Xcode e seguire il menu Runner -> Signing and
Capabilities -> Team e procedere con l’apple ID (email);
dopo la login, viene creato un Personal Team e potete
selezionarlo direttamente dalle impostazioni dell’account;
abilitare il Developer mode sul device (in Privacy e
Sicurezza);
eseguire la build in Xcode (genererà un errore sul device in
fase di run poichè l’account developer non è verificato:
“Untrusted Developer”);
procedere con il trust dell’account per l’app demo_meteo in
Generali -> VPN e gestione dispositivi -> App sviluppatore;
eseguire l’app.
Se incontraste qualche difficoltà, l’invito è di seguire la guida
di Flutter: https://docs.flutter.dev/deployment/ios.
NOTA
In VS Code, nel menu Esegui è possibile configurare la tipologia di
build tramite il file launch.json. Sarà possibile scegliere nel tab Run la
tipologia di profilo, una volta che ne sarà stata creata una per la
modalità “release”.
NOTA
Quando incappate in problemi di run, prima di imbarcarvi in un
troubleshooting approfondito, il consiglio è di disinstallare l’app sul
device e procedere a un flutter clean e successivamente a un flutter
pub get per per ottenere nuovamente le dipendenze dopo aver fatto
pulizia delle build.
Profile
La modalità profile consente di accedere a una serie di
dettagli legati alle performance dell’applicativo. L’app buildata
con flutter run --profile sostanzialmente esegue una build
“release” con il supporto dei devTools esclusivamente nel
particolare delle performance.
Una build profile è in genere usata per indagare o riscontrare
se sono presenti problemi di performance. È importante che la
build sia eseguita su un device fisico e che sia un device di
fascia media, proprio per cercare di rispecchiare il target
comune.
In Figura 12.8, eseguiamo una build profile in un simulatore,
solo per semplicità. È possibile vedere le informazioni che si
possono ricavare dai DevTools; inoltre, abilitando Performance
Overlay è possibile visionare la fase di rendering direttamente
sul device.
Il grafico presente direttamente sull’applicazione mostra in
realtime il carico della GPU e della UI: nel primo caso si può
evincere il carico di lavoro causato dalla fase di painting e
rendering, nel secondo tutto il restante codice applicativo.
In tempo reale, muovendosi tra le varie schermate dell’app
sarà possibile notare eventuali picchi che ci indirizzeranno sulla
giusta strada. In genere, un problema di performance si
presenta in situazioni in cui i frame impiegano più di 16ms per
essere renderizzati.
Test
Unit Test
L’obiettivo del nostro unit test sarà quello di testare la classe
Weather. I test che predisporremo sono 4:
test del costruttore;
test della factory;
test del metodo convertDirection;
test del metodo iconFromCode.
NOTA
Premessa importante e doverosa. Nel nostro caso lavoreremo
“cablando” (in gergo si dice così) all’interno del test la risposta che ci
aspetteremo dalle API esposte dal nostro provider meteo. Questa
pratica, per quanto sia efficace, andrebbe approcciata con l’uso di un
MockServer. Vediamo di approfondire il tema poco più avanti.
void main() {
test('Weather class initialization', () {
Map t = jsonDecode(
'{"temperature": 11.5,"windspeed": 8.7,"winddirection":
336.0,"weathercode": 0,"time": "2023-01-22T00:00"}');
Weather w = Weather(
time: t['time'],
code: t['weathercode'],
codeIcon: Weather.iconFromCode(t['weathercode']),
temperatureMin: t['temperature_2m_min'],
temperatureMax: t['temperature_2m_max'],
windspeed: t['windspeed'],
winddirection:
Weather.convertDirection(t['winddirection'].toDouble()));
expect(w.windspeed, 8.7);
});
expect(w.code, 0);
});
test('Weather convertDirection method', () {
String w = Weather.convertDirection(337);
expect(w, 'North');
});
expect(t, 0xe820);
});
}
expect(t, 0xe820);
}, tags: 'iconFromCode');
Infine, retry indica quante volte il test deve essere ripetuto per
marcarlo come fallito; di default non è prevista nessuna
ripetizione.
La callback (body) che viene eseguita dalla funzione test è il
cuore del test. Come possiamo vedere, non facciamo altro che
fornire il dato in input necessario al test specifico e verificare
tramite la funzione expect che il dato corrisponda al valore atteso.
Per esempio, nel test del metodo iconFromCode, non facciamo
altro che invocare il metodo passando come parametro il valore
48 e verificando che l’output atteso sia 0xe820. Così facendo,
verifichiamo la logica del metodo e, se questo dovesse
cambiare nel tempo, il nostro test fallirà; questo è classico un
esempio di regressione.
Più approfonditi saranno i test, maggiore sarà la possibilità di
riscontrare un bug nel codice in fase di sviluppo.
Eseguiamo il codice direttamente da VS Code oppure da
console con il comando:
$ flutter test test/weather_test.dart
expect(w, 'North');
});
expect(t, 0xe820);
});
});
Widget Test
Questa tipologia di test consente di verificare se il widget è
funzionante. A grandi linee, si può riassumere come un test per
verificare se un widget è presente nel relativo albero e che il suo
comportamento e la sua UI rispetti le aspettative.
Esattamente come per gli unit test, i widget test necessitano
di una dipendenza specifica. È necessario verificare la presenza
di questa porzione all’interno del file pubspec.yaml. Questa
dipendenza è comunque presente nel package:test,
precedentemente installato.
dev_dependencies:
flutter_test:
sdk: flutter
void main() {
testWidgets('CurrentWeather Widget data loaded', (tester) async {
const widget = MyApp();
await tester.pumpWidget(widget);
expect(find.byType(MyHomePage), findsOneWidget);
});
}
Il test viene definito dalla funzione testWidgets. Il suo costrutto
è:
void testWidgets(
String description,
WidgetTesterCallback callback,
{bool? skip,
Timeout? timeout,
@Deprecated('This parameter has no effect. Use `timeout` instead. '
'This feature was deprecated after v2.6.0-1.0.pre.') Duration?
initialTimeout,
bool semanticsEnabled = true,
TestVariant<Object?> variant = const DefaultTestVariant(),
dynamic tags}
)
void testWidgets(
String description,
WidgetTesterCallback callback,
{bool? skip,
Timeout? timeout,
@Deprecated('This parameter has no effect. Use `timeout` instead. '
'This feature was deprecated after v2.6.0-1.0.pre.') Duration?
initialTimeout,
bool semanticsEnabled = true,
TestVariant<Object?> variant = const DefaultTestVariant(),
dynamic tags}
)
NOTA
Semantic, che ho tradotto con il concetto di semantica, è un widget
usato per arricchire con informazioni i widget che compongono
l’albero. Questo consente la lettura dell’applicazione a strumenti terzi,
per esempio tools per l’accessibilità.
Mock
Il successivo widget da testare è CurrentWeatherView.
Questo widget introduce una complessità maggiore, in quanto il
widget in questione necessita di reperire i dati tramite una
chiamata http. Questo rappresenta una attività mandatoria per il
nostro test e abbiamo necessità di risolverlo con un nuovo
approccio in quanto Flutter non consente di eseguire realmente
chiamate http durante i test.
Ogni chiamata http effettuata in fase di test viene intercettata
dal framework e fornisce un falso http code 400 (Bad Request),
come se la chiamata non avesse avuto successo. Il perché non
sia possibile effettuare chiamate http “reali” durante la fase di
test è condivisibile. Introdurre una chiamata di rete all’interno di
un test introduce possibilità di errori non previsti, soprattutto in
queste tipologie di test che dovrebbero ragionare a scatola
chiusa.
Detto ciò, abbiamo due possibilità: una buona e una cattiva.
Quella buona ragiona sul fatto che una dependency injection
che si rispetti dovrebbe farci ragionare e scrivere codice
migliore; non è questo il caso, noi siamo più pragmatici.
Il widget CurrentWeatherView non implementa la logica di
business all’interno del suo widget, ma lo fa tramite una
funzione esterna che non ha nessuna dipendenza da
CurrentWeatherView. Questa implementazione non è del tutto
corretta, ma aspettavo questo momento per confessarlo.
Una corretta implementazione sarebbe stata quella di rendere
argomento delle funzioni fetchCurrentWeather e fetchForecastWeather
il client http. Così facendo, avremmo la possibilità, all’interno del
nostro test, di usare un client http fake come oggetto argomento
delle funzioni, e in questo caso avremmo parlato di dependency
injection.
Il concetto di client http fake può essere tradotto come mock.
Di fatto, un mock è un oggetto che simula il comportamento di
un altro oggetto, riuscendo a testare il codice senza poter
accedere alla risorsa originale.Per esempio:
…
Future<Weather>
fetchCurrentWeather(
{
required double latitude,
required double longitude,
required http.Client client
}
) async {
final response = await client.get(Uri.parse('https://api.open-
meteo.com/v1/forecast?
latitude=$latitude&longitude=$longitude¤t_weather=true'));
…
Rispetto a quanto fatto, non solo noi non abbiamo gestito il
client http come dipendenza, ma abbiamo usato il package:http
e il metodo statico get. Per l’uso del mock è necessario
modificare leggermente l’implementazione, sostituendo l’uso di
http.get con http.Client. Questo consente di passare l’istanza
Mockito
Se volessimo portare avanti questa strada, il tool che fa al
nostro caso è mockito (https://pub.dev/packages/mockito). Creiamo
un piccolo esempio.
@GenerateMocks([http.Client])
void main() {
testWidgets('....', (WidgetTester tester) async {
final client = MockClient();
when(client.get(url)).thenAnswer(
(value) async {
return http.Response('response..', 200);
},
);
});
}
Nock
L’alternativa alla precedente strada è un pò più sbrigativa: si
tratta di Nock (https://pub.dev/packages/nock). Rispetto a quanto
visto (il connubio tra http e mockito), questa soluzione si basa
su un concetto diverso. Flutter consente, tramite la classe
HttpOverrides, di sovrascrivere la classe HttpClient. Questa, a
differenza del package http, è una classe a basso livello poco
user-friendly, che comunque non è da preferire a http (lo
consiglia anche la documentazione di Flutter), ma ci consente di
eseguire ugualmente chiamate di rete. Procediamo con il test
del widget CurrentWeatherView.
import 'package:flutter/material.dart';
import 'package:demo_meteo/screens/current_screen.dart';
import 'package:geolocator/geolocator.dart';
void main() {
setUpAll(nock.init);
setUp(() {
nock.cleanAll();
});
Position p = Position(
longitude: -122.406417,
latitude: 37.785834,
timestamp: DateTime.now(),
accuracy: 10,
altitude: 800,
heading: 1,
speed: 10,
speedAccuracy: 1);
'{"latitude":37.78929,"longitude":-122.422,"generationtime_ms":0.2579689
025878906,"utc_offset_seconds":0,"timezone":"GMT","timezone_abbreviation
":"GMT","elevation":49.0,"current_weather":
{"temperature":8.8,"windspeed":32.8,"winddirection":15.0,"weathercode":0
,"time":"2023-01-23T11:00"}}';
final widget =
MaterialApp(home: Scaffold(body: CurrentWeatherView(location:
p)));
await tester.pumpWidget(widget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
final widget =
MaterialApp(home: Scaffold(body: CurrentWeatherView(location:
p)));
await tester.pumpWidget(widget);
await tester.pump(const Duration(seconds: 5));
expect(find.text('Temperature: '), findsOneWidget);
});
}
Integration Test
I test precedenti miravano a testare porzioni di codice
sorgente, i test di integrazione hanno l’obiettivo di testare l’intera
applicazione.
Per gestire i test di integrazione è necessario modificare il file
pubspec.yaml, aggiungendo la chiave integration_test.
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
Procediamo creando la directory integration_test dentro la
nostra root e al suo interno posizioniamo il file app_test.dart
void main() {
Profile
Rispetto a quanto visto, è necessario introdurre nel test la
classe IntegrationTestWidgetsFlutterBinding, che ci consente di
intercettare dati di strumentazione nativi prima dell’esecuzione
dell’app. Inoltre, potremo utilizzare l’istanza
IntegrationTestWidgetsFlutterBinding.ensureInitialized() per accedere
al metodo traceAction e profilare le operazioni che possono
essere eseguite nella callback parametro di traceAction.
È possibile utilizzare il set di metodi offerti da WidgetTester,
per esempio simulare le fasi scroll, inserire dati, tappare e
quant’altro. Queste operazioni possono essere profilate e i dati
generati verranno storati tramite la key reportKey.
…
final binding =
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
await binding.traceAction(
() async {
await tester....
},
reportKey: 'profile_timeline',
);
});
…
Future<void> main() {
return integrationDriver(
responseDataCallback: (data) async {
if (data != null) {
final timeline =
driver.Timeline.fromJson(data['scrolling_timeline']);
Coverage
Come introdotto in precedenza, Flutter offre la possibilità di
generare un report di coverage. Eseguiamo il seguente
comando:
$ flutter test --coverage
00:03 +9: All tests passed!
Rendering
Widget tree
Prendiamo in esempio questa porzione di codice:
Center(
child: Container(
color: Colors.blue,
child: const Text('Hello World', textDirection: TextDirection.ltr),
));
(https://github.com/flutter/flutter/blob/master/packages/flutter/lib/sr
c/widgets/text.dart), mentre all’interno del widget Container
(https://github.com/flutter/flutter/blob/master/packages/flutter/lib/sr
c/widgets/container.dart) ci sono delle condizioni per cui possono
essere creati ulteriori widget figli.
Sbirciamo una porzione del codice sorgente del widget
Container:
@override
Widget build(BuildContext context) {
Widget? current = child;
...
current = Align(alignment: alignment!, child: current);
}
...
if (color != null) {
current = ColoredBox(color: color!, child: current);
}
...
if (decoration != null) {
current = DecoratedBox(decoration: decoration!, child: current);
}
...
if (constraints != null) {
current = ConstrainedBox(constraints: constraints!, child:
current);
}
...
return current!;
}
Element tree
Il widget, che abbiamo già spiegato essere immutabile, di
fatto non contiene altro che una serie di informazioni che
vengono fruite dal framework nel processo di rendering. Il
concetto di immutabilità, però, non si sposa bene con
un’interfaccia utente che di fatto muta e rende possibile la
creazione di un’app interattiva.
Per risolvere l’arcano è bene chiarire che l’albero dei widget
non è l’unico aspetto su cui Flutter si basa per la creazione della
nostra UI.
Dietro l’albero dei widget, esiste un altro albero, chiamato
albero degli elementi (Element tree). Per ogni widget che viene
posizionato nell’albero dei widget, Flutter invoca un metodo
chiamato createElement che crea un corrispettivo al widget
nell’albero degli elementi, chiamato Element.
Un Element ha lo scopo di mantenere un riferimento al widget
tree e si occupa di fare da tramite per la fase di rendering.
Possiamo immaginarlo come un layer intermedio che ha la
finalità ultima di rendere il più performante possibile la fase di
rendering.
Pensiamo quante volte nel corso dei nostri esperimenti
abbiamo invocato il metodo build di un widget, ne abbiamo
creati di nuovi, rimossi e ricreati nuovamente, tutte operazioni
che fanno parte del ciclo di vita dell’applicativo e potrebbero
avere un impatto notevole sulle sue performance. Questo
processo è reso performante da Flutter proprio perché quanto
noi facciamo con l’uso dei widget non ha un reale impatto sulla
fase di rendering, ma è il componente Element a gestire per
conto nostro una serie di ottimizzazioni che consentono un
riutilizzo massivo dei widget.
Torniamo momentaneamente al nostro widget tree. Vediamo
nel dettaglio i nostri widget e la loro gerarchia.
Center:Object->DiagnosticableTree->Widget->RenderObjectWidget-
>SingleChildRenderObjectWidget->Align->Center
Container: Object->DiagnosticableTree->Widget->StatelessWidget-
>Container
ColoredBox:Object->DiagnosticableTree->Widget->RenderObjectWidget-
>SingleChildRenderObjectWidget->ColoredBox
Text:Object->DiagnosticableTree->Widget->StatelessWidget->Text
RichText:Object->DiagnosticableTree->Widget->RenderObjectWidget-
>MultiChildRenderObjectWidget->RichText
Element? _child;
@override
void visitChildren(ElementVisitor visitor) {
if (_child != null) {
visitor(_child!);
}
}
@override
void forgetChild(Element child) {
assert(child == _child);
_child = null;
super.forgetChild(child);
}
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
_child = updateChild(_child, (widget as
SingleChildRenderObjectWidget).child, null);
}
@override
void update(SingleChildRenderObjectWidget newWidget) {
super.update(newWidget);
assert(widget == newWidget);
_child = updateChild(_child, (widget as
SingleChildRenderObjectWidget).child, null);
}
@override
void insertRenderObjectChild(RenderObject child, Object? slot) {
final RenderObjectWithChildMixin<RenderObject> renderObject =
this.renderObject as RenderObjectWithChildMixin<RenderObject>;
assert(slot == null);
assert(renderObject.debugValidateChild(child));
renderObject.child = child;
assert(renderObject == this.renderObject);
}
@override
void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object?
newSlot) {
assert(false);
}
@override
void removeRenderObjectChild(RenderObject child, Object? slot) {
final RenderObjectWithChildMixin<RenderObject> renderObject =
this.renderObject as RenderObjectWithChildMixin<RenderObject>;
assert(slot == null);
assert(renderObject.child == child);
renderObject.child = null;
assert(renderObject == this.renderObject);
}
}
RenderObject tree
Come già dovrebbe essere chiaro, l’albero dei RenderObject
è quello che viene usato dal framework per disegnare gli oggetti
sullo schermo. Questi oggetti contengono i dati relativi al layout
degli oggetti stessi, utilizzati nella fase di painting. In Figura
14.4, si evince che è possibile ispezionare il contenuto del
renderObject. Vi invito a farlo.
Prendiamo in esempio il RenderObject relativo al widget
ColoredBox.
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderColoredBox(color: color);
}
@override
void updateRenderObject(BuildContext context, RenderObject
renderObject) {
(renderObject as _RenderColoredBox).color = color;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Color>('color', color));
}
}
@override
void paint(PaintingContext context, Offset offset) {
// It's tempting to want to optimize out this `drawRect()` call if
the
// color is transparent (alpha==0), but doing so would be incorrect.
See
// https://github.com/flutter/flutter/pull/72526#issuecomment-
749185938 for
// a good description of why.
if (size > Size.zero) {
context.canvas.drawRect(offset & size, Paint()..color = color);
}
if (child != null) {
context.paintChild(child!, offset);
}
}
}
Keys
Quanto appena spiegato apre conoscenze approfondite sul
framework e abbiamo compreso quanto e come sia stato
strutturato per avere performance ottimali. Ora è il momento di
approfondire un altro tema: le chiavi. Nei capitoli precedenti
abbiamo incontrato più volte il costruttore di un widget che viene
definito con una chiave. Questo passaggio non è mai stato
affrontato nel dettaglio, ma calza perfettamente con il tema che
stiamo affrontando.
Partiamo da questo.
Listato 14.5 main.dart
import 'package:flutter/material.dart';
import 'dart:math';
@override
State<StatefulWidget> createState() => ExampleWidgetState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(children: tiles),
floatingActionButton: FloatingActionButton(
onPressed: changePosition, child: const
Icon(Icons.change_circle)),
);
}
changePosition() {
setState(() {
tiles.insert(1, tiles.removeAt(0));
tiles.insert(2, tiles.removeAt(3));
});
}
}
@override
Widget build(BuildContext context) {
return Container(
height: 100,
width: 100,
color: Colors.green,
child: Text('Stateful: $number'));
}
}
@override
Widget build(BuildContext context) {
return Container(
height: 100,
width: 100,
color: Colors.red,
child: Text('Stateless: $number'));
}
}
@override
State<TestStatefulWidget> createState() => _TestStatefulWidgetState();
}
L’inserimento della key farà in modo che, quando Flutter
confronterà widget ed Element, questi non corrisponderanno
(runtimeType && key) in quanto al primo elemento dell’albero
corrisponderà il primo elemento dell’albero degli elementi con
una key differente. In questo caso. Flutter sarà costretto a
disattivare gli Element e cercare nella sua porzione di albero dei
widget se sono presenti child con le relative key. Se questi
vengono trovati, l’albero verrà ricostruito rispettando
l’ordinamento del widget tree e, infine, gli elementi verranno
renderizzati correttamente. A quel punto l’applicazione avrà il
comportamento atteso.
GlobalKey
Il confronto delle chiavi che abbiamo visto poc’anzi avviene
con un limite ben preciso. In Figura 14.9 viene mostrato che la
ricerca della corrispondenza della key tra i due alberi viene fatta
solo allo stesso livello. Se viene creato un nuovo albero, questo
non verrà analizzato.
Figura 14.10 Ricerca key solo allo stesso livello.
RepaintBoundary
Un aspetto importante del funzionamento interno di Flutter è
senz’altro il processo di “painting”, che in linea di massima viene
nascosto del tutto allo sviluppatore. Abbiamo abbondantemente
compreso il processo di building e rebuilding, che comporta in
molti casi anche il processo di painting, ovvero il processo di
disegno vero e proprio che è a carico del framework ed è
implementato nei vari RenderObject.
Per quanto è vero che nella maggior parte dei casi il metodo
build di un widget scatenerebbe un processo di painting, non è
vero al contrario. Quando un’applicazione diventa piuttosto
complessa potremmo incappare in problematiche legate alle
performance o comunque essere più attenti a questa
problematica.
Il processo di painting, come abbiamo detto, può essere non
del tutto controllato e anche indipendente dal processo di
building del widget. Per esempio, il framework potrebbe
invocare il metodo RenderObject.markNeedsPaint di un widget a
causa di un evento non legato a un setState o comunque a un
cambio dati del modello, a causa di un scroll o a un’animazione
che coinvolge un altro widget.
Il widget RepaintBoundary consente di limitare il processo di
pittura dei widget, che a cascata obbligherebbe di ridipingere i
figli.
void main() {
debugRepaintRainbowEnabled = true;
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: ListView(
padding: const EdgeInsets.all(8),
children: <Widget>[
Container(
height: 250,
color: Colors.amber[600],
child: const Center(child: Text('Entry A')),
),
Container(
height: 250,
color: Colors.grey[100],
child: const Center(child: Text('Entry B')),
),
Container(
height: 250,
color: Colors.green[300],
child: const Center(child: Text('Entry C')),
),
Container(
height: 250,
color: Colors.red[300],
child: const Center(child: Text('Entry C')),
),
],
));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Container(
height: 250,
color: Colors.amber[600],
child: const Center(child: Text('Entry A')),
),
floatingActionButton:
RepaintBoundary(child: FloatingActionButton(onPressed: ()
{})));
}
}
Vi invito a rimuovere il widget RepaintBoundary per
comprendere quanto una semplice interazione con il bottone di
fatto scateni un repainting di tutto il layer, inoltre senza che
avvenga nessun rebuild in quanto non è presente nessun
setState.
CustomPainter
CustomPainter è un widget che ci consente di disegnare sulla
canvas. Il suo utilizzo si caratterizza da una classe che estende
CustomPainter, e l’omonimo widget disegnerà a video quanto
definito dal metodo paint.
La classe Path consente di tracciare linee, posizionarsi sulla
canvas in un determinato punto, disegnare figure geometriche.
Metodi come addPolygon, addOval, addArc e addRect, offerti dalla
classe Path, consentono di disegnare le forme più disparate.
Facciamo un esempio, in tema Hitchcock, utilizzando un
approccio più grezzo, ovvero indicare dei punti sulla canvas e
tracciare delle linee, che infine vengono “riempite” creando
figure (chiedo umilmente scusa per la scarsa immaginazione).
Listato 14.8 main.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
debugRepaintRainbowEnabled = true;
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Example',
home: PaintWidget(),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
decoration: BoxDecoration(border: Border.all()),
height: 300,
width: 152,
child: CustomPaint(
painter: PaintingView(),
)),
));
}
}
paint.color = Colors.green;
path.moveTo(150, 40);
path.lineTo(130, 70);
path.lineTo(120, 70);
path.lineTo(125, 80);
path.lineTo(120, 100);
path.lineTo(150, 90);
path.lineTo(120, 130);
path.lineTo(110, 180);
path.lineTo(120, 210);
path.lineTo(140, 220);
path.lineTo(140, 230);
path.lineTo(140, 290);
path.lineTo(110, 298);
canvas.drawPath(path, paint);
path = Path();
path.moveTo(135, 85);
path.lineTo(128, 90);
canvas.drawPath(path, paint);
path = Path();
path.moveTo(110, 180);
path.lineTo(150, 180);
canvas.drawPath(path, paint);
path = Path();
path.addOval(Rect.fromCircle(center: const Offset(145, 60), radius:
2));
canvas.drawPath(path, paintBall);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
Layout Design
Responsive
Le interfacce responsive sono una caratteristica importante di
un applicativo mobile. Per chi non conoscesse questo termine,
con responsive si intende un’interfaccia che si adatta alle
dimensioni dello screen del device. Il design responsive
consente di modellare un’interfaccia “unica” per diverse tipologie
di device. L’esperienza d’uso responsive è, inoltre,
caratterizzata da un adattamento legato all’orientamento del
device, landscape o portrait, ed è in genere quello che un utente
si aspetta. Per esempio, se l’utente sta visionando una photo
gallery in cui le foto sono organizzate in una gridview, questa
dovrebbe configurarsi diversamente al cambiamento
dell’orientamento del device, offrendo all’utente la possibilità di
visionare le foto nel migliore dei modi.
Dal punto di vista prettamente tecnico, Flutter offre diversi
strumenti per creare un layout responsive. Nonostante il fatto
che non esiste un pattern specifico per fare questo, è comunque
possibile realizzarlo in diversi modi.
SafeArea
Il primo widget a cui fare riferimento è senz’altro SafeArea.
Questo widget, a cui abbiamo fatto già riferimento in passato,
può esservi d’aiuto. SafeArea è un widget che consente di
escludere dal layout della nostra applicazione eventuali
sovrapposizioni con il sistema operativo, per esempio eventuali
barre nel top o nel bottom del sistema operativo.
Figura 15.1 SafeArea.
MediaQuery
Uno dei primi passi, se non il primo, per disegnare
un’interfaccia responsive è senz’altro sapere quali sono le
dimensioni del nostro screen, e di conseguenza anche
conoscere il suo orientamento.
@override
Widget build(BuildContext context) {
MediaQueryData screen = MediaQuery.of(context);
return Scaffold(
body: SafeArea(
child: Center(
child: Text(
textAlign: TextAlign.center,
'height:${screen.size.height} *
width:${screen.size.width} - orientation: ${screen.orientation} -
devicePixelRatio: ${screen.devicePixelRatio}'))));
}
Figura 15.2 MediaQuery (portrait).
Figura 15.3 MediaQuery(landscape).
NOTA
Il sito unsplash.com offre una API per la generazione di immagini
casuali.
Figura 15.4 Orientation(1).
Figura 15.5 Orientation(2).
OrientationBuilder
Il widget OrientationBuilder può essere usato per creare un
layout che si basa sull’orientation. A differenza dell’esempio in
cui facciamo esclusivamente uso delle MediaQuery, in questo
caso l’orientation non è riferito allo screen del device, ma alle
dimensioni del widget parent. Se OrientationBuilder fosse figlio
di un Container con una larghezza maggiore dell’altezza,
nonostante il device sia orientato in ‘portrait’, l’orientation
ottenuta da OrientationBuilder sarebbe landscape. Viceversa,
se usassimo MediaQuery alle stesse condizioni, otterremmo
l’orientation portrait.
@override
Widget build(BuildContext context) {
return Scaffold(body: OrientationBuilder(builder: (context,
orientation) {
return SafeArea(
child: GridView.count(
crossAxisCount: orientation.name == 'portrait' ? 2 : 4,
children: List.generate(30, (index) {
return Center(
child: Card(
child: Image.network(
'https://source.unsplash.com/random/200x200?
sig=${index}')),
);
})));
}));
}
AspectRatio, FractionallySizedBox
Abbiamo già trattato il widget AspectRatio. Questo widget può
essere usato per realizzare layout responsive proprio perché
non ragiona su dimensioni fisse, ma su proporzioni. La stessa
logica si applica anche per il widget FractionallySizedBox,
introdotto nel capitolo Layout Widgets.
Size size = MediaQuery.of(context).size;
return Scaffold(body:
SafeArea(child: OrientationBuilder(builder: (context,
orientation) {
return AspectRatio(
aspectRatio:
size.aspectRatio * (orientation.name == 'portrait' ? 5 :
2.5),
child: Container(
child: Card(
Flexible
Il widget Flexible consente di gestire, con maggiore efficienza
rispetto a quanto già visto, i figli dei widget Row, Column e Flex.
La feature principale di questo widget è la possibilità di
controllare la “flessibilità”, intesa come capacità di estensione,
dei singoli child.
Diamo un’occhiata al costrutto.
const Flexible(
{Key key,
int flex: 1,
FlexFit fit: FlexFit.loose,
@required Widget child}
)
Percentuali
Rispetto a quanto appena detto, il parametro flex può
esprimere un valore tramite una percentuale, come unità di
misura dello spazio occupabile.
I modi per utilizzare questo approccio, che garantisce la
possibilità di disegnare un layout responsive, sono
approssimativamente due (quantomeno utilizzando le strutture
più semplici): Flexible e MediaQuery. Procediamo con un esempio
che le racchiude entrambi e che sia del tutto responsive, in
altezza e larghezza.
void main() {
runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Example'),
), //AppBar
body: Builder(builder: (BuildContext context) {
Size screen = MediaQuery.of(context).size;
return Column(children: [
Flexible(
flex: 60,
child: Row(
children: <Widget>[
Flexible(
flex: 30,
fit: FlexFit.loose,
child: Container(
margin: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(),
),
), //Container
), //Flexible
Flexible(
flex: 50,
fit: FlexFit.loose,
child: Container(
margin: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(),
),
), //Container
), //Flexible
Flexible(
flex: 20,
fit: FlexFit.loose,
child: Container(
margin: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(),
),
), //Container
), //Flexible
], //<Widget>[]
),
), //Flexible
Flexible(
flex: 40,
child: Row(
children: <Widget>[
Container(
width: screen.width * 0.3 - 12,
margin: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(),
),
), //Container
Container(
width: screen.width * 0.5 - 12,
margin: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(),
),
), //Container
Container(
width: screen.width * 0.2 - 12,
margin: const EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(),
),
), //Container
], //<Widget>[]
)),
]); //Row
//<widget>[]
//Column
}))));
}
LayoutBuilder
Un altro strumento che Flutter offre per la gestione dei layout
è LayoutBuilder. Il widget in questione consente, esattamente
come per la funzione Builder, di creare un albero dei widget e,
allo stesso tempo, fornire i vincoli del genitore.
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {}
Adaptive
I widget che consentono di realizzare un applicativo
responsive sono gli stessi che utilizzeremmo per creare un
design adattivo. In questa tipologia di progettazione ciò che fa la
differenza non è, come per l’approccio responsive, modificare il
comportamento dell’applicativo alla conseguente modifica della
risoluzione, ma piuttosto definire a priori un layout, un
comportamento, per una tipologia o categoria di device.
L’approccio adattivo consente di migliorare notevolmente
l’esperienza d’uso quando abbiamo un applicativo che nasce
per essere usato, per esempio, sia su device mobili che
desktop. In una situazione di questo tipo, la densità di spazio a
disposizione, piuttosto che un’interazione che si basa su input
differenti (mouse, keyboard...) o su una gesture, ci
consentirebbe di progettare la nostra UX in modo
completamente differente. Una navigazione basata su
bottomNavigationBar potrebbe essere sostituita su desktop con
NavigationRail. Potrebbero essere introdotti comportamenti
legati all’uso del mouse, come onHover, e anche i testi possono
essere gestiti diversamente: avendo maggiore spazio,
potremmo essere maggiormente “descrittivi”. Insomma, credo
che il concetto sia chiaro.
NOTA
Il widget NavigationRail consente di creare una barra di navigazione
nella parte lateriale dello schermo.
NOTA
onHover è un evento legato al passaggio del mouse su un widget.
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Adaptive example',
home: CheckHomeScreen());
}
}
@override
Widget build(BuildContext context) {
if (kIsWeb || Platform.isMacOS || Platform.isLinux ||
Platform.isWindows) {
return DesktopLayout();
}
return MobileLayout();
}
}
State Management
Design pattern
I design pattern sono strumenti che consentono di
approcciare problemi comuni nella progettazione del software.
Flutter si basa su Dart e, come sappiamo, Dart è un linguaggio
Object Oriented. I design pattern, nati con queste tipologie di
linguaggi, consentono di migliorare, rendere più efficiente,
leggibile, usabile e manutenibile il codice sorgente.
In Flutter abbiamo affrontato il tema “gestione dello stato”
tramite l’uso di setState.
L’approccio di Flutter usato nello sviluppo del codice è definito
dichiarativo. Quello che in Flutter viene eseguito per creare la UI
è dichiarare un determinato “comportamento”, e questo è
caratterizzato da due componenti: widget e stato. Alla modifica
di quest’ultimo viene immediatamente modificata la UI,
delegando di fatto tutti i passaggi intermedi per raggiungere
l’obiettivo al Framework. Questa tipologia di programmazione
consente di essere molto più veloci rispetto a quella opposta,
conosciuta come “imperativa”.
NOTA
Possiamo immaginare il processo di setState come “funzionale” e
pertanto rientra nella logica della programmazione dichiarativa.
L’approccio imperativo, invece, può essere immaginato come la
definizione di un algoritmo, dove ogni riga di codice è necessaria al
fine di raggiungere l’ obiettivo.
Callback
Prima di muoverci verso soluzioni più evolute, procediamo
con un esempio piuttosto semplice. Creiamo una piccola
applicazione e optiamo per l’uso di una callback come
strumento di condivisione di dati tra figlio e genitore. È
chiaramente una soluzione veloce e non strutturata, ma
raggiunge il suo scopo.
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
@override
State<MyHomePage> createState() => _MyHomePageState();
}
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
IncrementCounter(callback: _incrementCounter),
],
),
));
}
}
@override
Widget build(BuildContext context) {
return ElevatedButton(onPressed: callback, child: const
Icon(Icons.add));
}
}
SharedAppData
ShareAppData è un’altra soluzione per condividere
informazioni lungo i widget che compongono il widget tree. L’uso
del widget SharedAppData consente di definire una chiave a cui
viene associato un valore. I due metodi che stanno alla base del
widget SharedAppData, sono getValue e setValue. Il primo
consente di ottenere il dato dietro la coppia chiave/valore, il
secondo, invece, permette di scrivere il dato e scatena anche un
rebuild; ciò significa che non è necessario un setState().
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const ShowSharedValue(appDataKey: 'counter'),
const SizedBox(height: 6),
ElevatedButton(
child: const Icon(Icons.add),
onPressed: () {
_counter++;
SharedAppData.setValue<String, int>(context, 'counter',
_counter);
},
),
const SizedBox(height: 6),
ElevatedButton(
child: const Icon(Icons.remove),
onPressed: () {
_counter--;
SharedAppData.setValue<String, int>(context, 'counter',
_counter);
},
),
],
)),
);
}
}
@override
Widget build(BuildContext context) {
final int value =
SharedAppData.getValue<String, int>(context, appDataKey, () =>
0);
return Text('$appDataKey: $value');
}
}
InheritedWidget
InheritedWidget nasce con l’esatto scopo di propagare le
informazioni lungo l’albero dei widget. Creiamo un esempio
semplice ma efficace.
void main() {
runApp(
const MaterialApp(
home: MyApp(),
),
);
}
@override
State<MyApp> createState() => _MyAppState();
}
void incrementer() {
setState(() {
count++;
});
}
@override
Widget build(BuildContext context) {
return CounterState(
count: count,
incrementer: incrementer,
child: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[
SizedBox(height: 6),
GetCounter(),
IncrementCounter(),
],
),
)));
}
}
class GetCounter extends StatelessWidget {
const GetCounter({super.key});
@override
Widget build(BuildContext context) {
return Text('counter: ${CounterState.of(context).count}');
}
}
@override
bool updateShouldNotify(CounterState oldWidget) {
return count != oldWidget.count;
}
}
Provider
Provider (https://pub.dev/packages/provider) è uno state
manager. Il widget che sta alla base di Provider è
InheritedWidget. Provider ha esteso la naturale funzionalità di
InheritedWidget rendendolo uno strumento più completo e
versatile, ed è anche lo strumento che prediligo tra le varie
alternative.
La struttura di Provider consente di condividere le
informazioni tra i widget attraverso l’albero, poiché il widget
InheritedWidgets è di fatto il Parent della struttura e i widget che
ne necessitano possono accedere al suo state.
Figura 16.1 Provider/InheritedWidget design.
increment(int c) {
_i = c + 1;
notifyListeners();
}
}
Provider.of
Rispetto a quanto visto nell’esempio precedente, Provider
offre un’altra alternativa: Provider.of. In verità, più che
un’alternativa Provider.of è una primitiva, visto che Consumer si
basa su questa soluzione.
Rispetto a Consumer, Provider.of offre la possibilità di
accedere al Provider senza necessariamente scatenare un
evento di build. Nonostante ciò, rispetto a Consumer non è
possibile avvolgere una porzione di albero specifica, infatti
Provider.of effettua la build dell’intera sezione.
NOTA
Attenzione al context, che in questo esempio dovrà contenere il
Provider; per questo motivo è stato spostato in cima rispetto
all’esempio iniziale.
Watch
Un’alternativa all’utilizzo della classe Consumer può essere
data dall’uso dei metodi read, watch e select, presenti nel context.
L’utilizzo da favorire è quello che trovate più comodo.
Read: consente di accedere al modello dati
(ChangeNotifier) senza però scatenare processi di build del
widget al cambio del modello.
Watch: consente di monitorare cambiamenti di stato nel
modello e di procedere al build del widget.
Select: consente di usare la logica di watch specificamente
per un elemento del modello.
return Scaffold(
appBar: AppBar(
title: const Text('Example'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${counter.counter}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
)),
floatingActionButton: FloatingActionButton(
onPressed: () => counter.increment(counter.counter),
tooltip: 'Increment Counter',
child: const Icon(Icons.add),
));
}
}
increment(int c) {
_i = c + 1;
notifyListeners();
}
}
BLoC
BLoC (Business Logic Component, https://bloclibrary.dev) è
un design pattern sviluppato per Dart.
BLoC consente di dividere e organizzare più efficientemente
l’architettura dell’applicazione. La base di BLoC è la
programmazione reattiva e questa, come dice il nome stesso, si
basa sul concetto di propagare costantemente ogni
cambiamento relativo ai dati; così facendo, a ogni reazione che
viene scatenata dalla UI corrisponderà un evento che
aggiornerà l’applicazione.
BLoC usa gli stream per gestire gli eventi, e questo approccio
consente di costruire un controller per disaccoppiare la logica
dalla UI, rappresentata dal widget.
La differenza con Provider si basa sul fatto che BLoC si basa
su StreamBuilder, mentre Provider su Consumer. Il primo
rispecchia ogni cambiamento del modello tramite Stream, il
secondo si basa sul metodo notifyListeners per notificare il
Consumer. Inoltre, c’è una differenza architetturale: BLoC
consente di dettagliare meglio la divisione tra modello e
visualizzazione, definendo diversi stream per fields, mentre nel
caso di Provider vediamo che ChangeNotifier rispecchia il
modello nella sua interezza.
Redux
Redux è certamente un’alternativa a BLoC. Credo che a oggi
la community Flutter sia più orientata all’uso di BLoC, ma è
altresì vero che lo strumento migliore è quello che si conosce
meglio, e Redux è molto popolare tra gli sviluppatori React.
Detto ciò, Redux è uno state management, esattamente come
BLoC, ma con una differenza sostanziale.
L’uso di BLoC prevede una struttura di dialogo tra widget e
controller attraverso eventi asincroni bidirezionali, mentre in
Redux la logica è leggermente diversa. Redux si basa su uno
state management centralizzato: questo avviene tramite uno
storage che potrà essere consultato da tutti i widget senza
dover interagire con la struttura ad albero.
Lo scopo di questo capitolo non è assolutamente trattare
questi design pattern; credo che servirebbero tante altre pagine
per farlo in maniera esaustiva. L’obiettivo era presentare le
alternative a quello che rappresenta, dal mio punto di vista, la
prima scelta per un design che consenta un’efficiente gestione
degli stati, ovvero Provider.
Capitolo 17
Approfondimenti
Cupertino widgets
Purtroppo, per mancanza di tempo e spazio, non sono riuscito
ad affrontare il tema Cupertino. Tutto quello che abbiamo visto,
però, ci consente di approcciare il tema dei widget Cupertino
con le spalle larghe. In chiusura, voglio lasciare una risposta a
un quesito che a qualcuno sarà balenato.
È possibile avere un design dedicato per Android e uno per
iOS?
Certo che sì.
Anche in quest’ultimo capitolo, non mi tiro indietro dal
presentare altro codice.
In questo esempio faremo uso del package universal_platform
(https://pub.dev/packages/universal_platform).
Dopo aver installato il package flutter pub add
void main() {
runApp(const MyApp());
}
@override
Widget build(BuildContext context) {
if (UniversalPlatform.isIOS) {
return cupertinoWidget();
} else {
return materialWidget();
}
}
Widget platformDetection() {
return Center(
child: Text(
"Web: ${UniversalPlatform.isWeb}\n"
"MacOS: ${UniversalPlatform.isMacOS}\n"
"Windows: ${UniversalPlatform.isWindows} \n"
"Linux: ${UniversalPlatform.isLinux} \n"
"Android: ${UniversalPlatform.isAndroid} \n"
"IOS: ${UniversalPlatform.isIOS} \n"
"Fuschia: ${UniversalPlatform.isFuchsia} \n",
),
);
}
Widget materialWidget() {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Example Material'),
),
body: platformDetection(),
));
}
Widget cupertinoWidget() {
return CupertinoApp(
home: CupertinoPageScaffold(
backgroundColor: Colors.grey,
navigationBar: const CupertinoNavigationBar(
middle: Text('Example Cupertino'),
),
child: platformDetection(),
),
debugShowCheckedModeBanner: false);
}
}
Figura 17.1 Android.
Figura 17.2 iOS.
NOTA
Per quanto sia possibile usare Material design su iOS, il contrario è
vero solo in parte. Non è comunque consigliabile usare il design
Cupertino per applicazioni Android perché ci sono problemi legati
all’uso dei font causati dalla licenza Apple. Rimane comunque
possibile avere nella stessa codebase due stili differenti.
Quanto appena visto è solo il primo passo. Chiedo perdono di
non aver introdotto il design Cupertino come si deve, ma credo
che sarete in grado di affrontare anche questa sfida in
autonomia.
Data storage
La necessità di storare le informazioni affinché vivano oltre la
singola esecuzione dell’app è cosa comune. In Flutter ci sono
diverse alternative; alcune di queste sono focalizzate sulla
sicurezza, altre sui dati. La soluzione shared_preferences è quella,
credo, più semplice da applicare per memorizzare dati in
modalità chiave/valore. Inoltre, shared_preferences si basa sull’uso
di NSUserDefaults in iOS e SharedPreferences in Android. È
importante tenere conto che non è consigliato il suo utilizzo per
dati critici.
$ flutter pub add shared_preferences
void main() {
runApp(
const MaterialApp(
home: MyApp(),
),
);
}
@override
State<MyApp> createState() => _MyAppState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 6),
Text('counter: $count'),
ElevatedButton(onPressed: incrementer, child: const
Icon(Icons.add)),
],
),
));
}
}
Deploy
Un aspetto totalmente ignorato è quello relativo alla
pubblicazione della nostra applicazione negli store. Google Play
Store e Apple Store avrebbero richiesto altri capitoli e purtroppo
il tempo a disposizione non consentiva di approfondire anche
questi aspetti. A onor del vero, forse servirebbe un manuale a
parte per trattare il tema e tutte le sfaccettature che si porta
dietro.
Gli step iniziali per procedere alla pubblicazione di un’app
sugli store principali hanno comunque aspetti in comune tra le
diverse piattaforme. Le app dovranno essere compilate in
modalità release. Le app di release saranno ottimizzate
rimuovendo tutti gli altri aspetti fondamentali per la fase di
sviluppo, ma non necessari per la release, per esempio gli
strumenti di debug.
I passi per questa fase sono differenti per i differenti sistemi
operativi e per questo consiglio di seguire la documentazione
ufficiale.
Offuscamento
L’offuscamento è un processo molto comune in fase di
release, oltre che estremamente consigliato. L’offuscamento
consiste nel “modificare” il codice binario, rendendo molto più
complessa l’operazione di reverse engineering, poiché funzioni
e classi non abbiano i loro nomi originali, ma delle sigle. Il tool
flutter offre il supporto all’offuscamento in fase di build con il
comando –obfuscation.
Signature
Anche se in modalità differenti, un’app che viene distribuita
per essere verificata dovrà essere necessariamente firmata. Il
processo di firma di un’app consiste innanzitutto nel firmare
digitalmente l’applicazione attraverso l’uso di un keystore locale;
questo servirà per un confronto con le successive release.
I processi di firma e distribuzione, poi, cambiano e sono gestiti
diversamente a seconda dello store da utilizzare. È comunque
un passaggio obbligato.
Continuous delivery
Per quanto il processo di delivery possa essere gestito
manualmente, la scelta migliore rimane quella di automatizzarlo,
rendendo più efficienti sia il processo di sviluppo e i test che la
distribuzione. Se non fosse chiaro il concetto, immaginate quanti
“comandi e/o passaggi” dovrete eseguire per consentire il
rilascio in produzione del vostro applicativo, partendo dal codice
sorgente. Per quanto il Continuous delivery si rifaccia più che
altro a una metodologia, piuttosto che a un tecnicismo, è altresì
vero che per rilasciare “frequentemente” un’applicativo tutto il
processo deve essere automatizzato. È un argomento
estremamente ampio e sto già commettendo l’errore di trattarlo
superficialmente, pertanto vi invito ad approfondire il tema in
autonomia.
Vi lascio solo un riferimento legato all’utilizzo di una tool:
fastlane https://fastlane.tools/. Tra le tante soluzioni che
consentono di approcciare il tema continuous delivery, fastlane,
oltre a essere open-source, offre feature che coprono a 360° il
processo di deployment e non è così complesso mettere in piedi
una pipeline. Vedere di che cosa si occupa il tool vi darà già
un’idea più chiara di quale sia il tema.
Firebase
Uno strumento estremamente potente e forse imprescindibile
è Firebase. Non voglio introdurvi l’argomento poiché è talmente
vasto che in un capitolo conclusivo non saprei proprio come
affrontarlo. Il consiglio che vi voglio dare è di studiare quali sono
i servizi offerti e valutare l’integrazione con Flutter. Servizi di
push notification, building, deploy, ambiente di test e analitiche
rendono questo servizio di casa Google fondamentale.
Firebase e Flutter sono entrambi figli di Google. Per Firebase
il/i plugin è/sono direttamente sviluppati da Google. Vi lascio il
seguente indirizzo per comprendere quanto sia semplice
integrare i servizi offerti da Firebase nella vostra applicazione
Flutter: https://firebase.google.com/docs/flutter/.
Biometria
Un altro tema caldo che non abbiamo affrontato è l’uso della
biometria nelle fasi di autenticazione. La biometria è una
disciplina e può essere immaginata come una misurazione
biofisica con lo scopo di identificare univocamente un individuo.
Negli ultimi anni la biometria ha avuto un ruolo fondamentale nel
rivoluzionare i processi di autenticazione degli utenti in
applicazioni di ogni tipo: web, mobile, desktop, embedded.
Nel campo di un’autenticazione biometrica, l’accesso ad
un’app può essere eseguito tramite l’uso del fingerprint
(impronta digitale) o del riconoscimento facciale in alternativa a
quanto siamo abituati, ovvero con username e password. Il
riconoscimento biometrico in campo tecnologico è sempre in
fase di sviluppo e ricerca. Per esempio, non è inusuale trovare
applicazioni che supportino la biometria vocale, in cui l’utente
viene identificato tramite timbro vocale.
L’uso di autenticatori biometrici è un argomento complesso
prettamente tecnico che, però, nasconde anche temi di
sicurezza di grande importanza. Comunque sia, noi utenti mobili
siamo già abituati all’uso di questi scenari e Flutter, tramite il
plugin local_auth (https://pub.dev/packages/local_auth), offre la
possibilità di usufruire di “autenticatori” alternativi alle password.
A oggi, gli autenticatori biometrici supportati sono:
BiometricType.face BiometricType.fingerprint,
BiometricType.weak, BiometricType.strong. Weak e strong
identificano di fatto una tipologia di autenticazione biometrica
debole e forte che, per esempio, in Android corrispondono
rispettivamente al face recognition e al fingerprint.
Il plugin local_auth offre anche autenticatori non biometrici
come il passcode o PIN.
Risorse utili
https://flutter.dev/
https://docs.flutter.dev/
https://pub.dev/
https://github.com/flutter/flutter
https://www.youtube.com/@flutterdev
https://firebase.google.com/docs/flutter/
https://api.flutter.dev/
https://github.com/vgflutter/flutter_apogeo
https://flutter.gskinner.com/folio/
Supporto:
https://flutter.dev/community
Vi invito inoltre a seguire il sito https://www.apogeonline.com/
poiché verranno pubblicati articoli di approfondimenti in tema
Flutter anche sul sito di Apogeo.
Indice
Introduzione
Nativo e ibrido
Punti di forza
A chi è rivolto Flutter
I revisori
Il codice
Ringraziamenti
Capitolo 1 - Installazione
Flutter doctor
Android
iOS
Emulazione
Capitolo 2 - Hello world
Desktop
Browser
Device
Hello World
Build
Capitolo 3 - Dart in teoria
Aspetti peculiari
Esecuzione
Mobile
Flutter
Capitolo 4 - Linguaggio
Aspetti peculiari
Variabili
Tipi di dati
Funzioni
Classi
Istruzioni di controllo
Eccezioni
Moduli
Iterable
Operazioni asincrone
Capitolo 5 - Widget
MyApp widget
MyHomePage widget
Widget
Lifecycle
Widget Base
Capitolo 6 - Routing
Navigator
Named Routes
Passaggio dati
Navigator 2.0
Deep linking
Capitolo 7 - Widget Material Design
Material
Capitolo 8 - Scrollable Widget
Scroll
ReorderableListView
Capitolo 9 - Layout Widget
Vincoli
Single-child
Multi-child
Capitolo 10 - Animazioni
Controller
Ticker
Tipologie
Capitolo 11 - Gesture
GestureDetector
Advanced Gesture
InkWell
Capitolo 12 - Demo
API Platform
Custom Icons
Http
Struttura
Release
Profile
Esercizi per casa
Capitolo 13 - Test
Unit Test
Widget Test
Integration Test
Coverage
Capitolo 14 - Rendering
Widget tree
Element tree
RenderObject tree
Keys
RepaintBoundary
CustomPainter
Capitolo 15 - Layout Design
Responsive
Adaptive
Capitolo 16 - State Management
Design pattern
Provider
BLoC
Redux
Capitolo 17 - Approfondimenti
Cupertino widgets
Data storage
Deploy
Continuous delivery
Firebase
Biometria
Risorse utili