【Flutter绘制集录】第二画: 流光

4,994 阅读5分钟

零:本文效果简述

本文来通过一个小案例,介绍一下 Flutter 绘制Flutter 动画 的使用。如下,是一个七彩的圆环,其中有两个动画效果:

  • [1]. 四周有的光晕会进行扩散和收缩动画
  • [2]. 圆的外圈有一段流光 围绕圆环旋转。


一、静态效果绘制

1.外圈的绘制

下面定义一个 CircleHalo 组件用于展示 CircleHaloPainter 画板绘制的内容。由于后面要进行动画,使用这样定义为 StatefulWidget

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

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

class _CircleHaloState extends State<CircleHalo> {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size(200, 200),
      painter: CircleHaloPainter(),
    );
  }
}

先来画个圈,这应该对大家来说不是什么难事。

class CircleHaloPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.translate(size.width / 2, size.height / 2);
    
    final Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;

    final Path circlePath = Path();
    circlePath.addOval(
        Rect.fromCenter(center: Offset(0, 0), width: 100, height: 100));

    canvas.drawPath(circlePath, paint);
  }

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

现在来看下如何产生光晕:Paint 对象可以设置 maskFilter 属性,可以通过 MaskFilter.blur 让画笔进行模糊,BlurStyle.solid 模式会让画笔绘制时,四周产生模糊的阴影。第二参决定模糊程度,如下分别是模糊程度2、4、6 的效果。

sigma:2sigma:4sigma:6
final Paint paint = Paint()
  ..style = PaintingStyle.stroke
  ..strokeWidth = 1;

// 设置 maskFilter
paint.maskFilter = MaskFilter.blur(BlurStyle.solid, 4);

接下来就是设置彩色画笔,可以通过 Paint 对象的 shader 属性设置着色器,如下是一个多彩的扫描渐变着色。

import 'dart:ui' as ui ;

List<Color> colors = [
  Color(0xFFF60C0C),
  Color(0xFFF3B913),
  Color(0xFFE7F716),
  Color(0xFF3DF30B),
  Color(0xFF0DF6EF),
  Color(0xFF0829FB),
  Color(0xFFB709F4),
];

colors.addAll(colors.reversed.toList());

final List<double> pos = List.generate(colors.length, (index) => index / colors.length);
// 设置 shader
paint.shader =
    ui.Gradient.sweep(Offset.zero, colors, pos, TileMode.clamp, 0, 2 * pi);
// 设置 maskFilter
paint.maskFilter = MaskFilter.blur(BlurStyle.solid, 4);

到这,我们就完成了 1/4 ,光晕扩散和收缩动画 其实就是动态更改模糊遮罩的 sigma 值而已。


2.外圈流光静态效果

外圈旋转的静态效果如下最左侧,是一个月牙形 的圆弧。可以通过两个圆路径通过 difference 进行联合得到,其中两个圆心在横向有略微的偏距,偏距越大,月牙也就越胖,下面是 偏距 =1 的效果。

class CircleHaloPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    canvas.translate(size.width / 2, size.height / 2);
    final Paint paint = Paint()
      ..style = PaintingStyle.stroke;
    paint.maskFilter = MaskFilter.blur(BlurStyle.solid, 4);
    
		//路径1
    final Path circlePath = Path()..addOval(
        Rect.fromCenter(center: Offset(0, 0), width: 100, height: 100));
    //路径2
    Path circlePath2 = Path()..addOval(
        Rect.fromCenter(center: Offset(-1, 0), width: 100, height: 100));
		//联合路径
    Path result = Path.combine(PathOperation.difference, circlePath, circlePath2);
    //颜色填充
    paint..style = PaintingStyle.fill..color = Color(0xff00abf2);
    canvas.drawPath(result, paint); //绘制
  }

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

二、外圈的动画

首先来实现如下的光晕扩散和收缩动画,动画周期为 2s ,不断重复执行。


1.状态类处理

自己处理动画,首先创建动画控制器。由于动画控制器构造时需要一个 TickerProvider 入参,可以让 _CircleHaloState 混入 SingleTickerProviderStateMixin 使状态类本身成为 TickerProvider 的实现类。如下,在 initState 中创建了一个 2s 的动画器,并通过 repeat 方法进行重复动画。在构造 CircleHaloPainter 时,将动画器作为入参。

class _CircleHaloState extends State<CircleHalo> with SingleTickerProviderStateMixin {
  
  AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
    );
    _ctrl.repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return  CustomPaint(
        size: Size(200, 200),
        painter: CircleHaloPainter(_ctrl),
    );
  }
}

2.画板的动画处理

我们的目的是让 MaskFilter.blur 的第二参数值随动画器进行改变,从而达到动画的效果。CustomPainter 子类构造可以通过 super(repaint:可见听对象),来关联 Listenable 对象。因为动画器 AnimationListenable 的子类,这里关联动画器,这样动画器数值改变时,就会通知画板重绘。

下面处理中,比较重要的点是通过 TweenSequence 定义一个来回变化的 Tween ,比如动画时长为 2s , 在第1秒在 0~4 间变化,第2秒在 4~0 间变化,这样就可以达到在一个动画周期中,数值一来一回。另外通过 chain 方法传入CurveTween 对象 ,可以让当前 Animatable 的数值变化增加动画曲线效果。

class CircleHaloPainter extends CustomPainter {
  Animation<double> animation;

  CircleHaloPainter(this.animation) : super(repaint: animation);

  final Animatable<double> breatheTween = TweenSequence<double>(
    <TweenSequenceItem<double>>[
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 0, end: 4),
        weight: 1,
      ),
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 4, end: 0),
        weight: 1,
      ),
    ],
  ).chain(CurveTween(curve: Curves.decelerate));

  @override
  void paint(Canvas canvas, Size size) {
    canvas.translate(size.width / 2, size.height / 2);
    final Paint paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1;

    Path circlePath = Path()
      ..addOval(Rect.fromCenter(center: Offset(0, 0), width: 100, height: 100));

    List<Color> colors = [
      Color(0xFFF60C0C), Color(0xFFF3B913), Color(0xFFE7F716), 
      Color(0xFF3DF30B), Color(0xFF0DF6EF),  Color(0xFF0829FB),
      Color(0xFFB709F4),
    ];
    colors.addAll(colors.reversed.toList());
    final List<double> pos = List.generate(colors.length, (index) => index / colors.length);
    
    paint.shader =
        ui.Gradient.sweep(Offset.zero, colors, pos, TileMode.clamp, 0, 2 * pi);
    
    paint.maskFilter =
        MaskFilter.blur(BlurStyle.solid, breatheTween.evaluate(animation));
    
    canvas.drawPath(circlePath, paint);
  }

  @override
  bool shouldRepaint(covariant CircleHaloPainter oldDelegate) =>
      oldDelegate.animation != animation;
}

三、流光的动画

拆开来看,外圈的旋转效果如下。动画实现也非常简单,就是根据动画器的值,让圆弧不断旋转而已。

@override
void paint(Canvas canvas, Size size) {
  canvas.translate(size.width / 2, size.height / 2);
  final Paint paint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 1;
  paint.maskFilter =
      MaskFilter.blur(BlurStyle.solid, breatheTween.evaluate(animation));
  
  final Path circlePath = Path()..addOval(
      Rect.fromCenter(center: Offset(0, 0), width: 100, height: 100));
  Path circlePath2 = Path()..addOval(
      Rect.fromCenter(center: Offset(-1, 0), width: 100, height: 100));
  Path result = Path.combine(PathOperation.difference, circlePath, circlePath2);
  
  canvas.save();
  canvas.rotate(animation.value * 2 * pi);
  paint..style = PaintingStyle.fill..color = Color(0xff00abf2);
  canvas.drawPath(result, paint);
  canvas.restore();
}

最后,绘制时,将两个东西都画出来就行了。


另外,本绘制已放入 FlutterUnit 的绘制集录中,大家可以更新查看。


下面把所有的代码贴一些,大家可以运行一下玩玩。

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

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

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

class _CircleHaloState extends State<CircleHalo>
    with SingleTickerProviderStateMixin {
  AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
    );
    _ctrl.repeat();

  }

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

  @override
  Widget build(BuildContext context) {
    return  CustomPaint(
        size: Size(200, 200),
        painter: CircleHaloPainter(_ctrl),
      );
  }
}

class CircleHaloPainter extends CustomPainter {
  Animation<double> animation;

  CircleHaloPainter(this.animation) : super(repaint: animation);

  final Animatable<double> rotateTween = Tween<double>(begin: 0, end: 2 * pi)
      .chain(CurveTween(curve: Curves.easeIn));

  final Animatable<double> breatheTween = TweenSequence<double>(
    <TweenSequenceItem<double>>[
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 0, end: 4),
        weight: 1,
      ),
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 4, end: 0),
        weight: 1,
      ),
    ],
  ).chain(CurveTween(curve: Curves.decelerate));

  @override
  void paint(Canvas canvas, Size size) {
    canvas.translate(size.width / 2, size.height / 2);
    final Paint paint = Paint()
      ..strokeWidth = 1
      ..style = PaintingStyle.stroke;

    Path circlePath = Path()
      ..addOval(Rect.fromCenter(center: Offset(0, 0), width: 100, height: 100));
    Path circlePath2 = Path()
      ..addOval(
          Rect.fromCenter(center: Offset(-1, 0), width: 100, height: 100));
    Path result =
        Path.combine(PathOperation.difference, circlePath, circlePath2);

    List<Color> colors = [
      Color(0xFFF60C0C), Color(0xFFF3B913), Color(0xFFE7F716), 
      Color(0xFF3DF30B), Color(0xFF0DF6EF), Color(0xFF0829FB), Color(0xFFB709F4),
    ];
    
    colors.addAll(colors.reversed.toList());
    final List<double> pos =
        List.generate(colors.length, (index) => index / colors.length);

    paint.shader =
        ui.Gradient.sweep(Offset.zero, colors, pos, TileMode.clamp, 0, 2 * pi);

    paint.maskFilter =
        MaskFilter.blur(BlurStyle.solid, breatheTween.evaluate(animation));
    canvas.drawPath(circlePath, paint);

    canvas.save();
    canvas.rotate(animation.value * 2 * pi);
    paint
      ..style = PaintingStyle.fill
      ..color = Color(0xff00abf2);
    paint.shader=null;
    canvas.drawPath(result, paint);
    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant CircleHaloPainter oldDelegate) =>
      oldDelegate.animation != animation;
}