flutter 动画专题

631 阅读36分钟

动画

理解原理

人眼在观察景物时,光信号传入大脑神经,需经过一段短暂的时间,光的作用结束后,视觉形象并不立即消失,这种残留的视觉称“后像”,视觉的这一现象则被称为“视觉暂留”,又称“余晖效应”。

静态的画面之所以能够运动,正是基于这一原理。具体应用则是电影放映,动画片播放等场景。

img

每张画面帧的快速切换

img

相关概念

帧是影像动画中单幅画面,一帧就是一幅静止的画面。例如电影胶片中的每一格即为一帧。

FPS

FPS(Frame Per Second)指每秒显示的帧数。例如电影每秒播放24帧,即帧率为24FPS。帧率越大显示的画面越流畅。

刷新频率

通常指显示器的刷新频率,市面上的显示器刷新频率主要为60Hz或120Hz。这里60Hz表示屏幕一秒钟可以刷新60张画面。

FPS帧率是由GPU决定,刷新频率是由显示器决定,显示器的刷新频率在物理上约束了帧数的表现上限。

Flutter 动画

详见Flutter官方文档 Flutter中的动画

Flutter中的动画可以分为两种类型

  • 补间动画(Tween Animation)
  • 基于物理的动画(Physics-based animation)

基于物理的动画是一种遵循物理学定律的动画形式。例如弹簧、阻尼效果等等

三种动画模式

  • 列表或网格动画(Animated list or grid)。指item的添加或删除操作,见AnimatedList 示例
  • 共享元素转换(Shared element transition)。实现路由(页面)之间的共享元素过渡动画,通常称为Hero动画
  • 交错动画(Staggered animation)。动画被分解为较小的动作,它们可以是连续的,或者可以部分或完全重叠。

插值器

主要描述值的变化规律(匀速、加速),决定变化的趋势,能更细腻的表达运动的物理特性。插值器通常是一个数学函数。简单说,插值器就是根据时间流失的百分比 计算当前属性改变的百分比

估值器

插值器描述了变化规律,接下来则需要交给估值器计算出具体的数值。因此估值器的主要作用就是协助插值器实现非线性运动的动画。

简单说,估值器就是根据当前属性改变的百分比来计算改变后的属性值

总结,插值器决定属性值随时间变化的规律;而具体变化属性数值则交给估值器去计算

相关对象

Ticker

可以被应用在 Flutter 的每个对象中,当对象实现了 Ticker 的功能后,每次动画帧改变便会通知该对象。Flutter 提供了 TickerProvider 类型,但它是一个抽象类,它有两个具体子类可快速实现该功能,SingleTickerProviderStateMixinTickerProviderStateMixin。前者只适用包含单个AnimationController的情况,如果你有多个AnimationController,则应使用后者。

用法:如在有状态控件下使用动画时,通常在 State 对象下混入 TickerProviderStateMixin

AnimationController

即动画控制器。用来控制动画,如动画的启动、暂停等。它接受两个参数,第一个是 vsync,必须是一个 Ticker 对象,其作用是当接受到来自 tweens 和 curves 的新值后通知对应对象,第二个参数是 duration, 为动画持续的时长。

要创建一个动画,首先要创建一个AnimationController。一旦创建了它,你就可以开始基于它构建其他动画。

常用方法:

  • forward() 从头向尾正向启动动画
  • reverse() 从尾到头逆向执行动画
  • fling() 使用阻尼效果驱动动画
  • repeat()正向运行此动画,并在完成后重复执行此过程
  • reset() 将控制器的值重置为开始值(如果正在进行则停止动画)
  • stop() 停止运行动画
  • dispose() 释放资源

Animation

是Flutter动画库中的一个核心类,也是一个抽象类,表示一个特定类型的值。其实它封装了动画过程中的值和状态。大多数执行动画的widgets都会收到一个Animation类型对象作为参数,它们从这个对象中读取动画的当前值,并监听该值的变化。

简单说,它生成指导动画的值,知道动画的当前状态(例如,它是开始、停止还是向前或向后移动),但它不知道屏幕上显示的内容。

主要是四种状态类型

  • AnimationStatus.forward 动画从头到尾执行
  • AnimationStatus.reverse 动画从尾到头执行
  • AnimationStatus.completed 动画已执行完成。注意,此状态指动画在终点停止
  • AnimationStatus.dismissed 动画处于停止状态。注意,此状态指动画回到起始点停止,即调用了reverse()方法

它的具体实现类:

  • AlwaysStoppedAnimation 始终以给定值停止的动画。该状态始终为AnimationStatus.forward
  • CompoundAnimation 一个用于组合多个动画的接口。子类只需要实现取值器来控制子动画的组合方式。可以链式组合2个以上的动画
  • CurvedAnimation 将曲线应用于另一个动画的动画。当你想对动画对象应用非线性曲线时,它很有用
  • ProxyAnimation 作为另一个动画的代理的动画
  • ReverseAnimation 与另一个动画相反的动画(反向动画)
  • TrainHoppingAnimation 接收两个父类,并在它们的值交叉时在它们之间切换
  • AnimationController 动画的控制器

需要注意,AnimationController 也是它的一个实现类。大多数 Animation 子类都采用明确的 “父级提供的” Animation<double>。可以说它们是由父级驱动的。

CurvedAnimation 子类接收一个 Animation<double>类(父级)和几个 Curve 类(正向和反向曲线)作为输入,并使用父级的值作为输入提供给曲线来确定它的输出。CurvedAnimation 是不可变和无状态的。

ReverseAnimation 子类接收一个 Animation<double> 类作为它的父级,但反转动画所有的值。父级动画的状态和方向也会被反转。ReverseAnimation 是不可变和无状态的。

ProxyAnimation 子类接收一个 Animation<double> 类作为其父级,并仅转发该父级的当前状态。然而,父级是可变的。

Tween

继承自Animatable,实际上它是一个补间值生成器。相当于在一个固定的时间内,生成一系列从begin到end的数值。对应到前面所述的概念,可以将之理解为一个线性的估值器

