Accessibility #
Aksesibilitas bukan fitur tambahan — ia adalah kualitas dasar dari software yang baik. Di Indonesia, ada jutaan pengguna dengan disabilitas visual, motorik, dan kognitif yang bergantung pada screen reader, navigasi keyboard, dan teks berukuran besar. Flutter memiliki dukungan aksesibilitas yang sangat baik secara built-in, dan sebagian besar hanya membutuhkan sedikit perhatian ekstra saat menulis widget.
Semantics — Informasi untuk Screen Reader #
Screen reader (TalkBack di Android, VoiceOver di iOS) menggunakan Semantics tree untuk memahami konten app. Flutter secara otomatis mengisi semantics dari banyak widget standar — tapi kadang perlu diatur manual.
// Widget standar sudah punya semantics otomatis
ElevatedButton(
onPressed: () {},
child: const Text('Beli'),
// Screen reader otomatis membaca: "Beli, tombol"
)
// Text juga otomatis
const Text('Flutter Book - Rp 150.000')
// Screen reader membaca teks apa adanya
// Image -- WAJIB berikan label jika gambar bermakna
Image.asset(
'assets/images/produk.png',
semanticLabel: 'Foto produk Flutter Book berwarna biru',
// Screen reader membaca label ini
)
// Image dekoratif -- beri tahu screen reader untuk skip
Image.asset(
'assets/images/background.png',
excludeFromSemantics: true, // screen reader tidak membaca ini
)
// Icon tanpa teks -- beri semantics label
IconButton(
icon: const Icon(Icons.shopping_cart),
tooltip: 'Keranjang belanja', // tooltip otomatis jadi semantics label
onPressed: () {},
)
// Kustom widget -- tambahkan Semantics secara eksplisit
Semantics(
label: 'Rating produk: 4.5 dari 5 bintang',
child: StarRatingWidget(rating: 4.5), // widget visual saja
)
Semantics Lanjutan #
// Semantics untuk state widget
Semantics(
label: 'Mode gelap',
toggled: isDarkMode, // membaca "aktif" atau "nonaktif"
onTap: () => toggleDarkMode(),
child: Switch(value: isDarkMode, onChanged: (_) => toggleDarkMode()),
)
// Heading untuk struktur halaman
Semantics(
header: true, // screen reader mengidentifikasi sebagai judul
child: Text('Daftar Produk', style: headlineStyle),
)
// Live region -- beri tahu screen reader ada update dinamis
Semantics(
liveRegion: true, // screen reader otomatis mengumumkan perubahan
child: Text(statusMessage), // misal: "Memuat...", "3 produk ditemukan"
)
// Custom action -- tambahkan aksi yang tidak terlihat secara visual
Semantics(
customSemanticsActions: {
const CustomSemanticsAction(label: 'Hapus dari favorit'): () {
removeFromFavorites();
},
const CustomSemanticsAction(label: 'Bagikan produk'): () {
shareProduk();
},
},
child: ProdukCard(produk: produk),
)
// Button yang terlihat disabled tapi masih bisa diakses untuk penjelasan
Semantics(
enabled: true,
onTap: () => showSnackbar('Lengkapi form terlebih dahulu'),
child: ElevatedButton(
onPressed: null, // terlihat disabled secara visual
child: const Text('Lanjutkan'),
),
)
MergeSemantics dan ExcludeSemantics #
// MASALAH: screen reader membaca tiap elemen ListTile secara terpisah
// "Avatar, gambar", "Budi Santoso", "[email protected]", "Menu, tombol"
// Ini membingungkan -- pengguna harus swipe 4x hanya untuk satu item
ListTile(
leading: CircleAvatar(child: Text('BS')),
title: const Text('Budi Santoso'),
subtitle: const Text('[email protected]'),
trailing: const Icon(Icons.more_vert),
)
// SOLUSI: MergeSemantics -- gabungkan semua children jadi satu semantics node
MergeSemantics(
child: ListTile(
leading: CircleAvatar(child: Text('BS')),
title: const Text('Budi Santoso'),
subtitle: const Text('[email protected]'),
// Screen reader membaca: "Budi Santoso, [email protected]"
trailing: ExcludeSemantics(
// Ikon "more" tidak perlu dibaca -- aksi sudah jelas dari konteks
child: const Icon(Icons.more_vert),
),
),
)
// ExcludeSemantics -- hapus dari semantics tree
ExcludeSemantics(
child: Divider(), // divider tidak perlu dibaca
)
ExcludeSemantics(
child: AnimatedContainer(
// Animasi dekoratif tidak perlu diumumkan
duration: const Duration(milliseconds: 300),
color: highlightColor,
child: const SizedBox(width: 4, height: 40),
),
)
Ukuran Teks Dinamis (Text Scaling) #
Pengguna bisa memperbesar ukuran teks di pengaturan sistem. App yang baik menghormati pengaturan ini:
// ANTI-PATTERN: paksa ukuran teks tetap
Text(
'Nama Produk',
style: const TextStyle(fontSize: 16),
// Pengaturan text scale pengguna diabaikan!
)
// BENAR: biarkan sistem menghitung skala
// Secara default Flutter menghormati text scale factor
// Jika perlu batasi scale factor (untuk layout yang sangat ketat)
Text(
'Nama Produk',
style: const TextStyle(fontSize: 16),
textScaler: TextScaler.noScaling, // nonaktifkan scaling untuk teks ini
// HATI-HATI: hanya gunakan jika benar-benar diperlukan!
)
// Atau batasi scale maksimum
Builder(
builder: (context) {
final textScaler = MediaQuery.textScalerOf(context)
.clamp(maxScaleFactor: 1.5); // maksimum 1.5x dari ukuran asli
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: const MyWidget(),
);
},
)
// Pastikan layout fleksibel dengan teks besar
// ANTI-PATTERN: container dengan tinggi tetap
SizedBox(
height: 40,
child: Text('Label', overflow: TextOverflow.clip), // terpotong dengan teks besar!
)
// BENAR: biarkan konten menentukan ukuran
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text('Label'), // akan mengembang secara vertikal
)
Kontras Warna #
Teks harus memiliki kontras yang cukup terhadap latar belakang agar bisa dibaca oleh pengguna dengan low vision:
// WCAG AA: rasio kontras minimal 4.5:1 untuk teks normal, 3:1 untuk teks besar
// WCAG AAA: 7:1 untuk teks normal, 4.5:1 untuk teks besar
// Hindari kombinasi yang tidak kontras:
Text('Teks', style: TextStyle(color: Colors.grey[400])) // abu terang di putih
// Rasio kontras: ~1.8:1 -- GAGAL WCAG
// Gunakan kombinasi yang kontras:
Text('Teks', style: TextStyle(color: Colors.grey[900])) // hampir hitam di putih
// Rasio kontras: ~18:1 -- LULUS WCAG AAA
// Cek kontras secara programatik
double calculateContrastRatio(Color foreground, Color background) {
final lF = foreground.computeLuminance();
final lB = background.computeLuminance();
final lighter = lF > lB ? lF : lB;
final darker = lF > lB ? lB : lF;
return (lighter + 0.05) / (darker + 0.05);
}
// Tools untuk cek kontras:
// - WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
// - Flutter Widget Inspector di DevTools menampilkan contrast info
Navigasi Keyboard (Desktop & Tablet) #
// Pastikan semua elemen interaktif bisa diakses via keyboard/tab
// Widget standar Flutter (Button, TextField, etc.) sudah keyboard-accessible
// Atur urutan focus dengan FocusTraversalGroup
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
// Urutan Tab: email → password → tombol login
TextField(key: const ValueKey('email-field'), ...),
TextField(key: const ValueKey('password-field'), ...),
ElevatedButton(onPressed: login, child: const Text('Masuk')),
],
),
)
// Trap focus di dalam modal/dialog
FocusTrap(
child: AlertDialog(
title: const Text('Konfirmasi'),
content: const Text('Yakin ingin menghapus?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Batal')),
TextButton(onPressed: hapus, child: const Text('Hapus')),
],
),
)
// Auto-focus ke elemen pertama saat dialog terbuka
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: TextField(
autofocus: true, // langsung fokus ke sini
decoration: const InputDecoration(labelText: 'Nama baru'),
),
),
)
Test Aksesibilitas #
// Test semantics di widget test
testWidgets('produk card punya semantics yang benar', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ProdukCard(
produk: Produk(id: '1', nama: 'Flutter Book', harga: 150000),
),
),
);
// Verifikasi semantics
expect(
tester.getSemantics(find.byType(ProdukCard)),
matchesSemantics(
label: contains('Flutter Book'),
isButton: true,
hasTapAction: true,
),
);
});
// Jalankan accessibility guidelines checker
testWidgets('memenuhi accessibility guidelines', (tester) async {
final handle = tester.ensureSemantics(); // aktifkan semantics tree
await tester.pumpApp(const HomeScreen());
// Cek semua guideline aksesibilitas bawaan Flutter
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
Checklist Aksesibilitas #
SCREEN READER:
□ Semua gambar bermakna punya semanticLabel
□ Gambar dekoratif pakai excludeFromSemantics: true
□ Icon tanpa teks punya tooltip
□ Widget kompleks punya Semantics yang deskriptif
□ MergeSemantics digunakan di ListTile/Card yang menjadi satu unit
TEKS DAN KONTRAS:
□ Rasio kontras teks ≥ 4.5:1 (WCAG AA)
□ Layout tidak pecah saat text scale 200%
□ Tidak ada teks yang dipaksakan tidak bisa di-scale (kecuali ada alasan kuat)
TARGET TAP:
□ Semua elemen yang bisa di-tap minimal 48x48 dp
□ Jarak antar elemen yang bisa di-tap minimal 8 dp
NAVIGASI:
□ Semua fungsi bisa diakses via keyboard (penting untuk tablet/desktop)
□ Urutan Tab logis mengikuti urutan visual
□ Focus tidak "terjebak" di luar dialog/modal yang terbuka
TESTING:
□ Jalankan app dengan TalkBack (Android) dan VoiceOver (iOS)
□ Test dengan screen reader dan verifikasi narasi yang masuk akal
□ Jalankan meetsGuideline() di widget test
Ringkasan #
- Flutter mengisi semantics tree otomatis dari widget standar — tapi widget kustom dan gambar perlu
SemanticsdansemanticLabelsecara eksplisit.MergeSemanticsmenggabungkan beberapa widget menjadi satu unit yang dibaca screen reader sebagai satu entitas — penting untuk ListTile, Card, dan item kompleks lainnya.ExcludeSemanticsmenyembunyikan elemen dekoratif dari screen reader — gunakan untuk divider, ikon pelengkap, dan animasi dekoratif.- Flutter menghormati text scale factor pengguna secara otomatis — jangan blokir ini kecuali layout benar-benar memaksa. Pastikan layout fleksibel dan tidak memiliki ukuran tetap yang terlalu ketat.
- Gunakan WCAG AA sebagai standar minimum kontras: rasio 4.5:1 untuk teks normal, 3:1 untuk teks besar (≥18pt atau 14pt bold).
- Semua elemen yang bisa di-tap harus minimal 48x48 dp — gunakan
meetsGuideline(androidTapTargetGuideline)di widget test untuk verifikasi otomatis.- Test dengan screen reader nyata (TalkBack/VoiceOver) — tidak ada test otomatis yang bisa menggantikan pengalaman mendengarkan narasi screen reader secara langsung.
← Sebelumnya: Internationalization Berikutnya: Flavors & Environment →