Flutter 绘制(基础应用篇)

2,784 阅读19分钟

cover

前言

在移动开发中,无论是平常的按钮还是炫酷的动画都离不开绘制,每个平台都有自己的绘制体系,系统绘制工具以及随时间积累下来的各种优秀的开源框架。日常开发中,我们不仅要会使用这些轮子,往往为了更好的满足设计的需求,还需要对其进行修修补补,升级改造。这个时候,我们就需要对绘制的知识有更深一步的了解,作为一名移动开发者,我们可能对 Android/iOS 的绘制有所了解,所谓触类旁通,今天一起了解下 Flutter 中的绘制知识。

绘制 API

代码基于 Flutter 2.2 版本

Dart API

绘制元素

生活中的绘画离不开笔、墨、纸,程序中的绘制也是如此。

  • 画布:Canvas
  • 画笔:Paint
  • 路径:Path
  • 颜色:Color

下面我们来认识一下这些绘制元素(不需要全部记住,用的时候会找就行):

1.画布:Canvas

用于记录图形操作的界面。

Canvas 对象用于创建 Picture 对象,这些对象本身可以与 SceneBuilder 一起使用来构建 Scene。然而,在正常使用中,这一切都由框架处理。

画布具有应用于所有操作的当前转换矩阵。最初,变换矩阵是恒等变换。可以使用 translate、scale、rotate、skew 和 transform 方法修改它。

画布还有一个当前剪辑区域,该区域适用于所有操作。最初,剪辑区域是无限的。可以使用 clipRect、clipRRect 和 clipPath 方法修改它 。

可以使用由 save、saveLayer 和 restore 方法管理的堆栈来保存和恢复当前的变换和剪辑。

画布裁剪:

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

    将剪辑区域缩小到当前剪辑和给定 Path 的交点。

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

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

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

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

画布绘制:

  • void drawPoints(PointMode pointMode, List<Offset> points, Paint paint)

    根据给定的 PointMode 绘制一系列点。

  • void drawLine(Offset p1, Offset p2, Paint paint)

    使用给定的颜料在给定的点之间画一条线。线条被描边,此调用将忽略 paint.style 的值。

  • void drawRect(Rect rect, Paint paint)

    使用给定的Paint绘制一个矩形。矩形是填充还是描边(或两者)由 paint.style 控制。

  • void drawOval(Rect rect, Paint paint)

    绘制一个轴对齐的椭圆,用给定的 Paint 填充给定的轴对齐矩形。椭圆是填充还是描边(或两者)由 paint.style 控制。

  • void drawCircle(Offset c, double radius, Paint paint)

    以第一个参数给出的点为中心绘制一个圆,其半径由第二个参数给出,Paint 在第三个参数中给出。圆圈是填充还是描边(或两者)由 paint.style 控制。

  • void drawImage(Image image, Offset p, Paint paint)

    将给定的 Image 绘制到画布中,其左上角位于给定的 Offset。使用给定的 Paint 将图像合成到画布中。

  • void drawParagraph(Paragraph paragraph, Offset offset)

    在给定的Offset处将给定 Paragraph 中的文本绘制到此画布中 。

  • void drawColor(Color color, BlendMode blendMode)

    将给定的 Color 绘制到画布上,应用给定的 BlendMode,给定的颜色是源,背景是目标。

  • void drawPath(Path path, Paint paint)

    使用给定的Paint绘制给定的 Path。

  • void drawPaint(Paint paint)

    用给定的 Paint 填充画布。

  • void drawShadow(Path path, Color color, double elevation, bool transparentOccluder)

    为表示给定材质标高的 Path 绘制阴影。

  • void drawPicture(Picture picture)

    将给定的图片绘制到画布上。要创建图片,请参阅 PictureRecorder。

