State
Bisher zeigt die App nur feste Daten an. Jetzt soll die Oberfläche
auf Eingaben reagieren: Ein Umschalter in der AppBar wechselt die
Temperaturanzeige zwischen °C und °F.
Code: schritt-05 ·
Änderungen
Erster Versuch
Eine normale Variable reicht nicht: Antippen ändert den Wert, aber die Anzeige bleibt gleich.
@override
Widget build(BuildContext context) {
var useCelsius = true; // funktioniert nicht!
return TextButton(
onPressed: () => useCelsius = !useCelsius,
child: Text(useCelsius ? '°C' : '°F'),
);
}Flutter weiß nicht, dass sich useCelsius geändert hat, und ruft
build deshalb nicht erneut auf. Selbst wenn, stünde useCelsius
danach wieder auf true, weil build von vorne ausgeführt wird.
Auch ein Feld im StatelessWidget hilft nicht: Widgets sind
unveränderlich, der Analyzer warnt bei Feldern ohne final
(must_be_immutable).
StatefulWidget
Ein StatefulWidget besteht aus zwei Klassen: dem Widget und seinem State, der über build hinweg erhalten bleibt.
class WeatherScreen extends StatefulWidget {
const WeatherScreen({super.key, required this.city, required this.weather});
// final-Felder city und weather ...
@override
State<WeatherScreen> createState() => _WeatherScreenState();
}
class _WeatherScreenState extends State<WeatherScreen> {
bool _useCelsius = true;
// build(...) wie bisher, Zugriff auf Felder des Widgets mit widget.city
}Den Umbau von WeatherScreen machen wir einmal von Hand, um zu sehen,
was dabei passiert.
1. WeatherScreen erbt von StatefulWidget statt von
StatelessWidget und bekommt die Methode createState. Die
build-Methode zunächst ausschneiden, sie zieht gleich um:
class WeatherScreen extends StatefulWidget {
const WeatherScreen({super.key, required this.city, required this.weather});
final City city;
final CurrentWeather weather;
@override
State<WeatherScreen> createState() => _WeatherScreenState();
}Ein StatefulWidget hat selbst keine build-Methode. createState
ruft Flutter einmal auf, wenn das Widget in den Baum kommt.
2. Darunter die Klasse _WeatherScreenState anlegen und build
dort einfügen:
class _WeatherScreenState extends State<WeatherScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.city.name)),
body: SingleChildScrollView(
child: CurrentWeatherCard(city: widget.city, weather: widget.weather),
),
);
}
}3. city und weather sind jetzt Felder einer anderen Klasse.
Der Analyzer meldet deshalb Fehler, bis jeder Zugriff widget.
voranstellt: widget.city, widget.weather. widget ist das aktuelle
Widget, zu dem dieser State gehört.
4. Erst jetzt kommt der eigentliche Zustand dazu: das Feld
bool _useCelsius = true; in _WeatherScreenState. Wie es geändert
wird, zeigt der nächste Abschnitt.
Der Unterstrich in _WeatherScreenState und _useCelsius macht beides
privat für die Datei. Das Widget selbst bleibt unveränderlich. Den
veränderlichen Zustand hält das State-Objekt, das Flutter einmal
erzeugt und bei jedem neuen build wiederverwendet. Damit übernimmt es
die Rolle von remember in Compose.
Wer den Umbau einmal von Hand gemacht hat, kann künftig den Quick Fix
nutzen: Cursor auf StatelessWidget, ⌘ . (Windows/Linux: Strg + .) und
Convert to StatefulWidget. Er erledigt die Schritte 1 bis 3.
setState
setState ändert den Zustand und sagt Flutter, dass build erneut aufgerufen werden muss.
TextButton(
onPressed: () => setState(() => _useCelsius = !_useCelsius),
child: Text(_useCelsius ? '°C' : '°F'),
)Merksatz wie bei Compose: UI = f(State). Die Oberfläche ist eine Funktion des Zustands; ändert sich der State, wird sie neu berechnet.
Das erneute Bauen lässt sich beobachten: in build von
CurrentWeatherCard die Zeile debugPrint('build CurrentWeatherCard');
einfügen und die Debug Console von VS Code öffnen. Jedes Umschalten
erzeugt eine neue Zeile. Anders als Compose überspringt Flutter dabei
nicht automatisch unveränderte Kinder: setState baut den ganzen
Teilbaum unterhalb dieses State neu. Ausgenommen sind nur
const-Widgets. Das klingt teuer, ist es aber nicht: Widgets sind
leichte Beschreibungen, gezeichnet wird nur, was sich wirklich geändert
hat.
Beim Drehen des Bildschirms bleibt der State erhalten, ein Gegenstück zu
rememberSaveable braucht es dafür nicht. Flutter verarbeitet die
Drehung selbst, statt die Activity neu zu erzeugen. Im Emulator
ausprobieren: auf °F schalten und das Gerät drehen.
Lifting State Up
Den State dort halten, wo alle ihn brauchen, und ihn nach unten weitergeben. Änderungen kommen als Callbacks zurück nach oben.
Der Umschalter (UnitToggle) und die Karte (CurrentWeatherCard)
brauchen beide useCelsius. Deshalb liegt der State im gemeinsamen
Eltern-Widget WeatherScreen. In Compose heißt das Muster State
Hoisting, in Flutter Lifting State Up; das Prinzip dahinter ist
dasselbe: State fließt nach unten, Events nach oben.
bool _useCelsius = true;
UnitToggle(
useCelsius: _useCelsius, // State ↓
onToggle: () => setState(() => _useCelsius = !_useCelsius), // Event ↑
),
CurrentWeatherCard(city: …, weather: …, useCelsius: _useCelsius), // State ↓Vollständig in _WeatherScreenState: UnitToggle steckt in den
actions der AppBar, die Karte im body des Scaffold.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.city.name),
actions: [
UnitToggle(
useCelsius: _useCelsius,
onToggle: () => setState(() => _useCelsius = !_useCelsius),
),
],
),
body: SingleChildScrollView(
child: CurrentWeatherCard(
city: widget.city,
weather: widget.weather,
useCelsius: _useCelsius,
),
),
);
}CurrentWeatherCard bekommt dafür ein neues Feld useCelsius und
zeigt die Temperatur mit formatTemperature an (siehe unten):
Code: CurrentWeatherCard in lib/ui/weather_screen.dart (schritt-05)
CurrentWeatherCard in lib/ui/weather_screen.dart (schritt-05)class CurrentWeatherCard extends StatelessWidget {
const CurrentWeatherCard({
super.key,
required this.city,
required this.weather,
required this.useCelsius,
});
final City city;
final CurrentWeather weather;
final bool useCelsius;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFFBBDEFB),
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(city.name, style: const TextStyle(fontSize: 28)),
Text(weather.condition.emoji, style: const TextStyle(fontSize: 72)),
Text(
formatTemperature(weather.temperature, useCelsius: useCelsius),
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 16),
Row(
children: [
Text('Wind: ${weather.windSpeed.round()} km/h'),
const Spacer(),
Text(weather.condition.label),
],
),
],
),
);
}
}UnitToggle steht in derselben Datei, zwischen
_WeatherScreenState und CurrentWeatherCard. Es ist
stateless, bekommt den aktuellen Wert und meldet das Antippen über
onToggle:
class UnitToggle extends StatelessWidget {
const UnitToggle({super.key, required this.useCelsius, required this.onToggle});
final bool useCelsius;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onToggle,
child: Text(useCelsius ? '°C' : '°F'),
);
}
}VoidCallback ist der Typ einer Funktion ohne Parameter und
Rückgabewert, wie () -> Unit in Kotlin.
Die Umrechnung steckt in einer Hilfsfunktion am Ende der Datei, außerhalb der Extension:
String formatTemperature(double celsius, {required bool useCelsius}) =>
useCelsius
? '${celsius.round()} °C'
: '${(celsius * 9 / 5 + 32).round()} °F';useCelsius ist hier ein benannter Parameter. So ist beim Aufruf klar,
was true bedeutet.
Übung: Zähler
In DartPad einen Zähler bauen:
- eine große Zahl, darunter Buttons „+1“, „+10“ und „Zurücksetzen“
- Bonus: Button „−1“, der nicht unter 0 zählt
- Bonus: die Buttons in ein eigenes
StatelessWidgetCounterButtonsauslagern
DartPad öffnet mit einem Dart-Beispiel; über Samples gibt es Flutter-Beispiele, deren Code wir ersetzen. Die Vorschau erscheint rechts. Übrigens: Die Vorlage Application von Flutter: New Project ist genau so ein Zähler.
Bei der zweiten Bonus-Aufgabe kommt Lifting State Up ins Spiel: Der State
bleibt im StatefulWidget, CounterButtons bekommt nur Callbacks.
Lösung
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: CounterScreen()));
}
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0;
void _add(int amount) {
setState(() => _count = max(0, _count + amount));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$_count', style: const TextStyle(fontSize: 96)),
CounterButtons(
onAdd: _add,
onReset: () => setState(() => _count = 0),
),
],
),
),
);
}
}
class CounterButtons extends StatelessWidget {
const CounterButtons({super.key, required this.onAdd, required this.onReset});
final ValueChanged<int> onAdd;
final VoidCallback onReset;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
FilledButton(onPressed: () => onAdd(-1), child: const Text('−1')),
FilledButton(onPressed: () => onAdd(1), child: const Text('+1')),
FilledButton(onPressed: () => onAdd(10), child: const Text('+10')),
OutlinedButton(onPressed: onReset, child: const Text('Zurücksetzen')),
],
);
}
}max aus dart:math verhindert negative Werte. ValueChanged<int> ist
der Typ eines Callbacks mit einem int-Parameter, wie (Int) -> Unit in
Kotlin. _add passt dazu und wird ohne Klammern übergeben. Wrap setzt
die Buttons nebeneinander und bricht um, wenn der Platz nicht reicht.
Zusammenfassung
State in Flutter: StatefulWidget mit einem State-Objekt, das den Wert
behält, setState zum Ändern, State so weit oben wie nötig, Events als
Callbacks nach oben.
| Compose | Flutter |
|---|---|
remember { mutableStateOf(…) } | Feld im State eines StatefulWidget |
Zuweisung useCelsius = … | setState(() => _useCelsius = …) |
| Recomposition | erneuter Aufruf von build |
rememberSaveable | beim Drehen nicht nötig |
| State Hoisting | Lifting State Up |
() -> Unit | VoidCallback |
Für einen einzelnen Schalter reicht setState. In der nächsten Lektion
wandert der Zustand des ganzen Screens eine Ebene höher: in ein
ViewModel.
Weiterführendes Material
- Interaktivität und State
- StatefulWidget : API-Dokumentation mit Lebenszyklus
- Lifting State Up