Aller au contenu
⬅️ Retour au sommaire Flutter ⬅️ Chapitre précédent ➡️ Chapitre suivant

Chapitre 2 — Jour 2 : Widgets, mise en page et navigation

Sommaire du chapitre

  1. Les widgets de mise en page
  2. Listes et grilles
  3. Structure Material : Scaffold, AppBar, Drawer
  4. Navigation entre écrans
  5. Exercices du jour
  6. TP de synthèse — Catalogue de livres

1. Les widgets de mise en page

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 et Column (Flexbox-like)

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),
  ],
)

représentation graphique

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).

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.


Expanded

Row(
  children: [
    Expanded(flex: 2, child: Container(color: Colors.red, height: 50)),
    Expanded(flex: 1, child: Container(color: Colors.blue, height: 50)),
  ],
)

représentation graphique expanded

Les 2 Container sont dans des Expanded, donc ils se partagent tout l’espace horizontal du Row selon leur flex :

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.


Flexible

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 :

Comparaison entre Expanded et Flexible :

représentation graphique comparaison flexible et expanded

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.

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, Padding

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é"),
)

représentation graphique container padding

Le schéma se lit de l’extérieur vers l’intérieur :

Deux points importants :

Pour centrer le texte dans le bloc, ajoutez simplement : alignment: Alignment.center au Container, ou entourez le Text d’un Center.


SizedBox, l’invisible bien pratique

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.

représentation graphique de SizedBox

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).

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).


Stack (superposition, positionnement libre)

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.

représentation graphique de 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)),
      ),
    ),
  ],
)

Deux détails à connaître :


2. Listes et grilles

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.

ListView.builder

représentation graphique de listview.builder

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.

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.

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.

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"
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 :

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

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 :

représentation graphique de listview.builder


3. Structure Material : Scaffold, AppBar, Drawer

Scaffold fournit la structure visuelle standard d’une application mobile Material Design :

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 :

représentation graphique de listview.builder


4. Navigation entre écrans

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.

Je vous donne toujours les bouts de codes que vous pouvez recopier et tester avec l’explication sous forme graphique.

représentation graphique des navigations possibles avec Flutter

// 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);

Passer des données à l’écran suivant

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} €"),
    );
  }
}

Récupérer une donnée en retour (comme une réponse HTTP)

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");

Routes nommées (pour les applications avec plusieurs écrans)

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.


5. Exercices du jour

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.

TP Carte Interactive avec une page expérience en alternance

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

Voici les rendus sur l’émulateur Android Pixel7:

Page 1

représentation graphique catalogue produits

Page 2

représentation graphique catalogue produits

Exercice 5.1 — Liste de produits

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).

représentation graphique catalogue produits

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",
      "🥭",
    ),
  ];

Exercice 5.2 — Navigation liste vers détail

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 :

représentation graphique liste vers détail

Exercice 5.3 — Menu latéral

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 :

représentation graphique liste vers détail

👉 Voir les corrigés complets


6. TP de synthèse — Catalogue de livres

Cahier des charges :

Développez une application catalogue_livres avec :

  1. Écran Liste : une AppBar titrée « Catalogue », une liste de 8 livres (titre, auteur, image de couverture via Image.network, note sur 5) affichés en ListView.builder sous forme de Card
  2. Écran Détail : au clic/tap sur un livre, naviguer vers un écran affichant la couverture en grand, le résumé, l’auteur, la note (avec des Icon(Icons.star) pleines/vides selon la note) et un bouton « Ajouter aux favoris »
  3. Gestion des favoris : utilisez un StatefulWidget racine pour stocker la liste des identifiants de livres favoris. L’écran détail doit passer par un callback (fonction passée en paramètre) pour notifier l’écran liste qu’un livre a été ajouté/retiré des favoris
  4. Écran Favoris accessible via un Drawer ou un BottomNavigationBar, comme vous voulez. Il doit afficher uniquement les livres marqués comme favoris.
  5. La liste des livres du catalogue est fournie ci-dessous.
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 :

représentation graphique catalogue des livres

👉 Voir le corrigé complet du TP


➡️ Suite du cours

➡️ Chapitre 3 — Gestion d’état, formulaires, persistance

⬅️ Retour au sommaire Flutter