Article original : Theming and Customization in Flutter: A Handbook for Developers
Le design ne se résume pas seulement à l'apparence d'un produit. Dans l'ingénierie produit, le design façonne le ressenti d'une expérience, la manière dont les utilisateurs interagissent avec elle et la cohérence avec laquelle la marque prend vie sur chaque écran.
Flutter offre des outils puissants pour cela, mais la véritable maîtrise de la thématisation va bien au-delà du simple changement de quelques couleurs ou polices. Elle implique la construction d'un langage de design unifié, son application prévisible sur l'ensemble des composants, la gestion de l'échelle et la garantie que l'interface utilisateur reste accessible, performante et maintenable à mesure que le produit se développe sur mobile, web et bureau.
Ce manuel s'adresse aux ingénieurs et aux équipes produit qui souhaitent créer des applications Flutter de qualité production avec l'excellence du design au cœur du projet. Il dépasse la thématisation de base pour plonger dans l'architecture des systèmes de thèmes robustes, des ColorScheme de Material 3, de la typographie et des systèmes d'élévation, jusqu'aux extensions de thèmes personnalisées avancées, aux gestionnaires de styles réutilisables, aux surcharges au niveau des composants, au changement de thème à l'exécution, aux stratégies réactives et aux principes d'accessibilité.
Nous examinerons des modèles concrets et des exemples de code complets, et je fournirai des explications claires sur l'importance de chaque décision dans des environnements d'ingénierie pratiques.
À la fin, vous comprendrez non seulement le fonctionnement de la thématisation dans Flutter, mais vous serez également équipé pour architecturer un système de design évolutif et axé sur la marque, l'adapter à l'identité de votre produit et livrer systématiquement des interfaces intentionnelles, performantes et agréables, quel que soit l'endroit où elles s'exécutent.
Table des matières
Ce que « Thème » signifie dans Flutter et pourquoi c'est important
La transition des champs de couleur manuels vers ColorScheme
Accéder aux valeurs du thème depuis les widgets et éviter les pièges courants
Déconstruction d'un thème Flutter réel
Conseils pratiques sur la structuration du code de thème dans un projet
Migrer une application existante vers un système de thème approprié
Prérequis
Pour bien saisir les concepts et les exemples présentés ici, il est utile d'avoir une base solide en développement Flutter. Vous devriez avoir installé et configuré le SDK Flutter, en utilisant la dernière version stable.
Une familiarité avec la programmation Dart de base, y compris la syntaxe, les classes, les objets et les opérations asynchrones utilisant async et await, est essentielle. Une compréhension fondamentale des widgets Flutter, spécifiquement StatelessWidget, StatefulWidget, l'arborescence des widgets et les composants de base comme MaterialApp et Scaffold, sera très bénéfique.
De plus, connaître les bases de la gestion d'état via setState est crucial. Une compréhension conceptuelle de modèles plus avancés comme ChangeNotifier et Provider vous aidera également à comprendre comment la thématisation dynamique fonctionne en pratique.
Enfin, disposer d'un environnement de développement intégré (IDE) tel que Visual Studio Code ou Android Studio facilitera le processus de développement.
Ce que « Thème » signifie dans Flutter et pourquoi c'est important
Un thème dans Flutter est essentiellement la définition centralisée des jetons de design visuel et des valeurs par défaut des composants dont les widgets peuvent hériter. Les thèmes vous permettent d'exprimer l'identité de la marque, de fournir un espacement et une typographie cohérents, de prendre en charge le mode sombre et de séparer le style de la logique métier.
Les thèmes minimisent la duplication et facilitent les mises à jour visuelles globales. Lorsqu'une application monte en charge, le thème devient la source unique de vérité pour les couleurs, la typographie, les formes, les élévations, les styles de composants et les jetons de design personnalisés. Comprendre ce système est essentiel si vous voulez construire des applications Flutter maintenables, accessibles et facilement personnalisables.
ThemeData et le modèle d'héritage
ThemeData est l'objet principal que vous assemblerez et fournirez au widget MaterialApp pour définir l'apparence d'une application. Considérez-le comme un objet de configuration immuable contenant des champs pour les couleurs, les thèmes de texte, les thèmes de composants, et plus encore.

Lorsque vous placez un ThemeData sur l'arborescence des widgets, les widgets descendants peuvent le lire en utilisant Theme.of(context). Mieux encore, de nombreux widgets Material standard consultent automatiquement le thème actuel pour déterminer comment se dessiner. Si vous avez besoin de surcharger les styles pour une section spécifique de votre application, vous pouvez placer un widget Theme plus profondément dans l'arborescence, ce qui surchargera le ThemeData hérité pour sa sous-arborescence.
Voici un exemple minimal :
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primaryColor: Colors.blue,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
textTheme: TextTheme(
bodyMedium: TextStyle(fontSize: 16, height: 1.4),
headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(padding: EdgeInsets.all(16)),
),
),
home: HomePage(),
);
}
}
Cet extrait montre une application minimale où ThemeData définit une couleur primaire, un ColorScheme basé sur une graine, des valeurs de thème de texte et un thème ElevatedButton. Ces valeurs se propagent aux widgets descendants, de sorte que les boutons, le texte et les autres composants utilisent les mêmes jetons de design sans répétition de style local.
La transition des champs de couleur manuels vers ColorScheme
Par le passé, les développeurs définissaient souvent directement des champs de couleur comme primaryColor et accentColor. Mais ColorScheme est désormais la méthode moderne et recommandée pour exprimer le système de couleurs d'une application dans Flutter, en s'alignant sur Material Design. Vous devriez remplir un ColorScheme et laisser ThemeData harmoniser les couleurs des widgets à partir de ces jetons canoniques.
ColorScheme contient des rôles de couleur sémantiques tels que primary, onPrimary, background, surface, error, et leurs homologues « on ». Ces rôles décrivent comment les couleurs doivent être utilisées et associées pour garantir une interface utilisateur lisible.

