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

Flutter: TextField ve TextEditingController Kullanımı

Ahmet Balaman

7 dk okuma

FlutterTextFieldTextEditingControllerFocusNodeInputDecorationInput
Flutter: TextField ve TextEditingController Kullanımı

TextField, kullanıcıdan metin almanın temel widget'ıdır: arama kutusu, sohbet girişi, telefon numarası, şifre. Tek başına ekrana koyması kolaydır; asıl iş, içindeki metni okumak ve yönetmek, klavyeyi doğru açmak, odağı alanlar arasında taşımak ve yanlış girişi baştan engellemektir. Bu yazıda TextEditingController'ın yaşam döngüsünden FocusNode'a, inputFormatters'tan şifre alanına kadar bir metin alanının ihtiyaç duyduğu parçaları sırayla kuruyor, sonunda da hepsini bir kayıt ekranında birleştiriyoruz. Doğrulama ve toplu gönderim tarafı Form yazısının konusu; burada onun temelini oluşturan alanın kendisine odaklanıyoruz.

Temel Kullanım: TextEditingController

Alandaki metne koddan ulaşmanın standart yolu bir TextEditingController'dır. Controller bir nesne olduğu ve dinleyiciler tuttuğu için State sınıfında bir kez oluşturulur ve dispose() içinde kapatılır:

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(); // Kendi oluşturduğun controller'ı kendin kapat
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _controller,
          decoration: const InputDecoration(labelText: 'Adın'),
        ),
        FilledButton(
          onPressed: () {
            final name = _controller.text.trim();
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Merhaba, $name')),
            );
          },
          child: const Text('Selam ver'),
        ),
      ],
    );
  }
}

dispose kuralı TextEditingController'a özel değil; FocusNode, AnimationController, ScrollController gibi kendi oluşturduğun her şey için geçerli. Neden initState ve dispose üzerinden gittiğimizi widget yaşam döngüsü yazısında ayrıntılı anlattım.

Controller ile Neler Yapılır?

Controller yalnızca okumak için değil, alanı koddan yönetmek için de kullanılır:

// Okumak
final current = controller.text;

// Temizlemek
controller.clear();

// Başlangıç metni vermek
final cityController = TextEditingController(text: 'İstanbul');

// Metni değiştirip imleci sona koymak
const newText = 'Ankara';
controller.value = const TextEditingValue(
  text: newText,
  selection: TextSelection.collapsed(offset: newText.length),
);

// Tüm metni seçili hâle getirmek
controller.selection = TextSelection(
  baseOffset: 0,
  extentOffset: controller.text.length,
);

controller.text = 'Ankara' de metni değiştirir, ama seçimi (imleç konumunu) geçersiz bir değere sıfırlar. İmlecin nerede duracağı önemliyse yukarıdaki gibi value'yu metin ve seçimle birlikte ver.

TextEditingController aslında bir ValueNotifier<TextEditingValue>, yani dinlenebilir. Bu, alan her değiştiğinde yalnızca ilgili parçayı yeniden çizmeyi kolaylaştırır. Örneğin içi doluyken beliren bir "temizle" butonu:

ListenableBuilder(
  listenable: controller,
  builder: (context, child) => TextField(
    controller: controller,
    decoration: InputDecoration(
      hintText: 'Ara',
      prefixIcon: const Icon(Icons.search),
      suffixIcon: controller.text.isEmpty
          ? null
          : IconButton(
              icon: const Icon(Icons.clear),
              tooltip: 'Temizle',
              onPressed: controller.clear,
            ),
    ),
  ),
)

Bir ayrıntı: controller dinleyicileri yalnızca metin değişince değil, imleç ya da seçim değişince de tetiklenir. "Metin değişti mi?" sorusunu soruyorsan önceki değeri saklayıp karşılaştırman gerekir.

InputDecoration: Alanın Görünümü

Etiket, ipucu, ikon ve hata metni gibi alanın etrafındaki her şey InputDecoration ile verilir:

const TextField(
  decoration: InputDecoration(
    labelText: 'E-posta',
    hintText: '[email protected]',
    helperText: 'Makbuz bu adrese gönderilir',
    prefixIcon: Icon(Icons.email_outlined),
    border: OutlineInputBorder(),
  ),
)
Özellik Ne işe yarar?
labelText Alan boşken içinde, odaklanınca üstte duran etiket
hintText Alan boşken görünen örnek metin
helperText Alanın altında küçük açıklama
errorText Altta kırmızı hata metni; null değilse alan hata görünümüne geçer
prefixIcon, suffixIcon Alanın başındaki ve sonundaki ikon ya da buton
prefixText, suffixText Sabit metin, örneğin +90 ya da TL
border OutlineInputBorder (çerçeve) ya da UnderlineInputBorder (alt çizgi)
counterText maxLength sayacının metni; '' verirsen sayaç gizlenir

