Chương 13 · Custom Paint & Render

Custom Paint & Render

99% Flutter dev không bao giờ viết RenderObject custom. Nhưng 50% sẽ cần CustomPainter (chart, gauge, custom shape). Chương này dạy CustomPainter detail + intro 3 cây để hiểu Flutter render internals.

Độ dài: ~990 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2, 12 (animation)
🎯 Mục tiêu chương
  • Hiểu khi nào cần xuống low-level (CustomPainter, RenderObject).
  • Sử dụng CustomPainter với Canvas API.
  • Vẽ shape: line, rect, circle, path, polygon.
  • Vẽ text với TextPainter.
  • Combine CustomPainter + AnimationController.
  • RenderObject hierarchy: RenderBox, RenderSliver, RenderObject.
  • Tạo custom RenderObject (intro).
  • Hit testing.
  • 3 cây deep dive: Widget / Element / RenderObject.

1. Khi nào cần custom paint?

  • Chart không có package phù hợp (vd custom dashboard).
  • Gauge / speedometer / progress ring.
  • Badge shape phức tạp (ngôi sao, hình tim, polygon).
  • Gradient phức tạp (radial gradient với multi-stop).
  • Signature pad / drawing app.
  • Game UI 2D đơn giản.
💡 Trước khi custom paint, check package

Đã có sẵn cho nhiều use case: fl_chart, syncfusion_flutter_charts, percent_indicator. Tự viết khi requirement khác package + có thời gian.

2. CustomPaint + CustomPainter

class CirclePainter extends CustomPainter {
  final Color color;
  CirclePainter(this.color);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.fill;

    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      size.width / 3,
      paint,
    );
  }

  @override
  bool shouldRepaint(CirclePainter old) => old.color != color;
}

// Use
CustomPaint(
  painter: CirclePainter(Colors.amber),
  size: const Size(200, 200),
)

3. Canvas API

// Line
canvas.drawLine(Offset.zero, Offset(size.width, size.height), paint);

// Rect
canvas.drawRect(Rect.fromLTWH(10, 10, 100, 50), paint);
canvas.drawRRect(RRect.fromRectAndRadius(rect, Radius.circular(8)), paint);

// Circle
canvas.drawCircle(center, radius, paint);

// Arc
canvas.drawArc(rect, startAngle, sweepAngle, useCenter, paint);

// Path
canvas.drawPath(path, paint);

// Image
canvas.drawImage(image, offset, paint);

// Points
canvas.drawPoints(PointMode.points, [Offset(0, 0), Offset(10, 10)], paint);

// Shadow
canvas.drawShadow(path, Colors.black26, 4, true);
🧠 Coordinate system

(0, 0)top-left. X tăng sang phải, Y tăng xuống dưới (khác toán học truyền thống). Size = (width, height) của vùng vẽ.

4. Paint — properties

final paint = Paint()
  ..color = Colors.blue
  ..style = PaintingStyle.stroke    // fill | stroke
  ..strokeWidth = 4
  ..strokeCap = StrokeCap.round       // butt | round | square
  ..strokeJoin = StrokeJoin.round
  ..isAntiAlias = true;

// Gradient shader
final grad = Paint()
  ..shader = LinearGradient(colors: [Colors.amber, Colors.orange])
      .createShader(Rect.fromLTWH(0, 0, size.width, size.height));

// Blur
final blur = Paint()
  ..maskFilter = MaskFilter.blur(BlurStyle.normal, 5);

5. Path — combine shapes

final path = Path();
path.moveTo(0, 0);
path.lineTo(100, 0);
path.lineTo(100, 100);
path.close();    // nối về moveTo

// Bezier curves
path.quadraticBezierTo(controlX, controlY, endX, endY);
path.cubicTo(c1x, c1y, c2x, c2y, endX, endY);

// Arc
path.arcTo(Rect.fromLTWH(0, 0, 100, 100), 0, 3.14, false);

// Add other primitive
path.addRect(Rect.fromLTWH(10, 10, 50, 50));
path.addOval(rect);

canvas.drawPath(path, paint);

Ngôi sao 5 cánh

