İçeriğe geç / Skip to content / Zum Inhalt

Flutter: Form-Widget und Eingabevalidierung

Ahmet Balaman

Zuletzt aktualisiert:

6 Min. Lesezeit

FlutterFormTextFormFieldValidationFocusNodeInput
Flutter: Form-Widget und Eingabevalidierung

Form fasst mehrere Eingabefelder unter einem Dach zusammen und erlaubt es, alle mit einem einzigen Aufruf zu validieren. Jeder Bildschirm nach dem Muster „Nutzer füllt aus, wir prüfen, dann wird gesendet“ profitiert davon: Registrierung, Login, Adresse, Profil bearbeiten. Ohne Form müssten Sie den Fehlerzustand jedes Feldes einzeln per setState verwalten; Form nimmt Ihnen diese Buchhaltung ab.

Live-Demo

Sie können dieses Widget im interaktiven Beispiel unten ausprobieren:

💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.

Grundlegende Verwendung

Um von außen auf den Zustand des Formulars zuzugreifen, verwenden wir einen GlobalKey<FormState>. Über diesen Schlüssel kann der Senden-Button allen Feldern im Formular sagen: „Prüfe dich selbst“:

final _formKey = GlobalKey<FormState>();

Form(
  key: _formKey,
  child: Column(
    children: [
      TextFormField(
        validator: (value) {
          if (value == null || value.isEmpty) {
            return 'Dieses Feld darf nicht leer sein';
          }
          return null;
        },
      ),
      ElevatedButton(
        onPressed: () {
          if (_formKey.currentState!.validate()) {
            // Formular ist gültig, Aktion ausführen
          }
        },
        child: const Text('Senden'),
      ),
    ],
  ),
)

Die Regel ist einfach: Ein validator gibt bei einem Fehler den Fehlertext zurück und null, wenn alles in Ordnung ist. Der Text erscheint rot unter dem Feld; zusätzliche Oberfläche müssen Sie dafür nicht schreiben.

Eigenschaften von TextFormField

TextFormField ist das bekannte TextField, das zusätzlich mit einem Form sprechen kann; die Details des Feldes selbst wie Controller, Tastaturtypen, inputFormatters und Fokus behandelt der Beitrag zu TextField und TextEditingController. Das Aussehen steuert InputDecoration, das Tastaturverhalten keyboardType und textInputAction:

TextFormField(
  controller: _controller,
  decoration: const InputDecoration(
    labelText: 'E-Mail',
    hintText: '[email protected]',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),
  validator: (value) =>
      (value == null || value.isEmpty) ? 'Pflichtfeld' : null,
  keyboardType: TextInputType.emailAddress,
  textInputAction: TextInputAction.next,
  obscureText: false, // true für Passwörter
  maxLength: 50,
)

Der rote Rahmen im Fehlerzustand kommt automatisch aus dem Theme. Solange Sie keine andere Farbe brauchen, ist ein eigenes errorBorder überflüssig.

Validierungsfunktionen

Validierungsregeln in eigene Funktionen auszulagern macht sie wiederverwendbar und leicht testbar:

// E-Mail-Validierung
String? validateEmail(String? value) {
  if (value == null || value.trim().isEmpty) {
    return 'E-Mail ist erforderlich';
  }
  final regex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
  if (!regex.hasMatch(value.trim())) {
    return 'Ungültiges E-Mail-Format';
  }
  return null;
}

// Passwort-Validierung
String? validatePassword(String? value) {
  if (value == null || value.length < 6) {
    return 'Das Passwort muss mindestens 6 Zeichen haben';
  }
  return null;
}

Das E-Mail-Muster ist absichtlich locker gehalten: Es fragt nur „Gibt es ein @ und einen Punkt?“. Sehr strenge Muster lehnen irgendwann gültige Adressen mit neueren, längeren Domain-Endungen ab. Ob die Adresse wirklich existiert, kann ohnehin nur die Serverseite (Bestätigungsmail) klären.

validate(), save() und reset() im Vergleich

Formularablauf: zuerst validate, bei Fehlern werden Meldungen gezeigt und gestoppt, sonst läuft save und onSaved sammelt die Daten

Methode Was sie tut
validate() Führt den validator jedes Feldes aus, zeigt die Fehler an und gibt true zurück, wenn alle Felder gültig sind
save() Ruft den onSaved-Callback jedes Feldes auf; validiert nicht
reset() Setzt die Felder auf ihren Anfangswert zurück und löscht die Fehlermeldungen

Der Punkt, der am häufigsten verwechselt wird: save() validiert nicht. Die Reihenfolge lautet deshalb immer erst validate(), dann save():

String? _email;

TextFormField(
  onSaved: (value) => _email = value,
)

