Règles générales concernant :
Ce cours est la suite directe du premier Cours de Flutter que je donne et dont vous pourrez retrouver les slides sur cette plateforme.
Il est donc important d'avoir bien suivi et compris ce premier cours avant d'aller plus loin avec celui-ci
Pour commencer, nous allons réaliser une mini app qui va nous servir d'exemple pour comprendre la problématique de la gestion d'état global.
Pendant longtemps, il n'y a pas vraiment eu de solution "officielle" pour internationaliser son app en Flutter
Il y a donc de NOMBREUSES manières de le faire et beaucoup de packages externes
Nous allons nous intéresser à la version officielle pour le moment (car il y en finalement eu une)
Tout d'abord, on ajoute les packages Flutter nécessaires :
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: anyEnsuite, nous allons modifier notre MaterialApp en lui spécifiant :
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Demo',
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('en'), // English
Locale('es'), // Spanish
],
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}Ajoutons maintenant nos traductions
On va devoir faire de la génération de code, il faut donc l'autoriser dans le pubspec.yaml :
# The following section is specific to Flutter packages.
flutter:
generate: trueEnsuite, nous allons déclarer au package où seront situés nos fichiers de traductions
À la racine du projet, créer un fichier I18n.yaml (c'est un i majuscule) avec ce contenu :
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dartOn créé ensuite notre dossier /lib/I18n
Et dedans, notre premier fichier de trad anglaise app_en.arb avec par exemple ce contenu :
{
"helloWorld": "Hello World!",
"@helloWorld": {
"description": "The conventional newborn programmer greeting"
}
}Votre IDE vous propose sûrement de télécharger un Plugin pour mieux modifier les fichiers arb, n'hésitez pas à le prendre, ça peut aider !
On peut ensuite créer tous les fichiers de trads qu'on veut, par exemple celui en français (/lib/I10n/app_fr.arb) :
{
"helloWorld": "Salut le monde !"
}Nous pouvons désormais générer notre code !
Pour ce faire, on lance un petit :
flutter gen-l10nÇa a normalement dû générer le code en question dans .dart_tool/flutter_gen/gen_I10n/
On peut désormais y faire référence en l'important :
import 'package:flutter_gen/gen_l10n/app_localizations.dart';Dans un premier temps il faut l'ajouter comme nouveau Delegate de langues, dans la MaterialApp :
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],Et on peut également s'en servir pour définir les langues supportées :
supportedLocales: AppLocalizations.supportedLocales,Ne pas oublier, côté iOS, de préciser les langues dans le Info.plist :
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>fr</string>
</array>C'est bon ! Vous pouvez maintenant y faire référence dans votre code et tenter de changer la langue :
Text(
AppLocalizations.of(context).helloWorld,
),Il peut être très intéressant pour une app d'avoir plusieurs Scheme (iOS) et plusieurs Flavors (Android)
On peut voir des Flavors/Scheme comme des configurations de builds dans laquelle nous allons pouvoir changer certaines valeurs comme (pour ne citer que ça) :
Intérêts :
Intérêts :
Inconvénients :
Regardons les étapes côté iOS
Tout se passe côté XCode (en ouvrant, via XCode, le fichier XCworkspace)
On commence par "dupliquer" les configurations en cliquant sur le bouton "+" et en leur donnant un nom correspondant au Scheme
Product > Scheme > New Scheme
Product > Scheme > New Scheme
On clique sur le Scheme > Edit Scheme
Ici, on change toutes les configurations pour mettre la nouvelle !
Enfin, on va pouvoir changer le nom de l'app et son bundle ID :
Et on oublie pas d'afficher le PRODUCT_NAME comme nom de l'app :
Passons au côté Android maintenant !
La config est plus simple, ça se passe dans android/app/build.gradle
flavorDimensions "default"
productFlavors {
dev {
dimension "default"
resValue "string", "app_name", "My App Dev"
applicationIdSuffix ".dev"
}
preprod {
dimension "default"
resValue "string", "app_name", "My App Preprod"
applicationIdSuffix ".preprod"
}
prod {
dimension "default"
resValue "string", "app_name", "My App"
}
}Et on oublie pas de prendre en compte le nom de l'app dans le Manifest :
Il y a plusieurs manières de mettre en place des variables d'environnement en Flutter :
Au lancement de l'app via flutter run, on peut en effet donner des variables de cette manière :
flutter run --dart-define=API_BASE_URL=http://www.monapi.comEt les récupérer de cette manière :
const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: 'coucou');import 'package:flutter/material.dart';
void main() {
runApp(const ContextApp());
}
class ContextApp extends StatelessWidget {
const ContextApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Context')),
body: Container(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
),
),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Pourquoi ça ne fonctionne pas ?
Jetons un oeil à l'erreur en elle-même
Pour comprendre ce comportement, il faut comprendre ce qu'est un BuildContext
Le comprendre vous permettra de comprendre BEAUCOUP de choses
Tout d'abord, il faut comprendre quelque chose.... il n'y a pas que des Widgets dans Flutter...
Nous avons en fait 3 arbres différents :
Si on regarde les StateLess et Stateful widgets, les deux sont des descendants de la classe Widget :
abstract class StatefulWidget extends Widget {
// ....abstract class StatelessWidget extends Widget {
// ...Et dans la classe Widget :
abstract class Widget extends DiagnosticableTree {
//...
@protected
@factory
Element createElement();
//...Les Widgets ne sont finalement que des schémas (Blueprints en anglais) et les Element associés sont eux les vrais instances, détenteurs d'informations
Et le BuildContext dans tout ça ?
abstract class StatelessWidget extends Widget {
// ...
@override
StatelessElement createElement() => StatelessElement(this);
//.../// An [Element] that uses a [StatelessWidget] as its configuration.
class StatelessElement extends ComponentElement {
/// Creates an element that uses the given widget as its configuration.
StatelessElement(StatelessWidget super.widget);
@override
Widget build() => (widget as StatelessWidget).build(this);
// ...abstract class Element extends DiagnosticableTree implements BuildContext {
//...Les Elements sont en fait des BuildContext !
Ce qu'il faut comprendre : Lorsqu'un Widget est créé, le BuildContext qui lui est envoyé correspond en fait à l'Element dans l'arbre des éléments.
Le BuildContext porte donc bien son nom : il s'agit de l'emplacement exact de l'élément dans l'arbre : le "contexte" de celui-ci.
On peut donc remonter tous les ancêtres à partir d'un seul BuildContext, et c'est d'ailleurs la logique des InheritedWidget
Theme.of(context);Ici, l'arbre va être remonté jusqu'à trouver une instance de Theme.
import 'package:flutter/material.dart';
void main() {
runApp(const ContextApp());
}
class ContextApp extends StatelessWidget {
const ContextApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Context')),
body: Container(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
),
),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Reprenons l'exemple initial :
Le BuildContext auquel nous faisons référence dans notre affichage de SnackBar fait référence à l'emplacement de la ContextApp !
Flutter ira alors chercher les ancêtres à partir de ContextApp et ne verra ni la MaterialApp ni Scaffold !
D'où notre erreur !
Pour régler ça, il y a 2 solutions :
Créer un widget à part :
class CustomFloatingButton extends StatelessWidget {
const CustomFloatingButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Utiliser un Builder :
class ContextApp extends StatelessWidget {
const ContextApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Builder(
builder: (context) {
return Scaffold(
appBar: AppBar(title: const Text('Context')),
body: Container(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showSnackBar(context),
child: const Icon(Icons.add),
),
);
}
),
);
}
void _showSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Hello!')));
}
}Dans les deux cas, nous recevons le BuildContext (et donc l'élément) correspondant à notre position !
Comprendre le cas spécial de la Navigation :
Les différents OverlayEntry n'ont rien en commun à part la MaterialApp !
Leur BuildContext communs sera forcément celui de la MaterialApp ou au dessus
Pour comprendre l'intérêt des Keys, nous allons mettre en place un petit exemple :
import 'package:flutter/material.dart';
class StatelessColoredTile extends StatelessWidget {
final Color color;
const StatelessColoredTile({
Key? key,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 200,
width: 100,
color: color,
);
}
}Imaginons ce widget StateLess :
Imaginons maintenant qu'on les place dans une Row avec un bouton pour pouvoir changer leur ordre :
class KeysApp extends StatefulWidget {
const KeysApp({Key? key}) : super(key: key);
@override
State<KeysApp> createState() => _KeysAppState();
}
class _KeysAppState extends State<KeysApp> {
final List<Widget> _coloredTiles = const [
StatelessColoredTile(color: Colors.red),
StatelessColoredTile(color: Colors.blue),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Keys'),
),
body: Row(
children: _coloredTiles,
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
_coloredTiles.insert(0, _coloredTiles.removeAt(1));
});
},
child: const Icon(Icons.refresh),
),
);
}
}Si on switch, voici le résultat :
Jusque là, tout est normal.
Tentons maintenant de les convertir en boutons stateful :
import 'dart:math';
import 'package:flutter/material.dart';
class ColoredTile extends StatefulWidget {
const ColoredTile({Key? key}) : super(key: key);
@override
State<ColoredTile> createState() => _ColoredTileState();
}
class _ColoredTileState extends State<ColoredTile> {
Color _color = Colors.amber;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _changeColor,
child: Container(
height: 200,
width: 100,
color: _color,
),
);
}
void _changeColor() {
final colors = [
Colors.amber,
Colors.blue,
Colors.brown,
Colors.cyan,
Colors.deepOrange,
Colors.deepPurple,
Colors.green,
Colors.indigo,
Colors.lime,
Colors.pink,
];
setState(() {
_color = colors[Random().nextInt(colors.length)];
});
}
}Il s'agit simplement d'une cellule avec une couleur qui peut changer au clique
Changez maintenant les cellules dans le widget parent en mettant celles-ci :
class _KeysAppState extends State<KeysApp> {
final List<Widget> _coloredTiles = const [
ColoredTile(),
ColoredTile(),
];
//...Les cellules ne changent plus de place.
Pour comprendre cela, nous devons comprendre comment Flutter fonctionne pour re-dessiner les widgets
Pour rappel, voici une représentation des Widgets concernés et de leurs Elements associés
Que se passe-t-il si je change l'ordre des widgets ?
Flutter va alors parcourir l'arbre des Element et va vérifier si la structure des éléments est toujours la bonne.
Il va vérifier si l'ancien Widget et le nouveau sont les mêmes. Et c'est ici que tout se joue.
Pour vérifier si deux Widgets sont égaux, Flutter se base sur 2 informations :
Dans notre cas, les widgets sont toujours des StatelessTile, donc pour lui rien ne change.
Seulement, lorsqu'on est dans un StatelessWidget, l'information de la couleur est portée par le Widget en lui-même.
Bien que les Element n'aient pas été échangés, on a quand même les couleurs qui changent car l'info est récupérée depuis le Widget
Voici ce que ça donne par contre avec des StatefulWidget
Très important :
Dans ce cas, l'information de la couleur est portée par le State et est donc portée par l'Element.
Ce qui signifie que, lorsqu'on va échanger les Widgets, les Elements ne changeront pas de place et donc les couleurs affichées ne changerons jamais.
Pour rappel, Flutter compare deux widgets par rapport à :
Dès lors que l'on veut pouvoir faire se déplacer, réordonner, etc. des Stateful widgets, nous allons donc être obligés de leur ajouter des Keys.
Testez simplement comme ceci :
class _KeysAppState extends State<KeysApp> {
final List<Widget> _coloredTiles = [
ColoredTile(key: UniqueKey(),),
ColoredTile(key: UniqueKey(),),
];
//..Le problème est réglé 🎉
Pourquoi ? Parce que nous avons des Keys maintenant :
Si on change l'ordre, alors les Elements ne correspondent plus !
Dans ce cas, Flutter cherche parmi les Elements un autre élément qui correspond (au niveau du type ET de la Key)
Quelles Keys utiliser alors ?
À savoir :
UniqueKey, ValueKey et ObjectKey sont des LocalKey
Regardons ce qu'en dit la doc :
/// A key that is not a [GlobalKey].
///
/// Keys must be unique amongst the [Element]s with the same parent. By
/// contrast, [GlobalKey]s must be unique across the entire app.
///
/// See also:
///
/// * [Widget.key], which discusses how widgets use keys.
abstract class LocalKey extends Key {
//...Si vous avez bien compris ce que ça signifie, quel est le problème avec le code suivant ?
final List<Widget> _coloredTiles = [
Padding(
padding: const EdgeInsets.all(8.0),
child: ColoredTile(key: UniqueKey(),),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ColoredTile(key: UniqueKey(),),
),
];