Skip to content

Bab 10 — Responsive & Adaptive UI

Aplikasimu akan berjalan di ponsel 5 inci, tablet 12 inci, dalam mode potret dan lanskap, dengan papan ketik muncul dan tersembunyi, di Android dan iOS. Bab ini membahas cara membuatnya tetap masuk akal di semua kondisi itu.

Dua istilah yang berbeda

Orang sering memakai kedua kata ini bergantian, padahal maknanya berbeda.

Responsif berarti tata letak menyesuaikan diri terhadap ukuran ruang yang tersedia. Satu kolom di ponsel menjadi dua kolom di tablet.

Adaptif berarti aplikasi menyesuaikan diri terhadap platform. Tombol gaya Material di Android, gaya Cupertino di iOS. Dialog yang berbeda bentuk.

Keduanya sering diperlukan bersamaan, tetapi diselesaikan dengan teknik yang berbeda.

RESPONSIF                          ADAPTIF
─────────────────────────          ─────────────────────────
Merespons ukuran layar             Merespons sistem operasi

Ponsel  → satu kolom               Android → Material Switch
Tablet  → dua kolom                iOS     → Cupertino Switch
Potret  → grafik di atas           Android → dialog persegi
Lanskap → grafik di samping        iOS     → dialog membulat

MediaQuery: mengetahui ukuran layar

Cara paling langsung untuk mengetahui berapa besar ruang yang kamu punya:

dart
@override
Widget build(BuildContext context) {
  final media = MediaQuery.of(context);

  final lebar = media.size.width;
  final tinggi = media.size.height;
  final orientasi = media.orientation;
  final ruangPapanKetik = media.viewInsets.bottom;
  final tepiAman = media.padding;

  return Text('Layar: ${lebar.toInt()} × ${tinggi.toInt()}');
}

Properti yang paling sering dipakai:

PropertiIsinya
size.width / size.heightUkuran layar dalam piksel logis
orientationOrientation.portrait atau landscape
viewInsets.bottomTinggi papan ketik saat muncul
paddingRuang untuk notch, status bar, home indicator
textScalerPengaturan ukuran teks pengguna
platformBrightnessPreferensi mode gelap perangkat

MediaQuery memicu rebuild

MediaQuery.of(context) mendaftarkan widget-mu untuk dibangun ulang setiap kali salah satu propertinya berubah — termasuk saat papan ketik muncul, padahal kamu mungkin hanya peduli pada lebar.

Sejak Flutter 3.10 ada versi yang lebih efisien, yang hanya memantau satu properti:

dart
final lebar = MediaQuery.sizeOf(context).width;
final orientasi = MediaQuery.orientationOf(context);
final papanKetik = MediaQuery.viewInsetsOf(context).bottom;

Gunakan bentuk ini kapan pun bisa.

Contoh: tata letak berubah menurut orientasi

dart
class Beranda extends StatelessWidget {
  const Beranda({super.key});

  @override
  Widget build(BuildContext context) {
    final lebar = MediaQuery.sizeOf(context).width;

    final grafik = Grafik(pengeluaran: _pengeluaran);
    final daftar = Expanded(
      child: DaftarPengeluaran(pengeluaran: _pengeluaran),
    );

    return Scaffold(
      appBar: AppBar(title: const Text('Pengeluaran')),
      body: lebar < 600
          // Potret / ponsel: grafik di atas, daftar di bawah
          ? Column(
              children: [
                grafik,
                daftar,
              ],
            )
          // Lanskap / tablet: grafik di kiri, daftar di kanan
          : Row(
              children: [
                Expanded(child: grafik),
                daftar,
              ],
            ),
    );
  }
}

Perhatikan bahwa grafik dan daftar dibuat sekali di atas lalu dipakai di kedua cabang. Ini menghindari duplikasi kode dan memastikan keduanya identik.

Titik henti yang umum

Tidak ada aturan resmi, tetapi angka-angka ini banyak dipakai dan mengikuti panduan Material:

dart
const lebarPonsel = 600;    // di bawah ini: ponsel
const lebarTablet = 840;    // 600–840: tablet kecil
const lebarDesktop = 1200;  // di atas ini: desktop
dart
Widget bangunTataLetak(BuildContext context) {
  final lebar = MediaQuery.sizeOf(context).width;

  if (lebar < 600) return const TataLetakPonsel();
  if (lebar < 1200) return const TataLetakTablet();
  return const TataLetakDesktop();
}

