Skip to content

Bab 4 — Layout: Menyusun Antarmuka

Kalau Bab 3 mengajarkan bahwa segalanya adalah widget, bab ini menjawab pertanyaan berikutnya: bagaimana menaruh widget-widget itu di tempat yang kamu inginkan. Layout adalah keterampilan yang paling sering dipakai dan paling sering membuat frustrasi di awal — sebagian besar karena orang menghafal widget tanpa memahami aturan mainnya.

Jadi mari kita mulai dari aturan mainnya.

Aturan dasar: constraint turun, ukuran naik

Flutter menentukan tata letak dengan satu kalimat yang layak kamu hafal:

Constraint turun. Ukuran naik. Induk menentukan posisi.

Artinya begini. Induk memberi tahu anak batasan ruang yang boleh dipakai — "lebarmu antara 0 dan 300, tinggimu antara 0 dan 500". Anak memutuskan sendiri ukuran aslinya dalam batasan itu, lalu melaporkannya kembali ke atas. Setelah tahu ukuran anaknya, induk menentukan di mana anak itu ditaruh.

        ┌─────────────────────────────┐
        │          INDUK              │
        │                             │
        │   1. "Kamu boleh selebar    │
        │       0–300, setinggi 0–500"│
        │              │              │
        │              ▼ constraint   │
        │   ┌──────────────────────┐  │
        │   │        ANAK          │  │
        │   │  2. "Baik, aku       │  │
        │   │      200 × 100"      │  │
        │   └──────────┬───────────┘  │
        │              │ ukuran       │
        │              ▲              │
        │   3. Induk menaruhnya       │
        │      di posisi (50, 20)     │
        └─────────────────────────────┘

Konsekuensi penting: anak tidak bisa memaksakan ukurannya sendiri kalau induk tidak mengizinkan. Ini penyebab kebingungan nomor satu di Flutter. Kamu menulis Container(width: 500) tetapi hasilnya hanya selebar 300, karena induknya membatasi. Widget tidak "diabaikan" — ia hanya tidak diberi ruang.

Container: pisau lipat serbaguna

Container adalah widget pertama yang dipelajari semua orang, karena ia menggabungkan banyak kemampuan sekaligus: ukuran, warna, padding, margin, border, bayangan, dan transformasi.

dart
Container(
  width: 200,
  height: 120,
  padding: const EdgeInsets.all(16),
  margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
  alignment: Alignment.center,
  decoration: BoxDecoration(
    color: Colors.indigo.shade50,
    borderRadius: BorderRadius.circular(12),
    border: Border.all(color: Colors.indigo, width: 2),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withValues(alpha: 0.1),
        blurRadius: 8,
        offset: const Offset(0, 2),
      ),
    ],
  ),
  child: const Text('Isi kotak'),
)

Ada satu jebakan yang hampir pasti kamu temui: color dan decoration tidak boleh dipakai bersamaan.

dart
// ❌ Error saat runtime
Container(
  color: Colors.blue,
  decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
)

// ✅ Pindahkan warna ke dalam decoration
Container(
  decoration: BoxDecoration(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(8),
  ),
)

Alasannya, color sebenarnya hanyalah pintasan yang membuat BoxDecoration di belakang layar. Menyediakan keduanya berarti kamu memberi dua dekorasi yang saling bertabrakan.

Padding dan margin

Keduanya memakai kelas EdgeInsets, dan perbedaannya klasik: padding adalah ruang di dalam, margin adalah ruang di luar.

   ┌─────────── margin ──────────────┐
   │  ┌─────── border ────────────┐  │
   │  │  ┌──── padding ────────┐  │  │
   │  │  │                     │  │  │
   │  │  │      child          │  │  │
   │  │  │                     │  │  │
   │  │  └─────────────────────┘  │  │
   │  └───────────────────────────┘  │
   └─────────────────────────────────┘

Empat cara menulis EdgeInsets:

dart
EdgeInsets.all(16)                                  // keempat sisi sama
EdgeInsets.symmetric(horizontal: 24, vertical: 8)   // kiri-kanan, atas-bawah
EdgeInsets.only(left: 16, top: 8)                   // sisi tertentu saja
EdgeInsets.fromLTRB(16, 8, 16, 24)                  // kiri, atas, kanan, bawah

Untuk sekadar memberi padding, widget Padding lebih ringan dan lebih jelas maksudnya daripada Container:

dart
// Lebih baik daripada Container(padding: ..., child: ...)
Padding(
  padding: const EdgeInsets.all(16),
  child: const Text('Berjarak dari tepi'),
)

Column dan Row

Dua widget ini adalah tulang punggung hampir setiap layout Flutter. Column menyusun anak secara vertikal, Row secara horizontal.

dart
Column(
  children: [
    Text('Atas'),
    Text('Tengah'),
    Text('Bawah'),
  ],
)

Row(
  children: [
    Icon(Icons.star),
    Text('Kiri ke kanan'),
    Icon(Icons.star),
  ],
)

Sumbu utama dan sumbu silang

Ini konsep yang wajib dikuasai, dan sumber kebingungan terbesar kedua setelah constraint.

