Criar um widget de aplicativo Custom Paint Flutter

Tempo de leitura: 6 minutes

Quer criar uma interface de usuário altamente personalizada com uma animação incrível? Não tenha medo, o Flutter CustomPaint Widget tem tudo o que você precisa

Neste artigo, abordaremos tanto a animação explícita quanto a forma de trabalhar com o widget CustomPaint em um flutter.

Custom Paint: Um widget que fornece uma tela na qual desenhar durante a fase de pintura.

Basta adicionar o widget CustomPaint à sua árvore de widgets e fornecer o painter a ele, que é uma subclasse da classe abstrata CustomPainter.

Como o CustomPainter é uma classe abstrata, ele nos obriga a implementar dois métodos importantes, ou seja, paint() e shouldRepaint(). O tamanho especifica o size do widget desenhado. Vamos colocar a mão na massa:

class RingPainter extends StatelessWidget {
  const RingPainter({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        size: Size(200.0, 200.0),
        painter: MyPainter(),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // TODO: implement paint
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint
    throw UnimplementedError();
  }
}

O método paint() nos fornece dois parâmetros importantes:

  • Canvas: Canvas é a área na tela onde desenhamos nosso widget. Ele tem vários métodos para desenhar widgets personalizados. Alguns deles são: canvas.drawLine(), canvas.drawCircle(), canvas.drawArc(), canvas.drawOval() etc.
  • Size: Qual deve ser o tamanho do desenho. Por padrão, ele assume o tamanho do widget wrapper, ou seja, o widget que envolve nosso widget CustomPaint. Além disso, podemos fornecer parâmetros de tamanho dentro do nosso widget CustomPaint. Como queremos desenhar um círculo, passamos o mesmo valor para a largura e a altura.

O método shouldRepaint() é chamado sempre que o CustomPainter precisa ser reconstruído. Isso ficará claro quando implementarmos nosso projeto.

Agora que entendemos a classe CustomPainter, vamos desenhar algo na tela. Nosso objetivo é desenhar um círculo com alguma largura de traço.

Vamos ver como fazer isso:

@override
 void paint(Canvas canvas, Size size) {
   final strokeWidth = size.width / 15.0;
   final circleCenter = Offset(size.width / 2, size.height / 2);
   final circleRadius = (size.width - strokeWidth) / 2;

   final paint = Paint()
     ..color = Colors.grey
     ..strokeWidth = strokeWidth
     ..style = PaintingStyle.stroke;

   canvas.drawCircle(circleCenter, circleRadius, paint);
 }

Como queremos desenhar um círculo, usamos o método pré-construído canvas.drawCircle(), que espera três parâmetros, ou seja, o centro do círculo, o raio do círculo e um objeto de pintura. Calculamos o centro e o raio do círculo a partir do parâmetro size. A classe Paint() nos fornece várias propriedades, como a cor da paint, storkeWidth, style da tinta, ou seja, se ela deve ser traçada ou preenchida. A implementação atual resultará no seguinte:

Nosso próximo objetivo é desenhar um arco sobre o mesmo círculo que seria animado em torno do traço. Para conseguir isso:

@override
 void paint(Canvas canvas, Size size) {
   final strokeWidth = size.width / 15.0;
   final circleCenter = Offset(size.width / 2, size.height / 2);
   final circleRadius = (size.width - strokeWidth) / 2;

   final paint = Paint()
     ..color = Colors.grey
     ..strokeWidth = strokeWidth
     ..style = PaintingStyle.stroke;

   canvas.drawCircle(circleCenter, circleRadius, paint);

   final arcPaint = Paint()
     ..color = Colors.green
     ..strokeWidth = strokeWidth
     ..style = PaintingStyle.stroke;

   canvas.drawArc(Rect.fromCircle(center: circleCenter, radius: circleRadius),
       -pi / 2, 2 * pi * 0.4, false, arcPaint);
 }

Como queremos desenhar um arco, usamos o método canvas.drawArc() que espera cinco parâmetros, ou seja, o objeto Rect, startAngle em radianos, sweepAngle em radian, useCenter boolean e o objeto paint.

Além disso, queremos desenhar um arco ao redor do círculo, por isso usamos o método Rect.fromCircle() como primeiro parâmetro. O segundo parâmetro é o ângulo inicial que aplicamos a -pi/2, pois queremos iniciar o ângulo a partir do topo do círculo.

O ângulo de varredura especifica onde o arco deve terminar e aplicamos 40% do ângulo total, ou seja, nosso arco será desenhado de -pi/2 a 40% do total de 360 radianos.

Se o booleano useCenter for verdadeiro, o arco será fechado de volta ao centro, formando um setor de círculo. Caso contrário, o arco não será fechado, formando um segmento de círculo. E o argumento final é o nosso objeto de pintura, com o qual já estamos familiarizados.

A implementação atual com useCenter definido como true resultará no seguinte:

Mas não queremos esse setor circular, portanto, defini-la como falsa resultaria no seguinte:

Você pode ajustar o startAngle e o endAngle para ver os vários efeitos.

Agora estamos quase perto do que queremos alcançar. A única parte que falta é animar o progresso. Para isso, temos de usar a animação explícita, ou seja, temos de criar o AnimationController e passar seu valor para a classe RingPainter:

import 'dart:math';

import 'package:flutter/material.dart';

class RingPainter extends StatefulWidget {
  const RingPainter({Key? key}) : super(key: key);