Etiket için labelTexthintText mi sorusunun cevabı basit: kullanıcı yazmaya başlayınca hintText kaybolur, labelText ise yukarı kayıp görünmeye devam eder. Alanın ne olduğunu söyleyen bilgi labelText'e, biçim örneği hintText'e yazılır.

errorText ile Form kullanmadan da hata gösterebilirsin: bir String? state değişkeni tut, kontrol sonucunu ona ata ve InputDecoration(errorText: _usernameError) olarak ver.

Klavye: keyboardType ve textInputAction

keyboardType, telefonda hangi klavyenin açılacağını belirler; textInputAction ise klavyenin sağ alt tuşunu:

keyboardType Kullanım
TextInputType.emailAddress @ ve nokta öne çıkar
TextInputType.number Rakam klavyesi
TextInputType.numberWithOptions(decimal: true) Ondalık ayırıcılı rakam klavyesi
TextInputType.phone Telefon tuş takımı
TextInputType.url / ve . gibi tuşlar öne çıkar
TextInputType.multiline Enter tuşu yeni satır ekler (maxLines: null ile)

textInputAction için en sık kullanılanlar next (sonraki alana geç), done (bitti), search ve send. Flutter'ın varsayılan davranışı şöyle: next tuşuna basılınca odak okuma sırasındaki bir sonraki alana geçer; done, search, send gibi tamamlayıcı tuşlarda ise odak bırakılır ve klavye kapanır.

Önemli uyarı: keyboardType yalnızca klavyenin görünümünü değiştirir, girişi kısıtlamaz. Kullanıcı yapıştırma yapabilir; tablette ya da masaüstünde fiziksel klavyeyle istediği karakteri yazabilir. Girişi gerçekten sınırlamak inputFormatters'ın işidir.

inputFormatters: Yanlış Girişi Baştan Engellemek

Formatter'lar, kullanıcının her düzenlemesini metin alana yazılmadan önce süzer. Hazır olanlar package:flutter/services.dart içinden gelir:

import 'package:flutter/services.dart';

// Sadece rakam, en fazla 10 hane
TextField(
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(10),
  ],
)

// Boşluk yazılamayan kullanıcı adı
TextField(
  inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
)

// Sayaçlı, çok satırlı not alanı
const TextField(
  maxLength: 280,
  maxLines: null,
  keyboardType: TextInputType.multiline,
)

FilteringTextInputFormatter.allow ile de yalnızca belirli bir desene uyan karakterlere izin verebilirsin. maxLength ile LengthLimitingTextInputFormatter arasındaki fark görünürlüktedir: ilki uzunluğu sınırlar ve altta 12/280 gibi bir sayaç gösterir, ikincisi sessizce sınırlar. Formatter'lar onChanged'den önce çalışır; yani onChanged'e gelen değer zaten süzülmüş değerdir.

Şifre Alanı: obscureText

Şifre alanında karakterler obscureText: true ile gizlenir. Kullanıcının yazdığını kontrol edebilmesi için bir göster/gizle butonu eklemek iyi bir alışkanlıktır:

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: 'Şifre',
        suffixIcon: IconButton(
          icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
          tooltip: _obscure ? 'Şifreyi göster' : 'Şifreyi gizle',
          onPressed: () => setState(() => _obscure = !_obscure),
        ),
      ),
    );
  }
}

enableSuggestions: false ve autocorrect: false klavyenin şifreyi kelime önerisi olarak öğrenmesini ve düzeltmesini engeller. autofillHints ise işletim sisteminin şifre yöneticisine "bu bir şifre alanı" der; kayıtlı şifreyi otomatik doldurma böyle çalışır.

onChanged, onSubmitted ve onEditingComplete

