- Hiểu khi nào cần xuống low-level (CustomPainter, RenderObject).
- Sử dụng
CustomPaintervớiCanvasAPI. - 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.
Đã 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);
(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;
}
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
- 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
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).
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).
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.
TextPainter cần method nào trước paint?
Xem đáp án
Đáp án: .layout() — compute width/height. Bỏ → throw.
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).
RepaintBoundary quanh CustomPaint giúp gì?
Xem đáp án
Đáp án: Isolate paint layer. Parent rebuild không trigger CustomPainter repaint.
Path đóng kín — method nào?
Xem đáp án
Đáp án: path.close() nối end về start.
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.