final colorScheme = ColorScheme.fromSeed(seedColor: Color(0xFF0066CC));
final theme = ThemeData.from(colorScheme: colorScheme).copyWith(
useMaterial3: true,
);
Le code ci-dessus génère un ColorScheme complet à partir d'une couleur graine et construit un ThemeData à partir de celui-ci. Cela active les valeurs par défaut des composants Material 3 lorsque useMaterial3 est défini sur true. Créer un thème de cette manière rend les décisions de couleur cohérentes et conformes aux principes Material sur l'ensemble des composants.
Material 2 vs Material 3
Material 3 (M3) introduit des styles de composants mis à jour, des palettes tonales et des comportements de surface. Dans Flutter, vous pouvez activer l'apparence Material 3 en définissant useMaterial3: true dans votre ThemeData.
M3 est particulièrement pertinent lors de l'utilisation de ColorScheme.fromSeed car il utilise des palettes tonales et des capacités de couleur dynamique sur les plateformes prises en charge. Lors de la migration de Material 2 vers Material 3, sachez que certains composants ont des valeurs par défaut différentes et des API légèrement distinctes. Il est conseillé de vérifier les composants clés comme l' AppBar, les boutons et les composants de navigation pendant le processus de migration.

Typographie, mise à l'échelle du texte et accessibilité
Tout comme vous systématisez les couleurs, vous devriez systématiser le texte. TextTheme contient des styles typographiques mappés à des rôles sémantiques, tels que displayLarge, headlineLarge, bodyMedium et labelSmall.
Vous pouvez utiliser ces rôles de texte sémantiques dans toute votre application plutôt que de coder en dur des valeurs TextStyle. Cette approche vous permet de vous appuyer sur MediaQuery.textScaleFactor et DefaultTextStyle pour respecter automatiquement la mise à l'échelle de la police préférée de l'utilisateur.
Pour une typographie accessible, assurez-vous d'utiliser des tailles relatives entre les titres et le corps du texte, évitez les polices au pixel près et visez un contraste lisible avec les surfaces d'arrière-plan.
final textTheme = TextTheme(
headlineLarge: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w700),
bodyMedium: GoogleFonts.inter(fontSize: 16, height: 1.5),
);
Ce thème de texte utilise une police web via GoogleFonts (un exemple de package) et définit des échelles pour les titres et le corps. L'utilisation de noms sémantiques TextTheme encourage une utilisation cohérente de la typographie sur l'ensemble des widgets et prend en charge la mise à l'échelle dynamique du texte.
Thèmes de composants et leur importance
Bien que les couleurs et les polices globales soient importantes, vous avez parfois besoin d'un contrôle spécifique sur des widgets individuels. Les thèmes de composants vous permettent de définir l'apparence par défaut des widgets Material intégrés. Voici quelques exemples :
AppBarThemeElevatedButtonThemeDataInputDecorationThemeCheckboxThemeDataCardThemeBottomNavigationBarThemeData
Définir des thèmes de composants centralise les styles tels que le padding, la forme, l'élévation et la couleur pour ce type de composant.
final theme = ThemeData(
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) return Colors.grey.shade400;
return Colors.blue;
}),
padding: MaterialStateProperty.all(EdgeInsets.symmetric(vertical: 14, horizontal: 20)),
shape: MaterialStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
);
L' ElevatedButtonThemeData dans cet extrait utilise MaterialStateProperty pour résoudre les couleurs d'arrière-plan pour différents états, et InputDecorationTheme définit les valeurs par défaut pour les champs de texte. Les thèmes de composants vous permettent d'éviter de répéter la logique de style dans chaque instance de widget.
MaterialStateProperty et stylisation dépendante de l'état
Vous avez peut-être remarqué MaterialStateProperty dans l'exemple précédent. Il s'agit d'un modèle puissant qui vous permet de définir différentes valeurs de style pour les états des widgets tels que survolé, pressé, focalisé et désactivé. Vous pouvez utiliser MaterialStateProperty.resolveWith pour renvoyer les valeurs appropriées en fonction de l'ensemble d'états actuels.