// Beim Absenden des Formulars
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save(); // Ruft die onSaved-Callbacks auf
  print(_email);
}

Werte lassen sich auf zwei Wegen auslesen: in onSaved in eine Variable schreiben oder über einen TextEditingController mit controller.text lesen. Entscheiden Sie sich pro Formular für einen Weg. Müssen Sie den Text per Code ändern oder live mithören, nehmen Sie einen Controller; brauchen Sie den Wert nur beim Absenden, kommt onSaved mit weniger Code aus.

AutovalidateMode

autovalidateMode legt fest, wann Fehler sichtbar werden:

Form(
  key: _formKey,
  autovalidateMode: AutovalidateMode.onUserInteraction,
  child: ...
)
  • disabled (Standard): Fehler erscheinen nur, wenn validate() aufgerufen wird.
  • onUserInteraction: Ein Feld prüft sich selbst, sobald der Nutzer es berührt und zu tippen begonnen hat.
  • onUnfocus: Ein Feld wird geprüft, wenn der Nutzer es verlässt.
  • always: Alles wird geprüft, sobald der Bildschirm erscheint. Ein leeres Formular, das mit roten Fehlern begrüßt, ist so gut wie nie gewünscht.

In der Praxis bewährt sich ein Muster: Das Formular startet mit disabled und wechselt nach dem ersten fehlgeschlagenen Absenden auf onUserInteraction. So wird niemand getadelt, bevor er etwas getippt hat, sieht aber beim Korrigieren sofort, wie die rote Meldung verschwindet. Das Mini-Szenario weiter unten nutzt genau dieses Muster.

Fokussteuerung und textInputAction

textInputAction bestimmt, was auf der Taste unten rechts auf der Tastatur steht. Mit TextInputAction.next verschiebt Flutter den Fokus von selbst auf das nächste Feld; ein FocusNode ist dafür nicht nötig. Im letzten Feld verwenden Sie TextInputAction.done und rufen in onFieldSubmitted Ihre Absendefunktion auf – so lässt sich das gesamte Formular ausfüllen, ohne die Tastatur zu verlassen.

Einen FocusNode brauchen Sie, wenn der Fokus außer der Reihe springen soll, wenn Sie das erste fehlerhafte Feld ansteuern oder auf Fokuswechsel reagieren möchten. Legen Sie einen an, muss er wie ein Controller in dispose() geschlossen werden.

Wann verwenden – und wann nicht?

Greifen Sie zu Form, wenn zwei oder mehr Felder gemeinsam geprüft und gemeinsam abgesendet werden: Login, Registrierung, Adresse, Zahlung, Profil bearbeiten.

Hier ist es überflüssig:

  • Ein einzelnes Suchfeld oder eine Chat-Eingabe: Es gibt nichts zu validieren; ein schlichtes TextField mit onChanged oder Controller genügt.
  • Einstellungen, die sofort wirken: Wenn ein Switch oder Slider beim Ändern direkt speichert, gibt es keinen „Absenden“-Schritt und damit keinen Bedarf für ein Form.

Form ist nicht auf Textfelder beschränkt. Für eine Auswahlliste verwenden Sie DropdownButtonFormField statt eines einfachen DropdownButton; Widgets wie eine Checkbox nehmen am selben validate()-Aufruf teil, wenn Sie sie in ein FormField<T> einpacken:

FormField<bool>(
  initialValue: false,
  validator: (value) =>
      value == true ? null : 'Zum Fortfahren bitte zustimmen',
  builder: (state) => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      CheckboxListTile(
        value: state.value,
        onChanged: state.didChange,
        title: const Text('Ich akzeptiere die Nutzungsbedingungen'),
      ),
      if (state.hasError)
        Text(
          state.errorText!,
          style: TextStyle(color: Theme.of(context).colorScheme.error),
        ),
    ],
  ),
)

Häufige Fehler

1. Controller in build erzeugen und nie entsorgen

Symptom: Bei jedem Neuaufbau des Bildschirms verschwindet der getippte Text oder der Cursor springt an den Anfang. Ursache ist eine Zeile TextEditingController() innerhalb von build; jeder Rebuild erzeugt einen nagelneuen Controller. Der Controller gehört als Feld in die State-Klasse und muss in dispose() geschlossen werden:

class _ProfileFormState extends State<ProfileForm> {
  final _nameController = TextEditingController();

  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }
  // ...
}

Ein nie entsorgter Controller bleibt im Speicher, auch wenn der Bildschirm längst geschlossen ist. Die Reihenfolge von initState und dispose erkläre ich ausführlich im Beitrag zum Lebenszyklus von StatefulWidget.

2. TextField innerhalb eines Form verwenden