  @override
  State<RingPainter> createState() => _RingPainterState();
}

class _RingPainterState extends State<RingPainter>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    //create animation controller
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    );
    //start the animation
    _controller.forward();
    //add a callback to obeserve various state during animation
    _controller.addListener(() {
      setState(() {});
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        size: const Size(200.0, 200.0),
        painter: MyPainter(
          progress: _controller.value,
          defaultColor: Colors.grey,
          fillColor: Colors.green,
        ),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  final double progress;
  final Color defaultColor;
  final Color fillColor;

  MyPainter({
    required this.progress,
    required this.defaultColor,
    required this.fillColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final strokeWidth = size.width / 15.0;
    final circleCenter = Offset(size.width / 2, size.height / 2);
    final circleRadius = (size.width - strokeWidth) / 2;

    final paint = Paint()
      ..color = defaultColor
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;

    canvas.drawCircle(circleCenter, circleRadius, paint);

    final arcPaint = Paint()
      ..color = fillColor
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke;

    canvas.drawArc(Rect.fromCircle(center: circleCenter, radius: circleRadius),
        -pi / 2, 2 * pi * progress, false, arcPaint);
  }

  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) =>
      oldDelegate.progress != progress;
}

As várias coisas que fizemos agora são:

  1. Convertemos nosso RingPainter StatelessWidget em Stateful widget para reconstruir a interface do usuário sempre que o valor da animação for alterado.
  2. Criamos o AnimationController _controller misturando nossa classe _RingPainterState com SingleTickerProviderStateMixin e o inicializamos no método initState().
  3. Iniciamos a animação com _controller.forward()
  4. Adicionamos um listener ao controlador e um setState((){}) é chamado para que toda a nossa interface do usuário seja reconstruída sempre que o valor do nosso _controller for alterado.
  5. Agora nossa classe MyPainter espera três argumentos obrigatórios: progress, defaultColor do traço do círculo e cor de preenchimento. Isso é feito para passar os vários valores de fora dessa classe e para tornar esse componente reutilizável. Observe que o método shouldRepaint() foi atualizado de forma que nosso circle só deve ser desenhado quando o valor do progresso for diferente do valor anterior.
  6. Finalmente, passamos esses três argumentos para a classe MyPainter, sendo o valor de progresso o valor atual da animação.

Com a implementação atual, estamos conseguindo atingir esse objetivo com sucesso:

Uau, estamos quase no fim! A única coisa que falta é adicionar um ícone no meio do círculo e mudar sua cor, dependendo se a animação foi concluída ou não.

Para conseguir isso, atualize seu método de compilação da seguinte forma:

@override
 Widget build(BuildContext context) {
   return Center(
     child: Stack(
       children: [
         CustomPaint(
           size: const Size(200.0, 200.0),
           painter: MyPainter(
             progress: _controller.value,
             defaultColor: Colors.grey,
             fillColor: Colors.green,
           ),
         ),
         Positioned.fill(
           child: Icon(
             Icons.check,
             color: _controller.isCompleted ? Colors.green : Colors.grey,
             size: 68.0,
           ),
         )
       ],
     ),
   );
 }

A implementação a seguir resultaria nisso:

Portanto, agora alcançamos com sucesso o que queríamos.

Espero que você tenha aprendido algo sobre o widget CustomPaint. Agora você pode explorar por conta própria para obter vários outros efeitos.

Obrigado pela leitura.

Todo o projeto se encontra no meu GitHub (Git) (Pasta Custom Circular)