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

1. O Que Você Vai Aprender


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.

DART
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;
}
TEXT
> 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

100%
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]
TEXT
> 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

TEXT
> 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

DART
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;
}
TEXT
> 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

TEXT
> 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

DART
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;
}
TEXT
> 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

TEXT
> 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

DART
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;
}
TEXT
> 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

TEXT
> 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

DART
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
])
TEXT
> 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

DART
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});
}
TEXT
> 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, depois endRecording().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


📝 Exercícios

  1. Básico (dificuldade ⭐): Use CustomPainter para desenhar uma barra de progresso circular (0-100%) com gradiente de cor de verde a vermelho.
  2. 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.
  3. Desafio (dificuldade ⭐⭐⭐): Implemente um componente completo de assinatura manuscrita: desenho por toque + limpar + salvar como imagem + largura de traço variando com a velocidade.

← Aula Anterior | Próxima Aula →

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%