Path createStarPath(Size size) {
  const points = 5;
  final outerRadius = size.width / 2;
  final innerRadius = outerRadius / 2.5;
  final center = Offset(size.width / 2, size.height / 2);
  final path = Path();
  for (var i = 0; i < points * 2; i++) {
    final isOuter = i % 2 == 0;
    final r = isOuter ? outerRadius : innerRadius;
    final angle = -3.14 / 2 + i * 3.14 / points;
    final x = center.dx + r * cos(angle);
    final y = center.dy + r * sin(angle);
    if (i == 0) {
      path.moveTo(x, y);
    } else {
      path.lineTo(x, y);
    }
  }
  path.close();
  return path;
}

6. TextPainter — vẽ text

void paintText(Canvas canvas, Offset position, String text) {
  final painter = TextPainter(
    text: TextSpan(
      text: text,
      style: const TextStyle(color: Colors.black, fontSize: 14),
    ),
    textDirection: TextDirection.ltr,
  );
  painter.layout();    // CRITICAL — phải gọi trước paint
  painter.paint(canvas, position);
}

7. save / restore

canvas.save();           // push state
canvas.translate(50, 50);
canvas.rotate(0.5);
canvas.drawCircle(Offset.zero, 20, paint);   // drawn ở (50,50) rotated
canvas.restore();        // pop state

canvas.drawCircle(Offset.zero, 30, paint);   // drawn ở (0,0) no rotation

8. shouldRepaint — performance

@override
bool shouldRepaint(MyPainter old) {
  // Trả true khi data thay đổi cần redraw
  return old.color != color || old.value != value;
}
⚠️ Đừng luôn return true

Mặc định painter trong CustomPaint chỉ repaint khi parent đổi hoặc shouldRepaint trả true. Trả luôn true = repaint mọi frame nếu parent rebuild → waste CPU.

9. Combine với animation

class ProgressRingPainter extends CustomPainter {
  final Animation<double> progress;

  ProgressRingPainter(this.progress) : super(repaint: progress);
  // ↑ Pass animation vào super — mỗi tick → painter repaint tự động

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Rect.fromCircle(
      center: Offset(size.width / 2, size.height / 2),
      radius: size.width / 2 - 10,
    );
    final paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 8
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(rect, -1.57, 6.28 * progress.value, false, paint);
  }

  @override
  bool shouldRepaint(ProgressRingPainter old) => false;
  // shouldRepaint không cần custom — vì repaint: progress đã control
}

10. Hit testing

@override
bool? hitTest(Offset position) {
  // Trả true nếu position trong shape (vd hit star icon area)
  final path = createStarPath(_size);
  return path.contains(position);
}

// Wrap CustomPaint với GestureDetector để nhận tap
GestureDetector(
  onTapDown: (details) => print('tap at ${details.localPosition}'),
  child: CustomPaint(painter: StarPainter()),
)

11. Three trees deep dive

3 trees parallel Widget immutable spec Element instance + state + lifecycle RenderObject layout + paint thực sự Widget tree tái tạo mỗi build (cheap). Element tree giữ identity + state — không tái tạo khi widget rebuild cùng type. RenderObject tree reuse — chỉ layout/paint thay đổi. Element giữ state khi widget type giữ nguyên (key cũng giữ).
🧠 Vì sao 3 cây?
  • Widget immutable → cheap create/recreate. Hot reload work nhờ đây.
  • Element giữ state → swap widget cùng type giữ state.
  • RenderObject heavy (layout, paint, hit test) → reuse giữa builds. Performance.

12. RenderObject categories

  • RenderBox — 2D Cartesian, đại đa số widget (Container, Padding, Row, Column).
  • RenderSliver — scrollable area (ListView, GridView, CustomScrollView).
  • RenderObject — generic root, ít dùng trực tiếp.

13. Custom RenderBox (intro)

// Hiếm khi cần. CustomPaint cover 95% case.
// Khi cần: widget vừa decide size vừa paint custom mà CustomPainter không đủ.

class RedBox extends LeafRenderObjectWidget {
  @override
  RenderObject createRenderObject(BuildContext context) => _RedBoxRender();
}

class _RedBoxRender extends RenderBox {
  @override
  void performLayout() {
    size = constraints.constrain(const Size(100, 100));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    context.canvas.drawRect(offset & size, Paint()..color = Colors.red);
  }

  @override
  bool hitTestSelf(Offset position) => true;
}

