Mert Özen Her Satırda Daha İleri
Frontend Flutter ile Mobil — #10

Flutter'da Form ve Kullanıcı Girdisi: Form, TextFormField, Validation, Focus ve Klavye

Mert Özen 15 Kas 2026 9 dk 6 görüntülenme
Flutter'da Form ve Kullanıcı Girdisi: Form, TextFormField, Validation, Focus ve Klavye

Flutter'da form, Form ve GlobalKey<FormState> ile toplanır; validator null dönerse alan geçerlidir, sunucunun ProblemDetails hataları ise forceErrorText ile ilgili alana yazılır. Giriş ve teslimat adresi formlarında AutovalidateMode seçimini, FocusNode ile hatalı ilk alana odaklanmayı, input formatter'ların sınırını, klavye açılınca body'nin küçülmesini ve Angular Reactive Forms karşılıklarını gerçek test çıktılarıyla gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7) ve material_ui 1.11.0 ile denenmiştir. Bütün çıktılar flutter test koşularından; klavye, emulator ve cihaz davranışı test ortamında simüle edildi, gerçek cihazda çalıştırma yapılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) teslimat adresi formunda posta kodu alanı var. Alana iki input formatter bağladık: yalnız rakamlara izin veren ve uzunluğu 5 ile sınırlayan.

inputFormatters: [
  FilteringTextInputFormatter.digitsOnly,
  LengthLimitingTextInputFormatter(5),
],

Kullanıcı alana 34a7x1 yazıyor (ya da yapıştırıyor). Alanda ne görünür: 34a7x, 34710, 3471, yoksa hiçbir şey ve bir hata mesajı mı? Ve formu gönderdiğinde hata hangi alanda gösterilir? İkisini de testte göreceğiz.

Form, GlobalKey<FormState> ve TextFormField

Flutter'da bir form üç parçadan kuruluyor: alanları bir arada tutan Form, her alan için bir TextFormField ve formun state'ine dışarıdan ulaşmak için bir GlobalKey<FormState>. Altıncı yazıda GlobalKey'in ağacın başka bir yerinden bir state'e ulaşmak için olduğunu söylemiştik; işte kullanım yeri. Giriş ekranının state'i:

class _SignInScreenState extends State<SignInScreen> {
  final _formKey = GlobalKey<FormState>();
  final _email = TextEditingController();
  final _password = TextEditingController();
  var _obscure = true;
  var _submitting = false;
  String? _error;

  @override
  void dispose() {
    _email.dispose();
    _password.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (_submitting) return;
    if (!_formKey.currentState!.validate()) return;
    setState(() {
      _submitting = true;
      _error = null;
    });
    final error = await widget.onSignIn(_email.text.trim(), _password.text);
    if (!mounted) return;
    setState(() {
      _submitting = false;
      _error = error;
    });
  }

validate(), formdaki her alanın validator'ını çalıştırıyor, hataları alanların altına yazıyor ve hepsi geçerliyse true dönüyor. _submitting çift dokunuşta iki istek gitmesini engelliyor ve butonu devre dışı bırakıyor. await'ten sonraki mounted kontrolü altıncı yazıdaki kuralın aynısı.

TextEditingController alanın metnini tutan ve dinleyicilerine haber veren bir object. State içinde oluşturduğun her controller'ı dispose'ta kapatmak zorundasın; kapatmazsan ekran kapandıktan sonra da bellekte kalır. Formun alanlarından biri:

TextFormField(
  controller: _password,
  decoration: InputDecoration(
    labelText: 'Password',
    suffixIcon: IconButton(
      tooltip: _obscure ? 'Show password' : 'Hide password',
      icon: Icon(
        _obscure ? Icons.visibility : Icons.visibility_off,
      ),
      onPressed: () => setState(() => _obscure = !_obscure),
    ),
  ),
  obscureText: _obscure,
  textInputAction: TextInputAction.done,
  autofillHints: const [AutofillHints.password],
  validator: Validators.password,
  onFieldSubmitted: (_) => _submit(),
),

obscureText şifreyi gizliyor, göz ikonu açıp kapatıyor. autofillHints ve alanları saran AutofillGroup, işletim sisteminin şifre yöneticisine bu alanların e-posta ve şifre olduğunu söylüyor; kullanıcı kayıtlı bilgisini tek dokunuşla doldurabiliyor. onFieldSubmitted, klavyedeki "bitti" tuşuna basıldığında formu gönderiyor.

Validator'lar ve AutovalidateMode

Validator, alanın değerini alıp geçerliyse null, değilse hata metnini döndüren bir fonksiyon. Her formda aynı kuralları yeniden yazmamak için hepsini tek bir yerde topladık:

/// TextFormField için validator'lar: null geçerli demek, string ise hata metni.
abstract final class Validators {
  static final _email = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
  static final _postalCode = RegExp(r'^\d{5}$');