LayoutBuilder: ukuran ruang, bukan ukuran layar

MediaQuery memberi tahu ukuran seluruh layar. Tetapi widget-mu mungkin hanya menempati sebagian kecil darinya — misalnya berada di dalam kolom kanan sebuah tata letak dua kolom.

LayoutBuilder memberi tahu ukuran ruang yang tersedia untuk widget ini saja.

dart
LayoutBuilder(
  builder: (context, constraints) {
    // constraints.maxWidth adalah lebar yang tersedia DI SINI,
    // bukan lebar layar
    if (constraints.maxWidth < 400) {
      return const TataLetakSempit();
    }
    return const TataLetakLebar();
  },
)

Objek constraints bertipe BoxConstraints dan berisi:

dart
constraints.minWidth
constraints.maxWidth
constraints.minHeight
constraints.maxHeight

Contoh nyata — kartu yang berubah susunan sesuai ruang yang diberikan padanya:

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

class KartuAdaptif extends StatelessWidget {
  const KartuAdaptif({
    super.key,
    required this.judul,
    required this.deskripsi,
    required this.urlGambar,
  });

  final String judul;
  final String deskripsi;
  final String urlGambar;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // Ruang sempit: gambar di atas teks
        if (constraints.maxWidth < 400) {
          return Card(
            clipBehavior: Clip.antiAlias,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Image.network(
                  urlGambar,
                  height: 160,
                  width: double.infinity,
                  fit: BoxFit.cover,
                ),
                Padding(
                  padding: const EdgeInsets.all(12),
                  child: _teks(context),
                ),
              ],
            ),
          );
        }

        // Ruang lebar: gambar di kiri, teks di kanan
        return Card(
          clipBehavior: Clip.antiAlias,
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              SizedBox(
                width: 160,
                child: Image.network(urlGambar, fit: BoxFit.cover),
              ),
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [_teks(context)],
                  ),
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _teks(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        Text(judul, style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 4),
        Text(
          deskripsi,
          style: Theme.of(context).textTheme.bodySmall,
          maxLines: 3,
          overflow: TextOverflow.ellipsis,
        ),
      ],
    );
  }
}

Keunggulan pendekatan ini: kartu yang sama bisa dipakai di daftar satu kolom maupun di kisi tiga kolom, dan ia menyesuaikan diri sendiri tanpa induknya perlu memberi tahu.

Kapan memakai yang mana

  • MediaQuery ketika keputusanmu bergantung pada perangkat secara keseluruhan — misalnya "tampilkan navigation rail alih-alih bottom nav di tablet".
  • LayoutBuilder ketika keputusanmu bergantung pada ruang lokal — misalnya "kartu ini susunannya berubah kalau sempit".

SafeArea

Perangkat modern punya notch, kamera lubang, bilah status, dan indikator home. Konten yang digambar di area itu akan tertutupi.

dart
Scaffold(
  body: SafeArea(
    child: Column(
      children: [ /* ... */ ],
    ),
  ),
)

Scaffold dengan AppBar sudah menangani bagian atas secara otomatis. Kamu butuh SafeArea ketika:

  • Tidak memakai AppBar.
  • Menggambar konten di bawah layar (dekat indikator home iOS).
  • Memakai Stack yang mengisi seluruh layar.

Kamu bisa memilih sisi mana yang dilindungi:

dart
SafeArea(
  top: true,
  bottom: false,     // biarkan konten sampai ke tepi bawah
  left: true,
  right: true,
  child: /* ... */,
)

Untuk bottom sheet, ada pintasan:

dart
showModalBottomSheet(
  context: context,
  useSafeArea: true,
  builder: (ctx) => const FormulirSaya(),
);

Menangani papan ketik

Ketika papan ketik muncul, ia memakan sekitar sepertiga tinggi layar. Kalau tidak ditangani, kolom input bisa tertutupi.

Cara pertama — resizeToAvoidBottomInset (aktif secara bawaan). Scaffold mengecilkan body sehingga isinya bergeser ke atas:

dart
Scaffold(
  resizeToAvoidBottomInset: true,   // bawaan
  body: /* ... */,
)

Cara kedua — padding manual, wajib untuk bottom sheet:

dart
@override
Widget build(BuildContext context) {
  final ruangPapanKetik = MediaQuery.viewInsetsOf(context).bottom;

  return Padding(
    padding: EdgeInsets.only(
      left: 16,
      right: 16,
      top: 16,
      bottom: ruangPapanKetik + 16,
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [ /* kolom input */ ],
    ),
  );
}

Cara ketiga — bungkus dengan SingleChildScrollView agar pengguna bisa menggulir ke kolom yang tertutupi:

dart
SingleChildScrollView(
  padding: EdgeInsets.only(
    bottom: MediaQuery.viewInsetsOf(context).bottom,
  ),
  child: Column(
    children: [ /* ... */ ],
  ),
)

Untuk formulir panjang di bottom sheet, gabungkan ketiganya:

dart
showModalBottomSheet(
  context: context,
  isScrollControlled: true,   // izinkan melebihi setengah layar
  useSafeArea: true,
  builder: (ctx) {
    return Padding(
      padding: EdgeInsets.only(
        bottom: MediaQuery.viewInsetsOf(ctx).bottom,
      ),
      child: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [ /* formulir */ ],
        ),
      ),
    );
  },
);

Mengunci orientasi

Kalau aplikasimu memang hanya masuk akal dalam potret:

dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown,
  ]).then((_) {
    runApp(const AplikasiSaya());
  });
}

WidgetsFlutterBinding.ensureInitialized() diperlukan karena SystemChrome menyentuh lapisan platform sebelum aplikasi berjalan.

Pertimbangkan sebelum mengunci

Mengunci orientasi memang menyederhanakan pekerjaanmu, tetapi ia juga menghalangi pengguna tablet dan pengguna dengan kebutuhan aksesibilitas tertentu. Kalau memungkinkan, lebih baik membuat tata letak yang bekerja di kedua orientasi.

Widget proporsional

FractionallySizedBox

Memberi ukuran sebagai pecahan dari induknya:

dart
FractionallySizedBox(
  widthFactor: 0.8,     // 80% lebar induk
  heightFactor: 0.5,    // 50% tinggi induk
  child: Container(color: Colors.indigo),
)

Ini yang dipakai untuk batang grafik di Bab 8 — tinggi batang adalah pecahan dari tinggi wadahnya, sehingga otomatis menyesuaikan diri berapa pun tinggi grafiknya.

AspectRatio

Menjaga perbandingan lebar terhadap tinggi:

dart
AspectRatio(
  aspectRatio: 16 / 9,
  child: Container(color: Colors.black),
)

Berguna untuk pemutar video, gambar sampul, dan peta — apa pun yang bentuknya harus konsisten meskipun ukurannya berubah.

dart
// Kartu video yang selalu 16:9 berapa pun lebar layarnya
Card(
  clipBehavior: Clip.antiAlias,
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      AspectRatio(
        aspectRatio: 16 / 9,
        child: Image.network(urlSampul, fit: BoxFit.cover),
      ),
      const Padding(
        padding: EdgeInsets.all(12),
        child: Text('Judul Video'),
      ),
    ],
  ),
)

Expanded dan Flexible

Sudah dibahas di Bab 4, tetapi perlu diingat bahwa keduanya adalah alat responsif yang paling sering dipakai. Membagi ruang dengan flex otomatis menyesuaikan diri terhadap lebar apa pun.

dart
Row(
  children: [
    Expanded(flex: 1, child: PanelSamping()),
    Expanded(flex: 3, child: KontenUtama()),
  ],
)

Panel samping selalu seperempat lebar, konten selalu tiga perempat — di ponsel maupun di desktop.

Widget adaptif menurut platform

Deteksi platform

dart
import 'dart:io';

if (Platform.isIOS) {
  // hanya iOS
} else if (Platform.isAndroid) {
  // hanya Android
}

Platform tidak tersedia di web

dart:io tidak bisa diimpor pada target web dan akan menyebabkan error kompilasi. Kalau aplikasimu menargetkan web juga, pakai defaultTargetPlatform dari package:flutter/foundation.dart:

dart
import 'package:flutter/foundation.dart';

if (defaultTargetPlatform == TargetPlatform.iOS) {
  // ...
}

// Atau periksa apakah sedang di web
if (kIsWeb) {
  // ...
}

Konstruktor .adaptive