Üç callback de "kullanıcı bir şey yaptı" der ama farklı anlarda çalışır:

  • onChanged: Her tuş vuruşunda, silmede, yapıştırmada. Anlık arama filtresi, karakter sayacı, butonu etkinleştirme gibi işler için.
  • onSubmitted: Kullanıcı klavyenin eylem tuşuna (bitti, ara, gönder) bastığında, alanın son değeriyle. Aramayı başlatmak, mesajı göndermek, sonraki alana geçmek için.
  • onEditingComplete: Eylem tuşunda, onSubmitted'dan önce ve değer almadan. Verirsen Flutter'ın varsayılan davranışı (odağı bırakma ya da sonraki alana geçme) artık çalışmaz; o işi senin yapman gerekir. Çoğu durumda buna ihtiyacın olmaz.
TextField(
  textInputAction: TextInputAction.search,
  onChanged: (value) => _filterList(value), // Her harfte liste süzülür
  onSubmitted: (value) => _saveRecentSearch(value), // Ara tuşunda kaydedilir
)

Bir tuzak: onChanged yalnızca kullanıcının yaptığı değişikliklerde çalışır. controller.text = ... ya da controller.clear() ile koddan yaptığın değişikliklerde çağrılmaz. Her iki tür değişikliğe de tepki vermen gerekiyorsa controller'ı dinle.

FocusNode ile Odak Yönetimi

Odak, klavyenin hangi alana yazdığını belirler. textInputAction: TextInputAction.next çoğu formda odağı zaten taşır, ama sıra ekrandaki okuma sırasıdır. Belirli bir alana atlamak, bir alan odak kazanınca ya da kaybedince bir şey yapmak veya klavyeyi koddan kapatmak için FocusNode gerekir:

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(); // FocusNode da kapatılır
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          textInputAction: TextInputAction.next,
          onSubmitted: (_) => _cityFocus.requestFocus(), // İstediğin alana geç
        ),
        TextField(
          focusNode: _cityFocus,
          // Mobilde alan dışına dokununca klavye kendiliğinden kapanmaz
          onTapOutside: (_) => FocusScope.of(context).unfocus(),
        ),
      ],
    );
  }
}

Klavyeyi herhangi bir yerden kapatmak için FocusManager.instance.primaryFocus?.unfocus() da kullanılabilir. Bir alanın odak durumuna göre arayüzü değiştirmek istiyorsan focusNode.hasFocus değerini okursun; FocusNode da bir ChangeNotifier olduğu için ListenableBuilder ile dinlenebilir.

Mini Senaryo: Hesap Oluşturma Ekranı

