Dibujando y animando con CustomPainter en Flutter

jleondev

Joseph León

Posted on January 26, 2022

Dibujando y animando con CustomPainter en Flutter

Hola! Recientemente en la empresa donde trabajo me enfrenté al reto de animar un icono de localización (📍) con el propósito de darle feedback al usuario de que la app se encuentra en el estado de "loading" al momento de obtener la ubicación por gps.

Lo primero que hice fue investigar si Flutter ya trae animaciones en iconos, y si! 🤙🏻 Flutter tienen un widget específico para esto llamado AnimatedIcon. Lamentablemente este widget tiene animaciones limitadas para algunas acciones específicas dentro de una app 😕 y nada para el icono de localización.

Seguí investigando y encontré opciones como Rive o Lottie para incluir animaciones, pero esto sonaba innecesario para sólo una pequeña animación de loading, sin hablar del conocimiento extra de diseño que requieren 🤷🏻‍♂️.

La opción que me quedaba era utilizar CustomPaint. Honestamente desde el principio quería evitar utilizar esto porque pensaba que era extremadamente difícil dominarlo, pero para mi sorpresa, me divertí mucho utilizándolo.

¿Qué es CustomPaint?

Es un Widget que nos provee un canvas (un "lienzo") donde podemos dibujar.
Por lo tanto podemos definir un dibujo para cada "frame" de nuestra aplicación.

¿Cómo se usa?

Lo primero que tenemos que hacer es agregar CustomPaint Widget al árbol de widgets de nuestra app y definir el tamaño del lienzo.

...
@override
Widget build(BuildContext context) {
  return CustomPaint( // CustomPaint.
    size: Size(100, 100), // Tamaño del lienzo.
  );
}
Enter fullscreen mode Exit fullscreen mode

Importante: Si no definimos el tamaño del lienzo, este tomará todo el tamaño que le sea posible.

Este canvas sigue un modelo cartesiano, lo que quiere decir es que las distancias se miden desde un punto de origen, en nuestro caso, la esquina superior izquierda.


Ahora, nuestro CustomPaint necesita que le definamos la propiedad más importante, el painter. Esta clase se encargará de definir los dibujos dentro del lienzo.

...
@override
Widget build(BuildContext context) {
  return CustomPaint(
    size: Size(100, 100),
    painter: MyPainter(), // Definimos el painter.
  );
}

Ahora vamos a definir la clase MyPainter y la vamos a extender de la clase CustomPainter, esto nos obligará a sobre escribir dos métodos y nos quedaría así:

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

  @override
  void shouldRepaint(CustomPainter old) {
    // ...
  }
}

El método paint sirve para definir el dibujo en nuestro lienzo y el método shouldRepaint nos permitirá especificar cuándo queremos que nuestro lienzo se refresque y vuelva a pintar (esto es algo parecido a game loop en el desarrollo de videojuegos).

Pequeño ejemplo

Vamos a dibujar esto en nuestro lienzo:

Objetivo a dibujar

Básicamente, tenemos que dibujar un rectángulo de 60x100 a partir del punto (20, 50), veamos...

...
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: const Size(200, 200),
      painter: MyPainter(), // Definimos el painter.
    );
  }
} // Este el final del widget.

//Ahora aquí definimos nuestro painter...
class MyPainter() extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // Necesitamos un lápiz para dibujar
    final paint = Paint()
      ..color = Color(0xFFF) // Definimos color (negro)
      ..style = PaintingStyle.stroke // Sólo líneas, sin relleno.
      ..strokeWidth = 2; // Grosor de la línea

    // Vamos a crear un path, para indicarle al lápiz por dónde dibujar.
    final path = Path();

    path.moveTo(20, 50); // Nos movemos al punto inicial
    path.lineTo(80, 50); // →
    path.lineTo(80, 150); // ↓
    path.lineTo(20, 150); // ←
    path.lineTo(20, 50); // ↑

    canvas.drawPath(path, paint); // Le decimos al lápiz que dibuje en el lienzo con la ruta que definimos! ✐
  }

 // Por ahora dejemos esto siempre retornando verdadero.
  @override
  void shouldRepaint(CustomPainter old) => true;
}

Y listo! Deberíamos estar viendo esto:

Resultado dibujo con CustomPaint

Como ves, CustomPaint es muy intuitivo y permite no solo dibujar trazos o arcos a través de la clase Path, sino formas geométricas, puntos y demás a través de la clase Canvas. La idea es que explores todas las posibilidades que CustomPaint te da.

Ahora que ya sabes como usar CustomPaint, te contaré como solucioné el problema de el icono de localización animado.

Dibujando el icono de localización.

Lo que vamos a dibujar es lo siguiente...

Ejemplo de dibujo

Necesitamos dibujar el arco de un círculo sólo conociendo el punto C (centro del la circunferencia) y el punto P que es el vértice inferior del icono.

Aquí esto se convierte en un problema matemático, ya que hay que hallar los puntos tangenciales de la circunferencia con respecto al punto P y a continuación encontrar el ángulo α. No voy a detallar mucho este proceso pero mostraré como se dibuja con CustomPaint.

Vamos a montar un lienzo de 200x200 en nuestra app

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: CustomPaint(
          size: const Size(200, 200),
          painter: LocalizationPainter(), // Le pasamos el painter.
        ),
      ),
    );
  }
}

// Definimos nuestro painter.
class LocalizationPainter extends CustomPainter {
  @override
  paint(Canvas canvas, Size size) {}

  // Esto por ahora siempre retornando verdadero.
  @override
  bool shouldRepaint(LocalizationPainter _) => true;
}

Dentro de nuestra clase LocalizationPainter definimos dos métodos para obtener los puntos C y P respectivamente:

// En centro de las circunferencias a la mitad del eje x y en la primera tercera parte del eje y.
Offset _getC(Size size) {
  return Offset(size.width / 2, size.height / 3);
}

// El vértice del icono a una distancia h del punto C en el eje y.
Offset _getP(Offset c, double h) {
  return Offset(c.dx, c.dy + h);
}

Ahora vamos a trabajar en el método paint

@override
paint(Canvas canvas, Size size) {
  // Lapiz que que rellena la forma.
  final paintFill = Paint()
    ..color = Colors.blue
    ..style = PaintingStyle.fill;

    // Lapiz que solo delinea.
  final paintStroke = Paint()
    ..strokeWidth = 5.0
    ..color = Colors.blue
    ..style = PaintingStyle.stroke;

  // Hallamos punto C
  final c = _getC(size);
  // Hallamos punto P
  final p = _getP(c, 30);
  // Radio de la circunferencia exterior.
  const smallRadio = 15.0;
  // Radio de la círculo interior.
  const bigRadio = 30.0;

  final path = Path();

  // Hallamos ALFA:
  final alpha = acos(bigRadio / (bigRadio + (p.dy - c.dy)));    
  // Dibujamos la circunferencia exterior
  path.addArc(Rect.fromCircle(center: c, radius: bigRadio), pi / 2 + alpha, 2 * (pi - alpha));  
  // Tangente derecha hacia el punto P
  path.lineTo(c.dx, c.dy + bigRadio + (p.dy - c.dy));

  // Tangente izquierda desde el punto P
  path.lineTo(c.dx - bigRadio * sin(alpha), c.dy + bigRadio * cos(alpha));

  // Dibujamos el círculo interior con el lapiz con relleno:
  canvas.drawCircle(c, smallRadio, paintFill);

  // Por último dibujamos nuestro path con el lapiz sin relleno:
  canvas.drawPath(path, paintStroke);
}

El resultado es este:

Location icon with CustomPaint

Animando el icono de localización

Por último (y la razón por lo que empezamos todo esto) vamos a a animar el icono, lo que vamos a hacer es que el círculo interior titile.

Para esto necesitamos que el widget que envuelve al CustomPaint (lo llamaremos MyIcon) sea un StatefulWidget y la clase de estado debemos añadirle el mixin SingleTickerProviderStateMixin.

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

  @override
  State<MyIcon> createState() => _MyIconState();
}

class _MyIconState extends State<MyIcon> with SingleTickerProviderStateMixin{
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: CustomPaint(
          size: const Size(200, 200),
          painter: LocalizationPainter(),
        ),
      ),
    );
  }
}
...

Luego dentro de _MyIconState definimos:

// Para variar la opacidad de nuestro circulo (255 ~ 0).
final IntTween _opacityTween = IntTween(begin: 255, end: 0);
// Definimos una animación.
late Animation animation;
// Definimos un controlador para la animación.
late AnimationController _animationController;