默认情况下,Flutter 中的动画将任何给定时刻的值映射到介于 0.0 和 1.0 之间的 double 值。

Flutter 框架也为我们封装了许多用于具体属性动画的Tween类实现:

Curve(动画曲线)

用来调整动画过程中随时间的变化率,默认情况下,动画以均匀的线性模型变化。我们可以通过继承 Curve 类来定义动画的变化率,比如创建加速、减速或者先加速后减速等曲线模型。可以将之理解为一个插值器

Flutter 内部也提供了一系列实现相应变化率的 Curves 对象,见文档Curves 文档

同时,我们还可以通过继承Curve类重写transform方法来自定义自己的非线性插值器。

class MyCurve extends Curve{
​
  @override
  double transform(double t) {
    return math.sin(t * math.PI * 2);
  }
}

动画流程

总的来说,Flutter中的动画系统基于Animation对象,widget应该在build函数中读取Animation对象的当前值, 并且可以监听动画的状态改变。代码中我们使用AnimationController管理Animation,并设置监听。

img

动画监听

上图解析了Flutter整个动画的流程。但中间还缺了一个环节,即补间值不断生成,如何通知UI页面刷新呢?

这里,Animation 对象分别可以设置两种监听器

  • 动画帧监听器。用于根据生成的补间值不断更新动画帧。
  • 动画状态监听器。用于动画执行过程中的控制。
    /// 注册帧监听器
    _animation.addListener(() {
      setState(() {});
    });
​
    /// 注册动画状态监听器
    _animation.addStatusListener((status) {
      print(status);
    });

代码示例

以下是一个简单的匀速线性补间值生成示例

    _controller = AnimationController(vsync: this,duration: Duration(seconds: 2));
    Animation animation = Tween<double>(begin: 0.0, end: 10.0).animate(_controller);
    animation.addListener(() {
      // 在回调中输出生成的值
      print(animation.value);
    });
​
    // 向前开始
    _controller.forward();

请注意,获取补间值有两种方式,以上调用Tweenanimate方法返回一个animation对象,然后通过value获取值,这是最常用的一种方式。除此外,还可以使用如下方式获取

    _controller = AnimationController(vsync: this,duration: Duration(seconds: 2));
    Tween tween = Tween<double>(begin: 0.0, end: 10.0);
​
    _controller.addListener(() {
      // 输出补间值
      print(tween.evaluate(_controller));
    });
​
    // 向前开始
    _controller.forward();

直接使用匀速线性的插值器,动画看起来会生硬死板,缺乏细腻。为此我们需要添加非线性插值器。

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin{
  AnimationController _controller;
  Animation _animation;
​
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this,duration: Duration(milliseconds: 600));
​
    /// 给动画添加非线性插值器
    CurvedAnimation curvedAnimation = CurvedAnimation(parent: _controller,curve:Curves.decelerate);
​
    _animation = Tween<double>(begin: 100.0, end: 150.0).animate(curvedAnimation);
    _animation.addListener(() {
      setState(() {});
    });
​
    _animation.addStatusListener((status) {
      if(AnimationStatus.completed == status){
        _controller.reverse();
      }else if(AnimationStatus.dismissed == status){
        _controller.forward();
      }
    });
​
    // 向前开始
    _controller.forward();
  }
​
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          width: _animation.value,
          height: _animation.value,
          decoration: BoxDecoration(
            image: DecorationImage(image: NetworkImage("https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/heartshaped.png"))
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.play_arrow),
        onPressed: ()=>_controller?.stop(),
      ),
    );
  }
​
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

添加非线性插值器,还有另一种写法,这里使用CurveTween而不是CurvedAnimation

_controller = AnimationController(vsync: this,duration: Duration(milliseconds: 600));
​
/// 给动画添加非线性插值器
Animatable tween = Tween<double>(begin: 100.0, end: 150.0).chain(CurveTween(curve: Curves.decelerate));
_animation = _controller.drive(tween);
_animation.addListener(() {
  setState(() {});
});
​
_animation.addStatusListener((status) {
  if(AnimationStatus.completed == status){
    _controller.reverse();
  }else if(AnimationStatus.dismissed == status){
    _controller.forward();
  }
});
​
// 向前开始
_controller.forward();

动画控件

为了简化动画代码的编写,提高开发效率,Flutter为我们提供了许多动画控件。

自定义动画控件

主要是根据自己的业务逻辑来封装动画控件,以到达复用代码、简化逻辑的目的。

  • AnimatedWidget 要做动画的 Widget必须继承自 AnimatedWidget 。该类主要封装了动画状态监听和页面的刷新逻辑。
  • AnimatedBuilder 它将显示内容和动画逻辑分离(职责分离),更加方便的为特定的显示内容添加具体的动画。简单说,它的作用就是Animation 和要作用的 Widget 关联起来

隐式动画控件

利用AnimatedWidget已经可以方便地封装出一系列动画控件,但这种实现方式还需要我们自己提供 Animation 对象,然后通过提供的接口方法来启动我们的动画,控件的属性由 Animation 对象提供并在动画过程中改变而达到动画的效果。为了使动画更加简单方便,Flutter 框架还提供了一种更简单的方式实现了动画效果,即隐式动画控件(ImplicitlyAnimatedWidget)。

通过隐式动画控件,我们不需要手动实现补间值生成器、曲线等对象,也不需要使用 AnimationController 来控制动画,它的使用体验更接近普通控件,我们只需要通过 setState 方法改变隐式动画控件的属性值,其内部自行为我们实现动画过程的过渡效果,即隐藏了所有动画实现的细节。当然,它的缺陷也很明显,除了动画控件的属性值,开发人员只能为动画设置durationcurve,无法做到精细的控制。

同样的,Flutter 内部也为我们提供了多个实用的隐式动画控件:

动画拓展

  • Interval 用于延缓动画。例如,一个时长6秒的动画,如果使用Interval,其开始时间设置为0.5,结束时间设置为1.0,那么基本上就会变成一个时长3秒的动画,且在3秒后开始。
  • TweenSequence 用于定义补间序列。创建一个动画,其值由Tweens序列定义,每个TweenSequenceItem都有一个权重,定义其占动画持续时间的百分比。