14. Debug paint flags

import 'package:flutter/rendering.dart';

void main() {
  debugPaintSizeEnabled = true;          // vẽ border quanh mọi box
  debugPaintBaselinesEnabled = true;     // text baseline
  debugPaintPointersEnabled = true;      // highlight tap area
  debugRepaintRainbowEnabled = true;     // random color mỗi repaint
  runApp(const MyApp());
}

debugRepaintRainbowEnabled = tool tốt nhất để debug "tại sao widget này repaint nhiều thế".

15. Bài tập

Signature pad

Custom signature pad: GestureDetector capture pan, lưu list Offset, CustomPainter drawPath.

💡 Gợi ý đáp án

onPanUpdate add point. Path moveTo first, lineTo rest. Stroke paint.

Donut chart 4 slice

Donut chart hiển thị 4 slice từ data [(label, value, color)]. Animate sweep từ 0 → full khi mount.

💡 Gợi ý đáp án

drawArc cho mỗi slice. Sweep angle tỉ lệ value/total. Animate qua AnimationController + repaint param.

Progress ring

Custom progress ring với % ở giữa. Animate từ 0 → target khi value change.

💡 Gợi ý đáp án

Background ring (color nhạt) + foreground arc (color đậm, sweep theo progress). Text center qua TextPainter.

Gauge speedometer

Gauge: needle rotate -90° đến 90° theo value 0-100. Background scale lines.

💡 Gợi ý đáp án

drawArc background scale. drawLine cho needle: save/translate/rotate/drawLine/restore.

Star rating

5 sao paint với fill % theo rating (vd 3.5 sao). Half star = clipPath.

💡 Gợi ý đáp án

5 stars. Mỗi star: paint outline + fill clipped theo rating fraction. Path star từ section 5.

16. Quiz

Q1

CustomPainter.shouldRepaint luôn trả true — vấn đề?

Xem đáp án

Đáp án: Repaint mọi frame nếu parent rebuild, perf kém. Trả true chỉ khi data đổi (compare old vs new fields).

Q2

Canvas coordinate (0,0) ở đâu?

Xem đáp án

Đáp án: Top-left. Khác toán học truyền thống (gốc giữa).

Q3

canvas.translate(50, 0) rồi drawLine (0,0) → (100, 0) — vẽ đâu?

Xem đáp án

Đáp án: Từ (50, 0) đến (150, 0) screen. translate offset origin.

Q4

TextPainter cần method nào trước paint?

Xem đáp án

Đáp án: .layout() — compute width/height. Bỏ → throw.

Q5

RenderObject vs CustomPainter — khác?

Xem đáp án

Đáp án: CustomPainter chỉ paint trong size cấp. RenderObject cũng decide layout (size con + self).

Q6

RepaintBoundary quanh CustomPaint giúp gì?

Xem đáp án

Đáp án: Isolate paint layer. Parent rebuild không trigger CustomPainter repaint.

Q7

Path đóng kín — method nào?

Xem đáp án

Đáp án: path.close() nối end về start.

Q8

Animation passed vào painter constructor super(repaint: anim) — vai trò?

Xem đáp án

Đáp án: Mỗi tick anim → painter repaint tự động. Không cần shouldRepaint custom check value.

17. Tổng kết

  • ✅ CustomPaint + CustomPainter — 95% custom drawing.
  • ✅ Canvas API: drawLine, drawRect, drawCircle, drawArc, drawPath, drawText (TextPainter).
  • ✅ Paint properties: style, color, strokeWidth, shader (gradient), maskFilter (blur).
  • ✅ Path build: moveTo + lineTo + bezier + arcTo + close.
  • ✅ Save/restore canvas state cho transform isolate.
  • ✅ shouldRepaint logic chính xác = performance.
  • ✅ Combine với animation qua super(repaint: animation).
  • ✅ Hit testing trên CustomPainter shape.
  • ✅ 3 cây: Widget (spec) / Element (instance) / RenderObject (layout+paint).
  • ✅ RenderObject hiếm khi tự viết — CustomPainter đủ 95%.
  • ✅ Debug flags: paintSize, repaintRainbow.

18. Kết nối

  • Ch 12 — Animation: pair custom painter cho animated chart/gauge.
  • Ch 15: Performance overlay đo paint time.