Layout #
Sistem layout Flutter dibangun di atas satu prinsip yang konsisten: constraints go down, sizes go up, parent sets position. Semua widget layout — Row, Column, Stack, Expanded, Flexible — adalah ekspresi dari prinsip ini. Memahami cara kerjanya dari dalam membuat kamu mampu memprediksi perilaku layout dengan tepat, bukan menebak-nebak.
Constraint System — Fondasi Segalanya #
Setiap widget di Flutter menerima BoxConstraints dari parent-nya: batas minimum dan maksimum lebar serta tinggi yang diizinkan. Widget kemudian memilih ukurannya sendiri dalam batas tersebut.
Parent memberikan constraints ke child:
BoxConstraints(
minWidth: 0, maxWidth: 360,
minHeight: 0, maxHeight: 640,
)
Child memilih ukuran dalam batas itu:
Size(width: 200, height: 100) // harus dalam range constraints
Parent menetapkan posisi child:
Offset(x: 80, y: 270) // child tidak bisa memilih posisinya sendiri
Tiga Kategori RenderBox #
Dalam Flutter, widget dirender oleh RenderBox yang menentukan cara menangani constraints yang diterima. Ada tiga kategori: widget yang mencoba sebesar mungkin (seperti Center dan ListView), widget yang mencoba sama ukurannya dengan children (seperti Transform dan Opacity), dan widget yang mencoba ukuran tertentu (seperti Image dan Text).
// 1. Mencoba sebesar mungkin (greedy)
Center(child: ...) // ambil semua ruang yang tersedia
ColoredBox(color: red) // ambil semua ruang constraints
// 2. Mengikuti ukuran child (wrap content)
Padding(padding: ..., child: ...) // ukuran = child + padding
Transform.rotate(...) // ukuran = child
// 3. Ukuran spesifik (fixed)
SizedBox(width: 100, height: 50) // tepat 100x50
Image.asset('...') // ukuran dari gambar
Tight vs Loose Constraints #
Tight constraint: min == max
BoxConstraints.tight(Size(360, 640))
--> child DIPAKSA berukuran 360x640
--> Contoh: Screen memberikan constraint ke MaterialApp
Loose constraint: min < max (biasanya min == 0)
BoxConstraints(maxWidth: 360, maxHeight: 640)
--> child BEBAS memilih ukuran hingga 360x640
--> Contoh: Column memberikan constraint ke child-nya
Column dan Row — Layout Linear #
Column dan Row adalah widget layout paling dasar — menyusun children secara vertikal dan horizontal. Keduanya adalah implementasi dari Flex dan berbagi API yang hampir identik.
Column (vertikal): Row (horizontal):
┌───────────┐ ┌──┬──┬──┐
│ Child 1 │ │1 │2 │3 │
├───────────┤ └──┴──┴──┘
│ Child 2 │
├───────────┤ main axis → horizontal
│ Child 3 │ cross axis ↕ vertical
└───────────┘
main axis ↕ vertikal
cross axis → horizontal
MainAxisAlignment #
Mengontrol distribusi children di sepanjang main axis:
Column(
mainAxisAlignment: MainAxisAlignment.start, // default: atas
// .center -- tengah
// .end -- bawah
// .spaceBetween -- jarak merata, ujung menempel tepi
// .spaceAround -- jarak merata, ujung setengah jarak
// .spaceEvenly -- jarak sama rata termasuk ujung
children: [...],
)
spaceBetween: [A]──────[B]──────[C]
spaceAround: ──[A]────[B]────[C]──
spaceEvenly: ───[A]───[B]───[C]───
CrossAxisAlignment #
Mengontrol penyelarasan children di cross axis:
Row(
crossAxisAlignment: CrossAxisAlignment.center, // default: tengah
// .start -- atas (untuk Row) / kiri (untuk Column)
// .end -- bawah / kanan
// .stretch -- rentangkan penuh di cross axis
// .baseline -- sejajarkan baseline teks
children: [...],
)
MainAxisSize #
Secara default, Row atau Column menggunakan ruang sebanyak mungkin di sepanjang main axis. Namun jika ingin mengemas children sedekat mungkin, atur mainAxisSize ke MainAxisSize.min.
// Default: .max -- ambil semua ruang di main axis
Column(
mainAxisSize: MainAxisSize.max, // Column setinggi parent
children: [...],
)
// .min -- hanya sebesar children (wrap content)
Column(
mainAxisSize: MainAxisSize.min, // Column setinggi total children
children: [...],
)
Expanded dan Flexible — Membagi Ruang Sisa #
Ketika ada ruang sisa di dalam Row atau Column, Expanded dan Flexible mengontrol bagaimana children membagi ruang tersebut.
Expanded — Isi Semua Ruang Sisa #
Expanded membuat child dari Row, Column, atau Flex mengambil semua ruang tersedia di sepanjang main axis. Jika beberapa children di-expand, ruang yang tersedia dibagi berdasarkan flex factor.
Row(
children: [
// Widget tanpa Expanded -- ukuran intrinsiknya
const Icon(Icons.star), // ~24x24
// Expanded -- ambil SEMUA ruang sisa
Expanded(
child: Text('Nama yang mungkin panjang'),
),
// Expanded dengan flex factor
Expanded(
flex: 2, // dua kali lipat dari Expanded lain
child: Container(color: Colors.blue),
),
],
)
Sebelum Expanded:
[Icon 24px][ruang sisa: 300px]
Dengan satu Expanded:
[Icon 24px][──────── Text 300px ────────]
Dengan dua Expanded (flex: 1 dan 2):
[Icon 24px][── Text 100px ──][──── Container 200px ────]
flex: 1 flex: 2 (2x lipat)
Flexible — Ruang Sisa tapi Tidak Dipaksa #
Flexible memberikan child fleksibilitas untuk mengembang mengisi ruang tersedia di main axis, tapi berbeda dari Expanded, Flexible tidak memaksa child mengisi ruang tersebut.
Row(
children: [
// Expanded: DIPAKSA mengisi ruang yang dialokasikan
Expanded(
child: Text('Teks ini akan mengisi penuh ruang Expanded'),
),
// Flexible: boleh lebih kecil dari ruang yang dialokasikan
Flexible(
child: Text('Teks pendek'), // hanya selebar teksnya
),
],
)
Expanded vs Flexible — Kapan Memilih? #
EXPANDED (FlexFit.tight):
✓ Ingin widget selalu mengisi ruang yang dialokasikan
✓ Ingin membagi ruang secara proporsional
✗ Jangan jika konten bisa lebih kecil dari ruang yang tersedia
FLEXIBLE (FlexFit.loose):
✓ Ingin widget bisa lebih kecil dari ruang alokasi
✓ Konten bisa bervariasi ukurannya
✗ Tidak untuk pembagian ruang yang ketat dan proporsional
Spacer — Ruang Kosong Fleksibel #
Spacer adalah shorthand untuk Expanded(child: SizedBox.shrink()) — widget tak terlihat yang mengisi ruang sisa:
Row(
children: [
const Text('Kiri'),
const Spacer(), // dorong ke kanan
const Text('Kanan'),
],
)
Row(
children: [
const Text('A'),
const Spacer(flex: 2), // dua kali ruang dari Spacer lain
const Text('B'),
const Spacer(),
const Text('C'),
],
)
// A ──────────── B ────── C
// (2x) (1x)
Stack — Layout Bertumpuk #
Stack menyusun children secara berlapis — children berikutnya di-render di atas children sebelumnya. Berguna untuk overlay, badge, watermark, dan efek visual.
Stack(
alignment: Alignment.center, // alignment default untuk non-Positioned children
children: [
// Child pertama: paling belakang
Image.asset('background.jpg', fit: BoxFit.cover),
// Non-Positioned: mengikuti alignment Stack
const Text('Teks di tengah'),
// Positioned: posisi absolut dari tepi Stack
const Positioned(
top: 16,
right: 16,
child: Badge(label: Text('3')),
),
// Positioned.fill: isi seluruh Stack
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.3),
),
),
],
)
Stack Sizing #
// StackFit.loose (default): Stack mengikuti ukuran terbesar non-Positioned child
Stack(
fit: StackFit.loose,
children: [...],
)
// StackFit.expand: Stack mengambil constraint yang diberikan parent
// Semua non-Positioned children dipaksa mengisi Stack
Stack(
fit: StackFit.expand,
children: [
Image.asset('bg.jpg', fit: BoxFit.cover), // isi penuh
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Caption'),
),
),
],
)
Alignment — Positioning di Dalam Widget #
Alignment menggunakan sistem koordinat dari (-1, -1) di pojok kiri atas hingga (1, 1) di pojok kanan bawah, dengan (0, 0) di tengah:
(-1,-1) ──────── (0,-1) ──────── (1,-1)
| | |
(-1, 0) ──────── (0, 0) ──────── (1, 0)
| | |
(-1, 1) ──────── (0, 1) ──────── (1, 1)
Alignment.topLeft = Alignment(-1, -1)
Alignment.center = Alignment(0, 0)
Alignment.bottomRight = Alignment(1, 1)
// Align -- posisikan satu child di dalam parent
Container(
width: 200,
height: 200,
color: Colors.grey,
child: const Align(
alignment: Alignment.bottomRight,
child: Text('Pojok kanan bawah'),
),
)
// Center -- shorthand untuk Align(alignment: Alignment.center)
Center(child: const Text('Tengah'))
// FractionalOffset -- sistem koordinat (0,0) di kiri atas, (1,1) di kanan bawah
const FractionalOffset(0.5, 0.5) // sama dengan Alignment.center
Unbounded Constraints — Error yang Paling Umum #
Situasi paling umum di mana render box mendapat unbounded constraint adalah di dalam flex box (Row atau Column) dan di dalam scrollable region (seperti ListView). ListView, misalnya, mencoba mengisi ruang tersedia di cross direction-nya. Jika kamu meletakkan ListView vertikal di dalam ListView horizontal, inner list mencoba selebar mungkin, yang berarti lebar tak terbatas.
// ERROR KLASIK: Column di dalam Column tanpa constraint
Column(
children: [
Column( // ERROR! child Column mendapat unbounded height
children: [
const Text('A'),
const Text('B'),
],
),
],
)
// SOLUSI 1: bungkus dengan Expanded (jika di dalam Flex)
Column(
children: [
Expanded(
child: Column(
children: [
const Text('A'),
const Text('B'),
],
),
),
],
)
// SOLUSI 2: gunakan mainAxisSize.min
Column(
children: [
Column(
mainAxisSize: MainAxisSize.min, // wrap content, bukan expanded
children: [
const Text('A'),
const Text('B'),
],
),
],
)
// ERROR KLASIK 2: Expanded di dalam ListView
ListView(
children: [
Expanded( // ERROR! ListView sudah unbounded
child: Container(height: 100),
),
],
)
// SOLUSI: gunakan SizedBox dengan ukuran spesifik
ListView(
children: [
SizedBox(
height: 100,
child: Container(color: Colors.blue),
),
],
)
Widget Sizing Lainnya #
SizedBox — Ukuran Tetap #
// Ukuran spesifik
const SizedBox(width: 100, height: 50, child: Text('Isi'))
// Hanya lebar atau tinggi
const SizedBox(width: 16) // spacer horizontal
const SizedBox(height: 24) // spacer vertikal
// Paksa child mengisi parent
SizedBox.expand(child: ...)
// Widget kosong -- tidak mengambil ruang
const SizedBox.shrink()
ConstrainedBox — Batasan Tambahan #
// Tambahkan constraint di atas constraint yang sudah ada
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 100,
maxWidth: 300,
minHeight: 50,
),
child: const Text('Teks dengan constraint'),
)
// Ukuran yang diperluas ke parent jika memungkinkan
ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: Container(color: Colors.red),
)
IntrinsicHeight dan IntrinsicWidth #
// Masalah: Row dengan children berbeda tinggi, ingin semua sama tinggi
Row(
children: [
Container(height: 100, color: Colors.blue),
Container(height: 200, color: Colors.red), // tinggi beda!
Container(color: Colors.green), // tinggi mengikuti apa?
],
)
// Solusi: IntrinsicHeight -- samakan tinggi berdasarkan child tertinggi
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, // perluas ke tinggi max
children: [
Container(height: 100, color: Colors.blue),
Container(height: 200, color: Colors.red),
Container(color: Colors.green), // ikut 200px (tertinggi)
],
),
)
IntrinsicHeightdanIntrinsicWidthmahal secara performa. Mereka melakukan dua pass layout — pertama untuk menghitung ukuran intrinsik semua children, kemudian baru melakukan layout normal. Gunakan hanya jika benar-benar dibutuhkan dan tidak ada solusi lain.
Ringkasan #
- Sistem layout Flutter berdasarkan tiga aturan: constraints go down (parent memberikan batas ke child), sizes go up (child menentukan ukuran dalam batas), parent sets position (child tidak bisa memilih posisinya).
- Column dan Row menyusun children secara vertikal/horizontal. Gunakan
mainAxisAlignmentuntuk distribusi di main axis dancrossAxisAlignmentuntuk penyelarasan di cross axis.Expandedmemaksa child mengisi semua ruang yang dialokasikan (FlexFit.tight).Flexiblememperbolehkan child lebih kecil dari ruang alokasi (FlexFit.loose).Spaceradalah Expanded kosong yang mendorong sibling ke ujung.Stackmenyusun children berlapis. GunakanPositioneduntuk posisi absolut,Alignmentuntuk non-Positioned children.- Unbounded constraints terjadi saat Expanded atau widget greedy berada di dalam scrollable — solusinya dengan
SizedBoxberukuran spesifik ataumainAxisSize: MainAxisSize.min.IntrinsicHeight/IntrinsicWidthmenyamakan ukuran children tapi mahal secara performa — gunakan hanya jika tidak ada alternatif.