Implementasi Custom Drawing di Flutter dengan CustomPainter: Membuat Widget Visual Unik
Menggambar Langsung di Canvas dengan CustomPainter
Flutter menyediakan banyak widget bawaan untuk menyusun tampilan, mulai dari Container, Stack, hingga Row. Namun, beberapa bentuk visual tidak bisa dihasilkan dari kombinasi widget tersebut. Contohnya pola gelombang, bentuk bintang, atau grafik dengan gradien kompleks. Untuk kebutuhan seperti ini, CustomPainter memberi kita akses langsung ke Canvas.
Canvas adalah permukaan gambar dua dimensi tempat kita memanggil perintah gambar seperti garis, lingkaran, dan path. Setiap CustomPainter wajib mengimplementasikan dua anggota utama. Anggota pertama adalah paint(Canvas canvas, Size size), tempat semua perintah gambar dijalankan. Anggota kedua adalah shouldRepaint(covariant CustomPainter oldDelegate), yang menentukan apakah gambar perlu digambar ulang ketika widget di-rebuild.
Penting untuk membedakan dua hal yang sering tertukar. CustomPaint adalah widget yang menempatkan hasil gambar ke dalam tree, sementara CustomPainter adalah objek yang berisi logika gambarnya. Kita memberi instance painter ke widget CustomPaint, dan Flutter memanggil paint ketika area gambar perlu dirender.

Gambar: Diagram pipeline render Flutter, tempat tahap Paint dan Animation memanggil ulang painter — Sumber: Flutter Docs (CC BY 4.0)
Custom drawing tepat dipakai ketika bentuk yang diinginkan sulit dibangun dari widget biasa. Contoh kasusnya adalah grafik data, progress ring, pola dekoratif, atau ilustrasi yang bergantung pada parameter runtime. Satu painter juga bisa menggantikan banyak widget dekoratif dalam satu operasi gambar, sehingga tree tetap ringkas. Jika tampilan masih bisa disusun dari widget standar, cara itu tetap lebih sederhana dan lebih mudah diuji.
Menyiapkan CustomPaint dan Mengatur Ruang Gambar
Widget CustomPaint menerima parameter painter untuk gambar di belakang child dan foregroundPainter untuk gambar di depan child. Ketika kita tidak memberikan child, ukuran gambar mengikuti nilai size. Ketika child ada, area gambar mengikuti ukuran child hasil layout. Aturan ini penting agar koordinat gambar tidak meleset.
Objek Paint menyimpan properti gaya gambar. Kita mengatur color untuk warna, style untuk memilih PaintingStyle.fill atau PaintingStyle.stroke, serta strokeWidth dan isAntiAlias. Nilai strokeWidth diukur dalam logical pixel, sehingga tampilan tetap konsisten di layar dengan kepadatan berbeda. Satu objek Paint bisa dipakai ulang untuk beberapa perintah gambar dengan gaya yang sama.
Ketika kita perlu menggambar di atas child, misalnya overlay highlight pada sebuah gambar, gunakan foregroundPainter. Kedua painter berbagi size yang sama, sehingga koordinat keduanya tetap konsisten.
Contoh berikut menggambar kartu dengan border membulat dan dua aksen lingkaran.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: CardExample()));
class CardExample extends StatelessWidget {
const CardExample({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF101418),
body: Center(
child: CustomPaint(
painter: CardPainter(),
size: const Size(280, 160),
),
),
);
}
}
class CardPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final border = Paint()
..color = const Color(0xFF4C6FFF)
..style = PaintingStyle.stroke
..strokeWidth = 3
..isAntiAlias = true;
final accent = Paint()
..color = const Color(0xFF35D0BA)
..style = PaintingStyle.fill;
final rect = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(16)),
border,
);
canvas.drawCircle(Offset(size.width * 0.2, size.height * 0.3), 10, accent);
canvas.drawCircle(Offset(size.width * 0.8, size.height * 0.7), 18, accent);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}Kode di atas memisahkan dua objek Paint: satu untuk border dan satu untuk aksen. Logika utamanya menghitung Rect dari size, lalu menggambar RRect dengan sudut membulat. Dua lingkaran ditempatkan memakai koordinat relatif terhadap size sehingga posisinya tetap proporsional. Expected output-nya adalah kartu gelap dengan border biru dan dua lingkaran tosca di layar. shouldRepaint mengembalikan false karena gambar sepenuhnya statis.
Advanced Flutter State Management with BLoC
Master advanced Flutter state management by building a production-ready applicat...
Membentuk Path untuk Bentuk dan Pola yang Kompleks
Ketika bentuk tidak lagi sederhana, kita memakai Path. Objek ini menyimpan rangkaian perintah gambar, seperti moveTo, lineTo, quadraticBezierTo, arcTo, dan close. Rangkaian perintah tersebut dieksekusi sekali melalui canvas.drawPath.
Bentuk bintang dibuat dari koordinat polar. Kita menentukan titik di sepanjang lingkaran memakai sudut dan jari-jari, lalu bergantian antara jari-jari luar dan dalam. Kurva Bezier dipakai untuk gelombang, karena quadraticBezierTo menghasilkan lengkungan halus hanya dari tiga titik kontrol.