Şimdi parçaları birleştirelim. Ad, telefon ve şifre alanı olan; klavyeden "ileri" ile ilerlenen; telefon alanına yalnızca 10 rakam yazılabilen; şifresi gösterilip gizlenebilen ve alanlar geçerli olana kadar butonu kapalı tutan bir ekran:

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;

  // Üç controller'dan biri değişince buton yeniden değerlendirilsin
  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(); // Klavyeyi kapat
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Hoş geldin, ${_nameController.text.trim()}')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Hesap oluştur')),
      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: 'Ad soyad',
                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: '5XXXXXXXXX',
                prefixText: '+90 ',
                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: 'Şifre',
                helperText: 'En az 8 karakter',
                prefixIcon: const Icon(Icons.lock_outline),
                border: const OutlineInputBorder(),
                suffixIcon: IconButton(
                  icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
                  tooltip: _obscure ? 'Şifreyi göster' : 'Şifreyi gizle',
                  onPressed: () => setState(() => _obscure = !_obscure),
                ),
              ),
            ),
            const SizedBox(height: 24),
            ListenableBuilder(
              listenable: _fields,
              builder: (context, child) => FilledButton(
                onPressed: _canSubmit ? _submit : null,
                child: const Text('Kayıt ol'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Bu ekranda dikkat edilecek kararlar şunlar. Buton için setState yazmadık: Listenable.merge üç controller'ı tek bir dinlenebilir nesnede topluyor ve yalnızca butonu yeniden çiziyor. Telefon alanına harf ya da boşluk yapıştırılsa bile formatter onları siliyor; 10 hane kuralı da LengthLimitingTextInputFormatter ile garanti altında. Odak, onSubmitted içinde requestFocus ile açıkça bir sonraki alana taşınıyor, son alanda ise "bitti" tuşu doğrudan kaydı tetikliyor. autofocus: true ekran açılır açılmaz ilk alana odaklanıp klavyeyi açıyor. textCapitalization: TextCapitalization.words ise klavyeye her kelimeye büyük harfle başlamasını söylüyor; bu yalnızca bir klavye ipucu, kullanıcı yine küçük harf yazabilir, kesin bir kural gerekiyorsa formatter yazman gerekir. Gövdenin SingleChildScrollView olması, klavye açıldığında alanların taşmasını önlüyor; ayrıntısı ScrollView yazısında. Sonuç mesajı için de SnackBar kullandık.

TextField mı, TextFormField mı?

TextFormField, TextField'ın bir Form içinde çalışacak şekilde sarılmış hâlidir: validator, onSaved ve autovalidateMode ekler ve Form'un tek bir validate() çağrısına katılır. Bu yazıda anlattığımız her şey (controller, decoration, keyboardType, inputFormatters, focusNode, obscureText) orada da aynen geçerli; yalnızca onSubmitted'ın adı onFieldSubmitted.

Kısa kural: birlikte doğrulanıp birlikte gönderilen birden fazla alan varsa Form ve TextFormField; arama kutusu, sohbet girişi ya da yukarıdaki gibi basit kurallı bir ekran için TextField. Doğrulama akışının tamamı, validate() ve save() farkı ile birlikte Form yazısında. Metin dışındaki girişler (Switch, Checkbox, Slider) için de girdi widget'ları yazısına bakabilirsin.

Sık Yapılan Hatalar

1. Controller'ı build içinde oluşturmak

Belirti: Yazdıkça metin siliniyor ya da imleç başa atlıyor. build içindeki TextEditingController() satırı her yeniden çizimde yeni ve boş bir controller üretir. Controller State alanı olmalı ve dispose() içinde kapatılmalı. dispose unutulursa da ekran kapansa bile controller'ın dinleyicileri bellekte kalır.

2. onChanged içinde controller.text'i değiştirmek

Metni büyük harfe çevirmek ya da biçimlendirmek için onChanged içinde controller.text = ... yazmak imlecin zıplamasına yol açar, çünkü text ataması seçimi sıfırlar. Girişi dönüştürmenin doğru yeri inputFormatters'tır; hazır formatter yetmiyorsa TextInputFormatter sınıfından türeyen kendi formatter'ını yazarsın.

3. keyboardType'ın girişi kısıtladığını sanmak

Rakam klavyesi açılan alana yapıştırmayla harf gelebilir. TextInputType.number kullanıyorsan yanına FilteringTextInputFormatter.digitsOnly da ekle; sunucu tarafında da ayrıca kontrol et.

4. obscureText ile çok satırlı alan

obscureText: true iken maxLines 1'den farklıysa şu assert hatasını alırsın:

Obscured fields cannot be multiline.

Şifre alanı her zaman tek satırdır; maxLines vermeyi bırak.

5. TextField'ı Row içine çıplak koymak

Belirti: An InputDecorator, which is typically created by a TextField, cannot have an unbounded width. Row çocuklarına sınırsız genişlik sunar, TextField ise genişliğini bilmek zorundadır. Alanı Expanded ya da belirli genişlikte bir SizedBox ile sar; Expanded'ın nasıl çalıştığı Expanded yazısında.

Sık Sorulan Sorular

Alan dışına dokununca klavye neden kapanmıyor?

Flutter, platform alışkanlıklarına uymak için mobilde alan dışındaki dokunuşlarda odağı bırakmaz; masaüstünde fare tıklaması ise odağı bırakır. Mobilde de kapanmasını istiyorsan TextField'a onTapOutside: (_) => FocusScope.of(context).unfocus() ver ya da sayfanın boş alanına bir dokunuş algılayıcı koyup aynı çağrıyı yap.

Metni koddan değiştirince onChanged neden çalışmıyor?

Çünkü onChanged yalnızca kullanıcının yaptığı değişiklikleri bildirir; controller üzerinden yapılan değişiklikler için çağrılmaz. Hem kullanıcı hem kod kaynaklı değişikliklere tepki vermek istiyorsan controller'a addListener ile dinleyici ekle ya da alanı ListenableBuilder ile dinle.

Klavye açılınca alt taraftaki alan klavyenin arkasında kalıyor, ne yapmalıyım?

Scaffold varsayılan olarak klavye açıldığında gövdeyi küçültür (resizeToAvoidBottomInset: true). Gövde kaydırılabilir değilse içerik taşar ve alt alanlara ulaşılamaz. Formu SingleChildScrollView ya da ListView içine al; odaklanan alan kaydırılabilir bir alanın içindeyse Flutter onu görünür bölgeye kaydırır.

Yorumlar