交错动画

详见官方示例 交错动画

img

路由过度动画

其中secondaryAnimation表示,当导航器将新路由推入其堆栈的顶部时,旧的顶层路由的secondaryAnimation从0.0到1.0运行。

Navigator.push(
  context,
  PageRouteBuilder(
    transitionDuration: Duration(milliseconds: 500), 
    pageBuilder: (BuildContext context, Animation animation,
        Animation secondaryAnimation) {
      return FadeTransition(
        //使用渐隐渐入动画,
        opacity: animation,
        child: PageB(),
      );
    },
  ),
);

Hero动画

使用Hero控件包装需要共享的元素,并设置tag属性,一个用于唯一标识的字符串,在另一个路由中也需要指定该tag

Hero动画文档

FirstPage

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
​
import 'second_page.dart';
​
class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 此属性用于调试动画,这里以5倍慢放动画
​
    timeDilation = 5.0;
    return Scaffold(
      appBar: AppBar(
        title: Text("第一页"),
      ),
      body: Container(
        alignment: Alignment.topLeft,
        child: InkWell(
          child: Hero(
            tag: "sprite",
            child: Image.network(
                "https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/levitation_sprite.jpg",
                width: 100,height: 100,),
          ),
          onTap: () {
            Navigator.push(context, MaterialPageRoute(builder:(c)=>SecondPage()));
          },
        ),
      ),
    );
  }
}

SecondPage

import 'package:flutter/material.dart';
​
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("第二页"),
      ),
      body: Container(
        alignment: Alignment.bottomRight,
        child: Hero(
          tag: "sprite",
          child: Image.network(
            "https://gitee.com/arcticfox1919/ImageHosting/raw/master/img/levitation_sprite.jpg",
            width: 150,height: 150,),
        ),
      ),
    );
  }
}

动画库的使用

animations

官方在Flutter 1.17版本时,为我们提供了一个高质量的动画库 animations,其中提供了一些过度动画。

example

Flare 动画

img

Flare为应用程序和游戏设计师提供了强大的实时矢量设计和动画。Flare的主要目标是让设计师直接使用在最终产品中运行的资产,而无需在代码中重做这些工作。

Flare 官方文档

关于Flare与Rive,官方公告如下

最近,我们将旧的公司名称(2Dimensions)和旧的产品名称(Flare)合并为一个新的品牌(Rive)。

一段时间以来,我们一直希望将我们以前的公司名称2Dimensions与主要产品Flare整合在一起。我们不到一年前就推出了Flare,今年已经非常清楚地表明,我们公司的未来就是这种产品。我们希望向用户传达一个强烈的信息,即我们正在认真投资产品并壮大团队。

合并公司和产品的名称也是简化我们的消息传递和展示我们的关注点的明确方法。

起初,合乎逻辑的解决方案似乎是将我们的公司名称改为Flare。我们喜欢这个简单的名字和文字游戏(在你的应用程序中添加 "Flare")。但我们很快意识到,这是一个无处不在的词,被许多其他公司、品牌和产品使用。

简而言之,我们需要一个既能代表我们喜爱的公司及其个性特征的品牌,又能成长,保护和称呼自己的品牌。

那Nima呢?

Nima是我们的第一个产品。它是Rive的基础。您仍然可以创建 Nima 文件,但我们很快就会添加支持在 Rive 中打开您的 Nima 文件。你仍然可以在Rive中做你目前能用Nima做的一切事情。届时,当Nima的所有功能都将被添加到Rive中时,我们将从网站中移除Nima,进一步简化我们的用户体验和消息传递。

Rive是一款实时交互式设计工具,它允许你设计、制作动画,并立即将你的资产整合到任何平台上。 Rive有两个核心部分:编辑器和运行时。编辑器是你创建设计和制作动画的地方。运行时是开源库,允许你用Swift、Flutter、Android、JavaScript/WebGL、React、C++等语言加载和操作Rive文件(我们正在开发更多的语言)。Rive格式和运行时都是开源的,并通过MIT许可提供。

目前Rive 是以工程形式管理、创建动画项目,支持2种工程类型:

  • Flare :为App和Web构建实时、快速的动画
  • Nima :主要是为游戏引擎和应用构建2D动画

需要注意,Flare 项目导出的是 .flr 格式文件,依赖于flare_flutter库解析。而Nima 项目导出文件有2个:一个是png,一个.nma 文件,需手动把 .nma改成 .nima,然后把这2个文件都放到 asste 资源文件夹中,并依赖nima库解析。

一个Nima动画资源

Flare 动画 example

SVGA 动画

动画设计师专注动画设计,通过工具输出 svga 动画文件,提供给开发工程师在集成 svga player 之后直接使用。

SVGA 官方网站

Flutter 解析库 svgaplayer_flutter

自定义控件

Flutter 有三种自定义控件的方式

  • 组合并封装已有的控件
  • 使用Canvas绘制
  • 利用RenderObject实现

使用Canvas自绘控件

Canvas就是一块2D画布,内部封装了一些基本绘制的API,开发者可以通过Canvas绘制各种自定义图形。在Flutter中,提供了一个CustomPaint控件,它需要结合一个画笔CustomPainter来实现绘制自定义图形。

Canvas 内的坐标系,其原点在左上角,水平向右为 x 轴正方向,垂直向下为 y 轴正方向

img

具体步骤

  1. 写一个类继承自CustomPainter,并实现paintshouldRepaint方法

     class MyPainter extends CustomPainter {
    ​
       @override
       void paint(ui.Canvas canvas, ui.Size size) {
         /// 该方法中实现绘制逻辑
         Paint paint = Paint()
           ..isAntiAlias = true
           ..color = Colors.blue
           ..strokeWidth = 10
           ..style = PaintingStyle.fill;
    ​
         canvas.drawLine(Offset(10, 10), Offset(250, 250), paint);
       }
    ​
       @override
       bool shouldRepaint(CustomPainter oldDelegate) {
         ///  返回 true 会进行重绘,否则就只会绘制一次
         return false;
       }
     }
    
  2. 使用CustomPaint包装我们自定义的画笔类,如同普通Widget一样插入控件树中

     Widget build(BuildContext context) {
        return Center(
          child: CustomPaint(
            size: Size(300, 300), //指定画布大小
            painter: MyPainter(),
          ),
        );
      }
    

