Si Spring Boot organise le code en couches (Controller/Service/Repository), Flutter organise l’écran en arbre de widgets imbriqués. Ce sont les briques de base. Si vous n’aimez pas les CSS, vous allez appréciez les composants Flutter !
Pour information, il existe FlutterFlow un outil bien pratique qui va vous permettre de gagner du temps pour construire vos IHM un peu comme Figma et d’ailleurs, vous pouvez importer vos maquettes Figma ! (gratuit pour 2 projets sans export de code malheureusement)
Il y aussi FlutterViz builder drag-and-drop gratuit, conçu uniquement pour dessiner des écrans : on y place les widgets (~50 standard), on ajuste couleurs/espacements visuellement, avec aperçu en temps réel, puis on exporte le code Dart propre à intégrer dans son projet. Il n’y a pas de logique métier, c’est un “designeur d’écrans”, très adapté aux maquettes rapides. Le projet est aussi open source sur GitHub.
Row( // aligne les enfants horizontalement mainAxisAlignment: MainAxisAlignment.spaceBetween, // axe principal (horizontal ici) crossAxisAlignment: CrossAxisAlignment.center, // axe secondaire (vertical ici) children: const [ Icon(Icons.star), Text("Note : 4.5"), Icon(Icons.favorite), ], )
Quelques précisions sur le widget Icon :
Icon est un widget qui affiche un pictogramme (une petite image vectorielle issue d’une police d’icônes). Il se comporte comme du texte : sa taille et sa couleur sont réglables et il s’adapte au thème de l’application.
Icon(Icons.star)
Le premier argument est un IconData, c’est-à-dire un caractère d’une police d’icônes. Icons.star et Icons.favorite viennent de Material Icons, qui est incluse dans Flutter. Pour que ces icônes soient disponibles, il faut que uses-material-design: true soit présent dans notre fichier pubspec.yaml (c’est le cas par défaut dans un nouveau projet).
IconData
Icons.star
Icons.favorite
uses-material-design: true
pubspec.yaml
Icon( Icons.favorite, size: 32, // taille en pixels logiques (24 par défaut) color: Colors.red, // couleur (sinon celle du thème) semanticLabel: 'Ajouter aux favoris', // texte lu par les lecteurs d'écran (NVDA, JAWS), c'est la partie accessibilité )
Column fonctionne à l’identique que Row mais empile ses enfants verticalement.
Column
Row
Row( children: [ Expanded(flex: 2, child: Container(color: Colors.red, height: 50)), Expanded(flex: 1, child: Container(color: Colors.blue, height: 50)), ], )
Les 2 Container sont dans des Expanded, donc ils se partagent tout l’espace horizontal du Row selon leur flex :
Container
Expanded
Chaque Container n’a pas de largeur fixée : c’est Expanded qui la lui impose. La hauteur de 50 vient de height: 50 et s’applique aux 2 rectangles. Ils sont alignés en haut du Row, qui prend lui-même la hauteur de ses enfants (50px).
Dans un Row, Expanded ne s’applique qu’à l’axe principal (horizontal). Sans lui, un Container sans width se réduirait à zéro de largeur, car il n’a ni enfant ni taille imposée !
Pensez à Expanded comme au flex-grow en CSS : il répartit l’espace disponible restant entre les enfants d’un Row/Column.
flex-grow
Flexible, c’est le widget dont Expanded est dérivé : Expanded n’est en fait qu’un Flexible avec fit: FlexFit.tight. Flexible s’utilise comme enfant d’un Row, d’une Column ou d’un Flex, et il a deux paramètres :
Flexible
fit: FlexFit.tight
Flex
flex
fit
FlexFit.loose
FlexFit.tight
Comparaison entre Expanded et Flexible :
Voici le code ci-dessous pour la partie Flexible :
Row( children: [ Flexible( flex: 2, child: Container(color: Colors.red, height: 50, width: 40), ), Flexible( flex: 1, child: Container(color: Colors.blue, height: 50), ), ], )
Ici, avec Flexible seul, le Container rouge garde sa largeur de 40 px alors que sa part serait bien plus grande. Avec Expanded, il aurait été étiré pour remplir ses 2/3 de la largeur, en ignorant width: 40.
width: 40
En pratique, on utilise Expanded quand on veut remplir l’espace, et Flexible quand un enfant doit pouvoir rétrécir, par exemple un texte long à côté d’une icône, afin qu’il passe à la ligne au lieu de déborder.
Container( width: 200, padding: const EdgeInsets.all(16), // espace intérieur margin: const EdgeInsets.symmetric(vertical: 8), // espace extérieur decoration: BoxDecoration( color: Colors.amber[100], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.amber), ), child: const Text("Bloc encadré"), )
Le schéma se lit de l’extérieur vers l’intérieur :
margin
decoration
amber[100]
Border.all
padding
child
Text
Deux points importants :
width: 200
Pour centrer le texte dans le bloc, ajoutez simplement : alignment: Alignment.center au Container, ou entourez le Text d’un Center.
alignment: Alignment.center
Center
SizedBox(height: 16) est un widget bien pratique : il sert à créer un espacement fixe (l’équivalent d’un margin-top isolé) et aussi à imposer une taille fixe à un enfant, par exemple SizedBox(width: 120, height: 48, child: ElevatedButton(...)). Il existe aussi SizedBox.expand() pour remplir l’espace disponible et SizedBox.shrink() pour un widget vide.
SizedBox(height: 16)
margin-top
SizedBox(width: 120, height: 48, child: ElevatedButton(...))
SizedBox.expand()
SizedBox.shrink()
Vous voyez que le SizedBox ne dessine rien. Il occupe simplement 16 px de hauteur dans la Column, ce qui écarte le second enfant du premier (le schéma est à l’échelle ×1,5, la zone en pointillés représente cet espace vide).
SizedBox
Voici le code :
Column( children: [ Text("Premier enfant"), SizedBox(height: 16), Text("Second enfant"), ], )
Remarque : la direction compte. Dans une Column, height: 16 crée de l’espace vertical. Dans un Row, ce même SizedBox(height: 16) ne crée aucun espace horizontal : il faut SizedBox(width: 16).
height: 16
SizedBox(width: 16)
Stack empile ses enfants dans l’ordre de la liste : l’image est dessinée en premier, donc au fond, et le badge en dernier, donc par-dessus.
Stack
Stack( children: [ Image.network("https://picsum.photos/300/150", fit: BoxFit.cover), Positioned( bottom: 8, right: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: Colors.black54, child: const Text("Photo", style: TextStyle(color: Colors.white)), ), ), ], )
Positioned
bottom: 8
right: 8
Colors.black54
Deux détails à connaître :
fit: BoxFit.cover
width
height
Image.network
SizedBox(width: 300, height: 150, child: Stack(...))
Pour afficher une collection de données (l’équivalent d’un <table> alimenté par une liste JPA), Flutter propose des widgets paresseux : ils ne construisent que les éléments visibles à l’écran, quelle que soit la taille de la liste.
<table>
class Produit { final String nom; final double prix; Produit(this.nom, this.prix); // constructeur } class ListeProduits extends StatelessWidget { final List<Produit> produits; // immuabilité du widget const ListeProduits({super.key, required this.produits}); // constructeur const grâce au final, le required this.produits est une INJECTION ! @override Widget build(BuildContext context) { // le build lit les données reçues return ListView.builder( itemCount: produits.length, itemBuilder: (context, index) { final produit = produits[index]; return ListTile( leading: const Icon(Icons.shopping_bag), // icone du sac de course nom: Text(produit.nom), // nom du produit prix: Text("${produit.prix.toStringAsFixed(2)} €"), // prix du produit ); }, ); } }
Quelques explications sur le code ci-dessus :
Le code produit une liste déroulante où chaque produit devient une ligne. J’ai ajouté des traits fins entre les lignes pour la lisibilité, mais ListTile n’en dessine pas par défaut.
ListTile
Produit
ListView.builder
itemCount
itemBuilder
index
leading
Icons.shopping_bag
title
trailing
toStringAsFixed(2)
49.90 €
7.50 €
intl
Un ListTile à une seule ligne de titre, sans subtitle, mesure environ 56 px de haut. Si le texte du titre est trop long, il passe à la ligne ou est tronqué selon sa configuration, et la ligne peut grandir.
subtitle
Nous pourrions aussi écrire notre code de cette manière pour la partie ListView.builder :
ListView.builder( itemCount: produits.length, itemBuilder: (context, index) { final produit = produits[index]; return ListTile( key: ValueKey(produit.id), // ← ici, les Key deviennent utiles ! leading: const Icon(Icons.shopping_bag), title: Text(produit.nom), trailing: Text("${produit.prix.toStringAsFixed(2)} €"), ); }, )
itemBuilder est appelé à la demande, comme un Iterator : c’est ce qui permet d’afficher des listes de 10 000 éléments sans ralentissement, exactement comme la pagination côté Spring Data JPA évite de charger toute une table en mémoire.
Iterator
Quelques explications sur une partie du code :
const ListeProduits({super.key, required this.produits}); // └───┬────┘└─────┬──────────┘ // │ │ // "ce paramètre est OBLIGATOIRE" "ce paramètre initialise // (sinon erreur de compilation)" directement le champ this.produits"
this.produits
ListeProduits({required List<Produit> produits}) : produits = produits;
required
ListeProduits() // erreur : "The parameter 'produits' is required"
La classe mère n’en a pas besoin” mais ce n’est pas un obstacle. StatelessWidget n’exige rien, mais ces paramètres ne sont pas pour la classe mère. Ils sont pour le rôle métier du widget. Le parent (Widget) impose la plomberie (key), on ajoute nos données métier (produits). Chaque widget a les deux couches :
StatelessWidget
const ListeProduits({super.key, required this.produits}); // └───┬───┘ └─────────┬─────────┘ // plomberie Flutter nos données métier
Il y a une règle stricte en Dart : Un constructeur ne peut être const que si tous ses champs sont final (et les valeurs transmises sont elles-mêmes des constantes).
Le const du constructeur est donc la récompense de l’immuabilité : puisque le widget ne peut plus changer, le compilateur peut le mémoriser et le partager en mémoire (cf. notre discussion const vs final) :
const ListeProduits(produits: [...]) // une seule instance en mémoire, réutilisée partout
GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, // 2 colonnes crossAxisSpacing: 8, mainAxisSpacing: 8, childAspectRatio: 0.8, ), itemCount: produits.length, itemBuilder: (context, index) => Card(child: Center(child: Text(produits[index].nom))), )
Vous allez tout comprendre avec simplicité ce que peut donner le code ci-dessus :
Scaffold fournit la structure visuelle standard d’une application mobile Material Design :
Scaffold
Scaffold( appBar: AppBar( title: const Text("Mon App"), actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})], ), drawer: Drawer( // menu latéral coulissant child: ListView( children: [ const DrawerHeader(child: Text("Menu")), ListTile(leading: const Icon(Icons.home), title: const Text("Accueil"), onTap: () {}), ListTile(leading: const Icon(Icons.settings), title: const Text("Réglages"), onTap: () {}), ], ), ), body: const Center(child: Text("Contenu de la page")), floatingActionButton: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)), bottomNavigationBar: BottomNavigationBar( currentIndex: 0, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: "Accueil"), BottomNavigationBarItem(icon: Icon(Icons.person), label: "Profil"), ], onTap: (index) {}, ), )
Vous allez mieux comprendre avec le rendu ci-dessous sur ce que peut donner le code ci-dessus :
C’est l’équivalent mobile du routage côté Spring MVC (@GetMapping("/livres/{id}")). Flutter gère une pile d’écrans (stack) : naviguer « en avant » empile un écran, revenir en arrière le dépile.
@GetMapping("/livres/{id}")
Je vous donne toujours les bouts de codes que vous pouvez recopier et tester avec l’explication sous forme graphique.
// Depuis l'écran A, ouvrir l'écran B : Navigator.push( context, MaterialPageRoute(builder: (context) => const PageDetail()), ); // depuis l'écran B, revenir à l'écran A : Navigator.pop(context);
Navigator.push( context, MaterialPageRoute(builder: (context) => PageDetail(produit: produitSelectionne)), ); class PageDetail extends StatelessWidget { final Produit produit; const PageDetail({super.key, required this.produit}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(produit.nom)), body: Text("Prix : ${produit.prix} €"), ); } }
final resultat = await Navigator.push( context, MaterialPageRoute(builder: (context) => const PageFormulaire()), ); // resultat contient la valeur passée à Navigator.pop(context, valeur) sur l'écran suivant
// Dans PageFormulaire, pour renvoyer une valeur : Navigator.pop(context, "Nouvelle donnée saisie");
MaterialApp( initialRoute: '/', routes: { '/': (context) => const PageAccueil(), '/detail': (context) => const PageDetail(), '/parametres': (context) => const PageParametres(), }, ) // Navigation : Navigator.pushNamed(context, '/detail');
Pour des applications plus complexes avec des URLs profondes (on dit “deep-linking”), le package go_router — maintenu par l’équipe Flutter est aujourd’hui la référence en production. Nous nous concentrons ici sur le Navigator natif, suffisant dans le cadre de cette formation.
go_router
Navigator
Pour tous les exercices à réaliser, utilisez les exemples de bouts de code dans cette page de cours… et n’hésitez pas à fouiller dans la documentation de Flutter et du langage Dart dans laquelle il y a de nombreux exemples de codes.
L’objectif, ici est de reprendre soit la correction du TP du jour1 ou bien votre propre code et d’ajouter une page pour pouvoir naviguer entre les 2 pages. ça permet de ne pas tout réécrire. Je vous propose pour cet exercice de construire une structure différente avec des packages correspondants aux classes de widgets utilisées.
lib/ ├── main.dart point d'entrée avec "MaterialApp" (minimum pour lancer notre application) ├── pages/ │ ├── page_carte_visite.dart votre "pageCarteVisite" déjà existante │ └── page_experience.dart votre nouvelle "PageExperience" (à créer) └── widgets/ └── ligne_contact.dart votre "LigneContact" (réutilisable) et existant
_LigneContact
LigneContact
Voici les rendus sur l’émulateur Android Pixel7:
Créez une liste de 10 Produit (nom + prix + catégorie) codés en dur, affichés dans un ListView.builder avec une Card par produit (icône selon catégorie, nom en gras, prix aligné à droite).
Card
Pour vous aider, voici le code pour la liste des produits avec des Emoji (pas besoin de télécharger des images ou icones) :
final List<Produit> produits = const [ Produit( "Pomme", 0.50, "Fruits", "🍏", ), Produit( "Banane", 0.30, "Fruits", "🍌", ), Produit( "Jus d'orange", 2.90, "Boissons", "🧃", ), Produit( "Eau minérale", 0.80, "Boissons", "💧", ), Produit( "Baguette", 1.10, "Boulangerie", "🥖", ), Produit( "Croissant", 1.30, "Boulangerie", "🥐", ), Produit( "Kiwi", 0.60, "Fruits", "🥝", ), Produit( "Café", 3.50, "Boissons", "☕", ), Produit( "Pain au chocolat", 1.40, "Boulangerie", "🍫", ), Produit( "Mangue", 1.90, "Fruits", "🥭", ), ];
Réutilisez la liste de l’exercice 5.1. Au clic sur un produit, naviguez vers un écran de détail affichant toutes ses informations avec un bouton « Retour ». Cela doit ressembler à ceci :
Créez une application avec 3 “écrans” (Accueil, Favoris, Profil) qui sont des widgets de bases et navigables via un Drawer, avec l’élément actif visuellement mis en évidence comme l’exemple ci-dessous :
Drawer
👉 Voir les corrigés complets
Cahier des charges :
Développez une application catalogue_livres avec :
catalogue_livres
AppBar
StatefulWidget
BottomNavigationBar
final List<Livre> catalogueLivres = [ Livre( id: 1, titre: "1984", auteur: "George Orwell", resume: "Une dystopie glaçante sur la surveillance de masse et le totalitarisme.", imageUrl: "https://picsum.photos/seed/1984/200/300", note: 4.8, ), Livre( id: 2, titre: "Dune", auteur: "Frank Herbert", resume: "Sur la planète désertique Arrakis, une lutte de pouvoir façonne l'univers.", imageUrl: "https://picsum.photos/seed/dune/200/300", note: 4.7, ), Livre( id: 3, titre: "Fondation", auteur: "Isaac Asimov", resume: "L'effondrement d'un empire galactique prédit par la psychohistoire.", imageUrl: "https://picsum.photos/seed/fondation/200/300", note: 4.5, ), Livre( id: 4, titre: "Sapiens", auteur: "Yuval Noah Harari", resume: "Une brève histoire de l'humanité, de la préhistoire à nos jours.", imageUrl: "https://picsum.photos/seed/sapiens/200/300", note: 4.6, ), Livre( id: 5, titre: "Clean Code", auteur: "Robert C. Martin", resume: "Les bonnes pratiques pour écrire du code lisible et maintenable.", imageUrl: "https://picsum.photos/seed/cleancode/200/300", note: 4.3, ), Livre( id: 6, titre: "Le Petit Prince", auteur: "Antoine de Saint-Exupéry", resume: "Un conte poétique et philosophique sur l'enfance et l'essentiel.", imageUrl: "https://picsum.photos/seed/petitprince/200/300", note: 4.9, ), Livre( id: 7, titre: "Fahrenheit 451", auteur: "Ray Bradbury", resume: "Dans un futur où les livres sont interdits, un pompier brûle... des livres.", imageUrl: "https://picsum.photos/seed/f451/200/300", note: 4.4, ), Livre( id: 8, titre: "L'Étranger", auteur: "Albert Camus", resume: "Meursault, un homme indifférent au monde, confronté à l'absurde.", imageUrl: "https://picsum.photos/seed/etranger/200/300", note: 4.2, ), ];
Donc, il vous faut, une classe Livre, des pages de type StatelessWidget et StatefulWidget et la gestion des State (états). Cette mini-application n’est pas à réaliser totalement cette deuxième journée. Il n’y a pas de difficulté particulière mais il faut le temps de vous familiariser avec le Flutter et le langage Dart.
Voici ce que vous devez réaliser :
👉 Voir le corrigé complet du TP
➡️ Chapitre 3 — Gestion d’état, formulaires, persistance
⬅️ Retour au sommaire Flutter