ButtonStyle myStyle() {
return ButtonStyle(
overlayColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.pressed)) return Colors.blue.withOpacity(0.12);
if (states.contains(MaterialState.hovered)) return Colors.blue.withOpacity(0.06);
return null;
}),
);
}
Cet exemple produit des couleurs de superposition pour les états pressé et survolé, permettant un retour interactif cohérent sur les boutons et les contrôles similaires en centralisant la logique.
Theme Extensions pour les jetons de design personnalisés
Parfois, les champs de thème Material standard ne suffisent pas pour votre système de design spécifique. ThemeExtension est le moyen officiel d'ajouter des jetons de design sur mesure à ThemeData tout en les gardant typés et cohérents pour l'animation. Vous pouvez utiliser ThemeExtension pour stocker des valeurs telles que des rayons de marque, des échelles d'espacement, des palettes de couleurs personnalisées ou des durées d'animation.
@immutable
class AppSpacing extends ThemeExtension<AppSpacing> {
final double small;
final double medium;
final double large;
const AppSpacing({required this.small, required this.medium, required this.large});
@override
AppSpacing copyWith({double? small, double? medium, double? large}) {
return AppSpacing(
small: small ?? this.small,
medium: medium ?? this.medium,
large: large ?? this.large,
);
}
@override
AppSpacing lerp(ThemeExtension<AppSpacing>? other, double t) {
if (other is! AppSpacing) return this;
return AppSpacing(
small: lerpDouble(small, other.small, t)!,
medium: lerpDouble(medium, other.medium, t)!,
large: lerpDouble(large, other.large, t)!,
);
}
}
Cette ThemeExtension définit trois jetons d'espacement et implémente copyWith et lerp afin que Flutter puisse animer entre les instances de thème. L'ajout d'instances ThemeExtension à ThemeData.extensions les rend disponibles via Theme.of(context).extension().
Accéder aux valeurs du thème depuis les widgets et éviter les pièges courants
Maintenant que vous avez défini votre thème, vous devez savoir comment l'utiliser. L'accès aux données du thème permet à vos widgets personnalisés de s'adapter automatiquement aux changements d'apparence de l'application – mais le timing est crucial.
Vous pouvez appeler Theme.of(context) à l'intérieur des méthodes build pour accéder à ThemeData ou utiliser des helpers de style context.read sur les plateformes proposant des extensions. Cependant, vous devriez éviter d'appeler Theme.of(context) pendant initState. À ce stade, les widgets hérités de l'arborescence peuvent ne pas être encore disponibles. À la place, vous pouvez l'appeler dans didChangeDependencies ou à l'intérieur d'un callback post-frame.
@override
void didChangeDependencies() {
super.didChangeDependencies();
final textTheme = Theme.of(context).textTheme;
// Utiliser textTheme pour la logique initiale qui dépend des valeurs du thème.
}
L'utilisation de didChangeDependencies garantit que les thèmes hérités sont prêts et évite les valeurs nulles ou obsolètes qui pourraient survenir dans initState.
Surcharges locales avec le widget Theme
Occasionnellement, vous pourriez vouloir qu'une section spécifique de votre application (une sous-arborescence) utilise un thème modifié sans changer le thème global. Vous pouvez envelopper cette sous-arborescence avec un widget Theme et utiliser copyWith pour modifier uniquement les champs nécessaires.
Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(primary: Colors.green),
),
child: SomeLocalWidget(),
)
Ce code remplace temporairement la couleur primaire pour la sous-arborescence SomeLocalWidget, laissant le reste de l'application inchangé. Les surcharges locales sont utiles pour les dialogues, les sections spéciales ou les composants de marque.
Changement de thème à l'exécution et persistance
Une application véritablement moderne permet généralement aux utilisateurs de basculer entre les modes clair et sombre ou de choisir des thèmes personnalisés. Vous pouvez implémenter le basculement à l'exécution en pilotant ThemeMode via une solution de gestion d'état de haut niveau comme Provider, Riverpod, Bloc ou un ValueNotifier hérité.
Ensuite, vous pouvez persister le choix de l'utilisateur avec SharedPreferences, un stockage sécurisé ou une persistance au niveau de l'application afin que la préférence survive aux redémarrages.