需要注意,绘制是比较耗费性能的操作,所以在实现自绘控件时应该考虑到性能开销,做一些优化。

  • 利用好shouldRepaint方法的返回值。在控件树重新build时,如果我们绘制的UI不依赖外部状态,那么就应该始终返回false,因为外部状态改变导致重新build时不会影响我们的自绘控件;如果依赖外部状态,则应该在shouldRepaint中判断所依赖的状态是否改变,如果已改变则返回true来重绘,反之则应返回false不重绘。

  • 如果CustomPaint有子控件,为了避免子控件不必要的重绘而提高性能,可以将子控件包裹在RepaintBoundary 控件中,这样在绘制时会创建一个新的图层(Layer),其子控件将在新的图层上绘制,而父Widget将在原来图层上绘制。

    CustomPaint(
      size: Size(300, 300),
      painter: MyPainter(),
      child: RepaintBoundary(child:...)), 
    )
    
CustomPaint`有两个属性用于设置`painter
  • painter: 绘制效果会显示在child的下面
  • foregroundPainter: 绘制效果会显示在child的上面

关于Canvas 的大小

CustomPaint对象创建一块画布,其绘制区域大小与子控件的大小相同。如果未提供child参数(这是可选的),则绘制区域大小由实例化CustomPaint时其设置的size属性来确定。特别注意,默认Canvas是全屏的, 此处大小指的是绘制区域大小,而非Canvas 大小,千万不可混淆!

  1. 如果child == null,则绘制区域为size大小
  2. 如果child != null,则绘制区域为child大小
  3. 如果child != null 并且想指定指定绘制区域大小,可使用SizeBox包装CustomPaint

Canvas 的方法

Canvas 的操作主要有两类:

  • 针对 Canvas 的变换操作,如平移、旋转、缩放、图层等
  • 绘制基础图形的操作,如线段、路径、图片、几何图形等

绘制相关操作

  • drawArc(Rect rect,double startAngle, double sweepAngle, bool useCenter, Paint paint)

    在给定的矩形中画一个弧线。它从椭圆周围的startAngle弧度开始,直至椭圆周围的startAngle+sweepAngle弧度,零弧度是椭圆右侧与矩形中心相交的水平线相交的点,正角绕椭圆顺时针走。如果useCenter为真,弧线就会闭合回到中心,形成一个圆扇形。否则,弧线不闭合,形成一个圆段。

参数类型说明
rectRect圆弧所在椭圆的外接矩形
startAngledouble起始位置的弧度。弧度制
sweepAngledouble设置圆弧扫过多少弧度。弧度制
useCenterbool表示是否链接到圆弧所在椭圆的中心
paintPaint画笔

变换相关操作

  • scale(double sx, [double sy])

    在当前变换中添加一个轴对齐的标尺,在水平方向上按第一个参数缩放,在垂直方向上按第二个参数缩放。

  • skew(double sx, double sy)

    在当前变换中增加一个轴对齐的倾斜度,第一个参数是以原点为单位顺时针上升的水平倾斜度,第二个参数是以原点为单位顺时针上升的垂直倾斜度。

  • transform(Float64List matrix4)

    将当前变换乘以指定的4×4变换矩阵,该矩阵指定为以列为主的值列表。

  • translate(double dx, double dy)

    在当前的变换中添加一个平移,通过第一个参数水平移动坐标空间,通过第二个参数垂直移动坐标空间。

  • rotate(double radians)

    在当前的变换中加入旋转。参数的单位是顺时针的弧度。

  • getSaveCount()

    返回保存栈中的条目数,包括初始状态。这意味着如果画布是干净的,则返回1,每次调用savesaveLayer都会使其递增,每次匹配调用restore都会使其递减。

  • restore()

    弹出当前的保存堆栈(如果有要弹出的内容)。 否则,什么都不做。

  • save()

    在保存堆栈上保存当前变换和剪辑的副本。

  • saveLayer(Rect bounds, Paint paint)

    在保存堆栈上保存当前变换和剪辑的副本,然后创建一个新的组,后续调用将成为该组的一部分。当随后打开保存堆栈时,该组将被扁平化为一个图层,并应用给定的paintPaint.colorFilterPaint.blendMode

裁剪相关操作

  • clipPath(Path path, {bool doAntiAlias: true})

    将剪辑区域缩小至目前剪辑与给定路径的交点。如果doAntiAlias为true,则剪辑将被消除锯齿。

  • clipRect(Rect rect, {ClipOp clipOp: ClipOp.intersect, bool doAntiAlias: true})

    缩小剪辑区域至目前剪辑与指定矩形的交点

  • clipRRect(RRect rrect, {bool doAntiAlias: true})

    将剪辑区域缩小到当前剪辑和给定圆角矩形的交点。

Paint

在Canvas上绘图时要使用的样式的描述。大多数Canvas上的API都会取一个Paint对象来描述该操作要使用的样式。简单说,该类主要是用来设置真正画笔的一些属性。

属性类型简述
isAntiAliasbool是否开启抗锯齿,开启抗锯齿能够使边缘平滑
colorColor描边或填充形状时使用的颜色。
colorFilterColorFilter绘制形状或合成图层时应用的颜色滤镜。
filterQualityFilterQuality控制应用滤镜(如 maskFilter)或绘制图像(如 Canvas.drawImageRectCanvas.drawImageNine)时的性能与质量权衡。
invertColorsbool绘制时图像的颜色是否反转
maskFilterMaskFilter遮罩滤镜(例如,模糊),在形状被绘制后,但在它被合成到图像之前,应用于它。
imageFilterImageFilter绘制光栅图像时要使用的ImageFilter。例如,如果要使用Canvas.drawImage模糊图像,应用ImageFilter.blur
shaderShader描边或填充形状时要使用的着色器。
strokeCapStrokeCap当样式设置为PaintingStyle.stroke时,要放置在线条末端的边缘风格。如圆角、方形等
strokeJoinStrokeJoin设置两个绘制形状衔接处的风格。如圆角、方形等
strokeWidthdouble当样式设置为PaintingStyle.stroke时,画笔的宽度。宽度以逻辑像素为单位
stylePaintingStyle填充方式。PaintingStyle.fill充满;PaintingStyle.stroke空心
blendModeBlendMode像素混合模式。当画一个shape或者合成图层的时候会生效。