Cara termudah — beberapa widget sudah menyediakannya:

dart
Switch.adaptive(
  value: _aktif,
  onChanged: (v) => setState(() => _aktif = v),
)

Slider.adaptive(
  value: _nilai,
  onChanged: (v) => setState(() => _nilai = v),
)

const CircularProgressIndicator.adaptive()

Di iOS ini menampilkan gaya Cupertino, di Android gaya Material — tanpa percabangan manual.

Membangun widget adaptif sendiri

Untuk kasus yang tidak punya .adaptive, buat pembungkusmu sendiri:

dart
// lib/widgets/tombol_adaptif.dart
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';

class TombolAdaptif extends StatelessWidget {
  const TombolAdaptif({
    super.key,
    required this.teks,
    required this.saatDitekan,
  });

  final String teks;
  final VoidCallback? saatDitekan;

  @override
  Widget build(BuildContext context) {
    final iOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;

    if (iOS) {
      return CupertinoButton.filled(
        onPressed: saatDitekan,
        child: Text(teks),
      );
    }

    return ElevatedButton(
      onPressed: saatDitekan,
      child: Text(teks),
    );
  }
}

Dialog adaptif:

dart
Future<bool> konfirmasiAdaptif(
  BuildContext context, {
  required String judul,
  required String pesan,
}) async {
  final iOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;

  final aksi = [
    ('Batal', false, false),
    ('Lanjutkan', true, true),
  ];

  if (iOS) {
    return await showCupertinoDialog<bool>(
          context: context,
          builder: (ctx) => CupertinoAlertDialog(
            title: Text(judul),
            content: Text(pesan),
            actions: [
              for (final (label, hasil, utama) in aksi)
                CupertinoDialogAction(
                  isDefaultAction: utama,
                  onPressed: () => Navigator.pop(ctx, hasil),
                  child: Text(label),
                ),
            ],
          ),
        ) ??
        false;
  }

  return await showDialog<bool>(
        context: context,
        builder: (ctx) => AlertDialog(
          title: Text(judul),
          content: Text(pesan),
          actions: [
            for (final (label, hasil, _) in aksi)
              TextButton(
                onPressed: () => Navigator.pop(ctx, hasil),
                child: Text(label),
              ),
          ],
        ),
      ) ??
      false;
}

Perhatikan penggunaan record (String, bool, bool) dalam list aksi — fitur Dart modern yang memungkinkan mengelompokkan beberapa nilai tanpa membuat kelas. Ini menghindari duplikasi definisi tombol di kedua cabang.

Contoh lengkap: kerangka responsif

Menggabungkan semuanya menjadi kerangka aplikasi yang bekerja dari ponsel sampai desktop.

dart
// lib/screens/kerangka_responsif.dart
import 'package:flutter/material.dart';

class KerangkaResponsif extends StatefulWidget {
  const KerangkaResponsif({super.key});

  @override
  State<KerangkaResponsif> createState() => _KerangkaResponsifState();
}

class _KerangkaResponsifState extends State<KerangkaResponsif> {
  int _indeks = 0;

  static const _tujuan = [
    (Icons.home_outlined, Icons.home, 'Beranda'),
    (Icons.search_outlined, Icons.search, 'Cari'),
    (Icons.favorite_outline, Icons.favorite, 'Favorit'),
    (Icons.person_outline, Icons.person, 'Profil'),
  ];

  @override
  Widget build(BuildContext context) {
    final lebar = MediaQuery.sizeOf(context).width;
    final halaman = _halamanUntuk(_indeks);

    // --- Ponsel: bottom navigation ---
    if (lebar < 600) {
      return Scaffold(
        body: SafeArea(child: halaman),
        bottomNavigationBar: NavigationBar(
          selectedIndex: _indeks,
          onDestinationSelected: (i) => setState(() => _indeks = i),
          destinations: [
            for (final (ikon, ikonAktif, label) in _tujuan)
              NavigationDestination(
                icon: Icon(ikon),
                selectedIcon: Icon(ikonAktif),
                label: label,
              ),
          ],
        ),
      );
    }

    // --- Tablet & desktop: navigation rail di samping ---
    final railDiperluas = lebar >= 1200;

    return Scaffold(
      body: SafeArea(
        child: Row(
          children: [
            NavigationRail(
              selectedIndex: _indeks,
              onDestinationSelected: (i) => setState(() => _indeks = i),
              extended: railDiperluas,
              labelType: railDiperluas
                  ? NavigationRailLabelType.none
                  : NavigationRailLabelType.all,
              destinations: [
                for (final (ikon, ikonAktif, label) in _tujuan)
                  NavigationRailDestination(
                    icon: Icon(ikon),
                    selectedIcon: Icon(ikonAktif),
                    label: Text(label),
                  ),
              ],
            ),
            const VerticalDivider(width: 1),

            // Batasi lebar konten agar tidak terlalu melebar di layar besar
            Expanded(
              child: Center(
                child: ConstrainedBox(
                  constraints: const BoxConstraints(maxWidth: 900),
                  child: halaman,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _halamanUntuk(int i) => switch (i) {
        0 => const HalamanBeranda(),
        1 => const HalamanCari(),
        2 => const HalamanFavorit(),
        _ => const HalamanProfil(),
      };
}

Diagram keputusannya:

ConstrainedBox dengan maxWidth: 900 pada layar besar adalah detail yang sering dilupakan: teks yang membentang selebar 1900 piksel sangat sulit dibaca, karena mata kehilangan jejak baris berikutnya.

Latihan Mandiri

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

Variasi 1: Daftar Tiga Ukuran — ⭐⭐ · 40–60 menit

Tantangan: Ambil layar daftar mana pun yang sudah kamu punya, lalu buat ia berubah bentuk di tiga ukuran: satu kolom di ponsel, dua kolom di tablet, dan tata letak berdampingan (daftar di kiri, detail di kanan) di layar lebar.

Bayangan tampilan di tiga lebar:

text
 Ponsel (<600px)     Tablet (600-1200px)     Desktop (>1200px)
╭─────────╮          ╭─────────────────╮     ╭───────────────────────╮
│ Item 1  │          │ Item 1   Item 2 │     │ Item 1 │              │
│ Item 2  │          │ Item 3   Item 4 │     │ Item 2 │   Detail     │
│ Item 3  │          │ Item 5   Item 6 │     │ Item 3 │   Item 2     │
│ Item 4  │          ╰─────────────────╯     │ Item 4 │              │
╰─────────╯            2 kolom               ╰───────────────────────╯
 1 kolom                                       daftar kiri + detail kanan

Kriteria selesai:

  • Perpindahan bentuk ditentukan oleh ruang yang tersedia bagi widget itu, bukan oleh ukuran layar perangkat.
  • Titik hentinya konsisten dan ditulis sebagai konstanta bernama, bukan angka yang bertaburan di beberapa berkas.
  • Di layar sangat lebar, lebar kolom teks dibatasi supaya barisnya tidak menjadi terlalu panjang untuk dibaca.
  • Tidak ada satu pun overflow saat memutar perangkat ke lanskap, dan papan ketik yang muncul tidak menutupi kolom input yang sedang aktif.

Petunjuk: Ini justru latihan tentang memilih alat yang benar dari dua yang mirip. MediaQuery memberi tahu ukuran layar; LayoutBuilder memberi tahu ukuran ruang yang benar-benar diberikan induk kepada widgetmu. Kalau widgetmu suatu saat dipakai di dalam panel yang sempit pada layar yang lebar, hanya satu dari keduanya memberi jawaban yang benar. Untuk membatasi lebar bacaan, ConstrainedBox dengan maxWidth di sekitar 600–700 piksel adalah rentang yang umum dipakai. Dan pakai MediaQuery.sizeOf(context) alih-alih MediaQuery.of(context).size — yang pertama tidak membangun ulang widgetmu ketika properti lain yang tidak relevan berubah.

Variasi 2: Formulir yang Melipat — ⭐⭐ · 30–45 menit

Tantangan: Bangun formulir pendaftaran kursus berisi sepuluh kolom yang tersusun satu kolom di layar sempit dan dua kolom berdampingan di layar lebar, dengan pasangan kolom yang dikelompokkan secara logis (nama depan dan belakang bersebelahan, kota dan kode pos bersebelahan).

Bayangan tampilan:

text
 Sempit                       Lebar
╭─────────────────╮   ╭───────────────────────────╮
│ Nama Depan       │   │ Nama Depan │ Nama Belakang│
│ Nama Belakang    │   │────────────┼──────────────│
│ Kota             │   │ Kota       │ Kode Pos     │
│ Kode Pos         │   ╰───────────────────────────╯
╰─────────────────╯

Kriteria selesai:

  • Pengelompokan pasangan didefinisikan sekali sebagai data, bukan ditulis dua kali untuk dua tata letak.
  • Urutan tab papan ketik tetap masuk akal di kedua tata letak.
  • Tidak ada kolom yang menjadi terlalu sempit untuk dibaca di lebar antara.
  • Papan ketik yang muncul tidak menutupi kolom yang sedang diisi.

Petunjuk: Menulis dua tata letak terpisah adalah godaan pertama dan sumber bug jangka panjang — begitu ada kolom baru, kamu harus ingat menambahkannya di dua tempat. Bangun satu daftar definisi kolom, lalu susun ia menjadi satu atau dua kolom tergantung ruang. Untuk papan ketik, Scaffold punya properti yang mengatur apakah tubuhnya menyusut saat papan ketik muncul; kalau formulirnya bergulir, itu biasanya sudah cukup.

Variasi 3: Navigasi Adaptif — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun kerangka aplikasi yang navigasinya berubah bentuk menurut lebar: bilah bawah di ponsel, rail vertikal di tablet, dan drawer permanen yang selalu terbuka di desktop. Tab yang aktif harus tetap sama saat bentuknya berubah.

Bayangan tampilan:

text
 Ponsel              Tablet                 Desktop
╭─────────╮          ╭────┬──────────╮      ╭───────────┬───────────╮
│ konten  │          │ 🏠 │ konten   │      │ 🏠 Beranda│ konten    │
│         │          │ 🔍 │          │      │ 🔍 Cari   │           │
│─────────│          │ 👤 │          │      │ 👤 Profil │           │
│ 🏠 🔍 👤│          ╰────┴──────────╯      ╰───────────┴───────────╯
╰─────────╯           rail (ikon saja)       drawer permanen + label
 bilah bawah

Memutar dari potret ke lanskap (masih sama-sama "ponsel" secara lebar): tab yang aktif dan posisi gulir konten tidak ikut reset.

Kriteria selesai:

  • Ketiga bentuk memakai satu sumber daftar tujuan yang sama.
  • Memutar perangkat dari potret ke lanskap tidak mengatur ulang tab aktif maupun posisi gulir isinya.
  • Rail menampilkan label di layar yang cukup lebar dan hanya ikon di layar sempit.
  • Tidak ada widget navigasi yang dibangun dua kali secara bersamaan.

Petunjuk: Kriteria kedua adalah yang paling sering gagal: mengganti struktur widget navigasi membuat Flutter membuang subpohon lama dan membangun yang baru, sehingga state di dalamnya ikut hilang. Menjaga isi halaman berada di luar bagian yang berganti bentuk menyelesaikannya — dan kalau tetap hilang, itu kasus untuk key yang stabil, seperti dibahas di Bab 12.

Variasi 4: Komponen Dua Wajah — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun satu layar pengaturan yang terasa asli di kedua platform: dialog, switch, indikator memuat, pemilih tanggal, dan gaya baris pengaturan semuanya mengikuti konvensi iOS di iOS dan Material di Android.

Bayangan tampilan:

text
   iOS                          Android
╭───────────────────╮  ╭───────────────────╮
│ Pengaturan          │  │ Pengaturan          │
│ Notifikasi   [◐──] │  │ Notifikasi  [●──○] │
│ ┌─────────────────┐│  │ ┌─────────────────┐│
│ │ Batal   Lanjut  ││  │ │ BATAL   LANJUT  ││
│ └─────────────────┘│  │ └─────────────────┘│
│     ⟳ memuat...     │  │     ◠◡◠ memuat...  │
╰───────────────────╯  ╰───────────────────╯

Kedua tampilan berasal dari satu kode yang sama — bedanya cuma defaultTargetPlatform, bukan dua berkas layar terpisah.

Kriteria selesai:

  • Deteksi platform memakai defaultTargetPlatform dan kIsWeb dari foundation.darttidak mengimpor dart:io.
  • Konstruktor .adaptive dipakai di mana pun tersedia, alih-alih menulis percabangan sendiri.
  • Ada satu komponen yang tidak punya varian adaptif bawaan, dan kamu menulis percabangannya sendiri di satu tempat terpusat.
  • Aplikasi tetap bisa di-build untuk web tanpa error.

Petunjuk: dart:io tidak ada di web, sehingga Platform.isIOS membuat build web gagal — dan kegagalannya terjadi saat kompilasi, bukan saat berjalan, yang sebenarnya kabar baik. defaultTargetPlatform bekerja di semua target dan juga bisa ditimpa saat pengujian, yang membuatnya pilihan yang lebih baik dalam segala hal.

Variasi 5: Dashboard yang Menyusun Ulang — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun dashboard berisi enam kartu berukuran berbeda yang menyusun ulang dirinya di empat rentang lebar: satu kolom, dua kolom, tiga kolom, dan tata letak "kartu utama besar dengan kartu kecil di sekitarnya" di layar sangat lebar.

Bayangan tampilan di empat lebar:

text
1 kolom   2 kolom       3 kolom        Sangat lebar
┌────┐   ┌────┐┌────┐  ┌──┐┌──┐┌──┐   ┌──────────┐┌──┐
│ K1 │   │ K1 ││ K2 │  │K1││K2││K3│   │          ││K4│
├────┤   ├────┤├────┤  ├──┤├──┤├──┤   │    K1    ││K5│
│ K2 │   │ K3 ││ K4 │  │K4││K5││K6│   │ (utama)  ││K6│
└────┘   └────┘└────┘  └──┘└──┘└──┘   └──────────┘└──┘

Keenam kartu (K1–K6) berasal dari satu definisi data yang sama di setiap tata letak — bukan empat susunan widget yang ditulis terpisah untuk tiap lebar.

Kriteria selesai:

  • Keempat tata letak berasal dari satu definisi kartu, bukan empat susunan yang ditulis terpisah.
  • Kartu mempertahankan rasio bentuknya di setiap tata letak.
  • Tidak ada satu pun ukuran piksel tetap untuk lebar kartu.
  • Mengubah ukuran jendela secara bertahap tidak pernah menghasilkan overflow di lebar antara mana pun.

Petunjuk: Kriteria terakhir adalah yang menemukan sebagian besar bug: jalankan di desktop atau web dan seret tepi jendela perlahan dari sempit ke lebar. Titik henti yang tampak aman biasanya gagal di beberapa piksel sebelum atau sesudah ambangnya, ketika ruang belum cukup untuk tata letak berikutnya tetapi kodenya sudah berpindah. Uji setiap ambang dari kedua arah.

Ikhtisar

  • Responsif merespons ukuran, adaptif merespons platform. Keduanya berbeda dan sering dibutuhkan bersamaan.
  • Pakai MediaQuery.sizeOf(context) alih-alih MediaQuery.of(context).size agar tidak dibangun ulang oleh perubahan properti yang tidak relevan.
  • MediaQuery untuk ukuran layar, LayoutBuilder untuk ukuran ruang lokal yang tersedia bagi widget itu sendiri.
  • Titik henti yang umum: 600 px (ponsel/tablet) dan 1200 px (tablet/desktop).
  • SafeArea menghindari notch dan indikator home. Scaffold dengan AppBar sudah menangani bagian atas.
  • Papan ketik: resizeToAvoidBottomInset untuk halaman biasa, MediaQuery.viewInsetsOf(context).bottom untuk bottom sheet.
  • FractionallySizedBox untuk ukuran proporsional, AspectRatio untuk menjaga bentuk, Expanded dengan flex untuk pembagian ruang.
  • Konstruktor .adaptive (Switch, Slider, CircularProgressIndicator) memberi gaya per platform secara gratis.
  • Jangan impor dart:io kalau menargetkan web — pakai defaultTargetPlatform dan kIsWeb dari package:flutter/foundation.dart.
  • Di layar sangat lebar, batasi lebar konten dengan ConstrainedBox agar teks tetap nyaman dibaca.

Berikutnya: Bab 11 — Animasi, agar perpindahan di aplikasimu terasa halus, bukan mengejutkan.

Transkrip asli

Disintesis dari 1_flutter_core/5_expense-tracker_responsive-addaptive-ui.md (10 video) dan 1_flutter_zero-to-hero/10_responsive-ui.md. Lihat PDF Flutter Core dan PDF Zero to Hero.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.