class ThemeController with ChangeNotifier {
ThemeMode _mode = ThemeMode.system;
ThemeMode get mode => _mode;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
final index = prefs.getInt('themeMode') ?? 2;
_mode = ThemeMode.values[index];
notifyListeners();
}
Future<void> setMode(ThemeMode mode) async {
_mode = mode;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
prefs.setInt('themeMode', mode.index);
}
}
Le ThemeController enveloppe ThemeMode et le persiste dans SharedPreferences. Vous pouvez fusionner cela avec un ChangeNotifierProvider à la racine de l'application pour reconstruire MaterialApp avec le ThemeMode choisi.
Concevoir un système de thème robuste
Une fois les bases posées, l'étape suivante consiste à transformer votre configuration de thème en un système complet capable de soutenir un produit réel. Un système de thème prêt pour la production doit pouvoir gérer des transitions visuelles fluides, s'intégrer correctement au système d'exploitation, maintenir des performances élevées et répondre aux attentes en matière d'accessibilité.
Les sous-sections suivantes détaillent chacun de ces domaines et montrent comment concevoir un système de thème qui évolue proprement en fonction des plateformes et des exigences du produit.
AnimatedTheme pour des transitions fluides
Lorsqu'un utilisateur change de thème, vous ne voulez pas que les couleurs changent brusquement. Vous pouvez utiliser AnimatedTheme pour animer les transitions visuelles lorsque ThemeData change pendant l'exécution. Cela permet un fondu et une interpolation conviviaux des propriétés dépendantes du thème.
AnimatedTheme(
data: currentThemeData,
duration: Duration(milliseconds: 300),
child: MaterialApp(
theme: lightThemeData,
darkTheme: darkThemeData,
themeMode: themeController.mode,
home: HomePage(),
),
)
AnimatedTheme écoute les changements de currentThemeData et anime automatiquement la transition entre l'ancien thème et le nouveau. La duration contrôle le temps que prend le fondu, et la MaterialApp à l'intérieur fournit toujours le thème clair, le thème sombre et le mode de thème. Lorsque le thème est mis à jour, l'application entière effectue une transition fluide au lieu de changer brusquement.
Luminosité de la plateforme et intégration système
Votre application devrait idéalement respecter les paramètres du système d'exploitation de l'utilisateur. MaterialApp accepte les paramètres theme, darkTheme et themeMode. Vous pouvez compter sur themeMode: ThemeMode.system pour vous adapter automatiquement aux préférences de mode sombre au niveau de l'OS.
Pour un contrôle plus fin ou pour les plateformes où vous souhaitez détecter directement la luminosité, vous pouvez utiliser MediaQuery.platformBrightness ou WidgetsBinding.instance.window.platformBrightness.
final brightness = MediaQuery.platformBrightnessOf(context);
if (brightness == Brightness.dark) {
// ajuster le comportement local si nécessaire
}
Dynamic Color (Android 12+)
Android 12 a introduit la couleur dynamique basée sur le fond d'écran de l'utilisateur. Flutter expose cela pour Material 3 via le package dynamic_color et ColorScheme.fromSeed.
// esquisse de pseudo-code ; l'utilisation du package dynamic_color est similaire
final corePalette = await DynamicColorPlugin.getCorePalette();
final colorScheme = ColorScheme.fromSeed(seedColor: Color(corePalette.primary.value));
Cela permet à votre application de paraître native sur les appareils dotés d'une thématisation basée sur le fond d'écran.
Considérations de performance
Du point de vue de la performance, évitez de reconstruire l'intégralité de l'arborescence des widgets lorsqu'une petite sous-arborescence seulement a besoin d'un changement de thème. Vous pouvez utiliser des surcharges locales de Theme pour les petits changements et des constructeurs const partout où cela est possible.
Vous devriez également éviter de recalculer des valeurs de thème complexes dans les méthodes build. Calculez-les une seule fois et stockez-les si elles sont statiques. Bien que l'accès à Theme.of(context) soit peu coûteux, évitez de l'utiliser dans des boucles de rendu serrées. Vous pouvez mettre les valeurs en cache si un widget se reconstruit fréquemment.
Accessibilité, contraste et daltonisme
Un bon thème est un thème accessible. Vous voudrez donc vous assurer que les rapports de contraste respectent les normes WCAG AA ou AAA lorsque cela est requis. Vous pouvez utiliser des outils pour calculer le contraste entre les couleurs du texte et de l'arrière-plan.
Vous devriez également fournir des variantes de thèmes à contraste élevé et respecter les options d'accessibilité au niveau de la plateforme comme le mode contraste élevé. C'est aussi une bonne idée d'utiliser la sémantique et des étiquettes appropriées pour les indicateurs basés uniquement sur la couleur, et d'éviter de transmettre des informations uniquement par la couleur.
RTL et localisation
La directionnalité influence certains widgets et mises en page. Les jetons de thème restent généralement agnostiques à la direction, mais vous devez être attentif aux formes qui se reflètent horizontalement. Utilisez Directionality et Localizations pour adapter toutes les décisions de mise en page pilotées par le thème qui dépendent de la langue ou des conventions culturelles.
Thématisation et tests
Enfin, vous devriez vérifier votre logique de thème avec des tests. Écrivez des tests "golden" et des tests de widgets qui affichent vos widgets sous les thèmes clair et sombre.
testWidgets('MyCard respecte le thème', (tester) async {
final theme = ThemeData.light().copyWith(cardTheme: CardTheme(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))));
await tester.pumpWidget(MaterialApp(home: Theme(data: theme, child: MyCard())));
// Ajouter des assertions pour la forme, le style de texte, etc.
});
Le test définit un thème personnalisé pour le widget, puis utilise des assertions pour s'assurer que le widget respecte les valeurs du thème.
Débogage avec DevTools
Si vous rencontrez des problèmes, l'inspecteur Flutter DevTools affiche l'arborescence des widgets et les styles appliqués. Vous pouvez l'utiliser pour visualiser le ThemeData hérité, voir d'où vient un style spécifique et détecter des surcharges inattendues.
Exemples avancés
Maintenant que nous avons couvert les concepts et les considérations d'ingénierie, voyons comment structurer une solution de thème complète.
Thème racine basé sur une graine avec extensions personnalisées
Ce modèle définit une classe de thème centrale qui génère à la fois des thèmes clairs et sombres à partir de la même couleur graine et attache des extensions personnalisées pour les jetons de design partagés.
class MyTheme {
static final lightColorScheme = ColorScheme.fromSeed(seedColor: Color(0xFF6750A4), brightness: Brightness.light);
static final darkColorScheme = ColorScheme.fromSeed(seedColor: Color(0xFF6750A4), brightness: Brightness.dark);
static ThemeData lightTheme() {
return ThemeData(
colorScheme: lightColorScheme,
useMaterial3: true,
textTheme: TextTheme(bodyMedium: TextStyle(fontSize: 16)),
extensions: [const AppSpacing(small: 8, medium: 12, large: 24)],
);
}
static ThemeData darkTheme() {
return ThemeData(
colorScheme: darkColorScheme,
useMaterial3: true,
textTheme: TextTheme(bodyMedium: TextStyle(fontSize: 16)),
extensions: [const AppSpacing(small: 8, medium: 12, large: 24)],
);
}
}
Cette classe construit des objets ThemeData clairs et sombres cohérents à partir d'une couleur graine partagée en utilisant la génération de couleurs dynamiques de Material 3. Elle inclut également une extension personnalisée AppSpacing, permettant à votre application d'utiliser des jetons d'espacement réutilisables directement via le thème.
Changement de thème à l'exécution avec ValueListenableBuilder
Ce modèle utilise un ValueNotifier pour suivre le ThemeMode actif et reconstruit l'application chaque fois que l'utilisateur bascule entre les thèmes clair et sombre, tandis qu' AnimatedTheme assure une transition fluide.
class ThemeToggleApp extends StatefulWidget {
@override
State<ThemeToggleApp> createState() => _ThemeToggleAppState();
}
class _ThemeToggleAppState extends State<ThemeToggleApp> {
final ValueNotifier<ThemeMode> _mode = ValueNotifier(ThemeMode.system);
@override
void dispose() {
_mode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: _mode,
builder: (context, mode, child) {
return AnimatedTheme(
data: mode == ThemeMode.dark ? MyTheme.darkTheme() : MyTheme.lightTheme(),
duration: Duration(milliseconds: 300),
child: MaterialApp(
theme: MyTheme.lightTheme(),
darkTheme: MyTheme.darkTheme(),
themeMode: mode,
home: Scaffold(
appBar: AppBar(title: Text('Theme Toggle')),
body: Center(
child: ElevatedButton(
onPressed: () {
_mode.value = _mode.value == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
},
child: Text('Toggle'),
),
),
),
),
);
},
);
}
}
ValueListenableBuilder écoute le ThemeMode actuel, et chaque fois que la valeur change, l'application se reconstruit avec le thème approprié. Le basculement est animé via AnimatedTheme, produisant un fondu fluide entre les modes clair et sombre.
Étendre l'idée d'un système de thème au-delà de ThemeData
À l'échelle de la production, un thème se limite rarement à une seule déclaration ThemeData à l'intérieur de main.dart. Au lieu de cela, il devient un système de design par couches.
Dans ce système, l'objet Flutter ThemeData n'est que la couche de mapping finale entre les jetons du produit et les valeurs par défaut des widgets. Le véritable système commence par les jetons de design de la marque ou de l'identité du produit, stockés dans des fichiers internes tels que app_colors.dart, font_manager.dart, styles_manager.dart et values_manager.dart. Ces fichiers servent de source canonique pour l'espacement, les échelles de couleurs, les échelles typographiques, les échelles de rayons d'angle, les valeurs de mouvement, les jetons d'opacité et les ombres.
Le thème mappe ces valeurs dans ThemeData, et ThemeData devient le point de vérité unique pour les widgets. Cette structure par couches évite les incohérences visuelles et rend les futures refontes prévisibles.