Setiap Column dan Row punya dua sumbu:

        COLUMN                          ROW
   ┌──────────────────┐        ┌──────────────────────┐
   │        ▲         │        │                      │
   │        │ main    │        │  ◄──── main ────►    │
   │        │ axis    │        │                      │
   │  ◄── cross ──►   │        │         ▲ cross      │
   │        │         │        │         │            │
   │        ▼         │        │         ▼            │
   └──────────────────┘        └──────────────────────┘

   Column: main = vertikal      Row: main = horizontal
           cross = horizontal        cross = vertikal

Sumbu utama (main axis) adalah arah penyusunan. Sumbu silang (cross axis) adalah arah tegak lurusnya.

Kamu mengatur keduanya lewat dua properti:

dart
Column(
  mainAxisAlignment: MainAxisAlignment.center,      // vertikal (untuk Column)
  crossAxisAlignment: CrossAxisAlignment.start,     // horizontal (untuk Column)
  children: [ /* ... */ ],
)

Nilai MainAxisAlignment yang tersedia:

NilaiEfek
startRapat ke awal (atas untuk Column, kiri untuk Row)
endRapat ke akhir
centerDi tengah
spaceBetweenJarak sama di antara anak, tanpa jarak di tepi
spaceAroundJarak di tepi setengah dari jarak antar-anak
spaceEvenlySemua jarak sama, termasuk di tepi

Digambarkan:

start         [A][B][C]..............
end           ..............[A][B][C]
center        .......[A][B][C].......
spaceBetween  [A].......[B].......[C]
spaceAround   ...[A]......[B]......[C]...
spaceEvenly   .....[A].....[B].....[C].....

Nilai CrossAxisAlignment:

NilaiEfek
startRata kiri (Column) atau rata atas (Row)
endRata kanan atau rata bawah
centerDi tengah — ini nilai bawaan
stretchRegangkan anak memenuhi sumbu silang
baselineSejajarkan berdasarkan garis dasar teks

stretch sangat berguna untuk membuat tombol memenuhi lebar:

dart
Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    ElevatedButton(onPressed: () {}, child: const Text('Selebar layar')),
    const SizedBox(height: 8),
    OutlinedButton(onPressed: () {}, child: const Text('Juga selebar layar')),
  ],
)

MainAxisSize

Secara bawaan, Column mencoba memenuhi seluruh tinggi yang tersedia, dan Row memenuhi seluruh lebar. Kalau kamu ingin ia hanya sebesar isinya:

dart
Column(
  mainAxisSize: MainAxisSize.min,   // sepas isinya saja
  children: [ /* ... */ ],
)

Ini penting ketika Column berada di dalam Card, dialog, atau bottom sheet — tanpa min, kartunya akan meregang setinggi layar.

Expanded dan Flexible

Ketika kamu ingin anak-anak sebuah Row atau Column berbagi ruang tersisa, kamu butuh dua widget ini.

Expanded memaksa anaknya mengisi seluruh sisa ruang di sumbu utama:

dart
Row(
  children: [
    const Icon(Icons.search),
    const SizedBox(width: 8),
    Expanded(
      child: TextField(
        decoration: const InputDecoration(hintText: 'Cari...'),
      ),
    ),
    IconButton(icon: const Icon(Icons.clear), onPressed: () {}),
  ],
)

Tanpa Expanded, TextField di dalam Row akan memicu error constraint tak terbatas, karena TextField ingin selebar mungkin sementara Row tidak memberinya batas.

Properti flex menentukan pembagian proporsional:

dart
Row(
  children: [
    Expanded(flex: 2, child: Container(height: 100, color: Colors.red)),
    Expanded(flex: 1, child: Container(height: 100, color: Colors.green)),
    Expanded(flex: 1, child: Container(height: 100, color: Colors.blue)),
  ],
)
├──────────── merah (2) ────────────┼──── hijau (1) ────┼──── biru (1) ────┤

Total flex adalah 4, jadi merah mendapat dua per empat ruang, hijau dan biru masing-masing satu per empat.

Perbedaan Expanded dan Flexible:

  • Expanded wajib mengisi seluruh jatahnya.
  • Flexible boleh lebih kecil dari jatahnya kalau isinya memang tidak sebesar itu.
dart
Row(
  children: [
    Flexible(
      child: Container(color: Colors.amber, child: const Text('Pendek')),
    ),
    Expanded(
      child: Container(color: Colors.cyan, height: 40),
    ),
  ],
)

Sebenarnya Expanded hanyalah Flexible dengan fit: FlexFit.tight. Dalam praktik, Expanded yang paling sering kamu pakai.

Jebakan umum

Expanded hanya boleh menjadi anak langsung dari Row, Column, atau Flex. Menaruhnya di dalam Container atau Center akan menyebabkan error. Kalau kamu butuh proporsi di luar konteks flex, pakai FractionallySizedBox yang dibahas di Bab 10.

Center dan Align

Center menempatkan anaknya di tengah induk:

dart
Center(
  child: const Text('Tepat di tengah'),
)

Align lebih fleksibel — kamu bisa menentukan posisi mana pun:

dart
Align(
  alignment: Alignment.bottomRight,
  child: const Text('Pojok kanan bawah'),
)

Nilai Alignment yang tersedia membentuk kisi tiga kali tiga:

topLeft      topCenter      topRight
centerLeft   center         centerRight
bottomLeft   bottomCenter   bottomRight

Kamu juga bisa memakai koordinat kustom, di mana -1 adalah tepi kiri/atas, 0 adalah tengah, dan 1 adalah tepi kanan/bawah:

dart
Align(
  alignment: const Alignment(0.5, -0.8),   // kanan-atas, tidak sampai pojok
  child: const Icon(Icons.star),
)

SizedBox

Widget paling sederhana sekaligus paling sering dipakai. Ia punya tiga kegunaan.

Pemberi jarak — inilah pemakaian paling umum:

dart
Column(
  children: const [
    Text('Judul'),
    SizedBox(height: 16),     // jarak vertikal
    Text('Subjudul'),
  ],
)

Row(
  children: const [
    Icon(Icons.person),
    SizedBox(width: 8),       // jarak horizontal
    Text('Nama'),
  ],
)

Pemaksa ukuran — membungkus anak dengan ukuran tertentu:

dart
SizedBox(
  width: 200,
  height: 48,
  child: ElevatedButton(
    onPressed: () {},
    child: const Text('Tombol berukuran tetap'),
  ),
)

Pengisi lebar penuh — pintasan yang rapi:

dart
SizedBox(
  width: double.infinity,
  child: ElevatedButton(
    onPressed: () {},
    child: const Text('Selebar induk'),
  ),
)

Spacer

Kerabat SizedBox yang mengambil seluruh ruang tersisa di dalam Row atau Column:

dart
Row(
  children: [
    const Text('Kiri'),
    const Spacer(),          // dorong sisanya ke kanan
    const Text('Kanan'),
  ],
)

Ini sering lebih ekspresif daripada MainAxisAlignment.spaceBetween, terutama kalau kamu hanya ingin mendorong sebagian elemen:

dart
Row(
  children: [
    const Icon(Icons.receipt),
    const SizedBox(width: 8),
    const Text('Makan siang'),
    const Spacer(),
    const Text('Rp45.000', style: TextStyle(fontWeight: FontWeight.bold)),
  ],
)

Stack: menumpuk widget

Ketika kamu butuh widget saling menimpa — teks di atas gambar, tombol mengambang di sudut kartu, lencana di atas ikon — gunakan Stack.

dart
Stack(
  children: [
    // Anak pertama di paling bawah tumpukan
    Image.asset('assets/gambar/pantai.jpg', height: 200, fit: BoxFit.cover),

    // Anak berikutnya menimpa yang sebelumnya
    Positioned(
      bottom: 0,
      left: 0,
      right: 0,
      child: Container(
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.transparent,
              Colors.black.withValues(alpha: 0.75),
            ],
          ),
        ),
        child: const Text(
          'Pantai Kuta',
          style: TextStyle(
            color: Colors.white,
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    ),
  ],
)

Urutan di dalam children menentukan tumpukan: anak pertama paling bawah, anak terakhir paling atas.

Positioned menempelkan anak ke tepi tertentu dari Stack. Kamu bisa memberi top, bottom, left, right, width, dan height dalam kombinasi apa pun. Memberi left dan right sekaligus membuatnya meregang selebar Stack, seperti pada contoh di atas.

Anak yang tidak dibungkus Positioned akan diposisikan menurut properti alignment milik Stack:

dart
Stack(
  alignment: Alignment.center,
  children: [
    Container(width: 100, height: 100, color: Colors.indigo),
    const Icon(Icons.check, color: Colors.white, size: 48),
  ],
)

ListTile

Widget siap pakai untuk baris daftar. Ia menangani jarak, ukuran, dan penyelarasan yang sesuai standar Material — menghemat banyak pekerjaan.

dart
ListTile(
  leading: const CircleAvatar(child: Icon(Icons.person)),
  title: const Text('Firnanda'),
  subtitle: const Text('Terakhir aktif 5 menit lalu'),
  trailing: const Icon(Icons.chevron_right),
  onTap: () {
    // buka detail
  },
)
┌──────────────────────────────────────────────────┐
│ ┌───┐                                            │
│ │ ◯ │  Firnanda                              >   │
│ └───┘  Terakhir aktif 5 menit lalu               │
│ leading   title / subtitle             trailing  │
└──────────────────────────────────────────────────┘

ListTile sering dipasangkan dengan Card untuk memberi elevasi:

dart
Card(
  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  child: ListTile(
    leading: const Icon(Icons.folder),
    title: const Text('Dokumen'),
    subtitle: const Text('24 berkas'),
    trailing: const Icon(Icons.more_vert),
    onTap: () {},
  ),
)

Untuk daftar dengan pilihan, ada varian khusus:

dart
CheckboxListTile(
  value: _aktif,
  onChanged: (nilai) => setState(() => _aktif = nilai!),
  title: const Text('Aktifkan notifikasi'),
  subtitle: const Text('Terima kabar terbaru'),
)

SwitchListTile(
  value: _modeGelap,
  onChanged: (nilai) => setState(() => _modeGelap = nilai),
  title: const Text('Mode gelap'),
  secondary: const Icon(Icons.dark_mode),
)

RadioListTile<String>(
  value: 'indonesia',
  groupValue: _bahasa,
  onChanged: (nilai) => setState(() => _bahasa = nilai!),
  title: const Text('Bahasa Indonesia'),
)

Card dan Divider

Card memberi permukaan terangkat dengan sudut membulat:

dart
Card(
  elevation: 4,
  margin: const EdgeInsets.all(12),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(16),
  ),
  clipBehavior: Clip.antiAlias,   // penting kalau isinya gambar
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Image.asset('assets/gambar/produk.jpg', fit: BoxFit.cover),
      const Padding(
        padding: EdgeInsets.all(16),
        child: Text('Judul Produk'),
      ),
    ],
  ),
)

clipBehavior: Clip.antiAlias memastikan gambar ikut terpotong mengikuti sudut membulat kartu. Tanpa itu, gambarnya akan menonjol keluar dari lengkungan.

Divider menggambar garis pemisah horizontal:

dart
const Divider(
  height: 32,        // total ruang vertikal yang ditempati
  thickness: 1,      // ketebalan garisnya sendiri
  indent: 16,        // jarak dari kiri
  endIndent: 16,     // jarak dari kanan
  color: Colors.grey,
)

Perhatikan perbedaan height dan thickness: height adalah ruang total yang dipesan widget ini, sedangkan thickness adalah tebal garisnya. Jadi height: 32, thickness: 1 berarti garis setebal 1 piksel dengan ruang kosong 15 piksel di atas dan bawahnya.

Ada juga VerticalDivider untuk pemisah vertikal di dalam Row.

ClipRRect: memotong sudut

Untuk membulatkan sudut gambar atau widget apa pun:

dart
ClipRRect(
  borderRadius: BorderRadius.circular(16),
  child: Image.network(
    'https://example.com/gambar.jpg',
    width: 120,
    height: 120,
    fit: BoxFit.cover,
  ),
)

Ada juga ClipOval untuk bentuk lingkaran, dan ClipPath untuk bentuk kustom.

Wrap: baris yang bisa membungkus

Row akan meluber kalau anaknya terlalu banyak. Wrap menyelesaikan itu dengan memindahkan kelebihan ke baris berikutnya:

dart
Wrap(
  spacing: 8,          // jarak horizontal antar-anak
  runSpacing: 8,       // jarak vertikal antar-baris
  children: [
    for (final tag in ['Flutter', 'Dart', 'Firebase', 'BLoC', 'Riverpod'])
      Chip(
        label: Text(tag),
        backgroundColor: Colors.indigo.shade50,
      ),
  ],
)
Row (meluber):    [Flutter][Dart][Firebase][BLoC][Riverp▓▓▓ OVERFLOW
Wrap (rapi):      [Flutter][Dart][Firebase]
                  [BLoC][Riverpod]

Ini pilihan tepat untuk daftar tag, filter chip, atau kumpulan tombol yang jumlahnya tidak bisa diprediksi.

Gambar

Ada beberapa sumber gambar, masing-masing dengan konstruktornya sendiri:

dart
// Dari internet
Image.network(
  'https://example.com/gambar.jpg',
  width: 200,
  height: 150,
  fit: BoxFit.cover,
  loadingBuilder: (context, child, progress) {
    if (progress == null) return child;
    return const Center(child: CircularProgressIndicator());
  },
  errorBuilder: (context, error, stack) => const Icon(Icons.broken_image),
)

// Dari aset lokal
Image.asset('assets/gambar/logo.png', width: 120)

// Dari berkas di perangkat
Image.file(berkasGambar)

Nilai fit menentukan bagaimana gambar mengisi ruang yang diberikan:

NilaiPerilaku
coverIsi penuh, potong kelebihannya — paling sering dipakai
containMuat seluruhnya, sisakan ruang kosong
fillRegangkan hingga pas, rasio bisa berubah
fitWidthSesuaikan lebar, tinggi mengikuti
fitHeightSesuaikan tinggi, lebar mengikuti

Mendaftarkan aset

Gambar lokal harus didaftarkan di pubspec.yaml, dan indentasinya sangat sensitif:

yaml
flutter:
  uses-material-design: true

  assets:
    - assets/gambar/
    - assets/ikon/

Perhatikan garis miring di akhir — itu mendaftarkan seluruh isi folder. Kalau kamu ingin berkas tertentu saja, tulis jalur lengkapnya:

yaml
  assets:
    - assets/gambar/logo.png

Setelah mengubah pubspec.yaml, jalankan hot restart (R), bukan hot reload. Perubahan aset tidak terdeteksi oleh hot reload.

SingleChildScrollView

Kalau isi halamanmu lebih tinggi dari layar, Column biasa akan memicu peringatan overflow. Bungkus dengan ini:

dart
SingleChildScrollView(
  padding: const EdgeInsets.all(16),
  child: Column(
    children: [
      // banyak sekali widget di sini
    ],
  ),
)

Kapan tidak memakainya

SingleChildScrollView membangun semua anaknya sekaligus, bahkan yang belum terlihat. Untuk daftar panjang atau tak terbatas, gunakan ListView.builder yang dibahas di Bab 8. Aturan praktis: kalau jumlah itemnya tetap dan sedikit, SingleChildScrollView aman; kalau jumlahnya dinamis, pakai ListView.builder.

Menggabungkan semuanya

Mari bangun satu kartu produk lengkap yang memakai hampir seluruh widget di bab ini.

dart
// lib/widgets/kartu_produk.dart
import 'package:flutter/material.dart';

class KartuProduk extends StatelessWidget {
  const KartuProduk({
    super.key,
    required this.nama,
    required this.deskripsi,
    required this.harga,
    required this.urlGambar,
    required this.tags,
    this.diskon,
  });

  final String nama;
  final String deskripsi;
  final int harga;
  final String urlGambar;
  final List<String> tags;
  final int? diskon;

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 3,
      clipBehavior: Clip.antiAlias,
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          // --- Bagian gambar dengan lencana diskon di atasnya ---
          Stack(
            children: [
              Image.network(
                urlGambar,
                height: 180,
                width: double.infinity,
                fit: BoxFit.cover,
                errorBuilder: (_, __, ___) => Container(
                  height: 180,
                  color: Colors.grey.shade200,
                  child: const Icon(Icons.image_not_supported, size: 48),
                ),
              ),
              if (diskon != null)
                Positioned(
                  top: 12,
                  left: 12,
                  child: Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 10,
                      vertical: 4,
                    ),
                    decoration: BoxDecoration(
                      color: Colors.red,
                      borderRadius: BorderRadius.circular(20),
                    ),
                    child: Text(
                      '-$diskon%',
                      style: const TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 12,
                      ),
                    ),
                  ),
                ),
            ],
          ),

          // --- Bagian isi ---
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  nama,
                  style: const TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 4),
                Text(
                  deskripsi,
                  style: TextStyle(
                    fontSize: 14,
                    color: Colors.grey.shade600,
                  ),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 12),

                // Tag yang membungkus otomatis
                Wrap(
                  spacing: 6,
                  runSpacing: 6,
                  children: [
                    for (final tag in tags)
                      Chip(
                        label: Text(tag, style: const TextStyle(fontSize: 11)),
                        padding: EdgeInsets.zero,
                        visualDensity: VisualDensity.compact,
                        backgroundColor: Colors.indigo.shade50,
                        side: BorderSide.none,
                      ),
                  ],
                ),

                const Divider(height: 24),

                // Harga di kiri, tombol di kanan
                Row(
                  children: [
                    Text(
                      'Rp${_format(harga)}',
                      style: const TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                        color: Colors.indigo,
                      ),
                    ),
                    const Spacer(),
                    FilledButton.icon(
                      onPressed: () {},
                      icon: const Icon(Icons.shopping_cart_outlined, size: 18),
                      label: const Text('Beli'),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  /// Menyisipkan titik sebagai pemisah ribuan: 45000 → 45.000
  String _format(int angka) {
    final teks = angka.toString();
    final hasil = StringBuffer();
    for (var i = 0; i < teks.length; i++) {
      if (i > 0 && (teks.length - i) % 3 == 0) hasil.write('.');
      hasil.write(teks[i]);
    }
    return hasil.toString();
  }
}

Widget yang dipakai di sini: Card, Column, Stack, Positioned, Container, Image.network, Padding, Text, SizedBox, Wrap, Chip, Divider, Row, dan Spacer. Semuanya sudah kamu kenal sekarang.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Layar Profil — ⭐⭐ · 45–60 menit

Tantangan: Susun ulang satu layar profil aplikasi yang biasa kamu pakai — buka aplikasinya, lihat layar profilnya, lalu bangun ulang tata letaknya di Flutter. Cukup bentuk dan posisinya; teks boleh diisi sembarang, gambar boleh diganti blok warna.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  ┌─────────────────────────────┐  │
│  │▓▓▓▓▓▓▓ (sampul) ▓▓▓▓▓▓▓▓▓▓│  │
│  │              ╭────╮         │  │
│  │              │ AB │ [Edit]  │  │
│  └──────────────╰────╯─────────┘  │
│                                     │
│    120        340         58       │
│  Pengikut   Mengikuti   Post       │
│                                     │
│  [Desainer] [Flutter] [Jakarta]    │
│  [Fotografi] [Traveling]           │
│                                     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Ada minimal satu foto sampul dengan elemen yang menumpuk di atasnya (foto profil, tombol, atau lencana).
  • Ada satu baris statistik yang terbagi rata — misalnya jumlah pengikut, mengikuti, dan postingan — yang tetap rapi di lebar layar berapa pun.
  • Ada satu deretan label yang membungkus ke baris berikutnya ketika jumlahnya bertambah.
  • Layar bisa digulir dan tidak menghasilkan satu pun peringatan overflow, termasuk dalam mode lanskap.

Petunjuk: Elemen yang menumpuk berarti Stack, dan anak yang posisinya kamu tentukan sendiri di dalamnya butuh Positioned — ingat bahwa anak pertama digambar paling bawah. Untuk baris statistik yang terbagi rata, ada dua pendekatan yang sama-sama benar: membungkus tiap kolom dengan sesuatu yang memaksanya mengambil porsi yang sama, atau mengatur penyebaran di sumbu utama. Deretan label yang membungkus punya widget khusus yang menggantikan Row untuk kasus ini. Dan untuk overflow: kalau isinya tetap dan hanya kepanjangan, SingleChildScrollView sudah cukup — kamu tidak butuh ListView di sini.

Variasi 2: Kartu Produk E-commerce — ⭐⭐⭐ · 60–90 menit

Tantangan: Bangun satu kartu produk toko online: gambar produk dengan lencana diskon menumpuk di pojok, nama dan harga, baris rating dan jumlah terjual yang terbagi rata, daftar tag varian (ukuran atau warna) yang membungkus otomatis, dan tombol beli yang selalu menempel di bagian bawah kartu berapa pun panjang deskripsinya.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  ┌─────────────────────────────┐  │
│  │▓▓▓▓▓▓▓▓ gambar ▓▓▓ [-20%] │  │
│  │ Sepatu Lari Ringan          │  │
│  │ Rp 299.000                  │  │
│  │  ★4.8            120 terjual│  │
│  │ [39] [40] [41] [42] [43]    │  │
│  │ [44] [45]                   │  │
│  │ ┌─────────────────────────┐│  │
│  │ │      Beli Sekarang      ││  │
│  │ └─────────────────────────┘│  │
│  └─────────────────────────────┘  │
│                                     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Lencana diskon menumpuk di atas gambar tanpa mendorong tata letak lain.
  • Tombol beli selalu di posisi bawah kartu, tidak ikut bergeser kalau deskripsi produk panjang.
  • Tag varian membungkus rapi untuk dua sampai delapan tag.
  • Kartu tidak overflow di lebar layar sekecil 320 piksel.

Petunjuk: Tombol yang selalu di bawah terlepas dari panjang konten di atasnya adalah kasus untuk Column dengan Spacer di antara konten dan tombol, atau MainAxisAlignment.spaceBetween kalau strukturnya memungkinkan. Perhatikan bahwa keduanya hanya bekerja kalau Column-nya punya tinggi yang terbatas — di dalam kartu yang tingginya mengikuti isi, kamu perlu menetapkan tingginya lebih dulu.

Variasi 3: Dashboard Cuaca — ⭐⭐⭐ · 60–90 menit

Tantangan: Susun dashboard cuaca: ikon cuaca besar menumpuk dengan suhu di tengah layar, baris tiga statistik (kelembapan, angin, tekanan) yang terbagi rata, dan daftar horizontal ramalan per jam yang bisa digulir ke samping tanpa mengganggu gulir vertikal halaman.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│              ☀                     │
│             28°C                   │
│            Cerah                   │
│                                     │
│  💧 65%     🌬 12km/j    ⏲ 1013hPa│
│                                     │
│  ┌────┐┌────┐┌────┐┌────┐→ scroll  │
│  │09:00││10:00││11:00││12:00│      │
│  │ ☀  ││ ⛅  ││ 🌧  ││ ☀  │      │
│  └────┘└────┘└────┘└────┘        │
│                                     │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Ikon dan suhu tersusun rapi di tengah tanpa saling menutupi.
  • Tiga statistik terbagi rata dan tetap seimbang di lebar layar berbeda.
  • Daftar ramalan per jam bergulir horizontal secara independen dari gulir vertikal halaman.

Petunjuk: Gulir horizontal di dalam halaman yang bergulir vertikal butuh ListView dengan scrollDirection: Axis.horizontal, dibungkus dalam SizedBox bertinggi tetap supaya tidak bentrok dengan constraint tak terbatas dari induknya. Ini penerapan langsung aturan emas bab ini: induk yang tidak membatasi tinggi membuat anak yang ingin "setinggi mungkin" kehilangan acuan.

Variasi 4: Kartu Undangan Digital — ⭐⭐⭐⭐ · 90–120 menit

Tantangan: Rancang kartu undangan digital dengan latar bergradasi, ornamen dekoratif yang menumpuk di sudut-sudut tanpa memotong konten utama, judul acara di tengah dengan bayangan teks, dan detail tanggal, lokasi, serta dresscode dalam tata letak yang tetap seimbang baik dalam mode potret maupun lanskap.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  ┌─────────────────────────────┐  │
│  │❖                         ❖│  │
│  │                              │  │
│  │     Pernikahan A & B         │  │
│  │                              │  │
│  │   Sabtu, 12 Des 2026         │  │
│  │   Gedung Serbaguna, Jakarta  │  │
│  │   Dresscode: Formal          │  │
│  │                              │  │
│  │❖                         ❖│  │
│  └─────────────────────────────┘  │
│                                     │
│  ↻ mode lanskap (struktur beda,    │
│    bukan cuma melebar):            │
│  ┌───────────────┬─────────────┐  │
│  │  Pernikahan   │ Sab, 12 Des │  │
│  │    A & B      │ Gd. Serba-  │  │
│  │               │ guna        │  │
│  └───────────────┴─────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Ornamen sudut tidak pernah menutupi teks penting di lebar layar berapa pun.
  • Tata letak berubah proporsi secara sengaja antara potret dan lanskap, bukan sekadar melebar.
  • Tidak ada satu pun nilai piksel yang ditulis mati untuk posisi — semuanya relatif terhadap ukuran layar atau induknya.

Petunjuk: Untuk tata letak yang benar-benar berbeda antara potret dan lanskap, MediaQuery.sizeOf atau LayoutBuilder dipakai untuk memilih struktur widget yang berbeda, bukan hanya mengatur ukuran yang sama. Perhatikan juga bahwa color dan decoration pada Container tidak boleh dipakai bersamaan — gradasi berarti kamu memakai decoration, jadi warnanya ikut pindah ke sana.

Variasi 5: Pemilihan Kursi Bioskop — ⭐⭐⭐⭐ · 90–120 menit

Tantangan: Bangun layar pemilihan kursi: layar bioskop melengkung di bagian atas, denah kursi berbentuk kisi dengan lorong di tengah dan baris yang jumlah kursinya berbeda-beda, keterangan warna di bawah, dan panel ringkasan harga yang menempel di dasar layar.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│      ⌢⌢⌢⌢⌢ LAYAR ⌢⌢⌢⌢⌢          │
│                                     │
│  A  □ □ □   |   □ □ □              │
│  B  □ ■ □   |   □ □ ■              │
│  C  □ □ □ □ | □ □ □ □              │
│                                     │
│  ■ Dipilih   □ Tersedia            │
│  ┌─────────────────────────────┐  │
│  │ 2 Kursi · Rp 150.000 [Bayar]│  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Denah kursi tetap berada di tengah dan proporsional di lebar layar berapa pun, tanpa kursi yang terpotong.
  • Baris dengan jumlah kursi berbeda tetap sejajar terhadap sumbu tengah.
  • Panel harga menempel di bawah dan tidak ikut bergulir bersama denah.
  • Seluruh denah bisa digulir kalau layarnya pendek, tanpa overflow.

Petunjuk: Denah kursi bukan kasus GridView — jumlah kursi per baris berbeda, jadi susunan Column berisi beberapa Row justru lebih tepat dan lebih mudah dikendalikan. Untuk panel yang menempel di bawah sementara isinya bergulir, Scaffold punya slot khusus untuk itu, dan memakainya jauh lebih sederhana daripada menyusun Stack sendiri.

Variasi 6: Struk Belanja — ⭐⭐⭐⭐ · 75–100 menit

Tantangan: Susun tampilan struk pembayaran: kepala dengan nama toko dan nomor transaksi, daftar item dengan nama di kiri serta jumlah dan harga rata kanan, garis pemisah, ringkasan subtotal dan pajak, lalu total yang menonjol. Nama item yang panjang harus dipotong, tetapi kolom harga tidak boleh pernah tergeser.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  ┌─────────────────────────────┐  │
│  │      Toko Serba Ada          │  │
│  │      No. Transaksi: 00123    │  │
│  │  - - - - - - - - - - - - -  │  │
│  │  Kopi Susu Gula Ar...  25.000│  │
│  │  Roti Bakar             15.000│  │
│  │  Air Mineral             5.000│  │
│  │  - - - - - - - - - - - - -  │  │
│  │  Subtotal               45.000│  │
│  │  Pajak                   4.500│  │
│  │  TOTAL                  49.500│  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Kolom harga sejajar sempurna untuk seluruh baris, termasuk ketika nama item sangat panjang.
  • Angka rata kanan dan tidak pernah terpotong.
  • Ada garis pemisah putus-putus yang lebarnya mengikuti lebar struk.
  • Struk tampil benar untuk satu item maupun untuk tiga puluh item.

Petunjuk: Kolom yang sejajar sempurna adalah masalah pembagian ruang: satu sisi harus mengambil sisa ruang yang tersedia sementara sisi lain memakai ruang sepas isinya. Begitu kamu membiarkan kedua sisi fleksibel, lebar kolom harga akan berubah dari baris ke baris. Untuk garis putus-putus, Row berisi banyak Container kecil yang dihasilkan lewat List.generate sudah cukup — dan membungkusnya dengan LayoutBuilder memberimu lebar yang tersedia untuk menghitung jumlahnya.

Variasi 7: Pemutar Musik — ⭐⭐⭐⭐ · 90–120 menit

Tantangan: Bangun layar pemutar musik: sampul album besar di tengah dengan bayangan, judul lagu dan artis, bilah progres dengan penanda waktu di kedua ujungnya, baris kontrol pemutaran yang jarak antar tombolnya proporsional, dan kartu "berikutnya" yang mengintip dari bawah layar.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│      ┌───────────────┐             │
│      │   ▓▓▓▓▓▓▓▓▓   │             │
│      │   ▓▓▓▓▓▓▓▓▓   │             │
│      └───────────────┘             │
│        Judul Lagu — Artis          │
│  ──────●───────────────  2:14/3:40 │
│                                     │
│     ⏮      ⏸ (besar)      ⏭      │
│                                     │
│  ┌─────────────────────────────┐  │
│  │ ▲ Berikutnya: Lagu Lain      │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Kriteria selesai:

  • Sampul album selalu berbentuk persegi sempurna di layar apa pun, dan ukurannya menyesuaikan tinggi yang tersedia.
  • Tombol tengah lebih besar dari tombol lain, dan seluruh baris kontrol tetap seimbang terhadap sumbu tengah.
  • Kartu "berikutnya" mengintip dari tepi bawah dengan tinggi yang tetap.
  • Layar tidak overflow di perangkat pendek maupun dalam mode lanskap.

Petunjuk: Bentuk persegi yang tetap persegi apa pun ruangnya punya widget khusus yang tugasnya persis itu. Untuk baris kontrol yang seimbang meski tombol tengahnya lebih besar, perhatikan bahwa penyebaran di sumbu utama bekerja berdasarkan sisa ruang, bukan berdasarkan ukuran tiap anak — jadi tombol yang lebih besar tidak akan merusak keseimbangannya selama kamu tidak memaksakan lebar tetap pada masing-masing.

Variasi 8: Kalender Bulanan — ⭐⭐⭐⭐⭐ · 2–3 jam

Tantangan: Susun tampilan kalender satu bulan penuh: kepala nama hari, kisi tanggal yang baris pertamanya dimulai di hari yang benar, tanggal dari bulan sebelumnya dan berikutnya ditampilkan redup, penanda pada tanggal yang punya acara, dan panel daftar acara di bawahnya yang tingginya menyesuaikan.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│                                     │
│  Min Sen Sel Rab Kam Jum Sab       │
│   29  30  1   2   3   4   5        │
│   6   7   8   9  10  11  12        │
│  13  14  15• 16  17  18  19        │
│  20  21  22  23  24  25  26        │
│  27  28  29  30   1   2   3        │
│                                     │
│  ┌─────────────────────────────┐  │
│  │ 15 Des — Rapat Tim, 10:00    │  │
│  │ 15 Des — Deadline Proyek     │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

(tanggal redup = 29–30 di baris pertama & 1–3 di baris terakhir; = penanda acara, tidak menggeser posisi angka)

Kriteria selesai:

  • Kisi tanggal benar untuk bulan apa pun, termasuk Februari tahun kabisat dan bulan yang butuh enam baris.
  • Setiap sel tanggal berbentuk persegi dan seluruh kisi tetap proporsional di layar sempit maupun lebar.
  • Penanda acara tidak pernah menggeser posisi angka tanggalnya.
  • Panel acara di bawah mengambil sisa ruang yang tersedia dan bisa digulir sendiri kalau acaranya banyak.

Petunjuk: Ini latihan tata letak, bukan latihan tanggal — hitung dulu berapa sel kosong yang dibutuhkan di awal dari hari apa tanggal satu jatuh, lalu bangun daftar sel sebagai data sebelum menyentuh widget apa pun. Untuk sel persegi di dalam kisi, delegasi grid punya parameter rasio yang mengaturnya. Dan untuk penanda yang tidak menggeser angka, tumpuk keduanya alih-alih menyusunnya berurutan.

Ikhtisar

  • Aturan emas: constraint turun, ukuran naik, induk menentukan posisi. Widget tidak bisa lebih besar dari yang diizinkan induknya.
  • Container menggabungkan ukuran, dekorasi, padding, dan margin — tetapi color dan decoration tidak boleh dipakai bersamaan.
  • Padding di dalam, margin di luar. Keduanya memakai EdgeInsets.
  • Setiap Column/Row punya sumbu utama (arah penyusunan) dan sumbu silang (tegak lurus). mainAxisAlignment dan crossAxisAlignment mengaturnya.
  • MainAxisSize.min membuat Column sepas isinya — wajib di dalam Card, dialog, dan bottom sheet.
  • Expanded mengisi sisa ruang secara paksa; Flexible boleh lebih kecil. Keduanya hanya sah sebagai anak langsung Row/Column.
  • SizedBox untuk jarak, Spacer untuk mendorong ke tepi.
  • Stack + Positioned untuk menumpuk. Anak pertama paling bawah.
  • Wrap menggantikan Row ketika jumlah anak tidak bisa diprediksi.
  • ListTile menghemat banyak kerja untuk baris daftar; ada varian CheckboxListTile, SwitchListTile, dan RadioListTile.
  • Aset harus didaftarkan di pubspec.yaml dengan indentasi yang tepat, lalu butuh hot restart bukan hot reload.
  • SingleChildScrollView untuk isi tetap yang kepanjangan; ListView.builder untuk daftar dinamis.

Berikutnya: Bab 5 — State & StatefulWidget, tempat antarmuka statis ini mulai bisa berubah.

Transkrip asli

Disintesis dari 1_flutter_zero-to-hero/2_basic-layout.md, 3_master-layout.md, 8_clean-ui-widgets.md, dan 1_flutter_core/2_basics.md (video 52–70) serta 4_expense-tracker_interactive-widget-theming.md (video 6–9). Lihat PDF Zero to Hero dan PDF Flutter Core.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.