关于BlendMode类型,官方做了详细解释,这里给一篇 中文翻译链接

常用绘制方法示例

// 绘制直线
canvas.drawLine(Offset(10, 10), Offset(250, 250), paint);
​
// 绘制一系列的点,也可连成线段
canvas.drawPoints(
    PointMode.points,
    [Offset(200, 200), Offset(250, 250), Offset(50, 200), Offset(100, 250)],
    paint);
​
// 绘制路径
var path = Path()
      ..moveTo(30.0, 100.0)
      ..lineTo(120.0, 100.0)
      ..lineTo(90.0, 130.0)
      ..lineTo(180.0, 130.0)
      ..close();
canvas.drawPath(path, paint);
​
// 绘制矩形
Rect rect = Rect.fromCircle(
        center: Offset(size.width / 2, size.height / 2), radius: 100);
canvas.drawRect(rect, paint);
​
// 绘制圆角矩形
Rect rect1 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 150);
RRect rRect = RRect.fromRectAndRadius(rect1, Radius.circular(20));
canvas.drawRRect(rRect, paint);
​
// 绘制嵌套圆角矩形
Rect r1 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 140);
Rect r2 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 160);
RRect rRect1 = RRect.fromRectAndRadius(r1, Radius.circular(20));
RRect rRect2 = RRect.fromRectAndRadius(r2, Radius.circular(20));
// 第一个参数为外部矩形,第二个参数为内部矩形
canvas.drawDRRect(rRect2, rRect1, paint);
​
// 绘制圆形
canvas.drawCircle(Offset(size.width / 2, size.height / 2), 100, paint);
​
// 绘制椭圆
Rect rect2 = Rect.fromLTRB(size.width / 2 - 100, size.height / 2 - 50,
                           size.width / 2 + 100, size.height / 2 + 50);
canvas.drawOval(rect2, paint);
​
// 绘制圆弧
Rect rect3 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 140);
canvas.drawArc(rect3, 0, math.pi / 2, true, paint);
​
// 绘制阴影
Path path2 = Path()..addRect(rect.translate(20, 0));
canvas.drawShadow(path2, Colors.amberAccent, 20, true);
​
// 绘制背景色
canvas.drawColor(Colors.pinkAccent, BlendMode.srcIn);

PointMode类型的取值

enum PointMode {
  /// 分别绘制每个点。
  /// 如果Paint.strokeCap是StrokeCap.round,那么每个点被画成直径为Paint.strokeWidth的圆
  /// 否则,每个点被画成一个边长为Paint.strokeWidth的轴对齐的正方形,按照Paint.style的描述进行填充
  points,
​
  /// 将每两个点绘制为线段。如果点数为奇数,则忽略最后一个点。
  lines,
​
  /// 将整个点的序列画成一条线。线条按Paint.style的描述进行描边
  polygon,
}

矩形Rect的几种构造方式

  • Rect.fromPoints 根据两个点(左上角点/右下角点)来绘制
  • Rect.fromLTRB 以屏幕左上角为坐标系圆点,分别设置上下左右四个方向距离
  • Rect.fromLTWH 根据矩形左上角的点坐标与矩形宽高来绘制
  • Rect.fromCircle 根据给定圆形获得一个正方形

圆角矩形RRect的几种构造方式

  • RRect.fromLTRBXY 前四个参数用来绘制矩形位置,剩余两个参数绘制固定 x/y 弧度
  • RRect.fromLTRBR 前四个参数用来绘制矩形位置,最后一个参数绘制 Radius 弧度
  • RRect.fromLTRBAndCorners 前四个参数用来绘制矩形位置,剩余四个可选参数,根据需求设置四个角 Radius 弧度
  • RRect.fromRectXY 第一个参数绘制矩形,剩余两个参数绘制固定 x/y 弧度
  • RRect.fromRectAndRadius 第一个参数绘制矩形,最后一个参数绘制 Radius 弧度
  • RRect.fromRectAndCorners 第一个参数绘制矩形,剩余四个可选参数,根据需求设置四个角 Radius 弧度

需要注意,当使用drawColor绘制背景色时,其第二个参数BlendMode的类型较为复杂,不一定会达到预期的效果,详细参见BlendMode 文档

除了绘制几何图形,Canvas还能绘制图片,但需要注意,在flutter中有两个名为 Image 的类,它们位于不同的包中。一个是作为Widget的Image ;另一个是ui包中的Image,它是对原始解码图像数据(像素)的不透明句柄。

class MyImage extends CustomPainter{
  final ui.Image image;
​
  MyPainter(this.image);
​
  @override
  void paint(Canvas canvas, Size size) {
    if (_image != null) {
       var paint = Paint();
      canvas.drawImage(_image, Offset(0, 0), paint);
    }
  }
​
  @override
  bool shouldRepaint(MyPainter oldDelegate){
    return oldDelegate.image != this.image;
  }
}

加载图片

class _TestDrawImageState extends State<TestDrawImage> {
  ui.Image _img;
​
  @override
  void initState() {
    super.initState();
    _loadImage('assets/test.jpg').then((res) {
      setState(() {
        _img = res;
      });
    });
  }
​
  /// 加载图片
  Future<ui.Image> _loadImage(String path) async {
    var data = await rootBundle.load(path);
    var codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
    var info = await codec.getNextFrame();
    return info.image;
  }
​
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: MyPainter(_img),
    );
  }
}

当我们需要绘制文本时,有两种方式

  • 使用 drawParagraph方法。第一个参数是Paragraph,它来自dart.ui库,是引擎创建的类,不能被继承,需要通过ParagraphBuilder来构造;第二个参数是绘制的位置。需要注意,这里的TextStyle是来自ui

      import 'dart:ui' as ui;
    ​
      void paint(Canvas canvas, Size size) {
        /// 决定文本的大小和样式
        final textStyle = ui.TextStyle(
          color: Colors.black,
          fontSize: 30,
        );
    ​
        /// 决定了ParagraphBuilder用来定位文本段落中的行的配置
        final paragraphStyle = ui.ParagraphStyle(
          textAlign: TextAlign.center,
          textDirection: TextDirection.ltr,
        );
    ​
        final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle)
          ..pushStyle(textStyle)
          ..addText('Hello, world.'); // 添加文本和样式
    ​
        final paragraph = paragraphBuilder.build();
    ​
        /// 在绘制文本之前必须先进行布局。该任务被传递给Skia引擎
        paragraph.layout(ui.ParagraphConstraints(width: 300));
    ​
        canvas.drawParagraph(paragraph, Offset(50, 100));
      }
    
  • 使用TextPainter绘制。它是一个将TextSpan树绘制到Canvas中的对象,这是Flutter提供的一种简化的封装,无需再导入dart:ui,使用我们熟悉的TextStyleTextSpan即可。

    void paint(Canvas canvas, Size size) {
      final textSpan = TextSpan(
        text: 'Hello, world.',
        style: TextStyle(
          color: Colors.black,
          fontSize: 30,
        ),
      );
    ​
      /// 创建TextPainter对象
      final textPainter = TextPainter(
        text: textSpan,
        textDirection: TextDirection.ltr,
      );
    ​
      /// 进行布局
      textPainter.layout(
        minWidth: 0,
        maxWidth: size.width,
      );
    ​
      /// 绘制文本
      textPainter.paint(canvas, Offset(50, 100));
    }
    

在对Canvas进行变换相关操作时,需要先将画布save(或saveLayer),调用该函数之后的绘制操作和变换操作,会重新记录。变换完成后,再调用restore进行恢复。注意, save() 或者 saveLayer() 必须与 restore() 成对使用,即Canvas 的变换操作需要放到 save()saveLayer()restore() 之间进行

canvas.save();
// 平移画布
canvas.translate(100, 100);
canvas.drawImage(background, Offset.zero, paint);
canvas.restore();

saveLayersave类似,不同的是 saveLayer() 会创建一个新的图层。因此 saveLayer()restore() 之间的操作是在新图层上进行的,最终它们合成到一起。

自绘实例

img

import 'package:flutter/material.dart';
import 'dart:math' as math;
​
class CustomArcWidget extends StatelessWidget {
  final Color color;
  final Color bgColor;
  final double radius;
  final AlignmentGeometry alignment;
  final Widget child;
​
  CustomArcWidget(
      {this.color = Colors.white,
      this.bgColor = Colors.blue,
      @required this.radius,
      this.alignment,
      this.child});
​
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: ArcPainter(color, bgColor),
      child: Container(
          alignment: alignment, width: radius, height: radius, child: child),
    );
  }
}
​
class ArcPainter extends CustomPainter {
  final Color color;
  final Color bgColor;
​
  ArcPainter(this.color, this.bgColor);
​
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 5
      ..strokeCap = StrokeCap.round
      ..color = color;
​
    /// 创建一个新的指定大小的图层
    canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), paint);
​
    /// 绘制背景色
    canvas.drawColor(bgColor, BlendMode.src);
​
    /// 回到旧图层
    canvas.restore();
​
    final maxRadius = size.width / 2 - 5;
    canvas.drawArc(
        Rect.fromCircle(
            center: Offset(size.width / 2, size.height / 2), radius: maxRadius),
        -240 * (math.pi / 180),
        300 * (math.pi / 180),
        false,
        paint);
    paint.strokeWidth = 2;
    canvas.drawArc(
        Rect.fromCircle(
            center: Offset(size.width / 2, size.height / 2),
            radius: maxRadius - 5),
        -240 * (math.pi / 180),
        300 * (math.pi / 180),
        false,
        paint);
    paint.strokeWidth = 1;
    canvas.drawArc(
        Rect.fromCircle(
            center: Offset(size.width / 2, size.height / 2),
            radius: maxRadius - 10),
        -240 * (math.pi / 180),
        300 * (math.pi / 180),
        false,
        paint);
  }
​
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}

使用

CustomArcWidget(
  radius: 100,
  alignment: Alignment.center,
  child: Text(
    "99",
    style: TextStyle(color: Colors.white, fontSize: 20),
  ),
),

实现手绘板

