Flutter Form Validation: Nutzbare und sichere Formulare
# Flutter Form Validation: Benutzerfreundliche und sichere Formularabläufe
Gute Formulare führen Nutzer, statt sie zu bestrafen. Ein durchdacht gestaltetes Formular ist unsichtbar, wenn alles funktioniert, und hilfreich, wenn etwas schiefgeht. In diesem Artikel gehe ich auf Validierungsstrategien, echte Codebeispiele, Barrierefreiheit und häufige Fehler ein, die in Flutter-Apps die Conversion-Rate senken.
Warum Formularvalidierung so wichtig ist
Formulare sind das Tor zu jeder bedeutsamen Nutzeraktion: Registrierung, Bezahlvorgang, Profil-Update, Support-Anfrage. Bei schlechter Validierung brechen Nutzer den Vorgang ab. Bei guter Validierung merken sie kaum, dass sie da ist.
In E-Commerce-Apps, die ich entwickelt habe, stieg die Formular-Conversion um fast 20%, als wir von einem "Fehlerwand beim Absenden"-Muster auf kontextbezogene Inline-Validierung umgestellt haben. Die Codeänderungen waren minimal, aber die Auswirkung auf die Nutzerbindung war erheblich.
Die Flutter-Formular-Grundlagen
Flutter bietet uns `Form` und `TextFormField` als primäre Bausteine. Hier die grundlegende Struktur:
class RegistrierungsFormular extends StatefulWidget {
const RegistrierungsFormular({super.key});
@override
State<RegistrierungsFormular> createState() =>
_RegistrierungsFormularState();
}
class _RegistrierungsFormularState
extends State<RegistrierungsFormular> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwortController = TextEditingController();
final _nameController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwortController.dispose();
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: class="code-string">'Vollständiger Name',
hintText: class="code-string">'Vor- und Nachname eingeben',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return class="code-string">'Name ist erforderlich';
}
if (value.trim().length < class="code-number">2) {
return class="code-string">'Name muss mindestens class="code-number">2 Zeichen haben';
}
return null;
},
),
const SizedBox(height: class="code-number">16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: class="code-string">'E-Mail',
hintText: class="code-string">'beispiel@mail.de',
),
validator: _validateEmail,
),
const SizedBox(height: class="code-number">16),
TextFormField(
controller: _passwortController,
obscureText: true,
decoration: const InputDecoration(
labelText: class="code-string">'Passwort',
hintText: class="code-string">'Mindestens class="code-number">8 Zeichen',
),
validator: _validatePasswort,
),
const SizedBox(height: class="code-number">24),
ElevatedButton(
onPressed: _formularAbsenden,
child: const Text(class="code-string">'Konto erstellen'),
),
],
),
);
}
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return class="code-string">'E-Mail-Adresse ist erforderlich';
}
final emailRegex = RegExp(rclass="code-string">'^[\w-\.]+@([\w-]+\.)+[\w-]{class="code-number">2,class="code-number">4}$');
if (!emailRegex.hasMatch(value)) {
return class="code-string">'Bitte geben Sie eine gültige E-Mail-Adresse ein';
}
return null;
}
String? _validatePasswort(String? value) {
if (value == null || value.isEmpty) {
return class="code-string">'Passwort ist erforderlich';
}
if (value.length < class="code-number">8) {
return class="code-string">'Passwort muss mindestens class="code-number">8 Zeichen haben';
}
if (!value.contains(RegExp(rclass="code-string">'[A-Z]'))) {
return class="code-string">'Mindestens ein Großbuchstabe erforderlich';
}
if (!value.contains(RegExp(rclass="code-string">'[class="code-number">0-class="code-number">9]'))) {
return class="code-string">'Mindestens eine Zahl erforderlich';
}
return null;
}
void _formularAbsenden() {
if (_formKey.currentState!.validate()) {
class=class="code-string">"code-comment">// Alle Validatoren haben null zurückgegeben, Formular ist gültig
_registrierungDurchfuehren();
}
}
Future<void> _registrierungDurchfuehren() async {
class=class="code-string">"code-comment">// An das Backend senden
}
}Das zentrale Prinzip: `validator` gibt `null` für gültige Eingaben und einen Fehlertext für ungültige Eingaben zurück. Flutters `Form`-Widget koordiniert den Aufruf aller Validatoren, wenn `validate()` aufgerufen wird.
Eigene Validatoren erstellen
Inline-Validatoren werden schnell unübersichtlich. Ich bevorzuge es, wiederverwendbare Validator-Funktionen zu extrahieren und diese zu komponieren:
typedef Validator = String? Function(String?);
Validator pflichtfeld(String feldName) {
return (value) {
if (value == null || value.trim().isEmpty) {
return class="code-string">'$feldName ist erforderlich';
}
return null;
};
}
Validator mindestLaenge(int laenge) {
return (value) {
if (value != null && value.length < laenge) {
return class="code-string">'Mindestens $laenge Zeichen erforderlich';
}
return null;
};
}
Validator musterPruefen(RegExp muster, String nachricht) {
return (value) {
if (value != null && !muster.hasMatch(value)) {
return nachricht;
}
return null;
};
}
class=class="code-string">"code-comment">/// Mehrere Validatoren zu einem zusammenfassen.
class=class="code-string">"code-comment">/// Gibt den ersten gefundenen Fehler zurück oder null, wenn alle bestehen.
Validator zusammenfassen(List<Validator> validatoren) {
return (value) {
for (final validator in validatoren) {
final fehler = validator(value);
if (fehler != null) return fehler;
}
return null;
};
}Damit werden Ihre `TextFormField`-Validatoren sauber und deklarativ:
TextFormField(
validator: zusammenfassen([
pflichtfeld(class="code-string">'E-Mail'),
musterPruefen(
RegExp(rclass="code-string">'^[\w-\.]+@([\w-]+\.)+[\w-]{class="code-number">2,class="code-number">4}$'),
class="code-string">'Geben Sie eine gültige E-Mail-Adresse ein',
),
]),
)Dieses Muster skaliert gut in großen Projekten. Jeder Entwickler im Team nutzt dieselben Bausteine, und Fehlermeldungen bleiben konsistent.
Asynchrone Validierung
Manche Validierungen erfordern einen Server-Roundtrip. Das klassische Beispiel: Prüfen, ob eine E-Mail-Adresse bereits registriert ist. Flutters eingebauter `validator` ist synchron, daher braucht man einen anderen Ansatz:
class EmailFeld extends StatefulWidget {
final TextEditingController controller;
final ValueChanged<bool>? onVerfuegbarkeitGeprueft;
const EmailFeld({
super.key,
required this.controller,
this.onVerfuegbarkeitGeprueft,
});
@override
State<EmailFeld> createState() => _EmailFeldState();
}
class _EmailFeldState extends State<EmailFeld> {
Timer? _debounce;
String? _asyncFehler;
bool _prueftGerade = false;
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
void _beiAenderung(String value) {
_debounce?.cancel();
setState(() {
_asyncFehler = null;
_prueftGerade = false;
});
if (value.isEmpty) return;
_debounce = Timer(const Duration(milliseconds: class="code-number">600), () {
_emailVerfuegbarkeitPruefen(value);
});
}
Future<void> _emailVerfuegbarkeitPruefen(String email) async {
setState(() => _prueftGerade = true);
try {
final verfuegbar = await AuthService.checkEmail(email);
if (mounted) {
setState(() {
_prueftGerade = false;
_asyncFehler = verfuegbar
? null
: class="code-string">'Diese E-Mail-Adresse ist bereits registriert';
});
widget.onVerfuegbarkeitGeprueft?.call(verfuegbar);
}
} catch (e) {
if (mounted) {
setState(() {
_prueftGerade = false;
_asyncFehler =
class="code-string">'E-Mail konnte nicht überprüft werden. Versuchen Sie es erneut.';
});
}
}
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: widget.controller,
onChanged: _beiAenderung,
decoration: InputDecoration(
labelText: class="code-string">'E-Mail',
suffixIcon: _prueftGerade
? const SizedBox(
width: class="code-number">20,
height: class="code-number">20,
child: CircularProgressIndicator(strokeWidth: class="code-number">2),
)
: null,
errorText: _asyncFehler,
),
validator: (value) {
if (value == null || value.isEmpty) {
return class="code-string">'E-Mail ist erforderlich';
}
if (_asyncFehler != null) return _asyncFehler;
return null;
},
);
}
}Das Debouncing ist entscheidend. Ohne es würde jeder Tastendruck eine Netzwerkanfrage auslösen. Ich verwende typischerweise 500-700ms -- das fühlt sich reaktionsschnell an, ohne den Server zu überlasten.
Echtzeit- vs. Absende-Validierung
Dies ist eines der meistdiskutierten Themen in der Formular-UX. Hier die Situationen, in denen jeder Ansatz am besten funktioniert:
Echtzeit-Validierung (bei Änderung / bei Fokusverlust)
Am besten geeignet für:
Fallstricke:
Absende-Validierung (bei Buttonklick)
Am besten geeignet für:
Fallstricke:
Der hybride Ansatz, den ich empfehle
Starten Sie mit Absende-Validierung. Sobald ein Feld validiert wurde und einen Fehler anzeigt, wechseln Sie für dieses spezifische Feld auf Echtzeit-Validierung, damit der Nutzer beim Korrigieren sofortiges Feedback bekommt:
class SmartesFormularFeld extends StatefulWidget {
final String label;
final Validator validator;
const SmartesFormularFeld({
super.key,
required this.label,
required this.validator,
});
@override
State<SmartesFormularFeld> createState() =>
_SmartesFormularFeldState();
}
class _SmartesFormularFeldState extends State<SmartesFormularFeld> {
bool _wurdeValidiert = false;
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(labelText: widget.label),
autovalidateMode: _wurdeValidiert
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
validator: (value) {
setState(() => _wurdeValidiert = true);
return widget.validator(value);
},
);
}
}Das gibt Ihnen das Beste aus beiden Welten: eine saubere Anfangserfahrung ohne voreilige Fehler und sofortiges Feedback, sobald der Nutzer aktiv ein Feld korrigiert.
Barrierefreie Formulare
Barrierefreiheit ist kein nachträglicher Zusatz. Ein erheblicher Teil der Nutzer ist auf Screenreader, Switch-Steuerung oder andere assistive Technologien angewiesen. Flutters Semantics-System macht das möglich, aber man muss bewusst damit umgehen.
Semantische Labels und Hinweise
TextFormField(
decoration: const InputDecoration(
labelText: class="code-string">'E-Mail-Adresse',
hintText: class="code-string">'beispiel@mail.de',
),
class=class="code-string">"code-comment">// Semantische Informationen werden automatisch aus labelText abgeleitet.
class=class="code-string">"code-comment">// Für benutzerdefinierte Ansagen:
autovalidateMode: AutovalidateMode.onUserInteraction,
)Flutters `TextFormField` stellt den `labelText` automatisch im Accessibility-Baum bereit. Aber auch Fehlermeldungen brauchen Aufmerksamkeit.
Fehler-Ansagen
Bei fehlgeschlagener Validierung sollten Screenreader den Fehler ansagen. Flutter übernimmt das für `InputDecoration.errorText` automatisch. Wenn Sie aber eigene Fehler-Widgets erstellen, müssen Sie diese korrekt auszeichnen:
Semantics(
liveRegion: true, class=class="code-string">"code-comment">// Gibt Änderungen automatisch bekannt
child: Text(
fehlerNachricht,
style: const TextStyle(color: Colors.red),
),
)Fokus-Management
Nach einem Validierungsfehler sollte der Fokus auf das erste ungültige Feld verschoben werden. Das hilft sowohl Tastaturnutzern als auch Screenreader-Nutzern:
void _formularAbsenden() {
if (!_formKey.currentState!.validate()) {
class=class="code-string">"code-comment">// Erstes fehlerhaftes Feld finden und fokussieren
if (_validateEmail(_emailController.text) != null) {
_emailFocusNode.requestFocus();
} else if (_validatePasswort(_passwortController.text) != null) {
_passwortFocusNode.requestFocus();
}
return;
}
_registrierungDurchfuehren();
}Checkliste für barrierefreie Formulare
Häufige Formular-UX-Fehler
Diese Muster sehe ich immer wieder in Kundenprojekten und Open-Source-Apps:
1. Absende-Button deaktivieren, bis alles gültig ist
Das klingt logisch, schafft aber eine furchtbare Erfahrung. Der Nutzer füllt das Formular aus, tippt auf Absenden -- und nichts passiert. Er hat keine Ahnung, was falsch ist. Lassen Sie den Button stattdessen aktiv und zeigen Sie Validierungsfehler beim Drücken an.
2. Felder bei Fehler zurücksetzen
Setzen Sie Formularfelder nach einem Validierungsfehler niemals zurück. Eingegebene Daten zu verlieren ist einer der schnellsten Wege, einen Nutzer zu vertreiben.
3. Vage Fehlermeldungen
"Ungültige Eingabe" sagt dem Nutzer nichts. Jede Fehlermeldung sollte erklären, was falsch ist und wie es korrigiert werden kann:
4. Zu früh validieren
Sofort "E-Mail ist erforderlich" anzuzeigen, wenn der Nutzer gerade erst in das E-Mail-Feld tippt (bevor er etwas eingegeben hat), fühlt sich an, als würde die App ihn anschreien. Geben Sie dem Nutzer die Chance zur Interaktion.
5. Tastaturtypen ignorieren
Setzen Sie `keyboardType` immer passend. Ein E-Mail-Feld sollte `TextInputType.emailAddress` zeigen, ein Telefonfeld `TextInputType.phone`. Das reduziert Fehler bereits auf Eingabeebene, bevor die Validierung überhaupt greift.
6. Einfügen nicht berücksichtigen
Nutzer fügen häufig Inhalte in Formulare ein. Stellen Sie sicher, dass Ihre Validatoren und Input-Formatter eingefügten Text korrekt verarbeiten -- nicht nur Eingabe per Tastendruck.
7. Ladezustände vergessen
Während asynchroner Validierung oder Formularübermittlung deaktivieren Sie den Button und zeigen Sie einen Ladeindikator. Doppelte Übermittlungen verursachen echte Probleme, besonders bei Zahlungsformularen.
ElevatedButton(
onPressed: _sendetGerade ? null : _formularAbsenden,
child: _sendetGerade
? const SizedBox(
width: class="code-number">20,
height: class="code-number">20,
child: CircularProgressIndicator(strokeWidth: class="code-number">2),
)
: const Text(class="code-string">'Absenden'),
)Ein vollständiges Login-Formular-Beispiel
Alles zusammengefasst -- hier ein produktionsreifes Login-Formular:
class LoginFormular extends StatefulWidget {
const LoginFormular({super.key});
@override
State<LoginFormular> createState() => _LoginFormularState();
}
class _LoginFormularState extends State<LoginFormular> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwortController = TextEditingController();
final _emailFocus = FocusNode();
final _passwortFocus = FocusNode();
bool _sendetGerade = false;
bool _passwortVerborgen = true;
@override
void dispose() {
_emailController.dispose();
_passwortController.dispose();
_emailFocus.dispose();
_passwortFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: class="code-string">'E-Mail',
prefixIcon: Icon(Icons.email_outlined),
),
validator: zusammenfassen([
pflichtfeld(class="code-string">'E-Mail'),
musterPruefen(
RegExp(rclass="code-string">'^[\w-\.]+@([\w-]+\.)+[\w-]{class="code-number">2,class="code-number">4}$'),
class="code-string">'Geben Sie eine gültige E-Mail-Adresse ein',
),
]),
onFieldSubmitted: (_) =>
_passwortFocus.requestFocus(),
),
const SizedBox(height: class="code-number">16),
TextFormField(
controller: _passwortController,
focusNode: _passwortFocus,
obscureText: _passwortVerborgen,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: class="code-string">'Passwort',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(
_passwortVerborgen
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_passwortVerborgen = !_passwortVerborgen;
});
},
),
),
validator: zusammenfassen([
pflichtfeld(class="code-string">'Passwort'),
mindestLaenge(class="code-number">8),
]),
onFieldSubmitted: (_) => _formularAbsenden(),
),
const SizedBox(height: class="code-number">24),
ElevatedButton(
onPressed:
_sendetGerade ? null : _formularAbsenden,
child: _sendetGerade
? const SizedBox(
height: class="code-number">20,
width: class="code-number">20,
child: CircularProgressIndicator(
strokeWidth: class="code-number">2,
),
)
: const Text(class="code-string">'Anmelden'),
),
],
),
),
);
}
Future<void> _formularAbsenden() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _sendetGerade = true);
try {
await AuthService.signIn(
email: _emailController.text.trim(),
password: _passwortController.text,
);
} on AuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} finally {
if (mounted) {
setState(() => _sendetGerade = false);
}
}
}
}Beachten Sie die Details: `AutofillGroup` für Passwort-Manager, `textInputAction` für den Tastaturfluss, korrekte `dispose`-Aufrufe und `mounted`-Prüfungen nach asynchronen Lücken.
Fazit
Durchdachte Validierung erhöht die Conversion und reduziert Nutzerfrustration. Die besten Formulare fühlen sich mühelos an, erkennen Fehler früh ohne aggressiv zu sein und bleiben für alle zugänglich. Investieren Sie früh im Projekt in Ihre Formular-Infrastruktur -- es zahlt sich auf jedem Screen aus, der Nutzereingaben entgegennimmt.
Gerne unterstütze ich Sie bei einem Form-UX-Review für Onboarding- und Checkout-Formulare -- Validierung, Barrierefreiheit und Conversion.
Verwandte Artikel
Flutter State Management: Riverpod, Provider und Bloc im Vergleich
Vergleichen Sie State-Management-Ansätze in Flutter. Verstehen Sie Riverpod, Provider und Bloc mit klaren Entscheidungskriterien.
Flutter Navigation: Skalierbares Routing mit go_router
Implementieren Sie moderne Flutter-Navigation mit go_router inklusive Nested Routes, Redirects und Deep Links.
Flutter Accessibility (a11y): Apps für alle Nutzer
Setzen Sie Barrierefreiheit in Flutter um: Semantics, Kontrast, Textskalierung und tastaturfreundliche Bedienung.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen