Прийшов час осідлати справжнього Буцефала🏇🏻Приборкай норовливого коня разом з Newxel🏇🏻Умови на сайті
×Закрыть

Создаем анимации во Flutter с помощью Canvas

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Доброго времени суток, меня зовут Андрей, я являюсь Flutter разработчиком. По мере моего изучения flutter, я часто работал с анимациями. На самом деле, Для любителей создания анимации, Flutter — хороший выбор, так как большинство решений в нем уже реализовано. К примеру, при помощи виджета AnimatedOpacity, можно в пару кликов сделать анимацию понижения или повышения прозрачности. А если немного поиграться с параметром curve, то можно создать еще и кривую, по которой будет происходить эта анимация.

Также для тех, кто только начинает изучения анимаций во flutter, рекомендую ознакомиться с виджетом AnimatedContainer. Для создания простых и несложных анимаций, он будет очень полезен и понятен для использования. Если в кратце то имеет почти такие же параметры что и обычный Container, однако добавляет на изменяемые параметры анимацию.

@override
Widget build(BuildContext context) {
 return AnimatedContainer(
   decoration: BoxDecoration(
     color: startAnimation ? Colors.lightGreen : Colors.red,
     borderRadius: BorderRadius.circular(startAnimation ? 15.0 : 0.0),
   ),
   width: startAnimation ? 100 : 200,
   height: startAnimation ? 100 : 200,
   curve: Curves.easeInOutCubic,
   duration: Duration(seconds: 1),
 );
}

Однако бывают ситуации, когда мы хотим большего чем изменение параметров у виджета Container. Для этого мы можем рассмотреть еще два подхода к созданиям анимации.

  1. Создания анимации при помощи Animation и AnimationСontroller.
  2. Создание анимации при помощи Tween.

В ходе данной статьи мы не будем останавливаться на них. Скорее всего, их сравнение и примеры использования мы рассмотрим в следующей статье. Однако для создания примера, мы затронем первый подход.

Изучив принцип работы при помощи 2-х подходов, описанных выше, мы сможем сделать многое, однако иногда нам может потребоваться пойти еще дальше. При помощи анимации мы можем изменять только существующее виджет. А что, если мы хотим изменять виджет, который мы сами нарисовали при помощи CustomPaint? Именно такую ситуацию мы и рассмотрим в данной статье.

Для примера представим, что нам необходимо сделать собственную анимированную кнопку «Play» для нашего видео-плеера. Первый вариант решения этой проблемы — использование стандартного во flutter виджета AnimatedIcon.

import 'package:flutter/material.dart';

class FlutterAnimatedIconWidget extends StatefulWidget {

 FlutterAnimatedIconWidget();

 @override
 _FlutterAnimatedIconWidgetState createState() => _FlutterAnimatedIconWidgetState();
}

class _FlutterAnimatedIconWidgetState extends State<FlutterAnimatedIconWidget> with SingleTickerProviderStateMixin {
 AnimationController animationController;

 @override
 void initState() {
   animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 400));
   super.initState();
 }

 @override
 Widget build(BuildContext context) {
   return InkWell(
     onTap: _onTap,
     child: AnimatedIcon(
       icon: AnimatedIcons.play_pause,
       progress: animationController,
       color: Colors.orange,
       size: 150.0,
     ),
   );
 }

 void _onTap() {
   if (animationController.value == 0) {
     animationController.forward();
     return;
   }
   animationController.reverse();
 }
}

Это самый простой и быстрый вариант. Однако, он имеет множество недостатков:

  • Очень небольшой выбор иконок.
  • Тяжело редактировать.
  • Не дает полную свободу в создании анимаций.

Второй вариант: Использование пакета animate_icons (pub.dev/packages/animate_icons). Он дает нам возможность анимировать любые иконки. Подробно на нем не будем останавливаться, так как мне он кажется на данный момент слишком недоработанным. Остановимся просто на небольшом примере.

Widget build(BuildContext context) {
 return AnimateIcons(
   startIcon: Icons.play_arrow,
   endIcon: Icons.pause,
   size: 150.0,
   onStartIconPress: () {
     return true;
   },
   onEndIconPress: () {
     return true;
   },
   duration: Duration(milliseconds: 500),
   color: Colors.orange,
   clockwise: false,
 );
}

Однако полную свободу действий в создании анимаций можно получить при использовании Canvas. Такой метод имеет недостатки в том что, это более трудный процесс.

Для начала нам необходимо создать класс CustomIconPaint, который расширяется от CustomPainter.

class CustomIconPaint extends CustomPainter {
 final double value;
 final double sizeIcon;
 final Color color;
 final Color strokeColor;

 CustomIconPaint({
   @required this.value,
   @required this.sizeIcon,
   @required this.color,
   @required this.strokeColor,
 });

 @override
 void paint(Canvas canvas, Size size) {
   size = Size(sizeIcon, sizeIcon);
 }

 @override
 bool shouldRepaint(CustomPainter oldDelegate) => true;
}

В него мы будем передавать несколько параметров, для того чтобы мы могли его изменять извне. Давайте пройдемся быстро по параметрам. Параметр value — это переменный параметр который и будет отвечать за нашу анимацию. SizeIcon — отвечает за размер иконки. Color и strokeColor отвечают за цвет и обводку.

Следующим шагом будет создание кистей.

Мы будем создавать две кисти:

  1. Будет использоваться для заливки.
  2. Будет использоваться для создания контура.
@override
void paint(Canvas canvas, Size size) {
 size = Size(sizeIcon, sizeIcon);
 Paint paint = Paint()
   ..strokeCap = StrokeCap.round
   ..style = PaintingStyle.fill
   ..color = color ?? Colors.black;
 Paint paintStroke = Paint()
   ..strokeCap = StrokeCap.square
   ..strokeJoin = StrokeJoin.round
   ..strokeWidth = sizeIcon /10
   ..style = PaintingStyle.stroke
   ..color = strokeColor ?? Colors.black;
}	

Для создания динамически изменяющегося контура, мы используем вот такую строчку: strokeWidth = sizeIcon /10. В случае, если мы не передадим цвет, будет поставлен Colors.black.

Теперь у нас все готово, для создание иконки, для начала нам необходимо создать приватную функцию и передать все необходимые параметры.

@override
void paint(Canvas canvas, Size size) {
 size = Size(sizeIcon, sizeIcon);

 Paint paint = Paint()
   ..strokeCap = StrokeCap.round
   ..style = PaintingStyle.fill
   ..color = color ?? Colors.black;

 Paint paintStroke = Paint()
   ..strokeCap = StrokeCap.square
   ..strokeJoin = StrokeJoin.round
   ..strokeWidth = sizeIcon /10
   ..style = PaintingStyle.stroke
   ..color = strokeColor ?? Colors.black;

 _drawPath(canvas, paint, paintStroke);
}

void _drawPath(Canvas canvas, Paint paint, Paint paintStroke) {
 canvas.save();
}

Для использования canvas, нам необходимо сначала написать canvas.save();.

Для написания мы будем использовать, класс Path и его функцию addPolygon.

ИДля начала нам необходимо по точкам нарисовать одно из положений нашей иконки.

void _drawPath(Canvas canvas, Paint paint, Paint paintStroke) {
 canvas.save();
 Path path = Path();

 double space = sizeIcon / 8;
 path.addPolygon(
   [
     Offset(sizeIcon, (sizeIcon / 2 - space)),
     Offset(0, (sizeIcon / 2 - space)),
     Offset(0, 0),
     Offset(sizeIcon, 0),
   ],
   true,
 );
 path.addPolygon(
   [
     Offset(0, sizeIcon),
     Offset(sizeIcon, sizeIcon),
     Offset(sizeIcon, (sizeIcon / 2 + space)),
     Offset(0, (sizeIcon / 2 + space)),
   ],
   true,
 );
 canvas.drawPath(path, paintStroke);
 canvas.drawPath(path, paint);
}

По итогу выходит такой результат.

На данный момент она находиться в горизонтальном положении, так как мне так легче отрисовывать, а в дальнейшем мы добавим поворот иконки. С такого положения это будет легче сделать. Для того чтобы не повторять одинаковый код, я вынес sizeIcon / 8, в переменную space, эта переменная отвечает за отступ между прямоугольниками, который равен ⅛ от размера иконки. В самом конце мы отрисовываем результат два раза, сначала контур потом саму заливку.

 canvas.drawPath(path, paintStroke);
 canvas.drawPath(path, paint);

Далее нам надо создать саму анимацию перехода из одного состояния иконки в другое. Для этого у нас есть параметр value. При старте анимации параметр value = 0, а в конце value = 1. Конкретно как работает value и где он берется, мы сейчас и рассмотрим. Для этого нам необходимо создать виджет обертку над нашим канвасом.

import 'dart:math';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

import 'package:animation_with_canvas_example/ui/canvas/custom_icon_paint.dart';

class CanvasAnimatedIconWidget extends StatefulWidget {
 final Duration duration;
 final void Function() onTap;
 final Color color;
 final Color strokeColor;
 final double size;

 CanvasAnimatedIconWidget({
   @required this.duration,
   @required this.onTap,
   this.size = 25.0,
   this.color,
   this.strokeColor,
 }) : assert(duration != null);

 @override
 _CanvasAnimatedIconWidgetState createState() => _CanvasAnimatedIconWidgetState();
}

class _CanvasAnimatedIconWidgetState extends State<CanvasAnimatedIconWidget> with TickerProviderStateMixin {
 AnimationController rotateController;
 AnimationController animationController;

 @override
 void initState() {
   rotateController = AnimationController(duration: widget.duration, vsync: this, value: 0.0);
   animationController = AnimationController(duration: widget.duration, vsync: this, value: 0.0);

   rotateController.addListener(_rotateControllerUpdateListener);
   animationController.addListener(_animationControllerUpdateListener);
   super.initState();
 }

 @override
 void dispose() {
   rotateController.removeListener(_rotateControllerUpdateListener);
   animationController.removeListener(_animationControllerUpdateListener);

   rotateController.dispose();
   animationController.dispose();
   super.dispose();
 }

 @override
 Widget build(BuildContext context) {
   return Center(
     child: Transform.rotate(
       angle: -(pi / (2 / (1 - rotateController.value))),
       child: Container(
         width: widget.size,
         height: widget.size,
         child: InkWell(
           highlightColor: Colors.transparent,
           splashColor: Colors.transparent,
           onTap: _buttonTap,
           child: CustomPaint(
             foregroundPainter: CustomIconPaint(
               color: widget.color,
               strokeColor: widget.strokeColor,
               sizeIcon: widget.size,
               value: animationController.value,
             ),
           ),
         ),
       ),
     ),
   );
 }

 void _buttonTap() {
   widget.onTap();
   if (rotateController.value == 0) {
     rotateController.forward();
   } else {
     rotateController.reverse();
     animationController.reverse();
   }
 }

 void _rotateControllerUpdateListener() {
   if (rotateController.value == 1) {
     if (animationController.value != 1) {
       animationController.forward();
     }
   } else {
     animationController.reverse();
   }
   setState(() {});
 }

 void _animationControllerUpdateListener() => setState(() {});
}

Итак, давайте разберемся, что тут происходит и почему так. Для начала создается два контроллера rotateController и animationController. Первый необходим для создания анимации вращения, второй будет отвечать за саму анимацию. Далее мы добавляет listener, которые будут реагировать на изменение и перерисовать виджет по ходу выполнения анимации. Когда закончиться первая анимация у code>rotateController, запуститься анимация у animationController. Сделано это для улучшения визуала. Обратная анимация будет выполняться параллельно и там и там одновременно. Для вращение мы используем Transform.rotate, вращение которого зависит от rotateController.value. Для нашего CustomIconPaint, в качестве параметра value, мы передаем animationController.value. Промежуточный результат от 0 до 1, в течение времени, которое мы укажем в параметре duration, будет передаваться в наш paint, от него уже будет зависеть наша анимация. На данный момент мы получили такой результат.

Что ж, теперь осталось немного. Осталось только создать зависимость между animationController и canvas. Для этого необходимо добавить зависимость от value в момент отрисовки точек.

void _drawPath(Canvas canvas, Paint paint, Paint paintStroke) {
 canvas.save();
 Path path = Path();

 double space = sizeIcon / 8;

 path.addPolygon(
   [
     Offset(0, 0),
     Offset(sizeIcon, (sizeIcon / 2) * value),
     Offset(sizeIcon, (sizeIcon / 2 - (space * (1 - value)))),
     Offset(0, (sizeIcon / 2 - (space * (1 - value)))),
   ],
   true,
 );
 path.addPolygon(
   [
     Offset(0, sizeIcon),
     Offset(sizeIcon, sizeIcon - (sizeIcon / 2) * value),
     Offset(sizeIcon, (sizeIcon / 2 + (space * (1 - value)))),
     Offset(0, (sizeIcon / 2 + (space * (1 - value)))),
   ],
   true,
 );
 canvas.drawPath(path, paintStroke);
 canvas.drawPath(path, paint);
}

После добавление зависимости в некоторых точках от value, конечный результат выглядит таким.

Самое главное, что теперь для использования нам достаточно просто вызывать виджет CustomAnimatedIconWidget и передать несколько параметров.

CanvasAnimatedIconWidget(
 onTap: _onTap,
 strokeColor: Colors.green,
 size: 50.0,
 color: Colors.lightGreenAccent,
 duration: Duration(milliseconds: 400),
),

Как итог могу сказать, что отрисовка с помощью canvas имеет преимущество в том, что можно создать любую анимацию, однако это трудоемкий процесс, требующий познаний в математике, также это требует немалой практики.

Спасибо за внимание, оставляйте свои отзывы по поводу статьи, возможно я что-то мог сделать по другому. Пишите, оказалась ли эта статья для вас полезной и узнали ли вы что-то новое. С удовольствие буду ждать ваших отзывов.

Ссылка на git: bitbucket.org/...​n_canvas_example/src/dev

LinkedIn

Похожие статьи

Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Помогло решить волнующий вопрос! Как хорошо что в Flutter реализовано большинство решений. Очень помогает. Спасибо Вам, Андрей, за такую статью!

Подписаться на комментарии