Layout #
Sistem tata letak (layout system) di Flutter dirancang dengan satu prinsip arsitektural yang sangat konsisten dan berlaku mutlak di seluruh elemen antarmuka: Constraints go down, sizes go up, parent sets position (Batasan mengalir ke bawah, ukuran dilaporkan ke atas, induk menetapkan posisi). Memahami bagaimana hukum dasar ini berinteraksi dengan widget seperti Row, Column, Stack, dan bagaimana mengatasi kesalahan fatal unbounded constraints adalah keahlian wajib yang membedakan pengembang Flutter pemula dengan profesional. Kita akan membedah secara mendalam sistem constraints, layout linear, pembagian ruang sisa, serta teknik pemecahan masalah overflow layar yang sering terjadi pada aplikasi produksi.
Hukum Tata Letak Flutter (The Rules of Layout) #
Seluruh proses penataan visual di Flutter dikendalikan oleh tiga aturan emas yang dieksekusi secara berurutan pada pohon render (Render Tree):
- Constraints Go Down (Batasan Mengalir ke Bawah): Widget induk (parent) mengirimkan objek
BoxConstraintskepada widget anaknya (child). Batasan ini menentukan nilai lebar dan tinggi minimum serta maksimum yang diizinkan untuk dimiliki oleh widget anak. - Sizes Go Up (Ukuran Dilaporkan ke Atas): Widget anak membaca batasan tersebut, lalu menghitung dimensi ukuran dirinya sendiri (
Size) dalam rentang batas yang diizinkan. Setelah ukurannya ditentukan, widget anak melaporkan ukuran tersebut kembali ke parent. - Parent Sets Position (Parent Menetapkan Posisi): Setelah menerima laporan ukuran dari anak, parent bertugas menetapkan letak koordinat posisi relatif anak di layar menggunakan koordinat
Offset(x, y). Widget anak sama sekali tidak memiliki kuasa untuk memilih posisinya sendiri di layar.
Aliran instruksi tata letak ini dapat divisualisasikan melalui diagram alir berikut:
flowchart TD
Parent["Parent Widget"] -->|"1. Kirim BoxConstraints (min/max)"| Child["Child Widget"]
Child -->|"2. Hitung & Laporkan Size (width/height)"| Parent
Parent -->|"3. Tentukan Koordinat Offset (x, y)"| ChildTight Constraints vs Loose Constraints #
Flutter membagi batasan BoxConstraints ke dalam dua kategori perilaku utama:
- Tight Constraints (Batasan Ketat): Kondisi di mana nilai batas minimum dan maksimum disetel sama persis. Hal ini memaksa widget anak untuk memiliki ukuran yang sama dengan batasan tersebut, mengabaikan properti ukuran kustom yang ditulis di widget anak.
Contoh:
BoxConstraints.tight(Size(300, 300)). Jika kita meletakkanContainer(width: 50)di dalamnya, Container tersebut tetap akan dipaksa melebar menjadi 300x300. - Loose Constraints (Batasan Longgar): Kondisi di mana nilai batas minimum disetel ke angka nol. Hal ini membebaskan widget anak untuk menentukan ukurannya sendiri secara dinamis, selama ukurannya tidak melebihi batas maksimum yang diizinkan.
Contoh:
BoxConstraints(minWidth: 0, maxWidth: 300).
Column dan Row — Layout Linier Fleksibel #
Column (vertikal) dan Row (horizontal) adalah dua widget tata letak linier yang paling sering digunakan di Flutter. Keduanya diturunkan dari kelas dasar yang sama yaitu Flex.
Setiap layout fleksibel memiliki dua sumbu utama yang menentukan arah alur dan penyelarasan widget anak:
Row (Alur Horizontal):
Main Axis (Sumbu Utama): Horizontal (Kiri ke Kanan)
Cross Axis (Sumbu Silang): Vertikal (Atas ke Bawah)
Column (Alur Vertikal):
Main Axis (Sumbu Utama): Vertikal (Atas ke Bawah)
Cross Axis (Sumbu Silang): Horizontal (Kiri ke Kanan)
1. MainAxisAlignment (Distribusi Sumbu Utama) #
Digunakan untuk mengatur penyebaran dan jarak antar-widget anak di sepanjang sumbu utama.
MainAxisAlignment.start: Merapatkan seluruh widget anak di awal sumbu.MainAxisAlignment.end: Merapatkan seluruh widget anak di ujung sumbu.MainAxisAlignment.center: Mengelompokkan widget anak tepat di tengah-tengah.MainAxisAlignment.spaceBetween: Membagi ruang kosong secara merata di antara widget anak, membuat widget pertama dan terakhir menempel rapat pada tepi kontainer.MainAxisAlignment.spaceAround: Membagi ruang kosong secara merata, di mana ruang kosong di ujung awal dan akhir bernilai setengah dari ruang kosong di antara widget.MainAxisAlignment.spaceEvenly: Membagi seluruh ruang kosong secara sama rata di semua sela, termasuk pada ujung awal dan akhir.
2. CrossAxisAlignment (Penyelarasan Sumbu Silang) #
Digunakan untuk mengatur posisi widget anak secara tegak lurus terhadap alur sumbu utama.
CrossAxisAlignment.start: Menjajarkan widget anak pada tepi awal sumbu silang.CrossAxisAlignment.end: Menjajarkan widget anak pada tepi akhir sumbu silang.CrossAxisAlignment.center: Menjajarkan widget anak tepat di tengah sumbu silang (statis bawaan).CrossAxisAlignment.stretch: Memaksa seluruh widget anak meregang penuh memenuhi lebar/tinggi sumbu silang.
3. MainAxisSize (Ukuran Sumbu Utama) #
Secara standar, Row dan Column akan mencoba mengambil ruang kosong sebanyak-banyaknya di sepanjang sumbu utama (MainAxisSize.max). Jika kita ingin agar kontainer tersebut menciut mengikuti ukuran total dari seluruh widget anak di dalamnya, ubah pengaturannya menjadi MainAxisSize.min (mirip seperti perilaku wrap-content).
Expanded, Flexible, dan Spacer — Distribusi Ruang Sisa #
Ketika kita menyusun elemen di dalam Row atau Column, sering kali terdapat ruang kosong sisa (remaining space) di sumbu utama. Kita bisa mengontrol bagaimana widget anak membagi dan mengisi ruang sisa tersebut menggunakan tiga widget khusus:
Expanded #
Expanded memaksa widget anaknya untuk mengisi seluruh ruang sisa yang tersedia di sumbu utama. Widget ini secara implisit menyetel perilaku FlexFit.tight.
Row(
children: [
const Icon(Icons.star), // Berukuran tetap (24px)
Expanded(
child: Container(color: Colors.blue), // Mengambil SELURUH sisa lebar layar
),
],
)
Jika kita memiliki beberapa widget Expanded di dalam satu baris, kita bisa menggunakan properti flex untuk membagi proporsi ruang sisa tersebut secara matematis:
Row(
children: [
Expanded(
flex: 1, // Mengambil 1/3 bagian ruang sisa
child: Container(color: Colors.red),
),
Expanded(
flex: 2, // Mengambil 2/3 bagian ruang sisa (dua kali lebih lebar)
child: Container(color: Colors.blue),
),
],
)
Flexible #
Berbeda dengan Expanded, Flexible memberikan kebebasan kepada widget anak untuk mengisi ruang sisa, namun tidak memaksa anak untuk memenuhi ruang tersebut jika ukuran intrinsik anak lebih kecil (FlexFit.loose).
Row(
children: [
Flexible(
child: Container(
width: 50.0, // Meskipun ruang sisa 200px, Container tetap hanya berukuran 50px
color: Colors.green,
),
),
],
)
Spacer #
Spacer adalah widget kosong non-visual yang berfungsi sebagai pendorong. Di bawah kap, Spacer hanyalah penulisan singkat (shorthand) dari Expanded(child: SizedBox.shrink()).
Row(
children: [
const Text('Menu Kiri'),
const Spacer(), // Mendorong 'Menu Kanan' ke ujung paling kanan layar
const Text('Menu Kanan'),
],
)
Stack — Mengelola Elemen Bertumpuk (Overlay) #
Stack digunakan untuk menumpuk beberapa widget anak secara berlapis di atas satu sama lain (berdasarkan sumbu Z-index). Elemen yang ditulis pertama di dalam daftar children akan diletakkan di lapisan paling bawah, sedangkan elemen yang ditulis terakhir akan ditumpuk di lapisan paling atas.
Stack(
alignment: Alignment.bottomRight, // Penjajaran untuk non-positioned children
children: [
// Lapisan 1: Paling bawah
Image.network('https://example.com/card_bg.png'),
// Lapisan 2: Bertindak sebagai overlay gelap
Positioned.fill(
child: Container(color: Colors.black.withOpacity(0.4)),
),
// Lapisan 3: Paling atas dengan posisi absolut
const Positioned(
top: 16.0,
left: 16.0,
child: Text('Premium Card', style: TextStyle(color: Colors.white)),
),
],
)
Stack Sizing (fit) #
Properti fit pada Stack menentukan bagaimana batasan ukuran (constraints) dari parent diteruskan ke anak-anaknya yang tidak ditandai dengan Positioned:
StackFit.loose: Stack mengikuti ukuran dari anak non-positioned yang paling besar.StackFit.expand: Stack dipaksa meregang penuh mengikuti batasan maksimal parent, memaksa seluruh anak non-positioned ikut memenuhi ukuran Stack.
Mengatasi Kesalahan Fatal: Unbounded Constraints #
Kesalahan fatal yang paling sering ditemui oleh developer Flutter pemula di konsol debugging adalah:
A RenderFlex overflowed by X pixels on the bottom/right.(Garis kuning-hitam putus-putus di layar).Vertical viewport was given unbounded height.(Aplikasi langsung crash putih/merah).
Masalah ini terjadi karena adanya bentrokan Unbounded Constraints (Batasan Tanpa Batas). Ini adalah kondisi di mana sebuah widget meminta ruang tanpa batas, diletakkan di dalam kontainer yang juga tidak membatasi ukuran.
Mari kita pelajari dua skenario kesalahan klasik ini beserta solusi perbaikannya:
Kasus 1: Meletakkan ListView di dalam Column #
Secara standar, ListView vertikal akan mencoba mengambil tinggi ruang sebesar-besarnya secara tidak terbatas (greedy height). Sedangkan Column juga tidak membatasi tinggi maksimum anak-anaknya. Menggabungkan keduanya tanpa pelindung akan langsung memicu crash.
// ANTI-PATTERN: ListView di dalam Column langsung memicu Unbounded Height Crash!
Widget build(BuildContext context) {
return Column(
children: [
const Text('Daftar Produk:'),
ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => Text('Produk $index'),
),
],
);
}
Solusi 1: Membungkus dengan Expanded #
Bungkus ListView menggunakan Expanded agar Column membatasi tinggi ListView hanya sebatas sisa ruang layar yang tersedia secara aman.
// BENAR: Membatasi tinggi menggunakan Expanded
Widget build(BuildContext context) {
return Column(
children: [
const Text('Daftar Produk:'),
Expanded(
child: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => Text('Produk $index'),
),
),
],
);
}
Solusi 2: Menggunakan shrinkWrap dan NeverScrollableScrollPhysics #
Jika kita ingin agar ListView menciut ukurannya hanya setinggi jumlah elemen di dalamnya (sehingga bisa digulir bersama dengan Column utama), aktifkan properti shrinkWrap dan matikan fungsi scroll internalnya:
// BENAR: Menggunakan shrinkWrap untuk menciutkan tinggi ListView
Widget build(BuildContext context) {
return Column(
children: [
const Text('Daftar Produk:'),
ListView.builder(
shrinkWrap: true, // Menginstruksikan ListView hanya mengambil tinggi total elemennya
physics: const NeverScrollableScrollPhysics(), // Mematikan scroll internal ListView
itemCount: 5,
itemBuilder: (context, index) => Text('Produk $index'),
),
],
);
}
Widget Kontrol Dimensi Lanjutan #
Untuk mendesain tata letak yang presisi, Flutter menyediakan beberapa widget kontrol ukuran tingkat lanjut:
1. ConstrainedBox #
Digunakan jika kita ingin menyuntikkan batasan ukuran kustom tambahan di atas batasan yang kita terima dari parent.
ConstrainedBox(
// Memaksa agar tombol memiliki lebar minimal 200px meskipun teks di dalamnya pendek
constraints: const BoxConstraints(
minWidth: 200.0,
maxWidth: 300.0,
minHeight: 48.0,
),
child: ElevatedButton(
onPressed: () {},
child: const Text('Kirim'),
),
)
2. IntrinsicHeight & IntrinsicWidth #
Skenario klasik: kita membuat Row berisi beberapa Container dengan teks yang panjangnya berbeda-beda. Kita ingin agar seluruh Container di baris tersebut memiliki tinggi yang sama mengikuti Container dengan teks terpanjang.
// BENAR: Menyamakan tinggi elemen secara dinamis menggunakan IntrinsicHeight
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, // Meregangkan tinggi anak mengikuti kontainer
children: [
Container(color: Colors.red, child: const Text('Pendek')),
Container(color: Colors.blue, child: const Text('Teks\nIni\nSangat\nPanjang')),
Container(color: Colors.green, child: const Text('Sedang')), // Ikut tinggi merah (tertinggi)
],
),
)
Peringatan Performa: GunakanIntrinsicHeightdanIntrinsicWidthhanya jika tidak ada solusi alternatif lain. Kedua widget ini sangat mahal karena memaksa Flutter melakukan proses layout dua kali (two-pass layout): pertama untuk mengukur tinggi intrinsik anak-anaknya, dan kedua untuk merender secara fisik. Terlalu banyak menggunakan widget ini akan langsung memicu degradasi performa (frame drop).
Layout Responsif: LayoutBuilder vs MediaQuery #
Dalam membangun aplikasi yang berjalan di berbagai ukuran layar (smartphone, tablet, desktop), kita membutuhkan mekanisme untuk merespons perubahan ukuran ruang tayang secara dinamis. Flutter menyediakan dua alat utama untuk kebutuhan ini: MediaQuery dan LayoutBuilder. Keduanya memiliki tujuan dan cara kerja yang berbeda:
1. MediaQuery (Informasi Layar Global) #
MediaQuery digunakan untuk mendapatkan informasi ukuran layar fisik perangkat secara keseluruhan (viewport window) serta orientasi layar.
- Karakteristik: Berbasis inherited widget global. Perubahan ukuran jendela aplikasi (misalnya saat resize window di desktop/web atau rotasi layar) akan memicu pembangunan ulang (rebuild) dari seluruh widget di bawah context yang menggunakannya.
- Penggunaan Ideal: Menentukan tata letak makro (misal: jika lebar layar > 600px gunakan dua kolom, jika tidak gunakan satu kolom).
Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 600) {
return const WideLayoutWidget(); // Tampilan tablet/desktop
} else {
return const NarrowLayoutWidget(); // Tampilan ponsel
}
}
2. LayoutBuilder (Informasi Batasan Lokal) #
Berbeda dengan MediaQuery yang melihat ukuran layar secara global, LayoutBuilder mengukur batasan ukuran (constraints) yang diturunkan oleh parent kepada widget tersebut secara spesifik di posisinya pada widget tree.
- Karakteristik: Memiliki builder callback yang mengembalikan objek
BoxConstraints. Hal ini memungkinkan kita untuk menyesuaikan layout anak berdasarkan ruang nyata yang tersedia untuk widget itu sendiri, bukan berdasarkan ukuran layar keseluruhan. - Penggunaan Ideal: Desain komponen mikro (misal: widget kartu yang menampilkan gambar di sebelah kiri jika lebarnya cukup, tetapi di atas jika ruangnya sempit).
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth > 400) {
return Row(
children: [
const Icon(Icons.thumbnail),
Expanded(child: Text('Konten Detail')),
],
);
} else {
return Column(
children: [
const Icon(Icons.thumbnail),
Text('Konten Detail'),
],
);
}
},
);
}
Kustomisasi Tata Letak dengan CustomMultiChildLayout #
Untuk skenario tata letak yang sangat kompleks di mana susunan anak tidak dapat dicapai hanya menggunakan kombinasi Row, Column, atau Stack, Flutter menyediakan CustomMultiChildLayout. Widget ini memberikan kontrol penuh bagi kita untuk mengatur posisi dan ukuran masing-masing anak secara terprogram menggunakan delegasi layout kustom (MultiChildLayoutDelegate).
Dengan menggunakan CustomMultiChildLayout, kita harus melakukan dua langkah utama:
- Mengidentifikasi setiap anak dengan ID unik menggunakan widget
LayoutId. - Membuat kelas turunan dari
MultiChildLayoutDelegatedan mengimplementasikan metodeperformLayoutdanshouldRelayout.
Berikut adalah contoh implementasi delegasi layout kustom yang memposisikan widget anak kedua di bawah widget anak pertama dengan lebar yang sama persis:
class UnderneathLayoutDelegate extends MultiChildLayoutDelegate {
@override
void performLayout(Size size) {
// 1. Periksa apakah kedua anak dengan ID yang diharapkan ada dalam pohon layout
if (hasChild('header') && hasChild('body')) {
// 2. Tentukan ukuran header dengan melimpahkan batasan dari parent
final Size headerSize = layoutChild(
'header',
BoxConstraints.loose(size),
);
// 3. Posisikan header di koordinat (0, 0)
positionChild('header', Offset.zero);
// 4. Batasi lebar body agar sama dengan header, dan tingginya menyesuaikan sisa ruang
final Size bodySize = layoutChild(
'body',
BoxConstraints(
minWidth: headerSize.width,
maxWidth: headerSize.width,
minHeight: 0,
maxHeight: size.height - headerSize.height,
),
);
// 5. Posisikan body tepat di bawah header
positionChild('body', Offset(0, headerSize.height));
}
}
@override
bool shouldRelayout(covariant UnderneathLayoutDelegate oldDelegate) {
return false;
}
}
Penggunaan delegasi kustom ini di dalam widget tree kita sangat sederhana:
CustomMultiChildLayout(
delegate: UnderneathLayoutDelegate(),
children: [
LayoutId(
id: 'header',
child: Container(
color: Colors.teal,
padding: const EdgeInsets.all(16.0),
child: const Text('Header Kustom'),
),
),
LayoutId(
id: 'body',
child: Container(
color: Colors.grey[200],
padding: const EdgeInsets.all(16.0),
child: const Text('Konten utama diletakkan persis di bawah header dengan lebar yang sama.'),
),
),
],
)
Meskipun CustomMultiChildLayout memerlukan kode boilerplate yang sedikit lebih banyak, cara ini jauh lebih efisien dalam hal performa dibandingkan dengan menumpuk widget bertingkat yang tidak perlu, karena seluruh kalkulasi posisi dan ukuran diselesaikan dalam satu siklus layout tunggal.
Ringkasan #
- Hukum Utama Layout: Selalu ingat prinsip Constraints go down, sizes go up, parent sets position. Batasan mengalir ke bawah, ukuran dilaporkan ke atas, koordinat posisi ditentukan oleh induk.
- Flex Layout: Row dan Column mengelola alur sumbu utama (Main Axis) dan sumbu silang (Cross Axis). Gunakan
MainAxisSize.minuntuk menciutkan ukuran kontainer linear.- Expanded vs Flexible:
Expandedmemaksa anak mengisi seluruh sela ruang sisa (FlexFit.tight), sedangkanFlexiblememperbolehkan anak berukuran lebih kecil dari sisa ruang (FlexFit.loose).- Stack & Positioned: Menyusun widget secara bertumpuk berlapis berdasarkan sumbu Z-index. Gunakan
Positioneduntuk penempatan koordinat absolut.- Unbounded Constraints: Bentrokan batasan yang terjadi saat meletakkan widget greedy di dalam kontainer tanpa batas. Selesaikan dengan menggunakan
Expandedatau propertishrinkWrap: true.- Intrinsic Widgets: Berguna untuk menyamakan ukuran tinggi/lebar anak secara dinamis, namun memiliki biaya performa (two-pass layout) yang cukup mahal.