画布变换:

  • void skew(double sx, double sy)

    向当前变换添加轴对齐倾斜,第一个参数是围绕原点顺时针旋转的水平倾斜,第二个参数是围绕原点顺时针旋转的垂直倾斜。

  • void rotate(double radians)

    向当前变换添加旋转,参数是顺时针弧度。

  • void scale(double sx, [double sy])

    向当前变换添加轴对齐比例,按水平方向的第一个参数和垂直方向的第二个参数进行缩放。

  • void translate(double dx, double dy)

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

  • void transform(Float64List matrix4)

    将当前变换乘以指定为列优先顺序的值列表的指定 4⨉4 变换矩阵。

画布状态:

  • void save()

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

  • void saveLayer(Rect bounds, Paint paint)

    在保存堆栈上保存当前变换和剪辑的副本,然后创建一个新组,后续调用将成为其中的一部分。当稍后弹出保存堆栈时,该组将被展平为一个图层并应用给定paint 的 Paint.colorFilter 和 Paint.blendMode。

  • void restore()

    如果有任何东西要弹出,则弹出当前保存堆栈。否则,什么都不做。

  • int getSaveCount()

    返回保存堆栈上的项目数,包括初始状态。这意味着它为干净的画布返回 1,并且每次调用 save 和 saveLayer 都会增加它,并且每次匹配的恢复调用都会减少它。

2.画笔:Paint

在 Canvas 上绘图时使用的样式的描述。

Canvas 上的大多数 API 都采用 Paint 对象来描述用于该操作的样式。

Paint的属性:

  • blendMode--绘制形状或合成图层时应用的混合模式
  • color--颜色
  • colorFilter--颜色滤镜
  • filterQuality--滤镜的质量控制器
  • imageFilter--图片滤镜
  • invertColors--颜色是否反转
  • isAntiAlias--是否对画布上绘制的线条和图像应用抗锯齿
  • maskFilter--遮罩滤镜(例如:模糊)
  • shader--着色器
  • strokeCap--线帽类型,在绘制的线条末端放置的饰面类型
  • strokeJoin--线接类型,在段之间的连接上放置的饰面类型
  • strokeMiterLimit--在段上绘制斜接的限制
  • strokeWidth--线宽
  • style--画笔样式
  • enableDithering--绘制图像时是否抖动输出

3.路径:Path

平面的一个复杂的一维子集。

一条路径由许多子路径和一个当前点组成

子路径由各种类型的线段组成,例如直线、圆弧或贝塞尔曲线。子路径可以是开放的或封闭的,并且可以自相交。

封闭的子路径根据当前的 fillType 包围平面的(可能不连续的)区域。

在当前点最初是在原点。在每次向子路径添加段的操作之后,当前点被更新到该段的末尾。

路径可以使用 Canvas.drawPath 在画布上绘制,并且可以使用 Canvas.clipPath 创建剪辑区域。

常用方法:

  • void addPath(Path path, Offset offset, {Float64List? matrix4})

    添加一个新的子路径,该子路径由给定的path偏移量组成offset。

  • void addRect ( Rect rect )

    添加一个由四行组成的新子路径,这些行勾勒出给定的矩形。

  • void addOval(Rect oval)

    添加一个新的子路径,该路径由一条曲线组成,该曲线形成填充给定矩形的椭圆。

  • void moveTo(double x, double y)

    在给定坐标处开始一个新的子路径。

  • void lineTo(double x, double y)

    从当前点到给定点添加一条直线段。

  • void conicTo(double x1, double y1, double x2, double y2, double w)

    使用控制点 (x1,y1) 和权重 w,添加一条从当前点弯曲到给定点 (x2,y2) 的贝塞尔曲线段。如果权重大于1,则曲线为双曲线;如果权重等于 1,则为抛物线;如果小于 1,则为椭圆。

  • void relativeMoveTo(double dx, double dy)

    在距当前点的给定偏移处开始新的子路径。

  • void relativeLineTo(double dx, double dy)

    从当前点到距当前点给定偏移的点添加一条直线段。

  • void relativeQuadraticBezierTo(double x1, double y1, double x2, double y2)

    使用距当前点偏移 (x1,y1) 处的控制点,添加从当前点弯曲到距当前点偏移 (x2,y2) 处的点的二次贝塞尔曲线段。

  • void relativeConicTo(double x1, double y1, double x2, double y2, double w)

    添加一条贝塞尔曲线段,该曲线从当前点弯曲到距当前点偏移量 (x2,y2) 处的点,使用距当前点偏移量 (x1,y1) 处的控制点和权重 w。如果权重大于1,则曲线为双曲线;如果权重等于 1,则为抛物线;如果小于 1,则为椭圆。

  • void close()

    关闭最后一个子路径,就像从当前点到子路径的第一个点画了一条直线。

  • void reset()

    清除所有子路径的Path对象,将其返回到创建时的相同状态。在当前点复位到原点。

