Flutter: TextField und TextEditingController verwenden
9 Min. Lesezeit

TextField ist das grundlegende Widget, um Text vom Nutzer entgegenzunehmen: ein Suchfeld, eine Chat-Eingabe, eine Telefonnummer, ein Passwort. Ein Feld auf den Screen zu setzen, ist einfach; die eigentliche Arbeit besteht darin, den Text zu lesen und zu verwalten, die passende Tastatur zu öffnen, den Fokus zwischen Feldern zu bewegen und falsche Eingaben von vornherein zu verhindern. In diesem Beitrag bauen wir alles auf, was ein Textfeld braucht, vom Lebenszyklus des TextEditingController bis zum FocusNode, von inputFormatters bis zum Passwortfeld, und setzen am Ende alles in einem Registrierungs-Screen zusammen. Validierung und gemeinsames Absenden mehrerer Felder sind Thema des Form-Beitrags; hier geht es um das Feld, auf dem das aufbaut.
Grundlegende Verwendung: TextEditingController
Der Standardweg, um aus dem Code an den Text eines Feldes zu kommen, ist ein TextEditingController. Weil der Controller ein Objekt ist, das Listener hält, wird er einmal in der State-Klasse erzeugt und in dispose() geschlossen:
import 'package:flutter/material.dart';
class NameInput extends StatefulWidget {
const NameInput({super.key});
@override
State<NameInput> createState() => _NameInputState();
}
class _NameInputState extends State<NameInput> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose(); // Selbst erzeugt, also selbst schließen
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Ihr Name'),
),
FilledButton(
onPressed: () {
final name = _controller.text.trim();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hallo, $name')),
);
},
child: const Text('Begrüßen'),
),
],
);
}
}Die dispose-Regel gilt nicht nur für TextEditingController, sondern für alles, was Sie selbst erzeugen, etwa FocusNode, AnimationController und ScrollController. Warum wir über initState und dispose gehen, erkläre ich ausführlich im Beitrag zum Widget-Lebenszyklus.
Was kann man mit dem Controller machen?
Der Controller dient nicht nur zum Lesen, sondern auch dazu, das Feld aus dem Code zu steuern:
// Lesen
final current = controller.text;
// Leeren
controller.clear();
// Anfangstext vorgeben
final cityController = TextEditingController(text: 'München');
// Text ändern und den Cursor ans Ende setzen
const newText = 'Hamburg';
controller.value = const TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: newText.length),
);
// Gesamten Text markieren
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);controller.text = 'Hamburg' ändert den Text ebenfalls, setzt aber die Auswahl (die Cursorposition) auf einen ungültigen Wert zurück. Ist wichtig, wo der Cursor landet, setzen Sie wie oben value mit Text und Auswahl zusammen.
TextEditingController ist eigentlich ein ValueNotifier<TextEditingValue> und lässt sich daher beobachten. So können Sie bei jeder Änderung nur den betroffenen Teil neu zeichnen. Ein Beispiel ist ein „Leeren“-Button, der nur erscheint, wenn Text vorhanden ist:
ListenableBuilder(
listenable: controller,
builder: (context, child) => TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Suchen',
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: controller.clear,
),
),
),
)Ein Detail: Controller-Listener feuern nicht nur bei Textänderungen, sondern auch, wenn sich Cursor oder Auswahl bewegen. Lautet Ihre Frage „Hat sich der Text geändert?“, müssen Sie den vorherigen Wert speichern und vergleichen.
InputDecoration: das Aussehen des Feldes
Alles rund um das Feld, also Label, Hinweis, Icons und Fehlertext, wird über InputDecoration gesetzt:
const TextField(
decoration: InputDecoration(
labelText: 'E-Mail',
hintText: '[email protected]',
helperText: 'Die Quittung geht an diese Adresse',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
)| Eigenschaft | Wofür? |
|---|---|
labelText |
Label im leeren Feld, rutscht bei Fokus nach oben |
hintText |
Beispieltext, solange das Feld leer ist |
helperText |
Kleine Erklärung unter dem Feld |
errorText |
Roter Fehlertext darunter; ist er nicht null, wechselt das Feld in die Fehleransicht |
prefixIcon, suffixIcon |
Icon oder Button am Anfang und Ende des Feldes |
prefixText, suffixText |
Fester Text, zum Beispiel +49 oder EUR |
border |
OutlineInputBorder (Rahmen) oder UnderlineInputBorder (Unterstrich) |
counterText |
Text des maxLength-Zählers; '' blendet den Zähler aus |
labelText oder hintText? Ganz einfach: hintText verschwindet, sobald der Nutzer tippt, labelText rutscht nach oben und bleibt sichtbar. Was das Feld ist, gehört in labelText, ein Formatbeispiel in hintText.
Mit errorText können Sie auch ohne Form Fehler anzeigen: Halten Sie eine String?-Zustandsvariable, weisen Sie ihr das Prüfergebnis zu und übergeben Sie InputDecoration(errorText: _usernameError).
Die Tastatur: keyboardType und textInputAction
keyboardType bestimmt, welche Tastatur sich auf dem Handy öffnet, textInputAction die Taste unten rechts:
keyboardType |
Verwendung |
|---|---|
TextInputType.emailAddress |
@ und Punkt stehen im Vordergrund |
TextInputType.number |
Zifferntastatur |
TextInputType.numberWithOptions(decimal: true) |
Zifferntastatur mit Dezimaltrennzeichen |
TextInputType.phone |
Telefontastatur |
TextInputType.url |
Tasten wie / und . stehen im Vordergrund |
TextInputType.multiline |
Enter fügt eine neue Zeile ein (mit maxLines: null) |
Die häufigsten Werte für textInputAction sind next (zum nächsten Feld), done, search und send. Das Standardverhalten von Flutter: Bei next springt der Fokus zum nächsten Feld in Lesereihenfolge; abschließende Tasten wie done, search und send geben den Fokus ab und schließen die Tastatur.
Wichtig: keyboardType ändert nur das Aussehen der Tastatur, es schränkt die Eingabe nicht ein. Der Nutzer kann einfügen oder auf Tablet und Desktop mit einer Hardwaretastatur jedes Zeichen tippen. Die Eingabe wirklich zu begrenzen, ist Aufgabe von inputFormatters.
inputFormatters: falsche Eingaben von vornherein verhindern
Formatter filtern jede Änderung, bevor der Text ins Feld geschrieben wird. Die eingebauten kommen aus package:flutter/services.dart:
import 'package:flutter/services.dart';
// Nur Ziffern, höchstens 10
TextField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
)
// Benutzername ohne Leerzeichen
TextField(
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
)
// Mehrzeiliges Notizfeld mit Zähler
const TextField(
maxLength: 280,
maxLines: null,
keyboardType: TextInputType.multiline,
)Mit FilteringTextInputFormatter.allow lassen Sie nur Zeichen zu, die einem bestimmten Muster entsprechen. Der Unterschied zwischen maxLength und LengthLimitingTextInputFormatter liegt in der Sichtbarkeit: Ersteres begrenzt die Länge und zeigt unter dem Feld einen Zähler wie 12/280, Letzteres begrenzt still. Formatter laufen vor onChanged; der Wert, den onChanged erhält, ist also bereits gefiltert.
Passwortfelder: obscureText
In einem Passwortfeld werden die Zeichen mit obscureText: true verborgen. Ein Anzeigen/Verbergen-Button, damit Nutzer ihre Eingabe prüfen können, ist eine gute Gewohnheit:
class PasswordField extends StatefulWidget {
const PasswordField({super.key, required this.controller});
final TextEditingController controller;
@override
State<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
bool _obscure = true;
@override
Widget build(BuildContext context) {
return TextField(
controller: widget.controller,
obscureText: _obscure,
enableSuggestions: false,
autocorrect: false,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
labelText: 'Passwort',
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
tooltip: _obscure ? 'Passwort anzeigen' : 'Passwort verbergen',
onPressed: () => setState(() => _obscure = !_obscure),
),
),
);
}
}enableSuggestions: false und autocorrect: false verhindern, dass die Tastatur das Passwort als Wortvorschlag lernt oder korrigiert. autofillHints teilt dem Passwortmanager des Betriebssystems mit „Das ist ein Passwortfeld“; so funktioniert das automatische Ausfüllen gespeicherter Passwörter.
onChanged, onSubmitted und onEditingComplete
Alle drei Callbacks melden „Der Nutzer hat etwas getan“, aber zu unterschiedlichen Zeitpunkten:
onChanged: bei jedem Tastendruck, Löschen und Einfügen. Für Live-Suchfilter, Zeichenzähler oder das Aktivieren eines Buttons.onSubmitted: wenn der Nutzer die Aktionstaste der Tastatur drückt (fertig, suchen, senden), mit dem finalen Wert des Feldes. Zum Starten einer Suche, Senden einer Nachricht oder Wechseln zum nächsten Feld.onEditingComplete: bei der Aktionstaste, voronSubmittedund ohne Wert. Geben Sie ihn an, läuft das Standardverhalten von Flutter (Fokus abgeben oder zum nächsten Feld springen) nicht mehr; das müssen Sie dann selbst erledigen. Meistens brauchen Sie ihn nicht.
TextField(
textInputAction: TextInputAction.search,
onChanged: (value) => _filterList(value), // Filtert bei jedem Buchstaben
onSubmitted: (value) => _saveRecentSearch(value), // Speichert bei „Suchen“
)Eine Falle: onChanged feuert nur bei Änderungen durch den Nutzer. Bei Änderungen aus dem Code über controller.text = ... oder controller.clear() wird es nicht aufgerufen. Müssen Sie auf beides reagieren, beobachten Sie den Controller.
Fokussteuerung mit FocusNode
Der Fokus bestimmt, in welches Feld die Tastatur schreibt. textInputAction: TextInputAction.next bewegt den Fokus in den meisten Formularen bereits, allerdings in der Lesereihenfolge des Screens. Um gezielt zu einem Feld zu springen, auf Fokusgewinn oder -verlust zu reagieren oder die Tastatur aus dem Code zu schließen, brauchen Sie einen FocusNode:
class AddressForm extends StatefulWidget {
const AddressForm({super.key});
@override
State<AddressForm> createState() => _AddressFormState();
}
class _AddressFormState extends State<AddressForm> {
final _cityFocus = FocusNode();
@override
void dispose() {
_cityFocus.dispose(); // Auch der FocusNode wird geschlossen
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
textInputAction: TextInputAction.next,
onSubmitted: (_) => _cityFocus.requestFocus(), // Gezielt springen
),
TextField(
focusNode: _cityFocus,
// Auf Mobilgeräten schließt ein Tippen außerhalb die Tastatur nicht
onTapOutside: (_) => FocusScope.of(context).unfocus(),
),
],
);
}
}Um die Tastatur von überall zu schließen, können Sie auch FocusManager.instance.primaryFocus?.unfocus() verwenden. Soll sich die Oberfläche nach dem Fokuszustand eines Feldes richten, lesen Sie focusNode.hasFocus; da FocusNode ebenfalls ein ChangeNotifier ist, lässt er sich mit ListenableBuilder beobachten.
Mini-Szenario: ein Registrierungs-Screen
Jetzt setzen wir die Teile zusammen: ein Screen mit Name, Telefon und Passwort, durch den man mit „Weiter“ auf der Tastatur navigiert; das Telefonfeld nimmt nur 10 Ziffern an, das Passwort lässt sich ein- und ausblenden, und der Button bleibt deaktiviert, bis alle Felder gültig sind:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
State<SignUpPage> createState() => _SignUpPageState();
}
class _SignUpPageState extends State<SignUpPage> {
final _nameController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _phoneFocus = FocusNode();
final _passwordFocus = FocusNode();
bool _obscure = true;
// Button neu bewerten, sobald sich einer der drei Controller ändert
late final Listenable _fields = Listenable.merge([
_nameController,
_phoneController,
_passwordController,
]);
bool get _canSubmit =>
_nameController.text.trim().length >= 2 &&
_phoneController.text.length == 10 &&
_passwordController.text.length >= 8;
@override
void dispose() {
_nameController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_phoneFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
void _submit() {
if (!_canSubmit) return;
FocusScope.of(context).unfocus(); // Tastatur schließen
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Willkommen, ${_nameController.text.trim()}')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Konto erstellen')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _nameController,
autofocus: true,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.name],
onSubmitted: (_) => _phoneFocus.requestFocus(),
decoration: const InputDecoration(
labelText: 'Vor- und Nachname',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _phoneController,
focusNode: _phoneFocus,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
onSubmitted: (_) => _passwordFocus.requestFocus(),
decoration: const InputDecoration(
labelText: 'Telefon',
hintText: '1512345678',
prefixText: '+49 ',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: _obscure,
enableSuggestions: false,
autocorrect: false,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Passwort',
helperText: 'Mindestens 8 Zeichen',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
tooltip: _obscure ? 'Passwort anzeigen' : 'Passwort verbergen',
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
const SizedBox(height: 24),
ListenableBuilder(
listenable: _fields,
builder: (context, child) => FilledButton(
onPressed: _canSubmit ? _submit : null,
child: const Text('Registrieren'),
),
),
],
),
),
);
}
}Diese Entscheidungen verdienen einen Blick. Für den Button haben wir kein setState geschrieben: Listenable.merge fasst die drei Controller zu einem beobachtbaren Objekt zusammen und zeichnet nur den Button neu. Selbst wenn Buchstaben oder Leerzeichen ins Telefonfeld eingefügt werden, entfernt der Formatter sie, und LengthLimitingTextInputFormatter sichert die 10-Ziffern-Regel. Der Fokus wird in onSubmitted per requestFocus ausdrücklich zum nächsten Feld bewegt, im letzten Feld löst die Taste „Fertig“ direkt die Registrierung aus. autofocus: true fokussiert beim Öffnen des Screens sofort das erste Feld und öffnet die Tastatur. textCapitalization: TextCapitalization.words bittet die Tastatur, jedes Wort großzuschreiben; das ist nur ein Hinweis an die Tastatur, der Nutzer kann weiterhin kleinschreiben, für eine feste Regel brauchen Sie einen Formatter. Dass der Body ein SingleChildScrollView ist, verhindert einen Overflow, wenn sich die Tastatur öffnet; Details im ScrollView-Beitrag. Für die Ergebnismeldung haben wir eine SnackBar verwendet.
TextField oder TextFormField?
TextFormField ist ein TextField, das für die Arbeit in einem Form verpackt wurde: Es ergänzt validator, onSaved und autovalidateMode und nimmt am einzelnen validate()-Aufruf des Formulars teil. Alles aus diesem Beitrag (controller, decoration, keyboardType, inputFormatters, focusNode, obscureText) gilt dort unverändert; nur heißt onSubmitted dort onFieldSubmitted.
Kurze Regel: Werden mehrere Felder gemeinsam validiert und abgeschickt, nehmen Sie Form mit TextFormField; für ein Suchfeld, eine Chat-Eingabe oder einen einfachen Screen wie oben genügt TextField. Den vollständigen Validierungsablauf samt Unterschied zwischen validate() und save() finden Sie im Form-Beitrag. Für Eingaben jenseits von Text (Switch, Checkbox, Slider) lesen Sie den Beitrag über Eingabe-Widgets.
Häufige Fehler
1. Den Controller in build erzeugen
Symptom: Der Text verschwindet beim Tippen, oder der Cursor springt an den Anfang. Eine Zeile TextEditingController() in build erzeugt bei jedem Rebuild einen neuen, leeren Controller. Der Controller muss ein Feld der State-Klasse sein und in dispose() geschlossen werden. Vergessen Sie dispose, bleiben seine Listener im Speicher, auch wenn der Screen längst weg ist.
2. controller.text in onChanged ändern
Wer in onChanged per controller.text = ... in Großbuchstaben umwandelt oder formatiert, bringt den Cursor zum Springen, weil die Zuweisung an text die Auswahl zurücksetzt. Der richtige Ort zum Umwandeln der Eingabe ist inputFormatters; reichen die eingebauten Formatter nicht, schreiben Sie einen eigenen, der von TextInputFormatter erbt.
3. Annehmen, dass keyboardType die Eingabe einschränkt
In ein Feld mit Zifferntastatur können per Einfügen Buchstaben gelangen. Nutzen Sie TextInputType.number, ergänzen Sie FilteringTextInputFormatter.digitsOnly, und prüfen Sie zusätzlich auf dem Server.
4. obscureText mit mehrzeiligem Feld
Mit obscureText: true und einem maxLines ungleich 1 erhalten Sie diesen Assertion-Fehler:
Obscured fields cannot be multiline.Ein Passwortfeld ist immer einzeilig; lassen Sie maxLines einfach weg.
5. Ein TextField ungeschützt in eine Row setzen
Symptom: An InputDecorator, which is typically created by a TextField, cannot have an unbounded width. Eine Row bietet ihren Kindern unbegrenzte Breite, ein TextField muss seine Breite aber kennen. Umschließen Sie das Feld mit Expanded oder einer SizedBox mit fester Breite; wie Expanded funktioniert, steht im Expanded-Beitrag.
Häufig gestellte Fragen
Warum schließt sich die Tastatur nicht, wenn ich außerhalb des Feldes tippe?
Um den Plattformkonventionen zu folgen, gibt Flutter auf Mobilgeräten den Fokus bei Berührungen außerhalb des Feldes nicht ab; ein Mausklick am Desktop dagegen schon. Soll sich die Tastatur auch mobil schließen, geben Sie dem TextField onTapOutside: (_) => FocusScope.of(context).unfocus() mit oder legen einen Tipp-Erkenner auf die leere Fläche der Seite und rufen dort dasselbe auf.
Warum feuert onChanged nicht, wenn ich den Text aus dem Code ändere?
Weil onChanged nur Änderungen durch den Nutzer meldet; bei Änderungen über den Controller wird es nicht aufgerufen. Wollen Sie auf Änderungen durch Nutzer und Code reagieren, hängen Sie mit addListener einen Listener an den Controller oder beobachten Sie ihn mit ListenableBuilder.
Das untere Feld verschwindet hinter der Tastatur. Was tun?
Scaffold verkleinert den Body standardmäßig, wenn sich die Tastatur öffnet (resizeToAvoidBottomInset: true). Kann der Body nicht scrollen, läuft der Inhalt über, und die unteren Felder sind nicht mehr erreichbar. Legen Sie das Formular in ein SingleChildScrollView oder eine ListView; liegt das fokussierte Feld in einem scrollbaren Bereich, scrollt Flutter es in den sichtbaren Bereich.
Verwandte Artikel
Flutter: Form-Widget und Eingabevalidierung
Form und TextFormField in Flutter: validate() vs. save(), autovalidateMode, Fokussteuerung, Controller sauber entsorgen und ein komplettes Login-Formular.
Flutter: Helles und dunkles Theme mit ThemeData
Ein Flutter-Theme mit ThemeData aufbauen: ColorScheme.fromSeed, helles und dunkles Theme, ThemeMode, Theme.of, TextTheme, Komponenten-Themes, ThemeExtension.
Flutter: Widgets überlagern mit Stack und Positioned
Stack und Positioned in Flutter: wie ein Stack seine Größe bestimmt, fit, alignment, clipBehavior, Positioned.fill, PositionedDirectional, Badge und Overlays.