Flutter: Custom Paint e CustomPainter
Quando os widgets padrão não são suficientes, o Canvas é sua tela em branco — qualquer UI pode ser desenhada traço a traço.
📋 Pré-requisitos: Você já deve estar familiarizado com
- Aula 15: Sistema de Animação
1. O Que Você Vai Aprender
- CustomPaint + CustomPainter: ciclo de vida paint() / shouldRepaint()
- Canvas API: drawLine, drawRect, drawCircle, drawPath, drawParagraph
- Integração de gestos: GestureDetector + CustomPainter para um pad de assinatura
- Otimização de desempenho: verificações precisas de shouldRepaint + isolamento com RepaintBoundary
- ShopApp: gráfico de linha de dados de vendas + componente de assinatura manuscrita
2. Uma História Real de um Requisito de Gráfico
(1) O Problema: Bibliotecas de Gráficos de Terceiros Falta Personalização
O ShopApp do Bob precisa de um gráfico de linha de tendência de vendas com: preenchimento gradiente, toque para mostrar detalhes nos pontos de dados, e desenho animado. Bibliotecas de gráficos de terceiros ou não suportam gradientes, têm callbacks de toque inflexíveis, ou animações incontroláveis. Bob chegou a 80% com fl_chart mas não conseguiu modificar o código-fonte para os 20% restantes.
(2) A Solução com CustomPainter
CustomPainter dá controle total de desenho — cada traço, cada pixel é por sua conta.
import 'package:flutter/material.dart';
// Custom class definition source:
// - data: List<double> type, representing sales data points
class SalesChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Full control: draw exactly what you need
final linePaint = Paint()..color = Colors.blue..strokeWidth = 2..style = PaintingStyle.stroke;
final fillPaint = Paint()..shader = LinearGradient(colors: [Colors.blue.withOpacity(0.3), Colors.transparent])
.createShader(Rect.fromLTWH(0, 0, size.width, size.height));
// Draw path, fill, dots, labels...
}
@override
bool shouldRepaint(covariant SalesChartPainter old) => old.data != data;
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
(3) Benefício: 100% de Personalização + Zero Dependências de Terceiros
Bob implementou o requisito completo com CustomPainter em apenas 200 linhas, com zero dependências de terceiros e animações totalmente controláveis.
3. Sistema CustomPainter
graph LR
CP[CustomPaint] --> Painter[CustomPainter]
Painter --> Canvas[Canvas API]
Canvas --> DL[drawLine]
Canvas --> DR2[drawRect]
Canvas --> DC[drawCircle]
Canvas --> DP2[drawPath]
Painter --> |shouldRepaint| RP[Repaint Check]
CP --> RB[RepaintBoundary]
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
(1) Ciclo de Vida do CustomPainter
| Método | Quando Disparado | Propósito |
|---|---|---|
paint() |
Primeiro desenho ou quando shouldRepaint retorna true | Executar comandos de desenho |
shouldRepaint() |
Comparar Painter antigo vs novo | Decidir se deve redesenhar |
repaint() |
Notificar CustomPaint que redesenho é necessário | Funciona com AnimationController |
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: CustomPainter básico
import 'package:flutter/material.dart';
class PriceIndicatorPainter extends CustomPainter {
final double price;
final double maxPrice;
final Color color;
PriceIndicatorPainter({
required this.price,
required this.maxPrice,
this.color = Colors.green,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..color = color.withOpacity(0.2)
..style = PaintingStyle.fill;
// Calculate bar height
final barHeight = (price / maxPrice) * size.height;
final rect = Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
// Draw filled bar
canvas.drawRect(rect, fillPaint);
// Draw border
canvas.drawRect(rect, paint);
// Draw price text
final textSpan = TextSpan(text: '\$${price.toStringAsFixed(0)}',
style: TextStyle(color: color, fontSize: 12));
final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr);
tp.layout();
tp.paint(canvas, Offset(size.width / 2 - tp.width / 2, size.height - barHeight - 16));
}
@override
bool shouldRepaint(covariant PriceIndicatorPainter old) =>
old.price != price || old.maxPrice != maxPrice;
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
4. Aprofundamento na Canvas API
(1) Métodos de Desenho Comuns
| Método | Descrição | Parâmetros |
|---|---|---|
drawLine |
Desenhar uma linha | p1, p2, paint |
drawRect |
Desenhar um retângulo | rect, paint |
drawRRect |
Desenhar um retângulo arredondado | rrect, paint |
drawCircle |
Desenhar um círculo | center, radius, paint |
drawOval |
Desenhar uma elipse | rect, paint |
drawArc |
Desenhar um arco | rect, startAngle, sweepAngle, useCenter, paint |
drawPath |
Desenhar um caminho | path, paint |
drawShadow |
Desenhar uma sombra | path, color, elevation, transparentOccluder |
drawParagraph |
Desenhar texto | paragraph, offset |
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Desenhando um gráfico de linha com gradiente
import 'package:flutter/material.dart';
class LineChartPainter extends CustomPainter {
final List<double> dataPoints;
final Color lineColor;
final double maxValue;
LineChartPainter({
required this.dataPoints,
this.lineColor = Colors.blue,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
if (dataPoints.isEmpty) return;
final linePaint = Paint()
..color = lineColor
..strokeWidth = 2.5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final dotPaint = Paint()
..color = lineColor
..style = PaintingStyle.fill;
final gridPaint = Paint()
..color = Colors.grey[300]!
..strokeWidth = 0.5;
// Draw grid lines
for (int i = 0; i <= 4; i++) {
final y = size.height * i / 4;
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// Calculate points
final points = <Offset>[];
for (int i = 0; i < dataPoints.length; i++) {
final x = (i / (dataPoints.length - 1)) * size.width;
final y = size.height - (dataPoints[i] / maxValue) * size.height;
points.add(Offset(x, y));
}
// Draw gradient fill
final fillPath = Path()
..moveTo(points.first.dx, size.height)
..lineTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
fillPath.lineTo(points[i].dx, points[i].dy);
}
fillPath.lineTo(points.last.dx, size.height);
fillPath.close();
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [lineColor.withOpacity(0.3), lineColor.withOpacity(0.05)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawPath(fillPath, fillPaint);
// Draw line
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(linePath, linePaint);
// Draw dots
for (final point in points) {
canvas.drawCircle(point, 4, dotPaint);
}
}
@override
bool shouldRepaint(covariant LineChartPainter old) =>
old.dataPoints != dataPoints || old.maxValue != maxValue;
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
5. Integração de Gestos: Pad de Assinatura
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Componente SignaturePad
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class SignaturePad extends StatefulWidget {
final ValueChanged<ui.Image>? onSignatureComplete;
const SignaturePad({super.key, this.onSignatureComplete});
@override
State<SignaturePad> createState() => _SignaturePadState();
}
class _SignaturePadState extends State<SignaturePad> {
final List<Offset?> _points = [];
final _painterKey = GlobalKey();
void _onPointerDown(PointerDownEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerMove(PointerMoveEvent event) {
final box = _painterKey.currentContext!.findRenderObject() as RenderBox;
setState(() => _points.add(event.localPosition));
}
void _onPointerUp(PointerUpEvent event) {
setState(() => _points.add(null)); // null = line break
}
void _clear() => setState(() => _points.clear());
@override
Widget build(BuildContext context) {
return Column(children: [
Container(
height: 200,
decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(key: _painterKey, size: Size.infinite, painter: SignaturePainter(_points)),
),
),
),
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(onPressed: _clear, child: const Text('Clear')),
const SizedBox(width: 8),
FilledButton(onPressed: () {
// Capture signature as image
widget.onSignatureComplete?.call(null); // Simplified
}, child: const Text('Confirm')),
]),
]);
}
}
class SignaturePainter extends CustomPainter {
final List<Offset?> points;
SignaturePainter(this.points);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..strokeWidth = 2.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
for (int i = 0; i < points.length - 1; i++) {
if (points[i] != null && points[i + 1] != null) {
canvas.drawLine(points[i]!, points[i + 1]!, paint);
}
}
}
@override
bool shouldRepaint(covariant SignaturePainter old) => old.points != points;
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
6. Otimização de Desempenho
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Isolamento de repaint com RepaintBoundary
import 'package:flutter/material.dart';
// Custom class definition source:
// - ProductHeader/ProductDescription: custom Widgets
// - LineChartPainter: see Section 4 in this lesson
// - salesData: List<double> type
// Without RepaintBoundary: entire page repaints on chart update
Column(children: [
ProductHeader(product: product),
RepaintBoundary( // Only chart repaints
child: CustomPaint(painter: LineChartPainter(data: salesData)),
),
ProductDescription(product: product), // NOT repainted
])
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
| Técnica de Otimização | Efeito |
|---|---|
Verificação precisa de shouldRepaint |
Evitar redesenhos desnecessários |
RepaintBoundary |
Isolar escopo de redesenho |
Cache com PictureRecorder |
Cachear gráficos complexos como Image |
| Evitar criar objetos em paint | Reduzir pressão de GC |
7. Exemplo Completo: Componente de Gráfico de Vendas do ShopApp
import 'package:flutter/material.dart';
// Custom class definition source:
// - LineChartPainter: see Section 4 in this lesson
// - SalesData: see definition below
class SalesChart extends StatelessWidget {
final List<SalesData> data;
final double width;
final double height;
const SalesChart({super.key, required this.data, this.width = 350, this.height = 200});
@override
Widget build(BuildContext context) {
final maxRevenue = data.map((d) => d.revenue).reduce((a, b) => a > b ? a : b);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Monthly Revenue (USD)', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
SizedBox(width: width, height: height,
child: RepaintBoundary(child: CustomPaint(
painter: LineChartPainter(
dataPoints: data.map((d) => d.revenue).toList(),
maxValue: maxRevenue * 1.1,
lineColor: Theme.of(context).colorScheme.primary,
),
size: Size(width, height),
)),
),
const SizedBox(height: 8),
// Legend
Wrap(spacing: 16, children: data.take(6).map((d) => Text(
'${d.month}: \$${(d.revenue / 1000).toStringAsFixed(0)}K',
style: const TextStyle(fontSize: 11),
)).toList()),
]),
),
);
}
}
class SalesData {
final String month;
final double revenue;
const SalesData({required this.month, required this.revenue});
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
❓ Perguntas Frequentes
P: Quando o CustomPainter precisa redesenhar? R: Quando as propriedades do Painter mudam. Você deve implementar shouldRepaint corretamente: comparar propriedades antigas e novas, retornar true se mudaram.
P: Como o tamanho do CustomPaint é determinado? R: Se tiver um child, CustomPaint segue o tamanho do child. Sem child, use a propriedade size ou restrinja com SizedBox no parent.
P: Como desenhar um retângulo arredondado no Canvas? R: Use
drawRRect(RRect.fromRectAndRadius(rect, radius), paint).
P: Como salvar o pad de assinatura como imagem? R: Use
ui.PictureRecorder()para gravar o desenho do Canvas, depoisendRecording().toImage()para converter em ui.Image.
P: O que há de errado em sempre retornar true no shouldRepaint? R: Cada build dispara um redesenho, mesmo quando os dados não mudaram. Aceitável para componentes simples, mas causa engasgos em gráficos complexos.
P: Por que usar drawParagraph em vez de Text no paint? R: O Canvas não pode desenhar Widgets diretamente. O texto deve ser layoutado via TextPainter/Paragraph e depois pintado com paint(canvas, offset).
📖 Resumo
- CustomPainter + paint() dá controle total de desenho; shouldRepaint controla o redesenho
- A Canvas API é rica: linhas/retângulos/círculos/caminhos/texto/sombras/gradientes
- Eventos Listener/Pointer habilitam desenho baseado em gestos (pad de assinatura)
- RepaintBoundary isola o escopo de redesenho; verificações precisas de shouldRepaint evitam desperdício
- Gráficos de linha são a aplicação real mais típica do CustomPainter
📝 Exercícios
- Básico (dificuldade ⭐): Use CustomPainter para desenhar uma barra de progresso circular (0-100%) com gradiente de cor de verde a vermelho.
- Intermediário (dificuldade ⭐⭐): Implemente um gráfico de linha de vendas com linhas de grade, preenchimento gradiente, marcadores de pontos de dados, e um tooltip mostrando detalhes ao tocar em um ponto de dados.
- Desafio (dificuldade ⭐⭐⭐): Implemente um componente completo de assinatura manuscrita: desenho por toque + limpar + salvar como imagem + largura de traço variando com a velocidade.