Flutter Layout: Constraint'ler Aşağı, Size'lar Yukarı — Row, Column, Expanded, Stack ve Overflow Hataları
Flutter'da parent child'ına bir constraint verir, child bu sınırın içinde kendi boyunu seçer, konumu parent belirler; Row içindeki bir Text sınırsız genişlik aldığı için taşar. Constraint'leri testte yazdırıyor, tight ve loose farkını, Expanded ve Flexible'ı, üç klasik layout hatasını ve LayoutBuilder ile telefon/tablet düzeni olan kitap detay ekranını gerçek çıktılarla 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. Çıktılar flutter test koşularından ve web build'inin Playwright'ın headless Chromium'unda açılmasından; emulator ve cihazda çalıştırma bu yazı için yapılmadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) mobil uygulamasında kitap detay ekranını yazıyoruz. Başlık satırı basit: solda bir kitap ikonu, yanında kitabın tam adı. Kitap The Hobbit, tam adı "The Hobbit: or There and Back Again". Ekran 320 dp (density-independent pixel; ekran yoğunluğundan bağımsız mantıksal piksel) genişliğinde küçük bir telefon.
Row(
children: [
const Icon(Icons.menu_book),
Text(hobbit.displayTitle, style: const TextStyle(fontSize: 20)),
],
)
Ne olur: metin kendiliğinden ikinci satıra mı geçer, sonu "..." ile mi kesilir, yoksa ekranda sarı-siyah çizgili bir şerit ve konsolda bir hata mı görürsün? Web'de CSS Flexbox ile çalıştıysan ilk cevap mantıklı görünüyor. Flutter'da doğru cevap üçüncüsü, ve nedenini anlamak bu yazının konusu olan layout kuralının kendisi.
Layout Kuralı: Constraint'ler Aşağı, Size'lar Yukarı
Flutter'ın layout'u ağaçta tek bir geçişte yapılır ve resmî dokümandaki "Understanding constraints" sayfası bunu üç kısa cümleyle özetliyor: constraint'ler aşağı iner, size'lar yukarı çıkar, konumu parent belirler. Yani parent, child'ına bir constraint (en az ve en çok genişlik/yükseklik) verir; child bu sınırların içinde kendi boyunu seçip yukarı bildirir; child'ın nereye çizileceğine ise parent karar verir. Bir widget kendi konumunu bilmez ve istediği boyu seçemez, yalnız kendisine verilen aralıktan seçebilir.
Bunu tahmin etmek yerine görmek için her aldığı constraint'i yazdıran küçük bir yardımcı yazdık. LayoutBuilder, builder'ına parent'tan gelen constraint'i verir:
final log = <String>[];
/// Parent'tan aldığı constraint'i yazdırır, sonra kendi boyunu seçer.
Widget probe(String label, {Widget? child}) => LayoutBuilder(
builder: (context, constraints) {
log.add('$label: $constraints');
return child ?? const SizedBox(width: 100, height: 40);
},
);
Testte ekranı 390 x 844 dp'lik bir telefona ayarlayıp Scaffold'un gövdesine, 16'lık bir Padding içindeki Column'a ve onun farklı child'larına birer probe koyduk:
Scaffold body: BoxConstraints(0.0<=w<=390.0, 0.0<=h<=844.0)
Column child: BoxConstraints(0.0<=w<=358.0, 0.0<=h<=Infinity)
SizedBox(200x100) child: BoxConstraints(w=200.0, h=100.0)
Center child: BoxConstraints(0.0<=w<=358.0, 0.0<=h<=Infinity)
Row child: BoxConstraints(unconstrained)
Expanded in Row: BoxConstraints(w=258.0, 0.0<=h<=Infinity)
Satır satır okuyalım. Scaffold gövdesine ekran kadar yer veriyor ama küçük olmasına da izin veriyor. Padding iki yandan 16'şar çıkarınca genişlik üst sınırı 358'e iniyor. Column child'larına dikeyde sınır koymuyor (Infinity), çünkü child'ları alt alta dizecek ve her birinin kendi yüksekliğini seçmesini istiyor. SizedBox child'ına tek bir boy dayatıyor. Column içindeki bir Row ise non-flex child'larına iki eksende de sınır vermiyor; bu satırı aklında tut, overflow hatalarının kaynağı burası.
Kuralın ikinci ve üçüncü yarısı için aynı ekranda child'ların seçtiği boyu ve parent'ın onlara verdiği konumu okuduk:
title: size Size(57.0, 20.0), offset in parent Offset(71.5, 0.0)
box: size Size(200.0, 100.0), offset in parent Offset(0.0, 20.0)
column: size Size(200.0, 812.0), offset in parent Offset(16.0, 16.0)

