Implementasi Custom Drawing di Flutter dengan CustomPainter: Membuat Widget Visual Unik

Lhuqita Fazry
Mobile Development Flutter CustomPainter Canvas UI
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.

Diagram pipeline render Flutter dari Build, Layout, Paint, Composition, hingga Rasterize

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.

dart
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
Mobile App • Intermediate

Advanced Flutter State Management with BLoC

Master advanced Flutter state management by building a production-ready applicat...

Daftar

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.

Konstruksi bintang beraturan pada lingkaran dengan pusat O dan sudut 36 derajat pada setiap titik

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.

dart
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.

dart
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.

dart
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

TaskSync: Real-Time Collaborative Task Manager
Kursus Premium Mobile App

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.

Proyek Akhir

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.
7 Weeks Intermediate
Lihat Detail Kursus
DailyQuest: Gamified Habit Tracker
Kursus Premium Mobile App

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.

Proyek Akhir

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.
7 Weeks Beginner
Lihat Detail Kursus

Artikel Terkait