Symptom: validate() gibt immer true zurück, selbst wenn das Feld leer ist. Ein TextField meldet sich nie beim Form an; es besitzt nicht einmal einen validator-Parameter. Jedes Textfeld, das im Form geprüft werden soll, muss ein TextFormField sein.

3. GlobalKey in build erzeugen oder dem Form nicht übergeben

Wird der Schlüssel nie mit Form(key: ...) zugewiesen, stürzt die Zeile _formKey.currentState! mit „Null check operator used on a null value“ ab. Wird der Schlüssel in build erzeugt, setzt jeder Rebuild den Zustand des Formulars zurück: Fehlermeldungen verschwinden, die Tastatur schließt sich. Wie ein Controller sollte der GlobalKey einmalig als Feld der State-Klasse angelegt werden.

4. Das Formular läuft über, sobald die Tastatur erscheint

Symptom: gelb-schwarze Streifen mit „Bottom overflowed by ... pixels“, sobald die Tastatur offen ist. Die Lösung: die Column des Formulars in ein SingleChildScrollView legen, damit die unteren Felder bei geöffneter Tastatur erreichbar bleiben. Details stehen im Beitrag zu SingleChildScrollView.

Mini-Szenario: ein Login-Formular

Setzen wir die Teile zusammen: ein Login-Formular mit E-Mail und Passwort, bei dem die Tasten „Weiter“ und „Fertig“ den Ablauf steuern und der Button während des Absendens gesperrt ist:

class LoginForm extends StatefulWidget {
  const LoginForm({super.key});

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  AutovalidateMode _autovalidateMode = AutovalidateMode.disabled;
  bool _isSubmitting = false;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (!_formKey.currentState!.validate()) {
      // Nach dem ersten Fehlversuch Fehler beim Tippen aktualisieren
      setState(() => _autovalidateMode = AutovalidateMode.onUserInteraction);
      return;
    }
    FocusScope.of(context).unfocus(); // Tastatur schließen
    setState(() => _isSubmitting = true);
    await Future.delayed(const Duration(seconds: 1)); // Platzhalter für den Serveraufruf
    if (!mounted) return;
    setState(() => _isSubmitting = false);
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Willkommen, ${_emailController.text.trim()}')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      autovalidateMode: _autovalidateMode,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(labelText: 'E-Mail'),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            autofillHints: const [AutofillHints.email],
            validator: validateEmail,
          ),
          const SizedBox(height: 12),
          TextFormField(
            controller: _passwordController,
            decoration: const InputDecoration(labelText: 'Passwort'),
            obscureText: true,
            textInputAction: TextInputAction.done,
            onFieldSubmitted: (_) => _submit(),
            validator: validatePassword,
          ),
          const SizedBox(height: 20),
          FilledButton(
            onPressed: _isSubmitting ? null : _submit,
            child: _isSubmitting
                ? const SizedBox(
                    width: 20,
                    height: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Text('Anmelden'),
          ),
        ],
      ),
    );
  }
}

Drei Details verdienen Beachtung. Erstens: null für onPressed deaktiviert den Button, die Anfrage kann also nicht doppelt gesendet werden. Zweitens: Die Zeile if (!mounted) return; nach dem await verhindert, dass wir setState auf einem Widget aufrufen, das während des Wartens geschlossen wurde. Drittens: Die Meldung, mit der wir das Ergebnis anzeigen, wird im SnackBar-Beitrag erklärt.

Häufig gestellte Fragen

Was ist der Unterschied zwischen validate() und save()?

validate() führt die Validator-Funktionen aller Felder aus und gibt true zurück, wenn das Formular gültig ist. save() ruft lediglich die onSaved-Callbacks auf und prüft nichts. Deshalb wird zuerst validate() aufgerufen und save() nur, wenn das Ergebnis true ist.

Was ist der Unterschied zwischen TextField und TextFormField?

TextFormField ist ein TextField mit Form-Anbindung: Es besitzt die Parameter validator, onSaved und autovalidateMode und nimmt am validate()-Aufruf des Formulars teil. Für eine einzelne Eingabe ohne Validierung genügt TextField.

Wie aktualisiere ich Fehlermeldungen, während der Nutzer tippt?

Setzen Sie autovalidateMode am Form oder an einem einzelnen Feld auf AutovalidateMode.onUserInteraction. Wenn das Formular mit disabled startet und erst nach dem ersten fehlgeschlagenen Absenden umschaltet, erscheinen keine Fehler auf einem leeren Formular.

Muss ich einen TextEditingController verwenden?

Nein. Wenn der Wert nur beim Absenden gelesen wird, reicht onSaved. Einen Controller brauchen Sie, wenn der Text per Code geändert, geleert oder live beobachtet werden soll – und er wird in dispose() geschlossen.

Kommentare