import 'dart:ui';
​
import 'package:flutter/material.dart';
​
///
///  手绘板
///
class HandPaintedBoard extends StatefulWidget {
  final Color color;
  final double width;
  final double height;
  final BoardController controller;
​
  HandPaintedBoard(
      {this.color = Colors.blue, @required this.width, @required this.height,this.controller});
​
  @override
  _HandPaintedBoardState createState() => _HandPaintedBoardState();
}
​
class _HandPaintedBoardState extends State<HandPaintedBoard> {
​
  GlobalKey _gk = GlobalKey();
​
  final List<DrawStroke> strokes = List<DrawStroke>();
​
  // 总笔画数
  int _numOfStrokes = 0;
​
  ///
  /// 边界检查
  ///
  bool checkBoundary(Offset curPosition) {
    Size curSize = _gk.currentContext.size;
    RenderBox rBox = _gk.currentContext.findRenderObject();
    // 将控件内部的相对位置转换为屏幕上的绝对坐标值
    Offset topLeft = rBox.localToGlobal(Offset(0, 0));
    Offset bottomRight =
        rBox.localToGlobal(Offset(curSize.width, curSize.height));
​
    if (curPosition.dx < topLeft.dx || curPosition.dx > bottomRight.dx)
      return false;
    if (curPosition.dy < topLeft.dy || curPosition.dy > bottomRight.dy)
      return false;
​
    return true;
  }
​
  @override
  void initState() {
    super.initState();
    if(widget.controller != null){
      widget.controller.reset = reset;
    }
  }
​
  @override
  Widget build(BuildContext context) {
    return SizedBox(
        key: _gk,
        width: widget.width,
        height: widget.height,
        child: GestureDetector(
          onPanDown: (details) {
            if (checkBoundary(details.globalPosition)) {
              RenderBox rBox = context.findRenderObject();
              // 将屏幕上的绝对坐标值转换为控件内部的相对位置
              Offset curPoint = rBox.globalToLocal(details.globalPosition);
              var stroke = DrawStroke(widget.color);
              stroke.points.add(curPoint);
              strokes.add(stroke);
              setState(() {});
            }
          },
          onPanUpdate: (details) {
            RenderBox rBox = context.findRenderObject();
            Offset curPoint = rBox.globalToLocal(details.globalPosition);
​
            if (checkBoundary(details.globalPosition)) {
              setState(() {
                strokes[_numOfStrokes].points.add(curPoint);
              });
            }
          },
          onPanEnd: (details) {
            _numOfStrokes++;
          },
          child: CustomPaint(
            painter: HandPainter(strokes),
          ),
        ));
  }
​
  void reset(){
    _numOfStrokes = 0;
    strokes.clear();
    setState(() {});
  }
}
​
typedef ControllerReset = void Function();
class BoardController {
​
  ControllerReset reset;
}
​
class HandPainter extends CustomPainter {
  final List<DrawStroke> strokes;
​
  HandPainter(this.strokes):assert(strokes != null);
​
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..strokeWidth = 5.0
      ..isAntiAlias = true
      ..strokeCap = StrokeCap.round;
​
    for (var stroke in strokes) {
      paint.color = stroke.color;
​
      // 该模式将点连成线
      var _pointMode = PointMode.polygon;
      if(stroke.points.length == 1){
        // 只有一个点时,pointMode类型为PointMode.points,画点模式
        _pointMode = PointMode.points;
      }
      canvas.drawPoints(_pointMode, stroke.points, paint);
    }
  }
​
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}
​
///
/// 画的每一笔用一个[DrawStroke]对象表示
///
class DrawStroke {
  // 每一笔都由N个点序列组成
  final List<Offset> points = List<Offset>();
​
  // 这一笔的颜色
  Color color;
​
  DrawStroke(this.color);
}

使用

Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      DecoratedBox(
          decoration: BoxDecoration(
              border: Border.all(color: Colors.green)
          ),
          child: HandPaintedBoard(
            width: 400,height: 400,color: _color,controller: _controller,)),
      SizedBox(height: 16,),
​
      Wrap(
        children: List.of(Colors.primaries.map<Widget>((color){
          return InkWell(
            onTap: (){
              setState(() {
                _color = color;
              });
            },
            child: Container(
              margin: EdgeInsets.symmetric(horizontal: 4,vertical: 2),
              width: 30,
              height: 30,
              color: color,
            ),
          );
        })),
      ),
      SizedBox(height: 16,),
      IconButton(
        icon: Icon(Icons.delete_forever),
        onPressed: (){
          _controller.reset();
        },
      )
    ],
  ),
),

使用RenderObject 自定义控件

前面已经讲了使用Canvas自绘控件,为什么还需要了解使用RenderObject 自定义控件呢?两种有什么区别?

Canvas主要是进行底层绘制的,是最基础的一环。有时候一个控件除了绘制,还需要处理布局和事件,我们如果直接使用Canvas,就需要自己处理这些异常麻烦的事情,而Flutter的控件体系正是实现了这样一套机制,我们使用RenderObject去自定义控件就能复用这套体系。另外,通过学习使用RenderObject,也能加深我们对Flutter的控件、元素、渲染对象三者之间关系的理解。

布局原理

在Flutter中,布局阶段由两个线性传递构成:约束沿树向下传递,以及布局细节沿树向上传递。

img

img

过程如下:

  1. 父级给每个子级传递某些“约束”。这些约束是子级布置自己时必须遵守的一组规则。约束的一个简单示例是最大宽度约束。父级可以将允许渲染的最大宽度传递给其子级。当子级收到这些约束时,它知道不能超过父级约束的最大宽度。
  2. 接着,子级生成新的约束,并将其向下传递给自己的子级,这种情况一直持续到没有子级的叶子节点为止。
  3. 然后,此叶子节点控件根据传递给它的约束条件确定其“布局细节”。例如,如果其父级传递给它的最大宽度限制为500像素。它可以选择全部用光或只使用100像素。之后,叶子节点控件将确定的“布局细节”返回父级。
  4. 父级反过来也是这样做的。它利用子级返回的细节来确定自己的细节是什么,然后把它们传到渲染树上,一直沿着树往上传,要么传到根,要么达到某些限制为止。

至于 "约束 "和 "布局细节 "是什么,要看使用的布局协议。在Flutter中,主要有两种布局协议:box协议,和sliver协议。box协议用于在简单的二维笛卡尔坐标系中显示对象,而sliver协议用于显示对滚动有反应的对象。

在box协议中,父代传递给子代的约束称为BoxConstraints。这些约束决定了每个子代允许的最大和最小宽度和高度。例如,父代可能会将以下BoxConstraintsMinWidth=150,MaxWidth=300,MinHeight=100)传给它的子级。

img

这表示子级可以取得图中绿色范围内的值。 即介于150到300之间的任何宽度,大于100的任何高度(此处maxHeight为无穷大)。 由此,子级决定在这些限制条件下要拥有多大的尺寸,并将其决定通知父级。所以,“布局细节”是指子级选择的大小。

Sliver协议中,情况会更复杂。 父级向下传递SliverConstraints到其子级,其中包含滚动信息和约束,例如滚动偏移量,重叠部分等。子级又将SliverGeometry返回其父级。 Sliver协议非常复杂,本篇不涉及。

一旦父级知道其子级的所有布局细节,它就可以继续绘制自己和子级。Flutter会传递给它一个PaintingContext,其中包含一个Canvas,它可以在上面绘制。