  static String? required(String? value) =>
      value == null || value.trim().isEmpty ? 'This field is required' : null;

  static String? email(String? value) =>
      required(value) ??
      (_email.hasMatch(value!.trim()) ? null : 'Enter a valid email address');

  static String? password(String? value) =>
      required(value) ??
      (value!.length >= 8 ? null : 'Use at least 8 characters');

  /// Türkiye'de posta kodu beş rakam.
  static String? postalCode(String? value) =>
      required(value) ??
      (_postalCode.hasMatch(value!.trim()) ? null : 'Postal code has 5 digits');

Saf fonksiyon oldukları için widget olmadan, düz unit testle test ediliyorlar. E-posta için regex'i bilerek gevşek tuttuk: istemci yalnız bariz hataları yakalar, adresin gerçekten var olduğunu sunucu doğrular.

Ne zaman doğrulanacağını autovalidateMode belirliyor. Flutter 3.47'de beş değer var: disabled (yalnız validate() çağrılınca), always, onUserInteraction (her değişiklikten sonra), onUnfocus (alan odağı kaybedince) ve onUserInteractionIfError (yalnız hatası olan alanı, değiştikçe yeniden kontrol eder). Formlarımızda sonuncusunu kullandık: kullanıcı yazarken daha bitirmediği bir e-posta için kırmızı uyarı görmüyor, ama gönderdikten sonra hatayı düzelttiği anda uyarı kalkıyor. Testte boş formu gönderdik, sonra alanları düzelttik:

after empty submit: 2 errors
typed "ada@": 1 email error
fixed: 0 required errors left

Focus, Klavye Tipi ve Input Formatter

Mobilde form doldurmanın en büyük kısmı klavyeyle geçiyor. keyboardType hangi klavyenin açılacağını seçiyor (emailAddress'te @ tuşu, number'da rakam klavyesi, phone'da tuş takımı). textInputAction klavyenin sağ alt tuşunu belirliyor: next bir sonraki alana geçiyor, done klavyeyi kapatıyor. Testte e-posta alanındayken klavyenin "next" tuşunu simüle ettik:

before next: email focused true
after next: password focused true

Bunun için kod yazmadık: TextFormField, next aksiyonunda FocusScope'taki bir sonraki alana kendisi geçiyor. Odağı kod ile yönetmek istediğinde FocusNode devreye giriyor. Adres formunda her alan için bir FocusNode ve bir GlobalKey<FormFieldState> tuttuk ve gönderimde hatalı ilk alana odaklandık:

Widget _field(
  AddressField field,
  String label,
  FormFieldValidator<String> validator, {
  TextInputType? keyboardType,
  List<TextInputFormatter>? inputFormatters,
  Iterable<String>? autofillHints,
  TextInputAction action = TextInputAction.next,
}) {
  return Padding(
    padding: const EdgeInsets.only(bottom: 12),
    child: TextFormField(
      key: _fieldKeys[field],
      controller: _controllers[field],
      focusNode: _focusNodes[field],
      decoration: InputDecoration(labelText: label),
      keyboardType: keyboardType,
      inputFormatters: inputFormatters,
      autofillHints: autofillHints,
      textInputAction: action,
      validator: validator,
      // Kullanıcı alanı değiştirene kadar sunucunun mesajı geçerli
      forceErrorText: _serverErrors[field.apiName],
      onChanged: (_) {
        if (_serverErrors.containsKey(field.apiName)) {
          setState(() => _serverErrors.remove(field.apiName));
        }
      },

FocusNode da bir ChangeNotifier; controller'lar gibi dispose edilmesi gerekiyor. "Önce Tahmin Et"teki senaryoyu çalıştırdık: yalnız ad alanını doldurduk, posta koduna 34a7x1 yazdık ve gönderdik:

postal code text after formatters: "3471"
focused: Address true, Full name false

Formatter'lar sırayla çalışıyor: digitsOnly harfleri attı ve geriye 3471 kaldı; beşten kısa olduğu için uzunluk sınırı bir şey yapmadı. Formatter girişi düzeltiyor ama doğrulamıyor: dört rakam hâlâ geçersiz, ve bunu validator yakalayacak. Odak ise ad alanından sonraki ilk hatalı alana, adrese gitti. Uzun bir formda kullanıcının hatayı aramak için kaydırması gerekmiyor.

Klavye İçeriği Örttüğünde

Klavye açıldığında ekranın alt yarısını kaplıyor. Scaffold'un resizeToAvoidBottomInset property'si varsayılan olarak true: klavyenin yüksekliğini MediaQuery'deki viewInsets'ten okuyup body'ye o kadar az yer veriyor. Body'nin içi SingleChildScrollView olduğu için içerik kaydırılabiliyor ve odaklanan alan görünür alana kaydırılıyor. 320 x 568'lik küçük bir ekranda 260 piksellik bir klavye simüle edip en alttaki telefon alanına dokunduk:

no keyboard: body max height 512.0, phone field bottom 400.0
keyboard 260: body max height 252.0, phone field bottom 277.0 (keyboard top 308)

Klavye açılınca body'nin yüksekliği 512'den 252'ye indi. Telefon alanının alt kenarı 400'deydi, yani klavyenin (308'den başlıyor) arkasında kalacaktı; Flutter alanı 277'ye kaydırdı. İki şeyden biri eksik olsaydı alan klavyenin altında kalırdı: resizeToAvoidBottomInset: false verilmiş bir Scaffold ya da kaydırılamayan bir Column. İkincisi bir önceki yazılardaki overflow hatasını da getirir: 252 piksele sığmayan bir Column.

Sunucu Hatalarını Alanlara Eşlemek

İstemcideki validation kullanıcıya hızlı geri bildirim veriyor, ama son söz sunucunun: "bu posta koduna teslimat yok" ya da "stokta yalnız 2 adet var" gibi kuralları yalnız sunucu bilir. ASP.NET Core, validation hatalarında ValidationProblemDetails döndürüyor; ASP.NET Core Web API serisinin "ASP.NET Core'da Model Validation: Data Annotations ve FluentValidation" yazısında bu yanıtı gerçek bir API'den görmüştük. errors içindeki key'ler property yolu: ShippingAddress.PostalCode, Items[0].Quantity. Bu yazıda API'ye henüz bağlanmadığımız için aynı biçimde elle yazdığımız bir test girdisi kullandık; gerçek API'ye bağlanmak API entegrasyonu yazısında. Yanıtı okuyan küçük class:

/// ASP.NET Core ValidationProblemDetails yanıtındaki field hataları:
/// {"title": "...", "status": 400, "errors": {"PostalCode": ["..."]}}
class ValidationProblem {
  const ValidationProblem({required this.title, required this.errors});

  final String title;
  final Map<String, List<String>> errors;

  factory ValidationProblem.fromJson(Map<String, Object?> json) {
    final errors = <String, List<String>>{};
    if (json case {'errors': Map<String, Object?> raw}) {
      for (final MapEntry(:key, :value) in raw.entries) {
        if (value case List<Object?> messages) {
          errors[key] = [for (final m in messages) '$m'];
        }
      }
    }
    return ValidationProblem(
      title: json['title'] as String? ?? 'The request is not valid.',
      errors: errors,
    );
  }

  /// [prefix] ile başlayan her field'ın ilk mesajı, prefix atılmış olarak
  /// ("ShippingAddress.City" → "City").
  Map<String, String> fieldErrors({String prefix = ''}) => {
    for (final MapEntry(:key, :value) in errors.entries)
      if (key.startsWith(prefix) && value.isNotEmpty)
        key.substring(prefix.length): value.first,
  };
}

Dördüncü yazıdaki pattern matching burada JSON'u güvenle okumak için işe yarıyor: errors yoksa ya da beklenen tipte değilse case eşleşmiyor ve exception yerine boş bir map kalıyor. Testte iki adres hatası ve bir sepet hatası içeren bir yanıtı çözdük:

PostalCode -> We do not deliver to this postal code.
City -> The field City must be at most 40 characters.

Hatayı alana yazmak için TextFormField'in forceErrorText property'sini kullandık: verildiğinde validator hiç çağrılmıyor ve alan bu mesajla hatalı görünüyor. Kullanıcı alanı değiştirdiğinde onChanged mesajı siliyor ve kontrol tekrar validator'a geçiyor. Hiçbir alana karşılık gelmeyen hata (Items[0].Quantity) kaybolmasın diye formun üstünde gösteriliyor. Test, sunucu yanıtı gelince ne olduğunu ve kullanıcı posta kodunu düzeltince ne değiştiğini gösteriyor:

postal code error: 1, form error: 1, postal code focused: true
after editing: 0 postal code error

Angular Reactive Forms ile Kıyas

Angular serisindeki "Angular Reactive Forms: FormGroup, FormBuilder, FormArray ve Validator'lar" yazısını okuduysan kavramların çoğu tanıdık. Eşleme:

Angular Reactive FormsFlutter
FormGroupForm + GlobalKey<FormState>
FormControlTextFormField + TextEditingController
Validators.required, kendi ValidatorFn'inString? Function(String?) validator
form.valid / markAllAsTouched()formKey.currentState!.validate()
updateOn: 'blur'AutovalidateMode.onUnfocus
control.setErrors({ server: ... })forceErrorText
valueChangesonChanged ya da controller listener'ı

En büyük fark, Flutter'da formun değerlerinin tek bir model object'inde toplanmaması. Angular'da form.value hazır bir object verir; Flutter'da değerleri controller'lardan okuyup modeli (DeliveryAddress) kendin kurarsın.

Üretimde Dikkat

Validation kuralını iki tarafta da yaz, ama sunucuyu esas al. İstemcideki kural yalnız hızlı geri bildirim; eski bir uygulama sürümü ya da doğrudan API'ye giden bir istek istemciyi atlar. Sunucunun mesajlarını alanlara eşleyen kod bu yüzden istemci kurallarından daha önemli.

Formu gerçek klavyeyle dene. Test ortamı klavye yüksekliğini simüle ediyor ama klavyenin üstündeki öneri çubuğunu, iOS'taki şifre önerisini ve Android'deki farklı klavye uygulamalarını göstermez. Bu yazı için gerçek cihazda deneme yapılmadı.

Hata mesajlarını kullanıcının dilinde ver. Sunucu Accept-Language'e göre yerelleştirilmiş mesaj dönüyorsa (dokuzuncu yazıdaki locale ile) istemci aynı dili göndermeli; aksi halde Türkçe arayüzde İngilizce bir sunucu mesajı görünür.

Sık Düşülen Tuzaklar

Birinci tuzak, TextEditingController'ı build içinde oluşturmak. Her rebuild'de yeni bir controller oluşur, kullanıcının yazdığı metin kaybolur ve eski controller hiç dispose edilmez. Controller'lar State'in field'ı olur, dispose'ta kapatılır.

İkinci tuzak, formatter'ı validator yerine kullanmak. LengthLimitingTextInputFormatter(5) beş rakamdan fazlasını engeller, eksik girişi engellemez; "Önce Tahmin Et"teki 3471 bunun örneği.

Üçüncü tuzak, sunucu hatasını InputDecoration.errorText ile göstermek. Bu yalnız görünümü değiştirir; form yine geçerli sayılır ve validate() hatayı silmez. forceErrorText alanın state'ini de hatalı yapar.

Dördüncü tuzak, eşlenemeyen sunucu hatasını yutmak. Formda karşılığı olmayan bir key geldiğinde kullanıcı hiçbir mesaj görmeden butonun çalışmadığını düşünür; eşlenemeyenleri her zaman formun üstünde göster.

Küçük Bir Deneme

★☆☆ Giriş formunun autovalidateMode'unu onUserInteraction yap ve testi çalıştır. "typed ada@" satırı değişiyor mu? Kullanıcı açısından fark ne?

★★☆ Adres formuna "Fatura adresi teslimat adresiyle aynı" diye bir CheckboxListTile ekle; işaret kaldırılınca ikinci bir adres bölümü açılsın ve ValidationProblem'ı BillingAddress. prefix'iyle o bölüme eşle.

★★★ ValidationProblem'a Items[0].Quantity gibi index'li key'leri (index: 0, field: 'Quantity') record'una çeviren bir method ekle ve sepet ekranında hatayı ilgili satırın altında göstermeyi planla. Unit testini yaz.

Bir sonraki yazıda state'i widget'lardan çıkarıyoruz: resmî mimari rehberine göre MVVM (Model-View-ViewModel; ekranı, ekranın state'ini tutan ViewModel'den ayıran pattern), ChangeNotifier ve ListenableBuilder, provider ile dependency injection, Command pattern'i ve sepet ekranı için CartViewModel.