Ahora definimos toda la "magia" en el método initState

  @override
  void initState() {
    super.initState();

    // Definimos nuestro controlador con una duración de 800ms, pueden variar esto a placer.
    _animationController = AnimationController(
      vsync: this, 
      duration: const Duration(milliseconds: 800),
    );

    // Creamos nuestro animación a partir de _opacityTween con una curva Ease In Out que le dará un toque profesional.
    animation = _opacityTween.animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    ));

    // Especificamos que Flutter repinte este widget para cada frame de la animación.
    animation.addListener(() => setState(() {}));

    // Si es que la animación termina, la reproducimos al revés, y en viceversa.
    animation.addStatusListener((status) {
      setState(() {});
      if (status == AnimationStatus.completed) {
        _animationController.reverse();
      } else if (status == AnimationStatus.dismissed) {
        _animationController.forward();
      }
    });

    // Corremos la animación una vez.
    _animationController.forward();
  }

Pasamos la animación a nuestro painter para que este pinte de acuerdo a ella.

...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: CustomPaint(
          size: const Size(200, 200),
          // Pasamos la animación como parámetro.
          painter: LocalizationPainter(animation),
        ),
      ),
    );
  }
}

class LocalizationPainter extends CustomPainter {
  // Definimos la animación como propiedad de la clase.
  final Animation animation;

  // La recibimos como parámetro.
  LocalizationPainter(this.animation);

  @override
  paint(Canvas canvas, Size size) {
    final paintFill = Paint()
      // Utilizamos la propiedad value de la animación (0 ~ 255) para darle opacidad al color.
      ..color = Colors.blue.withAlpha(animation.value)
      ..style = PaintingStyle.fill;
...

Y ya por último modificamos el método shouldRepaint para que solo refresque el lienzo cuando los valores de la animación varía (esto podría mejorar el rendimiento de nuestra app).

@override
bool shouldRepaint(LocalizationPainter oldDelegate) => oldDelegate.animation.value != animation.value ;

Y tendremos como resultado...

Animación icono de localización con Flutter.

Aquí el código completo:

import 'dart:math';

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyIcon(),
    );
  }
}

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

  @override
  State<MyIcon> createState() => _MyIconState();
}

class _MyIconState extends State<MyIcon> with SingleTickerProviderStateMixin {
  final IntTween _opacityTween = IntTween(begin: 255, end: 0);
  late Animation animation;
  late AnimationController _animationController;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    );

    animation = _opacityTween.animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    ));

    animation.addListener(() => setState(() {}));

    animation.addStatusListener((status) {
      setState(() {});
      if (status == AnimationStatus.completed) {
        _animationController.reverse();
      } else if (status == AnimationStatus.dismissed) {
        _animationController.forward();
      }
    });

    _animationController.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: CustomPaint(
          size: const Size(200, 200),
          painter: LocalizationPainter(animation),
        ),
      ),
    );
  }
}

class LocalizationPainter extends CustomPainter {
  final Animation animation;

  LocalizationPainter(this.animation);

  @override
  paint(Canvas canvas, Size size) {
    // Lapiz que que rellena la forma.
    final paintFill = Paint()
      ..color = Colors.blue.withAlpha(animation.value)
      ..style = PaintingStyle.fill;

    // Lapiz que solo delinea.
    final paintStroke = Paint()
      ..strokeWidth = 5.0
      ..color = Colors.blue
      ..style = PaintingStyle.stroke;

    // Hallamos punto C
    final c = _getC(size);
    // Hallamos punto P
    final p = _getP(c, 30);
    // Radio de la circunferencia exterior.
    const smallRadio = 15.0;
    // Radio de la círculo interior.
    const bigRadio = 30.0;

    final path = Path();

    // Hallamos ALFA:
    final alpha = acos(bigRadio / (bigRadio + (p.dy - c.dy)));

    // Dibujamos la circunferencia exterior
    path.addArc(Rect.fromCircle(center: c, radius: bigRadio), pi / 2 + alpha,
        2 * (pi - alpha));

    // Tangente derecha hacia el punto P
    path.lineTo(c.dx, c.dy + bigRadio + (p.dy - c.dy));

    // Tangente izquierda desde el punto P
    path.lineTo(c.dx - bigRadio * sin(alpha), c.dy + bigRadio * cos(alpha));

    // Dibujamos el círculo interior con el lapiz con relleno:
    canvas.drawCircle(c, smallRadio, paintFill);

    // Por último dibujamos nuestro path con el lapiz sin relleno:
    canvas.drawPath(path, paintStroke);
  }

  @override
  bool shouldRepaint(LocalizationPainter _) => true;

  Offset _getC(Size size) {
    return Offset(size.width / 2, size.height / 3);
  }

  Offset _getP(Offset c, double h) {
    return Offset(c.dx, c.dy + h);
  }
}

Gracias por leerme!

💖 💪 🙅 🚩
jleondev
Joseph León

Posted on January 26, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related