4.颜色:Color

ARGB 格式的不可变 32 位颜色值。

考虑一下 Flutter 标志的浅蓝绿色。它是完全不透明的,红色通道值为 0x42 (66),绿色通道值为 0xA5 (165),蓝色通道值为 0xF5 (245)。在颜色值的常见散列语法中,它将被描述为#42A5F5.

以下是它可以构建的一些方法:

Color c = const Color(0xFF42A5F5);
Color c = const Color.fromARGB(0xFF, 0x42, 0xA5, 0xF5);
Color c = const Color.fromARGB(255, 66, 165, 245);
Color c = const Color.fromRGBO(66, 165, 245, 1.0);

如果您遇到的问题Color是,您的颜色似乎没有上色,请检查以确保您指定的是完整的 8 个十六进制数字。如果仅指定6位,则假定前两位数字为零,这意味着完全透明:

Color c1 = const Color(0xFFFFFF); // fully transparent white (invisible)
Color c2 = const Color(0xFFFFFFFF); // fully opaque white (visible)

也可以看看:

Colors,它定义了 Material Design 规范中的颜色。

Color 类最常用的方式就是直接使用构造函数进行一个颜色值的创建,除此之外,还有比较复杂的应用。如:通道的变换、混合模式的应用、着色器、过滤器等多种高级用法,后面用到时再进行介绍。

简单应用

画点和线

class Paper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      alignment: Alignment.center,
      child: CustomPaint(
        // 使用CustomPaint 背景画板
        painter: MyPainter(),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 创建画笔
    final Paint paintLine = Paint()
      ..color = Colors.black
      ..strokeWidth = 3;
    // 绘制线
    canvas.drawLine(Offset(-100, 0), Offset(100, 0), paintLine);
    canvas.drawLine(Offset(0, 100), Offset(0, -100), paintLine);
    // 创建画笔
    final Paint paintPoint = Paint()..color = Colors.red;
    // 绘制圆点
    canvas.drawCircle(Offset(0, 0), 10, paintPoint);
  }

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

效果:

效果图

使用 Path 画一个五角星

class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final Paint paint = Paint()
        ..color = Colors.red
        ..strokeWidth = 3
        ..style = PaintingStyle.stroke;
    Path path = Path();
    // 路径移动至起点
    path.moveTo(0,-100);
    // 连接各个端点
    path.lineTo(59,81);
    path.lineTo(-95,-31);
    path.lineTo(95,-31);
    path.lineTo(-59,81);
    path.lineTo(0,-100);
    // 绘制路径
    canvas.drawPath(path, paint);
  }

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

效果图

通过给 Text 的 forground 添加一个 Paint 来实现文字描边的效果

@override
Widget build(BuildContext context) {
  return Container(
        color: Colors.white,
        alignment: Alignment.center,
        child: Directionality(
          textDirection: TextDirection.ltr,
          child: Stack(
            children: <Widget>[
              // 边框文字
              Text(
                'BillionBottle',
                style: TextStyle(
                  fontSize: 100,
                  fontWeight: FontWeight.bold,
                  fontStyle: FontStyle.italic,
                  // 通过Paint实现文字描边
                  foreground: Paint()
                    ..style = PaintingStyle.stroke
                    ..strokeWidth = 6
                    ..color = Colors.blue[700],
                ),
              ),
              // 填充文字,两层绘制叠加,完成描边效果
              Text(
                'BillionBottle',
                style: TextStyle(
                  fontSize: 100,
                  fontWeight: FontWeight.bold,
                  fontStyle: FontStyle.italic,
                  color: Colors.white,
                ),
              ),
            ],
          ),
        ),
      );
}

