Flutter: TextField ve TextEditingController Kullanımı
7 dk okuma

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 labelText mı hintText 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.
İlgili Yazılar
Flutter: Form Widget ve Girdi Kontrolleri
Flutter Form ve TextFormField ile doğrulama: validate() ile save() farkı, autovalidateMode, odak yönetimi, controller dispose ve giriş formu örneği.
Flutter: Theme ve ThemeData ile Açık ve Koyu Tema
Flutter'da ThemeData ile tema kurmak: ColorScheme.fromSeed, açık ve koyu tema, ThemeMode, Theme.of ile okuma, TextTheme, bileşen temaları ve ThemeExtension.
Flutter: Stack ve Positioned ile Üst Üste Yerleşim
Flutter Stack ve Positioned rehberi: Stack'in boyutu, fit, alignment, clipBehavior, Positioned.fill, PositionedDirectional, rozet ve katman tarifleri, hatalar.