Exemple pratique de structure de mapping jeton-vers-thème
Pour visualiser cela, imaginez la structure de votre dossier lib. Vous avez généralement vos fichiers "manager" principaux qui agrègent les styles, puis les fichiers de jetons de niveau inférieur qui définissent les valeurs brutes.
lib/
theme/
app_theme.dart <-- Point d'entrée (getTheme)
theme_manager.dart <-- Couche logique
styles_manager.dart <-- Générateurs de styles de texte
values_manager.dart <-- Espacement/Tailles
font_manager.dart <-- Graisses/Familles de polices
app_colors.dart <-- Codes hexadécimaux bruts
Dans cette disposition, les jetons sont séparés de la logique de thème de Flutter consciente des widgets. Les designers mettent à jour les jetons tandis que les développeurs mettent à jour le mapping une seule fois. L'application se met à jour instantanément.
La couche de jetons (approche ascendante)
app_colors.dart contient généralement les couleurs de la marque :
class AppColors {
static const primaryColor = Color(0xFF0066CC);
static const secondaryColor = Color(0xFF1E88E5);
static const primarySecondaryBackground = Color(0xFFE6EEF6);
static const darkBackground = Color(0xFF0E0E0E);
static const lightBackground = Colors.white;
}
font_manager.dart définit les jetons typographiques :
class FontWeightManager {
static const regular = FontWeight.w400;
static const medium = FontWeight.w500;
static const semiBold = FontWeight.w600;
static const bold = FontWeight.w700;
}
class FontSize {
static const s12 = 12.0;
static const s14 = 14.0;
// ... s16, s18, s22, s32
}
values_manager.dart définit l'espacement, le rayon et les élévations :
class AppSize {
static const s4 = 4.0;
static const s8 = 8.0;
// ... s12, s16, s24, s32
}
class AppRadius {
static const r8 = Radius.circular(8);
static const r12 = Radius.circular(12);
static const r20 = Radius.circular(20);
}
class AppElevation {
static const level0 = 0.0;
static const level1 = 1.0;
static const level2 = 2.0;
static const level4 = 4.0;
}
styles_manager.dart expose des styles de texte sémantiques :
TextStyle _getTextStyle(double size, FontWeight weight, Color color) {
return TextStyle(fontSize: size, fontWeight: weight, color: color);
}
class AppTextStyles {
static TextStyle headlineLarge(Color color) =>
_getTextStyle(FontSize.s32, FontWeightManager.bold, color);
static TextStyle bodyMedium(Color color) =>
_getTextStyle(FontSize.s16, FontWeightManager.regular, color);
}
Ces fichiers reflètent un système de thème mature où la logique de design reste séparée de la construction des widgets.
Intégrer ces jetons dans un thème Flutter
Une fois vos jetons définis, vous devrez les mapper à ThemeData. Dans les bases de code plus anciennes ou d'entreprise antérieures à Material 3, vous pourriez voir un modèle où un ColorScheme est généré à partir d'un swatch, suivi de surcharges manuelles pour des couleurs d'arrière-plan ou de surface spécifiques.
ThemeData getTheme() {
return ThemeData(
colorScheme: ColorScheme.fromSwatch()
.copyWith(secondary: Colors.white)
.copyWith(background: Colors.white, onBackground: Colors.white),
primaryColor: AppColors.primaryColor,
primaryColorLight: Colors.black,
primaryColorDark: Colors.white,
scaffoldBackgroundColor: Colors.white,
disabledColor: AppColors.primarySecondaryBackground,
dialogBackgroundColor: Colors.white,
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.white,
elevation: 0,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(),
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
}
L'intérêt de cette approche est la flexibilité : vous contrôlez explicitement chaque couleur. Mais la recommandation moderne de Flutter (surtout pour Material 3) est de migrer vers une approche basée sur une graine.
Migrer les thèmes basés sur des jetons hérités vers les palettes de graines Material 3
Même lorsque les marques fournissent des couleurs hexadécimales spécifiques, vous pouvez dériver des palettes tonales de ces jetons en utilisant ColorScheme.fromSeed :
final _seed = AppColors.primaryColor;
final lightScheme = ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.light);
final darkScheme = ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.dark);
Ensuite, attachez des extensions personnalisées :
ThemeData(
colorScheme: lightScheme,
useMaterial3: true,
extensions: [
const AppSpacing(small: 8, medium: 12, large: 24),
],
);
Les palettes de graines s'adaptent mieux aux surfaces sombres/claires et aux contraintes d'accessibilité. Les marques peuvent conserver leurs identités de couleur exactes tout en gagnant en profondeur tonale et en harmonie au niveau du système.
Peaufinage : les détails qui comptent
Une fois la structure de base en place, la différence entre une bonne application et une excellente application réside dans les détails – comme la manière dont l'application gère l'UI système, le mouvement, les ombres et les normes spécifiques à la plateforme.
Stylisation de l'overlay de l'UI système
Les couleurs de la barre d'état et de la barre de navigation système impactent l'harmonie chromatique perçue. Flutter vous permet de les configurer via systemOverlayStyle. Garder cela dans le code du thème garantit que votre chrome système correspond toujours aux surfaces de votre marque. Si vous stylisez les overlays système par page, vous risquez l'incohérence et l'illisibilité.
Jetons de mouvement et design d'animation
Les systèmes de design incluent le mouvement. Flutter vous permet de centraliser les jetons de mouvement et de les interpoler dans le thème à l'aide d'extensions :
class MotionTokens extends ThemeExtension<MotionTokens> {
final Duration fast;
final Duration normal;
final Duration slow;
const MotionTokens({required this.fast, required this.normal, required this.slow});
@override
MotionTokens lerp(ThemeExtension<MotionTokens>? other, double t) {
if (other is! MotionTokens) return this;
return MotionTokens(
fast: Duration(milliseconds: lerpDouble(fast.inMilliseconds.toDouble(), other.fast.inMilliseconds.toDouble(), t)!.toInt()),
normal: Duration(milliseconds: lerpDouble(normal.inMilliseconds.toDouble(), other.normal.inMilliseconds.toDouble(), t)!.toInt()),
slow: Duration(milliseconds: lerpDouble(slow.inMilliseconds.toDouble(), other.slow.inMilliseconds.toDouble(), t)!.toInt()),
);
}
}
Les applications qui animent la mise en page, l'opacité et les transitions d'élévation paraissent plus premium lorsque ces durées sont cohérentes et pilotées par le thème.
Dégradés, ombres et formes
Les systèmes de design nécessitent souvent des dégradés et des ombres. Comme Flutter n'a pas de champs de thème de dégradé intégrés, vous pouvez les stocker dans des extensions :
class AppGradients {
static const primaryGradient = LinearGradient(
colors: [Color(0xFF0050BB), Color(0xFF3388FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
Vous pouvez ensuite les récupérer via Theme.of(context).extension<AppGradients>(). De même, vous pouvez standardiser vos jetons d'ombre et vos rayons d'angle pour assurer une hiérarchie et une courbure uniformes dans toute l'application.
Densité des composants et adaptation à la plateforme
Flutter prend en charge la densité adaptative via visualDensity. Sur ordinateur, vous voulez des contrôles plus serrés, tandis que sur mobile, des cibles tactiles plus grandes.
visualDensity: VisualDensity.adaptivePlatformDensity,
Vous pouvez combiner cela avec des jetons d'espacement pour produire des mises en page cohérentes sur toutes les plateformes.
Thématisation croisée Cupertino et Material
Lorsque vous ciblez iOS, vous pouvez construire un thème Cupertino qui reflète vos jetons Material. Comme ThemeData ne style pas directement les widgets Cupertino, vous devriez utiliser CupertinoThemeData ou des composants multiplateformes.
CupertinoThemeData(
primaryColor: AppColors.primaryColor,
textTheme: CupertinoTextThemeData(
textStyle: TextStyle(fontSize: FontSize.s16, fontWeight: FontWeightManager.regular),
),
)
Gestion robuste du mode sombre
Les thèmes sombres ne sont pas simplement des thèmes clairs inversés. Les bons thèmes sombres ajustent l'élévation du contenu, le chroma des accents et la teinte de surface.
surfaceTintColor: lightScheme.surfaceTint,
Vous pouvez utiliser des couleurs primaires légèrement désaturées pour le texte et les icônes en mode sombre. Assurez-vous simplement de respecter les attentes des utilisateurs et de maintenir les normes de contraste.
Stratégies de marque blanche et B2B
Pour les produits déployés auprès de plusieurs clients, envisagez d'utiliser l'ingestion de jetons basée sur JSON.
final config = BrandConfig.fromJson(json);
return AppTheme.fromBrand(config);
Chaque marque reçoit un fichier de jetons séparé, mais la structure reste unifiée.
Déconstruction d'un thème Flutter réel
Pour conclure, déconstruisons à quoi ressemble un fichier de thème réel dans une application en production. Cet exemple démontre la discipline consistant à avoir une source unique de vérité pour les styles, les surcharges de composants et la typographie.
Nous commencerons par un point d'entrée de thème centralisé. C'est là que le langage visuel devient une architecture applicable :
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../constants/app_colors.dart';
import 'styles_manager.dart';
import 'values_manager.dart';
import 'font_manager.dart';
// Thème Clair Sombre
ThemeData getTheme() {
return ThemeData(
// ...
Placer votre thème derrière une factory comme getTheme() signale une intention : les décisions de style appartiennent à cet endroit, pas à l'intérieur des widgets.
Fondations : Système de couleurs et rôles d'arrière-plan
Cette section définit l'identité visuelle de base de l'application et établit un contraste cohérent entre les composants. Le colorScheme définit les couleurs primaires, secondaires et d'arrière-plan, garantissant la lisibilité et la cohésion, tandis que des propriétés comme dialogBackgroundColor, primaryColor et scaffoldBackgroundColor offrent un contrôle explicite sur les surfaces clés et les éléments interactifs. Cela crée une interface utilisateur prévisible et visuellement équilibrée qui s'aligne sur votre marque et favorise l'accessibilité.
colorScheme: ColorScheme.fromSwatch()
.copyWith(
secondary: Colors.white,
)
.copyWith(
background: Colors.white,
onBackground: Colors.white,
),
dialogBackgroundColor: Colors.white,
primaryColor: AppColors.primaryColor,
primaryColorLight: Colors.black,
primaryColorDark: Colors.white,
disabledColor: AppColors.primarySecondaryBackground,
scaffoldBackgroundColor: Colors.white,
Identité du Floating Action Button
Cette section définit le style visuel et le comportement de tous les boutons d'action flottants de l'application. En utilisant floatingActionButtonTheme, vous pouvez standardiser des propriétés telles que la forme, la couleur et l'élévation pour assurer la cohérence et aligner le FAB avec votre langage de design global.
floatingActionButtonTheme: FloatingActionButtonThemeData(
// shape: const CircleBorder(),
),
Même une configuration inutilisée ici compte. Déclarer un thème FAB explicite garantit une évolution prévisible par la suite.
Cohérence des Bottom Sheets
Cette section garantit une apparence et un ressenti cohérents pour toutes les bottom sheets de l'application. En définissant bottomSheetTheme, vous pouvez contrôler la couleur d'arrière-plan, l'élévation et d'autres propriétés de surface, rendant les bottom sheets visuellement cohérentes avec votre thème général et réduisant les variations de style inattendues.
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Colors.white,
elevation: 0,
),
Les bottom sheets souffrent souvent de fragmentation entre les applications. Les unifier évite la dérive visuelle.
Boutons : l'héritage rencontre la structure moderne
Cette section standardise l'apparence des boutons hérités dans toute l'application. ButtonThemeData vous permet de définir les couleurs par défaut, les formes et les états désactivés, assurant un style cohérent tout en faisant le pont entre les anciens widgets de boutons et le système de design Material moderne.
buttonTheme: const ButtonThemeData(
buttonColor: AppColors.primaryColor,
shape: StadiumBorder(),
disabledColor: AppColors.primarySecondaryBackground,
),
Il s'agit de l'ancienne API Button. La véritable structure vient ensuite avec ElevatedButtonThemeData :
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppSize.s5),
),
backgroundColor: AppColors.primaryColor,
disabledBackgroundColor: AppColors.secondaryColor,
disabledForegroundColor: Colors.white,
elevation: 0,
textStyle: getRegularStyle(
color: Colors.white,
fontSize: FontSize.s14,
fontWeight: FontWeightManager.normal,
),
),
),
UI de dialogue et de sélection de date
Cette section définit le style visuel des dialogues et des sélecteurs de date. En utilisant DatePickerThemeData, vous pouvez personnaliser les couleurs d'arrière-plan, les formes, les couleurs d'en-tête et les styles de texte pour garantir une expérience utilisateur cohérente et soignée qui s'aligne sur le thème général de votre application.
datePickerTheme: DatePickerThemeData(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
headerBackgroundColor: AppColors.primaryColor,
headerForegroundColor: Colors.white,
// ...
),
Sélection de texte et comportement du curseur
Cette section contrôle l'apparence des champs de texte lors de l'interaction de l'utilisateur. TextSelectionThemeData définit la couleur du curseur, la mise en surbrillance de la sélection de texte et les couleurs des poignées, garantissant une expérience d'édition de texte cohérente et accessible dans toute l'application.
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
selectionColor: Colors.white38,
selectionHandleColor: Colors.white,
),
Entrées de formulaire et ADN des champs
Cette section définit le style de base de tous les champs de saisie de l'application. InputDecorationTheme définit les styles de bordure, le rayon d'angle, les couleurs et l'apparence des icônes, créant un « ADN » cohérent pour les éléments de formulaire qui s'aligne sur votre marque et améliore l'ergonomie sur tous les écrans.
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppSize.s10),
borderSide: const BorderSide(
color: AppColors.greyShade2,
),
),
// ...
prefixIconColor: AppColors.greyShade1,
),
Système de cases à cocher
Cette section standardise l'apparence de toutes les cases à cocher de l'application. CheckboxThemeData vous permet de contrôler la couleur de la coche, la couleur de remplissage et le style de bordure, assurant cohérence, clarté et alignement avec le langage de design global.
checkboxTheme: CheckboxThemeData(
checkColor: MaterialStateProperty.all(AppColors.primaryColor),
fillColor: MaterialStateProperty.all(AppColors.primaryFourElementText),
side: BorderSide.none,
),
AppBar Chrome et intégration de la couche système
Cette section définit le style et le comportement au niveau système des barres d'application. AppBarTheme contrôle les couleurs et les tailles des icônes, le style du texte du titre, l'élévation et la transparence de l'arrière-plan, tandis que systemOverlayStyle garantit que la barre d'état s'intègre parfaitement au thème de l'application, maintenant la lisibilité et la cohérence visuelle sur tous les écrans.
appBarTheme: AppBarTheme(
iconTheme: const IconThemeData(
color: Colors.black,
size: AppSize.s40,
),
centerTitle: false,
color: Colors.transparent,
elevation: AppSize.s0,
titleTextStyle: getRegularStyle(
color: Colors.black,
fontSize: FontSize.s18,
),
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarBrightness: Brightness.dark,
statusBarIconBrightness: Brightness.dark,
),
),
Typographie
Cette section établit le système typographique de l'application. TextTheme définit les styles pour différents rôles de texte, tels que les titres et le corps du texte, y compris la taille de la police, la graisse et la couleur, garantissant un texte lisible, cohérent et aligné sur la marque sur tous les écrans.
textTheme: TextTheme(
displayLarge: getMediumStyle(
color: Colors.black,
fontSize: FontSize.s16,
),
bodySmall: getRegularStyle(
color: Colors.black,
fontSize: FontSize.s12,
),
bodyLarge: getRegularStyle(
color: Colors.black,
),
),
Conseils pratiques sur la structuration du code de thème dans un projet
C'est une bonne idée d'organiser la thématisation comme une préoccupation architecturale de premier plan en plaçant tout le code de thème dans un répertoire dédié, tel que lib/theme, avec des fichiers bien définis comme light_theme.dart, dark_theme.dart, theme_extensions.dart et theme_factory.dart. Vous pouvez encapsuler les définitions de jetons, les classes d'extension et les fonctions de mapping, et exporter un point d'entrée unique, app_theme.dart, pour une utilisation dans toute l'application. Vous devriez également garder les factories de thèmes pures et déterministes pour simplifier les tests.
Un système de thème Flutter mature n'est pas seulement visuel – il est aussi structurel. Il sépare l'intention de design (jetons) de l'implémentation (ThemeData) et de la consommation (widgets). Lorsqu'il est bien fait, le design peut évoluer sans refactoriser le code de l'UI. Mais lorsqu'il est mal fait, chaque refonte devient une réécriture.
Vous pouvez construire une base évolutive en vous appuyant sur ColorScheme et ThemeExtension au lieu d'un style dispersé, en centralisant les thèmes de composants et en prenant en charge les modes système, clair et sombre avec des transitions fluides. Vous devriez persister les préférences de l'utilisateur, honorer les exigences d'accessibilité comme le contraste et la mise à l'échelle du texte, et vérifier le comportement avec des tests golden et de widgets. C'est une bonne idée d'utiliser Flutter DevTools pour tracer l'héritage des thèmes et l'utilisation des couleurs.
Avec une structure réfléchie et une exécution disciplinée, votre système de thématisation devient une couche de design résiliente et pérenne qui évolue en toute confiance avec votre application et votre vision produit.
Erreurs courantes et comment les éviter
Coder en dur les couleurs, les tailles et les valeurs TextStyle directement à l'intérieur des widgets individuels rompt la cohérence visuelle et rend les changements futurs coûteux. Lorsque vous dispersez des codes de couleur ou des tailles de police dans des dizaines de fichiers, la mise à jour d'une seule couleur de marque devient un processus manuel sujet aux erreurs.
Un autre problème courant est de s'appuyer uniquement sur primaryColor sans définir un ColorScheme complet. Les widgets Material modernes dépendent de multiples rôles de couleur : primary, secondary, surface, onSurface, outline, et d'autres. Si ces champs ne sont pas définis correctement, les widgets reviennent aux valeurs par défaut, produisant des résultats incohérents ou inattendus sur les écrans.
Les développeurs rencontrent également des bugs subtils en appelant Theme.of(context) trop tôt dans le cycle de vie du widget — par exemple, à l'intérieur des constructeurs d'objets ou en dehors de l'arborescence des widgets. De même, supposer que les valeurs de thème se propagent automatiquement à travers des widgets Material indépendants peut prêter à confusion ; l'héritage ne s'applique qu'au sein de la même MaterialApp et de la même sous-arborescence de widgets.
Pour éviter ces problèmes, adoptez une approche axée sur le thème. Définissez vos jetons de design (couleurs, échelles typographiques, espacement, élévations), mappez-les à ThemeData, ColorScheme et à toutes les ThemeExtensions personnalisées, puis appliquez des surcharges uniquement là où le design l'exige spécifiquement. Cela garantit la cohérence, réduit la duplication et rend les futures mises à jour sans douleur.
Migrer une application existante vers un système de thème approprié
Commencez par auditer l'ensemble de votre application pour détecter les valeurs codées en dur : couleurs, tailles de police, styles de texte, paddings, styles de boutons, ombres et décorations de widgets personnalisés. Faites une liste des valeurs et des modèles répétés, puis convertissez-les en jetons de thème réutilisables ou en extensions personnalisées.
Ensuite, créez un ColorScheme bien structuré qui couvre tous les rôles de couleur Material. Remplacez les variables de couleur autonomes par ce schéma unifié et ajustez les widgets concernés en conséquence. Passez ensuite en revue chaque composant Material (AppBar, TextField, BottomNavigationBar, ElevatedButton, Card, etc.) et déplacez le style local vers leurs champs de thème spécifiques (appBarTheme, inputDecorationTheme, bottomNavigationBarTheme, etc.).
Au fur et à mesure de votre migration, testez votre UI sous les thèmes clair et sombre, avec une échelle de texte augmentée et sur différentes dimensions d'appareils pour vous assurer que votre thème se comporte de manière réactive et cohérente.
Adoptez une approche incrémentale : commencez par le ThemeData global (ColorScheme, Typographie), puis migrez les composants de base et les widgets partagés, et enfin affinez les écrans spécialisés. Cette méthode par étapes évite de casser de grandes sections de l'application d'un coup et rend la migration plus facile à maintenir et à réviser.
Conclusion
Maîtriser la thématisation dans Flutter va au-delà du simple choix des couleurs et des polices. Il s'agit de construire un système visuel évolutif qui progresse avec votre produit, renforce l'identité de la marque, améliore l'accessibilité et garantit un comportement cohérent sur toutes les plateformes.
Lorsqu'elle est bien faite, la thématisation devient une fondation plutôt qu'une réflexion après coup, assez puissante pour supporter de multiples formats, assez flexible pour gérer la personnalisation à l'exécution et assez structurée pour évoluer avec votre équipe de développement et votre feuille de route de fonctionnalités.
À mesure que Flutter continue de mûrir, son écosystème de design évoluera également, et les développeurs qui comprennent profondément l'architecture des thèmes, les extensions, les principes Material et les considérations de performance seront positionnés pour créer des expériences soignées et prêtes pour l'avenir. Traitez donc votre thème comme un système de design vivant — affinez-le avec vos designers, testez-le comme une logique métier centrale et laissez-le guider votre UI, et non l'inverse.
Avec une structure réfléchie et une application attentive, vos applications Flutter ne seront pas seulement belles, mais elles paraîtront cohérentes, fonctionneront de manière fluide et s'adapteront gracieusement à travers les appareils et les contextes utilisateur.