效果: 效果图

使用图片着色器 ImageShader 给描边加上效果

图片着色器功能强大。它能让画笔自带图片进行绘制,和 Photoshop 中的笔刷有些相像。构造方法中需要传入ui.Image对象、水平、竖直排列模式及变换矩阵 Float64List。

class Paper extends StatefulWidget {
  @override
  _PaperState createState() => _PaperState();
}

class _PaperState extends State<Paper> with SingleTickerProviderStateMixin {
  ui.Image _image;

  @override
  void initState() {
    super.initState();
    // 资源图片需要提前加载
    loadImage().then((value) {
      setState(() {
        _image = value;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      alignment: Alignment.center,
      child: Directionality(
        textDirection: TextDirection.ltr,
        child: Stack(
          children: <Widget>[
            // 边框文字
            Text(
              'BillionBottle',
              style: TextStyle(
                fontSize: 120,
                fontWeight: FontWeight.bold,
                fontStyle: FontStyle.italic,
                foreground: Paint()
                  ..style = PaintingStyle.stroke
                  ..strokeWidth = 14
                  ..color = Colors.black
                  // 使用图片着色器将资源图片绘制到边框文字的前景色上
                  ..shader = ImageShader(
                      _image,
                      TileMode.repeated,
                      TileMode.repeated,
                      Float64List.fromList([
                        1, 0, 0, 0,
                        0, 1, 0, 0,
                        0, 0, 1, 0,
                        0, 0, 0, 1,
                      ])),
              ),
            ),
            // 填充文字,两层绘制叠加,完成描边效果
            Text(
              'BillionBottle',
              style: TextStyle(
                fontSize: 120,
                fontWeight: FontWeight.bold,
                fontStyle: FontStyle.italic,
                color: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// 读取 assets 中的图片
Future<ui.Image> loadImage() async {
  ByteData byteData = await rootBundle.load('assets/images/test.jpg');
  ui.Codec codec = await ui.instantiateImageCodec(byteData.buffer.asUint8List());
  ui.FrameInfo frameInfo = await codec.getNextFrame();
  return frameInfo.image;
}

资源图: 效果图

效果: 效果图

使用绘制知识实现一个波浪动效

波浪加载特效在日常开发中比较常见,如:

效果图

在客户端实现一个波浪效果,有多种多样的方式,除了gif的加载、动画等方式,还可以通过绘制贝塞尔曲线来实现。

相信很多同学都知道“贝塞尔曲线”这个词,我们在很多地方都能经常看到。但是,可能并不是每位同学都清楚地知道,到底什么是“贝塞尔曲线”,又是什么特点让它有这么高的知名度。

贝塞尔曲线的数学基础是早在 1912 年就广为人知的伯恩斯坦多项式。但直到 1959 年,当时就职于雪铁龙的法国数学家 Paul de Casteljau 才开始对它进行图形化应用的尝试,并提出了一种数值稳定的 de Casteljau 算法。然而贝塞尔曲线的得名,却是由于 1962 年另一位就职于雷诺的法国工程师 Pierre Bézier 的广泛宣传。他使用这种只需要很少的控制点就能够生成复杂平滑曲线的方法,来辅助汽车车体的工业设计。

正是因为控制简便却具有极强的描述能力,贝塞尔曲线在工业设计领域迅速得到了广泛的应用。不仅如此,在计算机图形学领域,尤其是矢量图形学,贝塞尔曲线也占有重要的地位。今天我们最常见的一些矢量绘图软件,如 Flash、Illustrator、CorelDraw 等,无一例外都提供了绘制贝塞尔曲线的功能。甚至像 Photoshop 这样的位图编辑软件,也把贝塞尔曲线作为仅有的矢量绘制工具(钢笔工具)包含其中。

贝塞尔曲线的具体公式,这里不做讲解,我们直接在 Flutter 中来实现一条简单的贝塞尔曲线

Path path = Path();
// 路径对象
Paint paint = Paint()// 画笔对象
  ..color = Colors.orange// 颜色
  ..style = PaintingStyle.stroke// 线条
  ..strokeWidth = 2;// 线宽

// 给path设置由当前点(0,0)控制点(0,80)终点(100,100)三个点组成的二次贝塞尔曲线
path.quadraticBezierTo(0,80,100,100);
// 绘制
canvas.drawPath(path, paint);

效果(为了方便查看点位,背景绘制了坐标系):

效果图

绘制波浪

  // 波长
  final double waveWidth = 80; 
  // 振幅
  final double waveHeight = 40; 

  @override
  void paint(Canvas canvas, Size size) {
	// 画布起始点移动到屏幕中心点
    canvas.translate(ScreenUtils.screenWidth / 2, ScreenUtils.screenHeight / 2);
    // 设置画笔
	Paint paint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill// 这里改为填充模式,不再是线条
      ..strokeWidth = 2;

	// 路径
    Path path = Path();
    // 绘制贝塞尔曲线 相对位移方法
	// 使用距当前点偏移 (x1,y1) 处的控制点,添加从当前点弯曲到距当前点偏移 (x2,y2) 处的点的二次贝塞尔曲线段。
	// 对应的点位为起始点(0,0)控制点(40,-80)终点(80,0)三个点,此时当前点为(80,0)
	path.relativeQuadraticBezierTo(waveWidth/2, -waveHeight*2, waveWidth, 0);
	// 第二条曲线,对应的点位为起始点(80,0)控制点(40,80)终点(160,0)三个点
	path.relativeQuadraticBezierTo(waveWidth/2, waveHeight*2, waveWidth, 0);
    // 绘制
	canvas.drawPath(path, paint);
  }

效果:

效果图

绘制连续的波,经过指定边框内:

    // 设置画笔
    Paint paint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.fill
      ..strokeWidth = 2;
    // 保存画布状态
    canvas.save();
    Path path = Path();
    // 第一组波浪
    path.relativeQuadraticBezierTo(waveWidth/2, -waveHeight*2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth/2, waveHeight*2, waveWidth, 0);
    // 第二组波浪
    path.relativeQuadraticBezierTo(waveWidth/2, -waveHeight*2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth/2, waveHeight*2, waveWidth, 0);
    // 当前结束点(x,y)与(x+0,y+100)进行连接
    path.relativeLineTo(0, 100);
    // 当前结束点(x,y)与(x-80*2*2,y)进行连接
    path.relativeLineTo(-waveWidth*2 * 2.0, 0);
    // 路径关闭
    path.close();
    // 绘制
    canvas.drawPath(path, paint);
    // 恢复画布
    canvas.restore();
    // 矩形边框
    Path pathRect = Path();
    pathRect.addRect(Rect.fromLTWH(160,-100, 160, 200));
    // 设置矩形边框画笔
    Paint paintCircle = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    // 绘制矩形
    canvas.drawPath(pathRect, paintCircle);

效果:

效果图

接下来通过添加动画和裁剪就可以展示初级的波浪效果了:

Flutter中的动画本质就是在数据不断变化时,让画布随之重绘,达到连续的视觉效果。根据这个原理,我们通过对波浪X坐标的不断重复右移,然后进行重绘,使之产生运动的效果。

这里我们主要使用 AnimationController 这个类

AnimationController 的构造器中需要传入 TickerProvider 对象

可以将 State 对象混入 SingleTickerProviderStateMixin 来成为该对象

lowerBound 是运动的下限,upperBound 是运动的上限,duration 是运动时长。

class _PaperState extends State<Paper2> with SingleTickerProviderStateMixin {
  // 动画控制器 x轴
  AnimationController _controllerX; 

  @override
  void initState() {
    super.initState();
	// 设置动画控制器参数,运动时长为600ms
    _controllerX = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this,
    )..repeat();// 重复模式
  }

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint(
        painter: MyPainter(_controllerX),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  // 波长
  final double waveWidth = 80; 
  // 振幅
  final double waveHeight = 40; 
  //x轴动画  
  final Animation<double> repaintX;

  const MyPainter(this.repaintX):super(repaint: repaintX);

  @override
  void paint(Canvas canvas, Size size) {
  	// 画布移动屏幕中央
    canvas.translate(ScreenUtils.screenWidth / 2, ScreenUtils.screenHeight / 2);
	// 画布保存状态
    canvas.save();
	// 根据进度修改画布x轴的位置,然后不断重绘进行动效的实现
    canvas.translate(2 * waveWidth * repaintX.value, 0);
    // 保存画布状态
    Path path = Path();
    // 第一组波浪
    path.relativeQuadraticBezierTo(waveWidth/2, -waveHeight*2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth/2, waveHeight*2, waveWidth, 0);
    // 第二组波浪
    path.relativeQuadraticBezierTo(waveWidth/2, -waveHeight*2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth/2, waveHeight*2, waveWidth, 0);
    // 当前结束点(x,y)与(x+0,y+60)进行连接
    path.relativeLineTo(0, 60);
    // 当前结束点(x,y)与(x-80*2*2,y)进行连接
    path.relativeLineTo(-waveWidth*2 * 2.0, 0);
    // 路径关闭
    path.close();
    // 绘制
    canvas.drawPath(path, paint);
    // 恢复画布
    canvas.restore();
    // 矩形边框
    Path pathRect = Path();
    pathRect.addRect(Rect.fromLTWH(160,-140, 160, 200));
    // 设置矩形边框画笔
    Paint paintCircle = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    // 绘制矩形
    canvas.drawPath(pathRect, paintCircle);
  }

  // 动画进度未完成就进行重绘
  @override
  bool shouldRepaint(MyPainter oldDelegate) => oldDelegate.repaintX != repaintX;
}

效果:

效果图

添加裁剪,让波浪只在指定区域内显示。

// 添加裁剪
canvas.clipRect((Rect.fromCenter(center: Offset( waveWidth*3, -60),width: waveWidth*2,height: 200.0)));

效果:

效果图

如果想让y轴方向也进行移动,我们可以新建一个y轴方响的动画控制器

class _PaperState extends State<Paper2> with SingleTickerProviderStateMixin {
  // 动画控制器 x轴
  AnimationController _controllerX; 
  // 动画控制器 y轴
  AnimationController _controllerY; 

  @override
  void initState() {
    super.initState();
	// 设置x轴动画控制器参数,运动时长为600ms
    _controllerX = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this,
    )..repeat();//重复模式
	// 设置y轴动画控制器参数,运动时长为5000ms
    _controllerY = AnimationController(
      duration: const Duration(milliseconds: 5000),
      vsync: this,
    )..repeat();//重复模式
  }

  @override
  void dispose() {
    _controllerX.dispose();
	_controllerY.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint(
        painter: MyPainter(_controllerX,_controllerY),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  // 波长
  final double waveWidth = 80;
  // 振幅
  final double waveHeight = 40; 
  // x轴动画
  final Animation<double> repaintX;
  // y轴动画
  final Animation<double> repaintY;

  const MyPainter(this.repaintX,this.repaintY);

  @override
  void paint(Canvas canvas, Size size) {
	...
	// 根据进度修改画布x轴和y轴的位置,然后不断重绘进行动效的实现
    canvas.translate(2 * waveWidth * repaintX.value, -200*repaintY.value);
	...
  }

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

这个时候运行会遇到一个错误

效果图

意思是当你的 state with SingleTickerProviderStateMixin 的状态下只能使用单个的动画控制器,具体 TickerProviderStateMixin 的原理这里不深究,我们根据提示做一些修改,并完善波浪效果。

class Paper2 extends StatefulWidget {
  @override
  _PaperState createState() => _PaperState();
}

class _PaperState extends State<Paper2> with TickerProviderStateMixin {
  // 动画控制器 x轴
  AnimationController _controllerX; 
  // 动画控制器 y轴
  AnimationController _controllerY; 

  @override
  void initState() {
    super.initState();
    _controllerX = AnimationController(
      duration: const Duration(milliseconds: 1000),
      vsync: this,
    )..repeat();
    _controllerY = AnimationController(
      duration: const Duration(milliseconds: 5000),
      vsync: this,
    )..repeat();
  }

  @override
  void dispose() {
    super.dispose();
    _controllerX.dispose();
    _controllerY.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint(
        // 使用CurveTween动画曲线来控制x轴运动速率
        painter: MyPainter(CurveTween(curve: Curves.linear).animate(_controllerX), _controllerY),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  // 波长
  final double waveWidth = 80;
  // 振幅
  final double waveHeight = 10; 
  final Animation<double> repaintX;
  final Animation<double> repaintY;

  const MyPainter(this.repaintX, this.repaintY) : super(repaint: repaintY);

  @override
  void paint(Canvas canvas, Size size) {
    // 调整画布起始为屏幕中心
    canvas.translate(ScreenUtils.screenWidth / 2, ScreenUtils.screenHeight / 2);
    // 添加裁剪
    canvas.clipRect(
        (Rect.fromCenter(center: Offset(waveWidth, -60), width: waveWidth * 2, height: 200.0)));

    // 设置画笔
    Paint paint = Paint()
      ..style = PaintingStyle.fill
      ..strokeWidth = 2;

    Path path = Path();
    // 添加3组波浪路径
    path.relativeQuadraticBezierTo(waveWidth / 2, -waveHeight * 2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth / 2, waveHeight * 2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth / 2, -waveHeight * 2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth / 2, waveHeight * 2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth / 2, -waveHeight * 2, waveWidth, 0);
    path.relativeQuadraticBezierTo(waveWidth / 2, waveHeight * 2, waveWidth, 0);
    // 当前结束点(x,y)与(x+0,y+300)进行连接
    path.relativeLineTo(0, 300);
    // 当前结束点(x,y)与(x-80*3*2,y)进行连接
    path.relativeLineTo(-waveWidth * 3 * 2.0, 0);

    canvas.save();
    // 绘制背景波浪 速度比前景快一倍,让波浪更有动感
    canvas.translate(-4 * waveWidth * repaintX.value, -220 * repaintY.value);
    canvas.drawPath(path, paint..color = Colors.orange.withAlpha(88));
    canvas.restore();

    canvas.save();
    // 绘制前景波浪
    canvas.translate(-2 * waveWidth * repaintX.value, -220 * repaintY.value);
    canvas.drawPath(path, paint..color = Colors.red);
    canvas.restore();

    // 矩形边框
    Path pathRect = Path();
    pathRect.addRect(Rect.fromLTWH(0, -160, 160, 200));
    // 设置矩形边框画笔
    Paint paintCircle = Paint()
      ..color = Colors.black
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    // 绘制矩形
    canvas.drawPath(pathRect, paintCircle);
  }

  // y轴动画没走完就继续重绘
  @override
  bool shouldRepaint(MyPainter oldDelegate) => oldDelegate.repaintY != repaintY;
}

效果:

效果图

这样就比较符合我们印象中的波浪 loading 效果了,我们可以把他放进不同的形状里,实现不同的加载 loading 动画,接下来我们将其应用到我们的 Logo 里面。

@override
void paint(Canvas canvas, Size size) {
	// 调整位置
    canvas.translate(0, ScreenUtils.screenHeight / 2 + 100);
    // 保存画布状态
    canvas.save();
	// 调整位置
    canvas.translate(0, -320);
	// 绘制红色文字放置底层
    _drawTextWithParagraph(canvas, TextAlign.center, Colors.red);
	// 恢复画布状态
    canvas.restore();

    // 矩形边框
    Path pathRect = Path();
    pathRect.addRect((Rect.fromCenter(
        center: Offset(waveWidth * 5, -100), width: waveWidth * 8.5, height: 140.0)));

    // 设置画笔
    Paint paint = Paint()
      ..style = PaintingStyle.fill
      ..strokeWidth = 2;

    Path path = Path();
    // 制作背景波浪路径 速度比前景快一倍,让波浪更有动感
    path.moveTo(-4 * waveWidth * repaintX.value, -220 * repaintY.value);
    // 添加波浪路径
    for (double i = -waveWidth; i < ScreenUtils.screenWidth; i += waveWidth) {
      path.relativeQuadraticBezierTo(waveWidth / 4, -waveHeight, waveWidth / 2, 0);
      path.relativeQuadraticBezierTo(waveWidth / 4, waveHeight, waveWidth / 2, 0);
    }
    path.relativeLineTo(0, 500);
    path.relativeLineTo(-waveWidth * 6 * 2.0, 0);
	// 合并路径,取波浪和矩形边框的交集
    var combine = Path.combine(PathOperation.intersect, pathRect, path);
	// 绘制
    canvas.drawPath(combine, paint..color = Colors.orange.withAlpha(88));
    // 路径还原
	path.reset();
    // 制作前景波浪路径
    path.moveTo(-2 * waveWidth * repaintX.value, -220 * repaintY.value);
    // 添加波浪路径
    for (double i = -waveWidth; i < ScreenUtils.screenWidth; i += waveWidth) {
      path.relativeQuadraticBezierTo(waveWidth / 4, -waveHeight, waveWidth / 2, 0);
      path.relativeQuadraticBezierTo(waveWidth / 4, waveHeight, waveWidth / 2, 0);
    }
    path.relativeLineTo(0, 500);
    path.relativeLineTo(-waveWidth * 6 * 2.0, 0);
    path.close();
	// 合并路径,取波浪和矩形边框的交集
    var combine1 = Path.combine(PathOperation.intersect, pathRect, path);
	// 绘制
    canvas.drawPath(combine1, paint..color = Colors.red);

	// 画布剪切
    canvas.clipPath(combine1);
	// 调整位置
    canvas.translate(0, -320);
	// 绘制与波浪交集的白色文字
    _drawTextWithParagraph(canvas, TextAlign.center, Colors.white);
}

// 绘制文字
void _drawTextWithParagraph(Canvas canvas, TextAlign textAlign, Color colors) {
	var builder = ui.ParagraphBuilder(ui.ParagraphStyle(
      textAlign: textAlign,
      fontSize: 40,
      textDirection: TextDirection.ltr,
      maxLines: 1,
    ));
    builder.pushStyle(
      ui.TextStyle(
        fontSize: 120,
        fontWeight: FontWeight.bold,
        fontStyle: FontStyle.italic,
        color: colors,
      ),
    );
    builder.addText("BillionBottle");
    ui.Paragraph paragraph = builder.build();
    paragraph.layout(ui.ParagraphConstraints(width: 800));
    canvas.drawParagraph(paragraph, Offset(0, 150));
}

效果:

效果图

总结:

本文主要介绍了在 Flutter 绘制中,使用画笔、画笔、路径等元素,通过位移、变换、裁剪以及滤镜效果来实现简单的效果。

除了本文的简单展示,通过更加深入的了解和学习,Flutter 绘制还可以创造出更多的可能性。虽然各种社区里已经有很多比较完善的开源组件,但亲手造一次轮子能让我们对相关知识有更加清晰的认知。很多炫酷的效果正是从第一个点、第一条线绘制开始的,九层之台,起于累土,与君共勉。