关于布局约束的深入理解,请阅读官方文档的解释 《深入理解布局约束》

自定义示例

渲染对象RenderObject是一个抽象类。我们需要继承它来完成自定义控件的渲染。它有两个重要的子类RenderBoxRenderSliver。这两个类分别实现box协议和sliver协议,这两个类还被其他几十个类继承,这些子类分别处理特定的场景,并实现渲染过程的细节。

如果我们直接从RenderObject继承,就无法复用已有的布局协议,通常来说,应该从它的子类RenderBox类去派生自定义类。但是直接继承RenderBox仍然会有些细节处理,较为繁琐,通常我们可以去继承RenderBox的两个子类RenderShiftedBoxRenderProxyBox

自定义RenderObject

这里继承自RenderShiftedBox

/// 自定义用于对齐布局的渲染对象
class MyAlignRenderBox extends RenderShiftedBox {
​
  AlignmentGeometry alignment;
​
  MyAlignRenderBox({
    this.alignment = Alignment.center,
    RenderBox child,
  }) : super(child);
​
  @override
  void performLayout() {
    /// 测量
    /// 父级向子级传递约束,子级必须服从给定的约束。
    /// parentUsesSize为true,表示父级依赖于子级的布局,子级布局改变,父级也要重新布局
    /// 反之,子级发生改变,不会通知父级。即父级不依赖子级
    child.layout(BoxConstraints(
        minHeight: 0.0,
        maxHeight: constraints.maxHeight,
        minWidth: 0.0,
        maxWidth: constraints.maxWidth
    ), parentUsesSize: true);
​
    /// 对子级进行布局
    /// 经过测量后,可通过 child.size 拿到 child 测量后的大小
    /// 这里parentData即负责存储父节点所需要的子节点的布局信息
    final BoxParentData childParentData = child.parentData;
    if(alignment == Alignment.center){
      // offset属性即用来设置子节点相对于父节点的位置
      childParentData.offset = Offset((this.constraints.maxWidth - child.size.width)/2,
          (this.constraints.maxHeight - child.size.height)/2);
    }else{
      childParentData.offset = Offset(0,0);
    }
​
    /// 确定自己的“布局细节”
    size = Size(this.constraints.maxWidth, constraints.maxHeight);
  }
}

上面关于方位的计算,可以直接利用Alignment已经封装的功能,无需使用if判断

childParentData.offset = (alignment as Alignment).alongOffset(Size(constraints.maxWidth, constraints.maxHeight)-child.size);

自定义Widget

有了渲染对象,还需要一个与之对应的Widget,用于插入控件树中。自定义的Widget中需要实现两个方法,用于创建与之相关的ElementRenderObject。为了简单,这里继承自SingleChildRenderObjectWidget,因为它内部会帮我们创建上下文Element,这样我们只需把精力放在RenderObject

/// 自定义对齐布局Widget
class MyAlignWidget extends SingleChildRenderObjectWidget{
​
  MyAlignWidget({this.alignment=Alignment.center,Widget child}):super(child:child);
​
  final AlignmentGeometry alignment;
​
  @override
  SingleChildRenderObjectElement createElement() {
      return super.createElement();
  }
​
  @override
  RenderObject createRenderObject(BuildContext context) {
     // 创建我们自定义的渲染对象
     return MyAlignRenderBox(alignment: alignment);
  }
}

使用自定义布局

Widget build(BuildContext context) {
  return Scaffold(
    body: MyAlignWidget(
      alignment: Alignment.center,
      child: Container(
        width: 100,
        height: 100,
        color: Colors.blue,
      ),
    ),
  );
}

其他示例

当我们的控件不想进行布局,而是交给它的子级去处理,而我们只是想改变某些行为时,可以继承一个RenderObject的代理类RenderProxyBox

以下是一个仅处理触摸事件的自定义控件示例,而RenderConstrainedBox正是一个RenderProxyBox的子类

class TouchHighlightRender extends RenderConstrainedBox {
  TouchHighlightRender() : super(additionalConstraints: const BoxConstraints.expand());
​
  // 自身是否可进行命中检测
  @override
  bool hitTestSelf(Offset position) => true;
​
  final Map<int, Offset> _dots = <int, Offset>{};
​
  // 实现该方法用于处理事件
  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    if (event is PointerDownEvent || event is PointerMoveEvent) {
      _dots[event.pointer] = event.position;
      markNeedsPaint();
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      _dots.remove(event.pointer);
      markNeedsPaint();
    }
  }
​
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    canvas.drawRect(offset & size, Paint()..color = const Color(0xFFE6E6FA));
​
    final Paint paint = Paint()..color = const Color(0xFFFFFF00);
    for (Offset point in _dots.values)
      canvas.drawCircle(point, 50.0, paint);
​
    super.paint(context, offset);
  }
}
​
///
/// 触摸高亮控件
/// 
class TouchHighlight extends SingleChildRenderObjectWidget {
  const TouchHighlight({ Key key, Widget child }) : super(key: key, child: child);
​
  @override
  TouchHighlightRender createRenderObject(BuildContext context) => TouchHighlightRender();
}

使用控件。当我们触摸屏幕时,触摸点会形成一个圆圈高亮效果

Widget build(BuildContext context) {
  return Scaffold(
    body: TouchHighlight(
      child: Center(
        child: Text("Hello"),
      ),
    ),
  );
}

总结

当我们使用这种方式自定义控件时,至少需要自定义一个Widget和一个RenderObject。通常,我们的Widget可以继承自以下三种类

  • SingleChildRenderObjectWidgetRenderObject只有一个 child
  • MultiChildRenderObjectWidget:可以有多个 child
  • LeafRenderObjectWidgetRenderObject是一个叶子节点,没有child

而我们的自定义的RenderObject通常可以从RenderShiftedBoxRenderProxyBox及其子类派生。

当然,并不推荐实际开发中直接使用这种方式去自定义布局,总体来说仍然显得繁琐。Flutter已为开发者提供了两个控件用于自定义布局

  • CustomSingleChildLayout 处理包含单个child 的布局
  • CustomMultiChildLayout 处理包含多个child的布局