Gambar: Konstruksi bintang beraturan di dalam lingkaran dengan pusat O dan sudut 36 derajat antar titik — Sumber: Wikimedia Commons (TealComet, CC0)
Semua koordinat dinormalkan terhadap size. Pola ini menjaga gambar tetap proporsional di ukuran layar mana pun. Perintah arcTo dapat dipakai untuk potongan melingkar seperti progress ring. PathMetric berguna ketika kita perlu mengukur panjang path, misalnya untuk menempatkan elemen di sepanjang jalur tersebut. Untuk bentuk berulang seperti ikon kustom atau tanda centang, definisikan urutan perintah path sebagai fungsi terpisah agar mudah dipakai ulang.
import 'dart:math' as math;
import 'package:flutter/material.dart';
class StarWavePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final starPaint = Paint()
..color = const Color(0xFFFFC857)
..style = PaintingStyle.fill;
final wavePaint = Paint()
..color = const Color(0xFF4C6FFF)
..style = PaintingStyle.stroke
..strokeWidth = 4;
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) * 0.3;
final starPath = Path();
for (var i = 0; i < 10; i++) {
final r = i.isEven ? radius : radius * 0.45;
final angle = (math.pi / 5) * i - math.pi / 2;
final point = Offset(
center.dx + r * math.cos(angle),
center.dy + r * math.sin(angle),
);
if (i == 0) {
starPath.moveTo(point.dx, point.dy);
} else {
starPath.lineTo(point.dx, point.dy);
}
}
starPath.close();
final wavePath = Path()
..moveTo(0, size.height * 0.8)
..quadraticBezierTo(
size.width * 0.25,
size.height * 0.6,
size.width * 0.5,
size.height * 0.8,
)
..quadraticBezierTo(
size.width * 0.75,
size.height,
size.width,
size.height * 0.8,
);
canvas.drawPath(starPath, starPaint);
canvas.drawPath(wavePath, wavePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}Pada contoh di atas, loop menghasilkan sepuluh titik bintang memakai math.cos dan math.sin. Perintah moveTo dipakai pada titik pertama, sedangkan lineTo untuk titik berikutnya. Gelombang disusun dari dua segmen quadraticBezierTo yang menyambung di tengah canvas. Expected output-nya adalah bintang kuning di tengah dan garis gelombang biru di bagian bawah.
Menambahkan Gradien, Bayangan, dan Animasi
Warna solid sering terasa datar. Kita bisa menambahkan LinearGradient atau RadialGradient melalui properti Paint.shader. Shader ini dipetakan ke area tertentu, misalnya Rect dari ukuran canvas, sehingga gradien mengikuti ruang gambar dengan tepat.
Untuk efek glow, Paint.maskFilter dengan MaskFilter.blur memberi kabut di tepi bentuk. Gunakan blur secukupnya, karena radius besar membebani GPU pada animasi yang berjalan terus-menerus. Ketika beberapa efek perlu dikompositkan, canvas.saveLayer memisahkannya sebagai satu layer sebelum digabungkan kembali.
Animasi tidak harus membuat ulang widget. Kita menghubungkan AnimationController ke painter melalui parameter repaint pada super. Dengan cara ini, Flutter hanya memanggil paint setiap frame tanpa rebuild widget. Transformasi seperti rotasi dan skala dilakukan dengan canvas.save, canvas.translate, canvas.rotate, dan canvas.restore.
import 'dart:math' as math;
import 'package:flutter/material.dart';
class GlowStar extends StatefulWidget {
const GlowStar({super.key});
@override
State<GlowStar> createState() => _GlowStarState();
}
class _GlowStarState extends State<GlowStar>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: GlowPainter(animation: _controller),
size: const Size(240, 240),
);
}
}
class GlowPainter extends CustomPainter {
GlowPainter({required this.animation}) : super(repaint: animation);
final Animation<double> animation;
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final glow = Paint()
..shader = const RadialGradient(
colors: [Color(0xFF35D0BA), Color(0x00000000)],
).createShader(rect)
..maskFilter = MaskFilter.blur(BlurStyle.normal, 24);
final shape = Paint()
..shader = const LinearGradient(
colors: [Color(0xFF4C6FFF), Color(0xFFFFC857)],
).createShader(rect);
final center = size.center(Offset.zero);
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(animation.value * 2 * math.pi);
canvas.drawCircle(Offset.zero, size.width * 0.22, glow);
canvas.drawRect(
Rect.fromCenter(
center: Offset.zero,
width: size.width * 0.3,
height: size.width * 0.3,
),
shape,
);
canvas.restore();
}
@override
bool shouldRepaint(covariant GlowPainter oldDelegate) =>
oldDelegate.animation != animation;
}Contoh di atas membuat painter yang menerima Animation<double> sebagai sumber repaint. Nilai animasi dipakai untuk memutar canvas sebesar 2 * math.pi, kemudian menggambar lingkaran glow dan persegi bergradien di titik pusat. Expected output-nya adalah bentuk yang berputar halus selama empat detik, lengkap dengan glow tosca dan persegi bergradien biru ke kuning.
Menjaga Performa dan Menguji Hasil Gambar
shouldRepaint adalah kunci performa render. Jika implementasinya selalu mengembalikan true, Flutter menggambar ulang pada setiap rebuild meski parameter tidak berubah. Bandingkan hanya parameter yang benar-benar memengaruhi gambar, dan kembalikan false ketika nilainya sama.
Alokasi objek juga berpengaruh. Membuat Paint dan Path baru di dalam paint() setiap frame menambah beban garbage collector. Bila properti gaya tidak berubah, kita bisa menyimpannya sebagai field dan memakai ulang objek tersebut.
RepaintBoundary mengisolasi area yang sering berubah dari sisa tree. Widget di sekitarnya tidak ikut digambar ulang ketika painter di dalamnya beranimasi. Kombinasi repaint pada painter dan RepaintBoundary memberi hasil paling ringan untuk konten dinamis.
Untuk stabilitas visual, golden test berguna. Test ini membandingkan hasil render dengan gambar referensi, sehingga perubahan tak sengaja pada output gambar langsung terdeteksi saat menjalankan flutter test. Uji juga pada perangkat kelas bawah, karena gambar berat di CPU dapat menurunkan frame rate. Profiler Flutter membantu menemukan frame yang melewati anggaran 16 milidetik.
import 'package:flutter/material.dart';
class LiveChart extends StatefulWidget {
const LiveChart({super.key});
@override
State<LiveChart> createState() => _LiveChartState();
}
class _LiveChartState extends State<LiveChart>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: CustomPaint(
painter: ChartPainter(progress: _controller),
size: const Size(200, 120),
),
);
}
}
class ChartPainter extends CustomPainter {
ChartPainter({required this.progress}) : super(repaint: progress);
final Animation<double> progress;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFF35D0BA)
..strokeWidth = 4
..strokeCap = StrokeCap.round;
final y = size.height * progress.value;
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
@override
bool shouldRepaint(covariant ChartPainter oldDelegate) =>
oldDelegate.progress != progress;
}Kode di atas menunjukkan dua praktik sekaligus. Parameter repaint pada super membuat painter menggambar setiap frame tanpa rebuild widget, sementara RepaintBoundary membatasi area yang digambar ulang. shouldRepaint membandingkan referensi progress agar pertukaran animasi tetap terdeteksi. Expected output-nya adalah garis bergerak naik turun di dalam boundary yang terpisah dari bagian UI lain.
Mulai dari widget dasar sampai rendering custom, kelas Flutter di Rumah Coding membantu Anda membangun UI yang tidak terbatas pada komponen bawaan. Pelajari pola CustomPainter, animasi, dan optimasi performa bersama mentor berpengalaman. Daftar sekarang dan bangun widget visual yang benar-benar unik.
Kursus Terkait
Advanced Flutter State Management with BLoC
Master advanced Flutter state management by building a production-ready application. This intermediate course uses a top-down, problem-driven approach, plunging you into real-world engineering challenges. You will learn to architect scalable applications, handle complex reactive states, manage multi-BLoC communication, synchronize real-time data, and implement optimistic UI updates using industry-standard BLoC patterns.
TaskSync: Real-Time Collaborative Task Manager
- Role-Based Authentication: Secure login and session management, dynamically reflecting user states across the entire application.
- Real-Time Task Board: A Kanban-style board that instantly updates across all devices when any team member creates, moves, or deletes a task.
- Advanced Search & Filtering: High-performance local search with event debouncing to prevent unnecessary API calls.
Flutter Mobile Development
Launch your mobile development journey with this immersive, project-based Flutter course. Designed specifically for beginners, this program takes you from coding fundamentals in Dart to deploying a fully functional mobile app. You will learn to craft beautiful, responsive UIs, handle global state management, and integrate cloud backends. By the end of the course, you will have built a real-world, cloud-synced application from scratch.
DailyQuest: Gamified Habit Tracker
- Secure Authentication: User registration and login functionality using email and password.
- Cloud Data Synchronization: Real-time database integration (using Supabase or Firebase) to securely store and retrieve user habits.
- Full CRUD Operations: The ability for users to Create, Read, Update, and Delete their daily tasks and habits.