Column'un genişliği en geniş child'ı kadar (200), yüksekliği ise verilen en büyük değer (812), çünkü mainAxisSize varsayılan olarak max. "Dune" yazısını yatayda ortalayan da Column: 71.5, (200 - 57) / 2. Yazının kendisi ortalandığını bilmiyor. Bir not: test ortamı gerçek font yerine her karakteri kare çizen bir test font'u kullanıyor, bu yüzden metin genişlikleri gerçek cihazdakinden farklı; constraint'ler ve konum mantığı ise aynı.
Tight ve Loose Constraint
Çıktıdaki iki biçim, iki constraint türüne karşılık geliyor. w=200.0, h=100.0 tight bir constraint: en az ve en çok değer aynı, child'ın seçeneği yok. 0.0<=w<=358.0 loose bir constraint: alt sınır sıfır, child 0 ile 358 arasında istediğini seçer. Center, Align ve Scaffold gövdesi gibi widget'lar aldıkları constraint'i loose hâle getirip child'a öyle verir; SizedBox, SizedBox.expand ve Expanded ise tight constraint verir.
Bu ayrım, Flutter'a yeni başlayan herkesin bir kez yaşadığı "Container'a width: 100 verdim ama ekranı kaplıyor" sorununu açıklıyor. MaterialApp'in home'una doğrudan konan bir widget ekran kadar tight constraint alır; 100 genişlik istemesi bir şeyi değiştirmez, çünkü parent'ı 390'dan başka bir değere izin vermiyor. Araya bir Center koyduğunda constraint loose olur ve 100 genişlik uygulanır. CSS'teki width bir istek, Flutter'daki width ise parent izin verirse uygulanan bir tercih.
Row, Column, Expanded ve Flexible
Row ve Column, CSS Flexbox'ın Flutter'daki karşılığı; ikisi de Flex widget'ının yatay ve dikey hâli. Web tarafından gelen okur için kavram eşlemesi:
| Flutter | CSS Flexbox | Fark |
|---|---|---|
Row / Column | flex-direction: row / column | Satır kaydırma yok; sığmayan içerik hata verir |
mainAxisAlignment | justify-content | Aynı değerler: start, center, spaceBetween... |
crossAxisAlignment | align-items | Column'da varsayılan center, CSS'te stretch |
Expanded | flex: 1 | Kalan alanı doldurmak zorunda (tight) |
Flexible | flex: 0 1 auto'ya yakın | Kalan alana kadar büyüyebilir, daha küçük kalabilir (loose) |
Wrap | flex-wrap: wrap | Ayrı bir widget |
Stack + Positioned | position: relative + absolute | Positioned yalnız doğrudan Stack child'ı olabilir |
Flex'in layout sırası şöyle: önce non-flex child'lar (sarılmamış olanlar) ana eksende sınırsız constraint'le ölçülür, sonra kalan alan flex değerlerine göre Expanded ve Flexible child'lar arasında paylaştırılır. Probe çıktısındaki Row child: unconstrained ve Expanded in Row: w=258.0 satırları tam olarak bu: 358 genişliğin 100'ünü ilk child aldı, kalan 258 Expanded'e tight olarak verildi.
"Önce Tahmin Et"teki cevap da buradan çıkıyor. Text bir non-flex child olduğu için sınırsız genişlik aldı, tam adını tek satırda yazdı ve Row'a sığmadı. Metni Expanded içine aldığında genişliği kalan alanla sınırlanıyor ve maxLines ile overflow devreye giriyor:
Row(
children: [
const Icon(Icons.menu_book),
Expanded(
child: Text(
hobbit.displayTitle,
style: const TextStyle(fontSize: 20),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
)
Üç Klasik Hata
Layout hataları debug build'de ekranda sarı-siyah çizgilerle, konsolda uzun bir açıklamayla gelir. Release build'de çizgi görünmez ama içerik yine kesik kalır. Üç klasik hatayı testte üretip ilk satırlarını topladık; testteki yardımcı, FlutterError.onError'ı geçici olarak değiştirip raporlanan hataları sayıyor:
without Expanded: A RenderFlex overflowed by 413 pixels on the right. (1 error(s))
with Expanded: no error
ListView in Column: Vertical viewport was given unbounded height. (15 error(s))
wrapped in Expanded: no error
Expanded in Padding: Incorrect use of ParentDataWidget. (1 error(s))
Birincisi "Önce Tahmin Et"teki satır: RenderFlex, Row ve Column'un render object'i. 413 piksel, test font'unda metnin taştığı miktar.
İkincisi, Column içine konan ListView. Column child'ına dikeyde sonsuz yer veriyor; ListView ise kaydırılabilir bir alan olduğu için mevcut yüksekliğin tamamını kaplamak istiyor. Sonsuzu kaplayamayacağı için hata veriyor. Tek pump'ta 15 hata raporlandı: ilki asıl neden, kalanların çoğu "RenderBox was not laid out" diyerek layout'u yarım kalan üst render object'leri tek tek sayıyor. Böyle bir yığında her zaman ilk hatayı oku. Çözüm, ListView'a sınırlı bir yükseklik vermek: Expanded ile kalan alanı ya da SizedBox ile sabit bir yükseklik. İnternette sık önerilen shrinkWrap: true hatayı kaldırır ama listenin lazy çalışmasını bozar; neden öyle olduğunu bir sonraki yazıda göreceğiz.
Üçüncüsü, Expanded'i Row, Column ya da Flex dışında kullanmak. Expanded kendi başına bir şey çizmez, parent'ına "beni flex child olarak yerleştir" bilgisini (parent data) taşır. Parent'ı bir Padding olunca bu bilginin gideceği yer yoktur. Positioned'ı Stack dışında kullanmak da aynı hatayı verir.
Gerçek bir ekranda bu hataları bulmak için Flutter DevTools'taki Flutter Inspector'ın layout görünümü (Layout Explorer), seçtiğin Row ya da Column'un constraint'lerini ve child'larının flex değerlerini gösterir ve değerleri orada değiştirip sonucu görmeni sağlar. Bu yazı için DevTools çalıştırılmadı; aynı bilgiyi yukarıdaki probe ve testlerle okuduk.
Stack, Positioned ve Wrap: Kapak ve Etiketler
Kitap detay ekranının kapağı, üst üste iki katmandan oluşuyor: kapak alanı ve e-kitaplarda sol üst köşedeki bir etiket. Üst üste bindirme Stack'in işi:
class BookCover extends StatelessWidget {
const BookCover({super.key, required this.book});
final Book book;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return AspectRatio(
aspectRatio: 2 / 3,
child: Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: colors.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.menu_book,
size: 64,
color: colors.onPrimaryContainer,
),
),
if (book.format == .ebook)
Positioned(
top: 8,
left: 8,
child: Chip(
label: const Text('E-book'),
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
}
AspectRatio, aldığı genişlikten 2:3 oranında bir yükseklik hesaplıyor; kapağın genişliğini parent'ı belirleyecek. StackFit.expand, konumlandırılmamış child'ların (DecoratedBox) Stack'in tamamını kaplamasını sağlıyor. Positioned ise child'ını kenarlara göre yerleştiriyor. Gerçek kapak görselini liste yazısında Image.network ile bu katmana ekleyeceğiz.
Kitap bilgilerinde iki Wrap var. Biri format, yıl ve ISBN (kitapların uluslararası kimlik numarası) etiketlerini sığdığı kadar yan yana, sığmayanı alt satıra diziyor. Diğerinin hikâyesi aşağıdaki "Üretimde Dikkat" bölümünde.
Responsive: LayoutBuilder, MediaQuery.sizeOf ve SafeArea
Detay ekranı telefonda kapak üstte, metin altta; tablette kapak solda, metin sağda olmalı. Karar LayoutBuilder'dan gelen genişliğe göre veriliyor:
class BookDetailsScreen extends StatelessWidget {
const BookDetailsScreen({super.key, required this.book, this.onAddToCart});
/// Bu genişliğin altında kapak metnin üstünde, bu genişlikten itibaren solda.
static const twoColumnMinWidth = 600.0;
final Book book;
final VoidCallback? onAddToCart;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(book.title)),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final info = BookInfo(book: book, onAddToCart: onAddToCart);
if (constraints.maxWidth >= twoColumnMinWidth) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(24),
child: SizedBox(width: 240, child: BookCover(book: book)),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(0, 24, 24, 24),
child: info,
),
),
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SizedBox(width: 180, child: BookCover(book: book)),
const SizedBox(height: 16),
info,
],
),
);
},
),
),
);
}
}
600 dp sınırı Material'ın pencere boyutu önerisinden geliyor; Flutter'ın adaptive tasarım dokümanı da 600'ün altını telefon düzeni, üstünü geniş düzen olarak örnekliyor. Neden MediaQuery yerine LayoutBuilder: MediaQuery.sizeOf(context) uygulama penceresinin boyunu verir, LayoutBuilder ise bu widget'a ayrılan alanı. Detay ekranı ileride tablette bir listenin yanında, ekranın yarısında açılırsa doğru cevabı LayoutBuilder verir. Pencere boyuna göre karar vermen gereken yerde (örneğin alt menü mi yan menü mü) MediaQuery.of(context).size yerine MediaQuery.sizeOf(context) kullan; ilki MediaQuery'deki herhangi bir değer değiştiğinde (klavye açılması gibi) rebuild yapar, ikincisi yalnız boyut değişince. Flutter dokümanı ayrıca cihaz tipine ("tablet mi?") ya da yöne (portrait/landscape) bakarak layout seçmemeni öneriyor: bölünmüş ekranda ve katlanabilir cihazlarda pencere boyuyla cihaz tipi örtüşmez.
SafeArea, çentik, durum çubuğu ve alttaki sistem çubuğunun kapladığı alanları MediaQuery'deki padding değerlerinden okuyup içeriği oradan uzak tutar. Burada AppBar üst kısmı zaten hallettiği için asıl işi yatay modda ve alt kenarda.
İki düzeni testte telefon ve tablet boyutunda açıp kapakla başlığın konumunu ölçtük:
phone 390: cover (105, 72) 180x270, title (16, 358) 358x96
tablet 1024: cover (24, 80) 240x360, title (288, 80) 712x64
Aynı build'i web'e derleyip Playwright'ın headless Chromium'unda 390 ve 1024 genişliğinde açtık, kartlardan The Hobbit'e dokunup başlığın çizildiği yeri okuduk:
phone 390: title at (16, 358), console errors: 0
tablet 1024: title at (288, 80), console errors: 0
Konumlar testtekiyle aynı. Kartlara dokunulunca detay ekranını açan Navigator.push şimdilik HomeScreen'de duran tek satırlık geçici bir çözüm; navigation yazısında bunu go_router'a taşıyacağız.
Üretimde Dikkat
Ekranı en küçük genişlikte ve büyük yazıyla test et. Detay ekranındaki fiyat satırı ilk hâliyle bir Row'du: solda Expanded içinde fiyat, sağda "Add to cart" butonu. 390 dp'de sorunsuz görünüyordu. Kullanıcıların erişilebilirlik ayarından yazıyı büyütebildiğini hatırlayıp testi 320 dp ve 2 kat yazı boyutuyla çalıştırdığımızda şu çıktı geldi:
Expected: null
Actual: FlutterError:<A RenderFlex overflowed by 63 pixels on the right.>
Buton, yazı büyüyünce Row'a sığmıyordu ve Expanded yalnız fiyatı daraltabiliyordu. Çözüm, satırı yer kalmayınca butonu alt satıra indiren bir Wrap'e çevirmek oldu:
// Row burada taşıyordu (320 dp, 2x yazı); Wrap butonu alt satıra indirir
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 16,
runSpacing: 8,
children: [
Text(book.price.format(), style: textTheme.titleLarge),
FilledButton.icon(
onPressed: onAddToCart,
icon: const Icon(Icons.add_shopping_cart),
label: const Text('Add to cart'),
),
],
),
320 dp, text x2: no overflow
Bu testi her ekran için yaz. Test, MediaQuery.withClampedTextScaling ile yazı ölçeğini sabitliyor ve tester.view.physicalSize ile ekran boyutunu değiştiriyor; emulator gerektirmiyor ve saniyenin altında çalışıyor.
Sabit yükseklik verme. Metin içeren bir kutuya SizedBox(height: 48) vermek, yazı büyüdüğünde ya da çeviri uzun geldiğinde taşma demek. Yüksekliği içeriğe bırak, uzun olabilecek ekranları SingleChildScrollView içine al.
Release build'de overflow sessizdir: çizgiler görünmez, hata konsola düşmez, kullanıcı yalnız kesik bir buton görür. Bu yüzden overflow'u debug'da ve testte yakalamak zorundasın.
Sık Düşülen Tuzaklar
Birinci tuzak, overflow'u her yere Expanded koyarak susturmak. Expanded yalnız Row/Column'un doğrudan child'ı olabilir ve child'ını kalan alanı doldurmaya zorlar. Child'ın daha küçük kalması gerekiyorsa Flexible, satır kaydırma gerekiyorsa Wrap doğru araç.
İkinci tuzak, Column içindeki ListView'ı shrinkWrap: true ile düzeltmek. Kısa listelerde fark görünmez, uzun listede bütün satırlar bir kerede layout'a girer.
Üçüncü tuzak, iç içe aynı eksende sınırsız alan üretmek: SingleChildScrollView içinde Column, onun içinde Expanded. Kaydırılabilir alan child'ına dikeyde sonsuz yer verir; Expanded sonsuzu paylaştıramaz ve hata verir.
Dördüncü tuzak, responsive kararı Platform.isIOS, cihaz modeli ya da yön üzerinden vermek. Karar, widget'a ayrılan alan (LayoutBuilder) ya da pencere boyu (MediaQuery.sizeOf) üzerinden verilir.
Küçük Bir Deneme
★☆☆ constraints_test.dart'taki Column'a crossAxisAlignment: CrossAxisAlignment.stretch ekle. "Column child" satırındaki constraint nasıl değişti, "Dune" yazısının konumu ne oldu?
★★☆ BookInfo'daki Wrap'i tekrar Row yap ve fiyatı Expanded yerine Flexible ile sar. 320 dp ve 2x yazı testi geçiyor mu? Geçmiyorsa taşan child hangisi?
★★★ Detay ekranına 900 dp'den geniş ekranlar için üçüncü bir düzen ekle: kapak, bilgiler ve sağda "Aynı yazarın diğer kitapları" için 280 dp'lik bir sütun. 1280 x 800 için bir test yaz ve üç sütunun konumlarını yazdır.
Bir sonraki yazıda katalog ekranına geçiyoruz: ListView.builder ile lazy rendering, GridView, sliver'lar ve CustomScrollView, Image.network ile kapak görselleri, pull-to-refresh, sayfalı yükleme ve boş, yükleniyor, hata durumları.