Flutter #28: Custom Widget & Reusable Component — Bikin Widget Sendiri yang Dipakai Ulang
Halo! Di artikel #27 kita sudah main-main sama Hero Animation dan Page Transition. Sekarang saatnya upgrade arsitektur kode kita — dari “tulis ulang terus” jadi “bikin sekali, pakai di mana-mana.”
Custom widget adalah kunci bikin app yang scalable. Bayangkan kamu punya card profile, button style, atau badge yang muncul di 10 halaman berbeda. Kalau copy-paste di setiap halaman, pas mau ubah satu hal harus ubah 10 tempat. Makan hati. 😅
Hari ini kita belajar bikin widget sendiri yang reusable, clean, dan enak dipakai. Let’s go!
1. Apa Itu Custom Widget?
Di Flutter, semua yang kamu lihat di layar adalah widget. Text, Container, Row — itu semua widget. Custom widget cuma artinya kamu bikin widget sendiri yang menggabungkan beberapa widget jadi satu komponen yang bisa dipakai ulang.
Dua cara bikin custom widget:
| Pendekatan | Kapan Pakai | Contoh |
|---|---|---|
| StatelessWidget | Widget tidak punya state/internal data | Badge, Avatar, InfoCard |
| StatefulWidget | Widget punya state yang bisa berubah | AnimatedButton, ToggleChip |
Aturan praktis: Mulai dari StatelessWidget dulu. Kalau butuh internal state baru upgrade ke StatefulWidget.
2. Contoh 1: Custom Info Card
Kita mulai dari sesuatu yang sering muncul di banyak halaman — info card dengan icon, title, dan subtitle.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Custom Widget Demo',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const DashboardPage(),
);
}
}
// ========== CUSTOM WIDGET: InfoCard ==========
class InfoCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final Color color;
const InfoCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.color = Colors.blue,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: color, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
],
),
),
);
}
}
// ========== PAGE YANG PAKAI InfoCard ==========
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dashboard')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Tinggal panggil InfoCard — satu baris!
const InfoCard(
icon: Icons.people,
title: 'Total Users',
subtitle: '12,345 aktif bulan ini',
color: Colors.blue,
),
const SizedBox(height: 12),
const InfoCard(
icon: Icons.attach_money,
title: 'Revenue',
subtitle: 'Rp 45.000.000',
color: Colors.green,
),
const SizedBox(height: 12),
const InfoCard(
icon: Icons.star,
title: 'Rating',
subtitle: '4.8 dari 2,100 review',
color: Colors.orange,
),
],
),
),
);
}
}
Yang perlu diperhatikan:
- Constructor yang jelas — Parameter wajib pakai
required, optional pakai default value. Siapa pun yang pakai widget ini langsung tahu harus isi apa. - Properti
key— Selalu terimasuper.keydi constructor. Ini standar Flutter untuk identifikasi widget. - Pisahkan custom widget dari halaman —
InfoCarddidefinisikan terpisah dariDashboardPage. Inilah prinsip reusable: widget punya dunianya sendiri.
3. Contoh 2: Custom Button dengan Banyak Variasi
Kadang kamu butuh tombol dengan style berbeda-beda, tapi strukturnya sama. Solusinya: bikin satu widget dengan variasi parameter.
// ========== CUSTOM WIDGET: CustomButton ==========
enum ButtonStyle { primary, secondary, danger }
class CustomButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
final ButtonStyle style;
final IconData? icon;
final bool isLoading;
const CustomButton({
super.key,
required this.label,
required this.onPressed,
this.style = ButtonStyle.primary,
this.icon,
this.isLoading = false,
});
// Getter untuk warna berdasarkan style
Color get _backgroundColor {
switch (style) {
case ButtonStyle.primary:
return Colors.blue;
case ButtonStyle.secondary:
return Colors.grey;
case ButtonStyle.danger:
return Colors.red;
}
}
Color get _textColor {
switch (style) {
case ButtonStyle.primary:
case ButtonStyle.danger:
return Colors.white;
case ButtonStyle.secondary:
return Colors.white;
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: isLoading ? null : onPressed,
icon: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(icon ?? Icons.arrow_forward, color: _textColor),
label: Text(
label,
style: TextStyle(
color: _textColor,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: _backgroundColor,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}
// ========== CONTOH PAKAI ==========
class FormPage extends StatelessWidget {
const FormPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Form Demo')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Tombol primary dengan icon
CustomButton(
label: 'Simpan Data',
icon: Icons.save,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tersimpan!')),
);
},
),
const SizedBox(height: 12),
// Tombol secondary
CustomButton(
label: 'Batal',
style: ButtonStyle.secondary,
icon: Icons.close,
onPressed: () => Navigator.pop(context),
),
const SizedBox(height: 12),
// Tombol danger
CustomButton(
label: 'Hapus Akun',
style: ButtonStyle.danger,
icon: Icons.delete,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Akun dihapus')),
);
},
),
const SizedBox(height: 12),
// Tombol dalam loading state
const CustomButton(
label: 'Loading...',
isLoading: true,
onPressed: null,
),
],
),
),
);
}
}
Fitur keren dari custom widget ini:
- Enum untuk variasi —
ButtonStyle.primary,.secondary,.dangerlebih aman dan readable daripada pakai string atau warna langsung. - Parameter optional dengan default —
icon,isLoading,stylepunya default value. Widget tetap bisa dipanggil dengan minimal parameter. VoidCallback onPressed— Tipe standar Flutter untuk callback tombol. Bisanullkalau disabled.- Loading state built-in — Cukup set
isLoading: true, tombol otomatis nonaktif dan tampilkan spinner.
4. Pola Organisasi File yang Bersih
Begitu custom widget mulai banyak, kamu butuh struktur folder yang rapi:
lib/
├── main.dart
├── models/
│ └── item_model.dart
├── pages/
│ ├── home_page.dart
│ └── detail_page.dart
└── widgets/ ← Custom widgets di sini
├── info_card.dart
├── custom_button.dart
├── user_avatar.dart
└── badge_chip.dart
Tips naming:
- Folder
widgets/khusus untuk komponen reusable - Nama file sama dengan nama class:
info_card.dart→ classInfoCard - Kalau widget spesifik untuk satu halaman, taruh di foldernya halaman itu
- Gunakan
exportuntuk mempermudah import:
// lib/widgets/widgets.dart (barrel file)
export 'info_card.dart';
export 'custom_button.dart';
export 'user_avatar.dart';
export 'badge_chip.dart';
Lalu cukup satu import di halaman:
import '../widgets/widgets.dart';
5. Best Practice Custom Widget
| Prinsip | Penjelasan |
|---|---|
| Satu tanggung jawab | Widget hanya handle satu hal. Jangan bikin “god widget” yang bisa segalanya |
| Constructor yang jelas | Pakai required untuk yang wajib, default value untuk optional |
Terima key |
Selalu pakai super.key di constructor |
| Jangan hardcode style | Pakai Theme.of(context) atau parameter untuk warna/ukuran |
| Naming konsisten | Widget card → *Card, button → *Button, tile → *Tile |
| Naming konsisten | Widget card → *Card, button → *Button, tile → *Tile |
| Export pattern | Satu barrel file untuk semua widgets di folder yang sama |
Recap Hari Ini
| Konsep | Fungsi | Kapan Pakai |
|---|---|---|
| Custom StatelessWidget | Widget tanpa state yang bisa dipakai ulang | Card, Badge, Avatar, Button |
| Custom StatefulWidget | Widget dengan state internal | Toggle, Form field, Animation |
| Enum untuk variasi | Tipe aman untuk variasi widget | Button styles, Card types |
| Barrel export | Satu import untuk semua widgets | Ketika widget sudah banyak |
| Folder widgets/ | Organisasi file yang rapi | Project yang mulai besar |
Yang harus diingat:
- Mulai dari
StatelessWidget, upgrade keStatefulWidgetkalau butuh state - Properti wajib pakai
required, optional pakai default value - Pisahkan widget reusable ke folder
widgets/ - Nama file = nama class = kegunaan widget
- Custom widget yang baik bikin kode pemanggil lebih pendek dan lebih jelas
Di artikel #29 kita akan masuk FASE 3: ADVANCED dengan BLoC: Cubit & BlocProvider — state management profesional yang dipakai di app production! 🏗️
Coba sendiri! Bikin library 3 custom widget sendiri (card, button, badge) dan pakai di satu halaman. Share hasilnya ke sosial media dan tag @ahsai001! 🚀