Flutter: Helles und dunkles Theme mit ThemeData
8 Min. Lesezeit

Schreiben Sie die Button-Farbe, die Schriftgröße der Überschrift oder den Eckenradius der Karte in jedem Widget einzeln, müssen Sie bei der ersten Designänderung Dutzende Dateien anfassen. Und wenn ein dunkles Theme dazukommen soll, stecken Sie fest. ThemeData bündelt diese Entscheidungen an einer Stelle: Farben, Textstile und das Standardaussehen der Komponenten. Lesen Widgets ihre Werte aus dem Theme, statt sie fest zu verdrahten, gibt es das dunkle Theme fast geschenkt. In diesem Beitrag geht es um den Aufbau eines Themes mit Material 3, helles und dunkles Theme, das Auslesen von Werten aus dem Theme, Komponenten-Themes und eigene Designwerte im Theme.
Ein Farbschema mit ColorScheme.fromSeed
In Material 3 (seit Flutter 3.16 Standard) kommen die Farben aus einem ColorScheme. Statt jede Farbe einzeln zu wählen, geben Sie eine „Seed“-Farbe vor, und ColorScheme.fromSeed erzeugt daraus das gesamte Schema in abgestimmten Tönen:
import 'package:flutter/material.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
),
themeMode: ThemeMode.system,
home: const HomePage(),
);
}
}Weil das dunkle Schema mit brightness: Brightness.dark aus derselben Seed-Farbe entsteht, wirken beide Themes wie Mitglieder derselben Familie. Jede Farbe im Schema hat eine Rolle, und Widgets werden nach diesen Rollen eingefärbt:
| Rolle | Wo sie verwendet wird |
|---|---|
primary / onPrimary |
Hauptaktion: gefüllter Button, ausgewählter Zustand; on... ist Text und Icon darauf |
primaryContainer / onPrimaryContainer |
Betonte, aber weichere Flächen, hervorgehobene Karten |
secondary, tertiary (+ ihre Container) |
Zweitakzente und ausgleichende Farben |
surface / onSurface |
Seiten- und Kartenhintergrund, Haupttext darauf |
onSurfaceVariant |
Sekundärer Text, Beschreibungen |
surfaceContainerLow ... surfaceContainerHighest |
Schicht für Schicht ansteigende Flächentöne |
outline, outlineVariant |
Rahmen und Trennlinien |
error / onError |
Fehlerzustände |
Die Regel ist einfach: Verwenden Sie eine Farbe als Hintergrund, wählen Sie für den Text darauf ihr on...-Gegenstück. So bleibt der Kontrast in beiden Themes erhalten.
Die Seed-Farbe muss nicht exakt als primary erscheinen; fromSeed kann sie nach dem Tonsystem von Material abschwächen. Soll Ihre Markenfarbe originalgetreu sichtbar sein, hält der Parameter dynamicSchemeVariant: DynamicSchemeVariant.fidelity die Paletten näher an der Seed-Farbe. Wollen Sie eine einzelne Rolle exakt festlegen, nimmt fromSeed diese Rolle auch direkt als Parameter an (zum Beispiel primary: brandColor).
ThemeMode: System, hell, dunkel
Drei Felder von MaterialApp arbeiten zusammen: theme ist das helle Theme, darkTheme das dunkle, und themeMode entscheidet, welches verwendet wird. ThemeMode.system folgt der Geräteeinstellung; stellt der Nutzer das Handy auf den Dunkelmodus um, wechselt auch die App. ThemeMode.light und ThemeMode.dark erzwingen unabhängig vom System eines der beiden. Geben Sie kein darkTheme an, wird unabhängig von themeMode immer theme verwendet. Bieten Sie dem Nutzer eine Auswahl an, erscheinen diese drei Optionen meist gemeinsam; den Standard auf system zu lassen, respektiert die Einstellung, die der Nutzer für das ganze Gerät getroffen hat.
Das Theme mit Theme.of(context) auslesen
Das Theme zu definieren, ist die halbe Arbeit; der eigentliche Gewinn entsteht, wenn Widgets ihre Werte daraus lesen:
class InfoCard extends StatelessWidget {
const InfoCard({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Heutiges Ziel',
style: text.titleMedium?.copyWith(color: colors.onPrimaryContainer),
),
const SizedBox(height: 4),
Text(
'3 Lektionen, 45 Minuten',
style: text.bodyMedium?.copyWith(
color: colors.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
);
}
}In dieser Karte steht keine einzige feste Farbe; ändert sich das Theme, ändert sich die Karte mit. Seit Flutter 3.27 gibt es außerdem die Abkürzungen ColorScheme.of(context) und TextTheme.of(context), die dieselben Werte liefern. Um die Transparenz einer Farbe zu ändern, verwenden Sie withValues(alpha: ...) statt des alten withOpacity.
Ein Widget, das Theme.of(context) aufruft, abonniert das Theme: Ändert sich das Theme, baut Flutter dieses Widget automatisch neu, ohne dass Sie setState aufrufen müssen. Die Suche läuft im Baum nach oben bis zum nächsten Theme. So können Sie das Theme nur für einen Teil der App ändern:
Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Theme.of(context).brightness,
),
),
child: FilledButton(onPressed: () {}, child: const Text('Konto löschen')),
)TextTheme: Textstile
In Material 3 bestehen die Textstile aus fünf Gruppen mit je drei Größen:
| Gruppe | Größen | Typische Verwendung |
|---|---|---|
display |
Large, Medium, Small | Sehr große, kurze Texte (Zähler, Begrüßung) |
headline |
Large, Medium, Small | Seiten- und Abschnittsüberschriften |
title |
Large, Medium, Small | Kartentitel, AppBar, Listenüberschriften |
body |
Large, Medium, Small | Absätze und allgemeiner Text |
label |
Large, Medium, Small | Button-Texte, kleine Beschriftungen |
Stile lesen Sie wie Theme.of(context).textTheme.titleLarge. Übergeben Sie dem Theme ein textTheme, ändern sich nur die angegebenen Felder; der Rest wird mit den Standardwerten zusammengeführt. Um die Schriftart der ganzen App zu ändern, genügt ThemeData(fontFamily: 'Inter'); die Schrift muss vorher in pubspec.yaml eingetragen sein. Alte Namen wie headline6 und bodyText1 gibt es nicht mehr; ihre Entsprechungen sind die neuen Namen wie titleLarge und bodyLarge.
Komponenten-Themes: Standardwerte an einer Stelle
ThemeData hat für jede Komponente ein eigenes Theme-Feld: appBarTheme, filledButtonTheme, inputDecorationTheme, cardTheme, snackBarTheme und weitere. Damit Sie dieselben Entscheidungen für helles und dunkles Theme nicht doppelt schreiben, ist es eine gute Gewohnheit, das Theme in einer Funktion zu erzeugen:
ThemeData buildTheme(Brightness brightness) {
final colors = ColorScheme.fromSeed(
seedColor: const Color(0xFF3F51B5),
brightness: brightness,
);
return ThemeData(
colorScheme: colors,
textTheme: const TextTheme(
headlineSmall: TextStyle(fontWeight: FontWeight.w700),
titleMedium: TextStyle(fontWeight: FontWeight.w600),
),
appBarTheme: const AppBarTheme(centerTitle: false),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
filled: true,
),
cardTheme: CardThemeData(
elevation: 0,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
),
),
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
);
}Jetzt ist jeder FilledButton der App volle Breite mit 12 Pixel abgerundeten Ecken, und jedes Textfeld hat Rahmen und Füllung. Braucht eine Stelle ein anderes Aussehen, überschreibt der eigene Parameter des Widgets das Theme. Die Theme-Felder dieser Komponenten zeige ich ausführlich in den Beiträgen zu AppBar und NavigationBar.
Was gehört ins Theme, was ins Widget?
Jeden Wert ins Theme zu verschieben, erzeugt ein eigenes Durcheinander. Eine praktische Regel, die sich im Unterricht bewährt: Wiederholt sich dieselbe visuelle Entscheidung an zwei oder mehr Stellen, gehört sie ins Theme; betrifft sie nur einen Screen, gehört sie in den Parameter des Widgets. Eckenradius der Buttons, Rahmen der Textfelder und Kartenhintergrund sollten überall in der App gleich sein und stehen deshalb im Theme. Die Farbe einer einmaligen großen Überschrift auf dem Willkommens-Screen darf dort bleiben, solange es weiterhin eine Rolle aus colorScheme ist. Abstände (8, 16, 24 usw.) haben in ThemeData kein fertiges Feld; sie als Konstanten oder in einer ThemeExtension wie unten zu halten, verhindert, dass Zahlen wahllos im Code verstreut werden.
Barrierefreiheit: hoher Kontrast
Mit dem Parameter contrastLevel von ColorScheme.fromSeed passen Sie den Kontrast an: 0 ist der Standard, mittlerer und hoher Kontrast aus den Material-Richtlinien entsprechen 0.5 und 1.0. Die Felder highContrastTheme und highContrastDarkTheme von MaterialApp greifen, wenn der Nutzer im Betriebssystem die Einstellung für erhöhten Kontrast aktiviert (zum Beispiel unter iOS). Beides lässt sich leicht kombinieren: Ergänzen Sie dieselbe Funktion buildTheme um einen Kontrastparameter und erzeugen Sie auch die kontrastreichen Themes dort.
Eigene Designwerte: ThemeExtension
ColorScheme kennt keine Rollen wie „Erfolg“ oder „Warnung“. Statt solche Werte als feste Farben zu verstreuen, können Sie sie mit ThemeExtension ins Theme aufnehmen; dann wechseln auch sie mit hellem und dunklem Theme:
@immutable
class StatusColors extends ThemeExtension<StatusColors> {
const StatusColors({required this.success, required this.warning});
final Color success;
final Color warning;
static const light = StatusColors(
success: Color(0xFF2E7D32),
warning: Color(0xFF8D6E00),
);
static const dark = StatusColors(
success: Color(0xFF81C784),
warning: Color(0xFFFFD54F),
);
@override
StatusColors copyWith({Color? success, Color? warning}) {
return StatusColors(
success: success ?? this.success,
warning: warning ?? this.warning,
);
}
@override
StatusColors lerp(StatusColors? other, double t) {
if (other == null) return this;
return StatusColors(
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
);
}
}Ins Theme kommt sie als extensions: [StatusColors.light] (im dunklen Theme StatusColors.dark), im Widget lesen Sie sie mit Theme.of(context).extension<StatusColors>()!.success. Die Methode lerp sorgt dafür, dass die Farben beim Theme-Wechsel weich überblenden.
Mini-Szenario: Theme-Auswahl mit Vorschau
Jetzt setzen wir die Teile zusammen: Wir ergänzen die Funktion buildTheme von oben um die Zeile extensions und bauen einen Screen, auf dem der Nutzer zwischen System, Hell und Dunkel wählt und das Ergebnis sofort sieht.
// Die Zeile, die im ThemeData von buildTheme ergänzt wird:
// extensions: [
// brightness == Brightness.light ? StatusColors.light : StatusColors.dark,
// ],
void main() => runApp(const ThemeDemoApp());
class ThemeDemoApp extends StatefulWidget {
const ThemeDemoApp({super.key});
@override
State<ThemeDemoApp> createState() => _ThemeDemoAppState();
}
class _ThemeDemoAppState extends State<ThemeDemoApp> {
final _themeMode = ValueNotifier(ThemeMode.system);
@override
void dispose() {
_themeMode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: _themeMode,
builder: (context, mode, child) => MaterialApp(
theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark),
themeMode: mode,
home: ThemePreviewPage(themeMode: _themeMode),
),
);
}
}
class ThemePreviewPage extends StatelessWidget {
const ThemePreviewPage({super.key, required this.themeMode});
final ValueNotifier<ThemeMode> themeMode;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
final status = Theme.of(context).extension<StatusColors>()!;
return Scaffold(
appBar: AppBar(title: const Text('Darstellung')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Theme', style: text.titleMedium),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.system, label: Text('System')),
ButtonSegment(value: ThemeMode.light, label: Text('Hell')),
ButtonSegment(value: ThemeMode.dark, label: Text('Dunkel')),
],
selected: {themeMode.value},
onSelectionChanged: (selection) => themeMode.value = selection.first,
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Wochenübersicht', style: text.headlineSmall),
const SizedBox(height: 8),
Text(
'Diese Woche hast du an 5 Tagen gelernt.',
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text('Ziel erreicht', style: TextStyle(color: status.success)),
],
),
),
),
const SizedBox(height: 16),
const TextField(decoration: InputDecoration(labelText: 'Notiz hinzufügen')),
const SizedBox(height: 16),
FilledButton(onPressed: () {}, child: const Text('Speichern')),
],
),
);
}
}Auf diesem Screen steht bei keinem Widget eine Farbe oder ein Eckenradius. Karte, Textfeld und Button holen ihr Aussehen aus den Komponenten-Themes in buildTheme, die Texte aus textTheme und „Ziel erreicht“ aus StatusColors. Tippt der Nutzer auf Dunkel, ändert sich der ValueNotifier, MaterialApp wird neu aufgebaut, und alles wechselt ins dunkle Theme. MaterialApp blendet kurz animiert zwischen den beiden Themes über; StatusColors.lerp liefert dabei die Zwischenfarben.
In diesem Beispiel geht die Auswahl verloren, wenn die App geschlossen wird. Um sie dauerhaft zu machen, verwenden Sie statt eines ValueNotifier einen Controller, der den Wert auf die Festplatte schreibt; die vollständige Fassung dieses Aufbaus, die den ThemeMode speichert, zeigt der Beitrag lokale Datenspeicherung mit SharedPreferences. Können mehrere Screens die Theme-Einstellung ändern, ist ein ChangeNotifier mit Provider die Weiterführung derselben Idee.
Häufige Fehler
1. Feste Farben verwenden
Symptom: Ein Screen, der im hellen Theme gut aussieht, ist im dunklen Theme voller greller weißer Kästen und unlesbarer Texte. Ursache sind feste Werte wie Colors.white und Colors.black. Verwenden Sie für Hintergründe surface oder einen surfaceContainer-Ton und für Text onSurface, sehen beide Themes richtig aus. Der Dark-Theme-Fehler im Card-Beitrag hat dieselbe Ursache. Für Icons gilt dasselbe: Statt color: Colors.black87 an ein Icon zu schreiben, lassen Sie die Farbe weg; das Icon übernimmt dann die passende Farbe aus dem Theme der Komponente, in der es steht, in der AppBar eine andere als in einer Karte.
2. Farben über primarySwatch setzen
Das in älteren Beispielen verbreitete ThemeData(primarySwatch: Colors.green) ändert in Material 3 das Farbschema nicht; Buttons bleiben in den violetten Standardtönen. In Material 3 werden Farben über colorScheme (oder die Abkürzung colorSchemeSeed) gesetzt.
3. brightness und colorScheme passen nicht zusammen
ThemeData(brightness: Brightness.dark, colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo)) wirft diesen Assertion-Fehler:
ThemeData.brightness does not match ColorScheme.brightness.fromSeed erzeugt standardmäßig ein helles Schema. Für ein dunkles Theme übergeben Sie brightness: Brightness.dark an fromSeed; an ThemeData müssen Sie brightness nicht zusätzlich setzen.
4. Theme.of(context) mit einem Context oberhalb von MaterialApp aufrufen
Rufen Sie Theme.of(context) in der build-Methode auf, die MaterialApp zurückgibt, liegt dieser context noch nicht innerhalb von MaterialApp, und Sie erhalten das Standard-Theme. Verschieben Sie den Code, der das Theme braucht, in ein Widget unter home, oder holen Sie sich mit einem Builder einen neuen context.
5. Veraltete Namen
In ColorScheme sind background, onBackground und surfaceVariant veraltet; an ihre Stelle treten surface, onSurface und surfaceContainerHighest. Statt withOpacity verwenden Sie withValues(alpha: ...), statt der alten TextTheme-Namen die Material-3-Namen. Ignorieren Sie die Analyzer-Warnungen nicht; die meisten nennen den neuen Namen direkt.
Häufig gestellte Fragen
Warum blitzt beim Start im Dunkelmodus kurz ein weißer Screen auf?
Das ist der native Startbildschirm, der gezeichnet wird, bevor Flutter läuft, und ThemeData hat keinen Einfluss darauf. Unter Android legt ihn das Drawable launch_background fest, unter iOS das LaunchScreen.storyboard; für den Dunkelmodus können Sie unter Android ein eigenes Drawable im Ordner drawable-night ablegen oder die Aufgabe einem Paket wie flutter_native_splash überlassen. Speichern Sie die Theme-Wahl des Nutzers auf der Festplatte, verhindert das Einlesen vor runApp außerdem, dass im ersten Frame das falsche Theme erscheint.
Aktualisiert sich die App automatisch, wenn sich das System-Theme ändert?
Ja, mit themeMode: ThemeMode.system. MaterialApp beobachtet die Helligkeitseinstellung der Plattform und wechselt zu darkTheme, sobald der Nutzer das Gerät in den Dunkelmodus schaltet; Sie müssen selbst nichts beobachten.
Können verschiedene Bereiche derselben App unterschiedliche Farben haben?
Ja. Es genügt, den betreffenden Teilbaum mit Theme(data: Theme.of(context).copyWith(...), child: ...) zu umschließen. Die Widgets darin sehen über Theme.of(context) das neue Theme, alles außerhalb bleibt unberührt. Typisch ist das für Buttons mit zerstörerischen Aktionen, die in einem roten Schema erscheinen.
Verwandte Artikel
Flutter: BottomSheet und showModalBottomSheet
showModalBottomSheet in Flutter: Werte zurückgeben, isScrollControlled, useSafeArea, showDragHandle, Tastatur, DraggableScrollableSheet und persistente Sheets.
Flutter: BottomNavigationBar im Vergleich zu NavigationBar
Von BottomNavigationBar zur Material-3-NavigationBar: Zuordnungstabelle, labelBehavior, NavigationBarThemeData, Entscheidungshilfe und adaptive Shell.
Flutter: DropdownButton verwenden und Eigenschaften
DropdownButton und DropdownButtonFormField in Flutter, das Material-3-DropdownMenu, abhängige Dropdowns und die Lösung des value-Fehlers.