
Questo articolo, come gli altri nella sezione Appunti sulla documentazione ufficiale di Flutter, è un breve appunto scritto durante lo studio della documentazione ufficiale di Flutter. Per approfondire i concetti, è utile leggere la documentazione ufficiale.
Questa parte è utile per creare una scheda che può essere utilizzata come la scheda articolo, la scheda di un gioco, la scheda di un e-commerce e in generale una pagina di dettaglio; permette di mostrare immagine, titolo, testo e un bottone a forma di stella per simulare l’inserimento dell’oggetto nei preferiti. Inoltre, grazie a come è costruita, permette di scrollare in alto e in basso se i contenuti occupano più spazio dello schermo visibile.
La costruzione del Layout e la definizione delle funzioni Interattive sono divise in due sezioni differenti; la terza è un’aggiunta e spiega come prendere in tempo reale i dati da un web service per riempire la scheda.
Nome progetto: flutter_tutorial_a
Lo step zero è creare un’app “Hello World” ed è in questo articolo. Dopo aver creato l’app Hello World, sostituire il main.dart con questo codice:
// Copyright 2018 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Welcome to Flutter',
home: Scaffold(
appBar: AppBar(
title: const Text('Welcome to Flutter'),
),
body: const Center(
child: Text('Hello World'),
),
),
);
}
}
In questo primo step la documentazione ufficiale mostra visivamente la struttura della pagina, consiglio di vederla direttamente da qui. Tutti gli elementi sono in una singola colonna: immagine, due righe e un blocco di testo.
Nei prossimi step vediamo come sono fatte le righe.
In questa sezione relativa nella documentazione ufficiale, questo codice va inserito nel metodo build() per costruire la colonna di sinistra nella sezione del titolo.
Widget titleSection = Container(
padding: const EdgeInsets.all(32),
child: Row(
children: [
Expanded(
/*1*/
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/*2*/
Container(
padding: const EdgeInsets.only(bottom: 8),
child: const Text(
'Oeschinen Lake Campground',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
Text(
'Kandersteg, Switzerland',
style: TextStyle(
color: Colors.grey[500],
),
),
],
),
),
/*3*/
Icon(
Icons.star,
color: Colors.red[500],
),
const Text('41'),
],
),
);
Poi va sostituito questo codice:
body: const Center(
child: Text('Hello World'),
con questo che richiama quanto inserito nel metodo build()
body: Column(
children: [
titleSection,
],
La sezione dei bottoni contiene tre colonne che usano lo stesso layout (un’icona sopra un testo). Dato che questa parte di layout si ripete, si può creare un helper method riutilizzabile => buildButtonColumn() che viene infatti utilizzato tre volte. Le tre colonne sono identiche e quindi nel metodo build() (cioè dopo Widget build(BuildContext context) {) di MyApp() inserire questo codice:
Column _buildButtonColumn(Color color, IconData icon, String label) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: color,
),
),
),
],
);
}
Adesso che l’helper è pronto, può essere utilizzato aggiungendo il seguente codice subito dopo titleSelection dentro il metodo build:
Color color = Theme.of(context).primaryColor;
Widget buttonSection = Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildButtonColumn(color, Icons.call, 'CALL'),
_buildButtonColumn(color, Icons.near_me, 'ROUTE'),
_buildButtonColumn(color, Icons.share, 'SHARE'),
],
);
Conclude l’ultima parte dello step 3 modificando di nuovo i children di MaterialApp() per aggiungere buttonSection dopo titleSection.
Per la parte del blocco di testo, definiamo la sezione come una variabile (Widget textSection = const Padding etc), inseriamo il testo con un padding, dello spazio tutto intorno e aggiungiamo il seguente codice dopo la buttonSection:
Widget textSection = const Padding(
padding: EdgeInsets.all(32),
child: Text(
'Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese '
'Alps. Situated 1,578 meters above sea level, it is one of the '
'larger Alpine Lakes. A gondola ride from Kandersteg, followed by a '
'half-hour walk through pastures and pine forest, leads you to the '
'lake, which warms to 20 degrees Celsius in the summer. Activities '
'enjoyed here include rowing, and riding the summer toboggan run.',
softWrap: true,
),
);
Impostando softwrap a true (nel codice qui sopra) le linee di testo riempiranno la larghezza della colonna. Aggiungere la textSection alla pagina aggiungendo textSection dove prima avevamo inserito titleSection e buttonSection cioè come children della home .
https://flutter.dev/docs/development/ui/layout/tutorial#step-5-implement-the-image-section
Creiamo la cartella images nella cartella principale del progetto, carichiamo un file nominato lake.jpg e modifichiamo il file pubspec.yaml per inserire la sezione assets:
flutter:
uses-material-design: true
assets:
- images/lake.jpg
Salviamo il file yaml e rilanciamo flutter pub get per aggiornare.
Aggiungiamo questo codice come children della Column sopra titleSection, buttonSection, etc
Image.asset(
'images/lake.jpg',
width: 600,
height: 240,
fit: BoxFit.cover,
),
BoxFit.cover indica che l’immagine deve essere più piccola possibile, ma deve coprire il suo box contenitore.
https://flutter.dev/docs/development/ui/layout/tutorial#step-6-final-touch
Se il testo è lungo, non rientra nella pagina e viene mostrata un’immagine in basso che indica che il testo non entra in pagina. Allora implementiamo ListView al posto di Container perchè questo widget permette all’utente di scrollare gli elementi l’app gira sui device piccoli e rendere quindi visibile tutto il testo. Dentro MaterialApp() sostituiamo:
body: Column(
con
body: ListView(
E gli elementi adesso sono scrollabili, se il testo non entra in pagina, non è più un problema.
In questa parte del tutorial viene spiegato come modificare l’app appena creata per farla reagire alle azioni dell’utente, andando a creare uno Stateful widget per gestire lo stato di due widget.
Quando l’app viene lanciata, è presente una stella statica che indica teoricamente che la scheda è stata già inserita nei preferiti; il numero accanto alla stella indica che 41 persone hanno impostato la stessa scheda come preferita.
Questa parte del tutorial permetterà di cliccare ancora sulla stella per rimuovere la preferenza e sostituire la stella attuale con una stella vuota e far decrescere il conto (da 41 a 40 in questo caso di esempio). Premendo ancora la stella, tornerà ad essere accesa e aumenterà il conto. Per fare questo, andremo a creare un widget che include sia il widget dell’icona della stella che il widget del numero del contatore.
Per capire meglio come funzionano e come vengono gestiti gli stati in Flutter, leggere questo articolo.
In questa parte della documentazione viene spiegato che i widget possono essere stateful (con la possibilità di gestire lo stato) oppure stateless (quindi con uno stato fisso e immodificabile). Quando un utente interagisce con un widget che cambia dopo l’interazione o quando il widget cambia dopo aver ricevuto dei dati, allora si tratta di uno stateful widget, mentre gli stateless widget come Icon, IconButton, e Text non cambiano mai durante l’esecuzione dell’app.
Al contrario stateful widget sono dinamici e cambiano il proprio modo di apparire in risposta ad un evento scaturito da un’interazione umana o quando ricevono dei dati, come ad esempio i widget Checkbox, Radio, Slider, InkWell, Form, e il TextField. [Uno stateful widget è una sotto classe di StatefulWidget].
Lo stato di un widget è memorizzato nell’oggetto State così da separare il proprio stato da come appare visivamente. Lo stato consiste nei valori che possono cambiare, come il valore corrente di un widget slider. Quando lo stato del widget cambia, l’oggetto State chiama setState() per dire al framework di ridisegnare il widget.
In questa sezione della documentazione ufficiale vengono creati, attraverso quattro step, i widget per gestire l’interazione. E’ presente anche una parte Managing state che approfondisce il widget stateful e aiuta a capire come utilizzarli, e che in questo Flutter tutorial ho trasferito in un articolo specifico.
Creare un’app da zero e copiare il codice di flutter_tutorial_b, la cartella /images e le info inserite in pucspec.yaml per avere il layout pronto così da replicare la prima parte di questo tutorial.
Questo progetto si chiama flutter_tutorial_b.
Lo stato di un widget può essere gestito in diversi modi ma in questo esempio sarà il widget FavoriteWidget a gestire il proprio stato perchè cambiare il colore (e la condizione che rappresenta) al widget icona è un’azione isolata rispetto ad altri widget e non impatta il widget contenitore o altre parti dell’interfaccia, quindi il widget gestirà lo stato internamente. Per appfondire la gestione degli stati del widget consiglio questo articolo specifico.
Nello step2 della documentazione viene spiegato come la classe FavoriteWidget gestisce il proprio stato così da sovrascrivere createState() per creare un oggetto State. Il framework chiamerà createState() quando vorrà creare il widget e restituire una istanza di _FavoriteWidgetState che implementeremo nello step 3.
Quindi in fondo al codice e fuori dal widget precedente, creiamo il nuovo widget:
class FavoriteWidget extends StatefulWidget {
const FavoriteWidget({Key? key}) : super(key: key);
@override
_FavoriteWidgetState createState() => _FavoriteWidgetState();
}
Nello step3 della documentazione viene spiegato come la classe _FavoriteWidgetState memorizza i dati modificabili che possono mutare durante l’utilizzo del widget. All’inizio l’app mostra una stella colorata che indica (nel caso di esempio) che la scheda è stata messa nei preferiti insieme ad altri 40 utenti. Questi valori sono memorizzati nei campi _isFavorited e _favoriteCount che inseriamo come nel codice di seguito.
Quindi dentro class _FavoriteWidgetState extends State { inseriamo due righe di codice:
bool _isFavorited = true;
int _favoriteCount = 41;
La classe definisce anche un metodo build() che crea una riga che contiene un widget IconButton di colore rosso perchè questo tipo di widget offre la proprietà “onPressed” che definisce la funzione da chiamare quando è l’icona è premuta e cioè la _toggleFavorite.
Di seguito il widget completo con le due righe precedenti riguardanti le variabili _isFavorited e _favoriteCount.
class _FavoriteWidgetState extends State<FavoriteWidget> {
bool _isFavorited = true;
int _favoriteCount = 41;
// ···
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(0),
child: IconButton(
padding: const EdgeInsets.all(0),
alignment: Alignment.centerRight,
icon: (_isFavorited
? const Icon(Icons.star)
: const Icon(Icons.star_border)),
color: Colors.red[500],
onPressed: _toggleFavorite,
),
),
SizedBox(
width: 18,
child: SizedBox(
child: Text('$_favoriteCount'),
),
),
],
);
}
}
Il metodo _toggleFavorite chiama setState() che dice che lo stato del widget è cambiato e il widget deve essere ricreato da zero. Nel setState() vengono aggiornate le variabili _favoriteCount e _isFavorited in base al valore di _isFavorited.
Quindi aggiungiamo questo codice dopo il build() nel nuovo widget FavoriteWidget():
void _toggleFavorite() {
setState(() {
if (_isFavorited) {
_favoriteCount -= 1;
_isFavorited = false;
} else {
_favoriteCount += 1;
_isFavorited = true;
}
});
}
Lo step 4 della documentazione è l’ultimo del capitolo Interactive e serve a inserire nell’app, al posto dell’icona statica, lo stateful widget appena creato, così che non apparirà più l’icona statica ma il nuovo widget dinamico.
Dunque inseriamo questo codice:
const FavoriteWidget(),
al posto di queste righe che prima gestivano staticamente l’icona e il testo:
Icon(
Icons.star,
color: Colors.red[500],
),
const Text('41'),
Abbiamo creato il layout della scheda e ne abbiamo resa dinamica una parte (il bottone/icona per i preferiti). Il dato viene memorizzato in variabili che verranno perse dopo il riavvio dell’app, quindi sarebbe interessante riuscire a salvare questa informazioni in locale nel dispositivo oppure attraverso un Webservice (questa è una funzione che faremo più avanti).
Una funzione ancora più interessante però è quella di rendere dinamici gli elementi della pagina e cioè immagine, titolo e testo della scheda che abbiamo realizzato negli step precedenti; effettivamente, un’app con una scheda statica non è molto utile, o quantomeno è molto scomoda da gestire perchè quando dovremo modificare un testo o un’immagine, dovremo rigenerare e ripubblicare l’app.
Un metodo che rende questa operazione infinamente più veloce è quello di forzare l’app a prendere i contenuti da un Webservice; questa parte del tutorial si riferita a questo articolo che tratta proprio la tecnica utilizzata per prendere dati da un Webservice e mostrarli in pagina.
Questo progetto si chiama flutter_tutorial_b_fetch_data
Per integrare la funzione per scaricare i dati da un Webservice, dobbiamo sostituire alcune parti statiche con il risultato della chiamata al Webservice.
Dobbiamo aggiungere queste librerie in alto nel codice:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; /*fetch data*/
Dopo la dichiarazione del main() che lasciamo invariata, aggiungiamo la classe Album e la funzione per recuperare i dati dal Webservice.
/*fetch data*/
class Album {
final int userId;
final int id;
final String title;
final String body;
Album({
required this.userId,
required this.id,
required this.title,
required this.body,
});
factory Album.fromJson(Map<String, dynamic> json) {
return Album(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
/*fetch data */
Future<Album> fetchAlbum() async {
final response = await http
.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
if (response.statusCode == 200) {
// If the server did return a 200 OK response,
// then parse the JSON.
return Album.fromJson(jsonDecode(response.body));
} else {
// If the server did not return a 200 OK response,
// then throw an exception.
throw Exception('Failed to load album');
}
}
/*fetch data--*/
Eliminiamo tutta la classe MyApp statica attuale (cioè eliminiamo class MyApp extends StatelessWidget) e ci ritroviamo sotto la class FavoriteWidget extends StatefulWidget che lasciamo invariata così come la class _FavoriteWidgetState extends State e in fondo incolliamo la parte integrata:
/*fetch data*/
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late Future<Album> futureAlbum;
@override
void initState() {
super.initState();
futureAlbum = fetchAlbum();
}
@override
Widget build(BuildContext context) {
/* //#step2 */
Widget titleSection = Container(
padding: const EdgeInsets.all(32),
child: Row(
children: [
Expanded(
/*1*/
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/*2*/
Container(
padding: const EdgeInsets.only(bottom: 8),
child: const Text(
'Oeschinen Lake Campground',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
Text(
'Kandersteg, Switzerland',
style: TextStyle(
color: Colors.grey[500],
),
),
],
),
),
/*3*/
const FavoriteWidget(), //#step4: mettiamo il nuovo widget al posto dell'icona statica che avevamo nelle righe appena precedenti a questa
],
),
);
/* //#step2 -- */
/* //#step3 */
Color color = Theme.of(context).primaryColor;
Widget buttonSection = Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildButtonColumn(color, Icons.call, 'CALL'),
_buildButtonColumn(color, Icons.near_me, 'ROUTE'),
_buildButtonColumn(color, Icons.share, 'SHARE'),
],
);
/* //#step3 -- */
return MaterialApp(
title: 'Fetch Data Example',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
title: const Text('Fetch Data Example'),
),
body: FutureBuilder<Album>(
future: futureAlbum,
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView( //#step2
children: [ //#step2
Image.asset(
'images/lake.jpg',
width: 600,
height: 240,
fit: BoxFit.cover,
),
titleSection, //#step2
buttonSection, //#step3
//textSection, //#step4
Text(snapshot.data!.body)
] //#step2
);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
}
// By default, show a loading spinner.
return const CircularProgressIndicator();
},
)//#step2
),
);
}
Column _buildButtonColumn(Color color, IconData icon, String label) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Container(
margin: const EdgeInsets.only(top: 8),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: color,
),
),
),
],
);
}
}
La parte importante è l’impostazione di https://jsonplaceholder.typicode.com/posts/1 al posto di quello utilizzato nell’esempio precedente che utilizzava /album che non ha il body, e l’integrazione del FutureBuilder dentro MaterialApp() nel build() di class _MyAppState extends State dove abbiamo sostituito “textSection” dentro “ListView( children: [” con “Text(snapshot.data!.body)” che contiene il dato ricevuto dal Webservice
ListView( //#step2
children: [ //#step2
Image.asset(
'images/lake.jpg',
width: 600,
height: 240,
fit: BoxFit.cover,
),
titleSection, //#step2
buttonSection, //#step3
//textSection, //#step4
Text(snapshot.data!.title)
] //#step2
);
Per capire meglio la parte in cui sostituiamo il dato statico con quello preso dal Webservice, proviamo a sostituire titleSection con un titolo dinamico.
Spostiamo quindi da /* –step2 */ fino a /* //#step2 — */ (che contiene il widget titleSection) subito sotto if(snapshot.hasData) e sostituiamo ‘Oeschinen Lake Campground’, con snapshot.data!.title, ed eliminiamo “const” alla riga precedente, subito prima del Text(). Il codice diventa come di seguito.
...
if (snapshot.hasData) {
/* //#step2 */
Widget titleSection = Container(
padding: const EdgeInsets.all(32),
child: Row(
children: [
Expanded(
/*1*/
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/*2*/
Container(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
snapshot.data!.title,
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
Text(
'Kandersteg, Switzerland',
style: TextStyle(
color: Colors.grey[500],
),
),
],
),
),
/*3*/
const FavoriteWidget(), //#step4: mettiamo il nuovo widget al posto dell'icona statica che avevamo nelle righe appena precedenti a questa
],
),
);
/* //#step2 -- */
return ListView( //#step2
...
Naturalmente lo stesso può essere fatto per gli altri elementi della pagina; infatti anche l’url dell’immagine può essere recuperato dal Webservice e utilizzato per caricare l’immagine. Quindi creiamo imageSection prendendo spunto dal codice per creare titleSection ma al posto del widget Text() impieghiamo Image()
/* //#step2 */
Widget imageSection =
/*2*/
Image.asset(
'images/lake.jpg',
width: 600,
height: 240,
fit: BoxFit.cover,
);
/* //#step2 -- */
A questo punto l’interfaccia non è cambiata perchè abbiamo ancora ‘images/lake.jpg’, e Image.asset() che utilizza immagini contenute nell’app mentre noi vogliamo che l’immagine venga presa dal Webservice, quindi sostituiamo Image.asset() con Image.network() e ‘images/lake.jpg’ con snapshot.data!.imageurl. Cosa manca? Perchè non funziona? Perchè imageurl non è una proprietà della classe Album e perchè nel Webservice di test che stiamo utilizzando ora, non esiste imageurl e quindi quest’ultima modifica non potrà funzionare con il Webservice di test ma quando andremo a realizzare app vere, forniremo l’url dell’immagine e l’app sarà in grado di prendere l’immagine da un Webservice e mostrarla dinamicamente.