Flutter 笔记 | Flutter 自定义组件

Flutter 自定义组件的几种方式

当Flutter提供的现有组件无法满足我们的需求,或者我们为了共享代码需要封装一些通用组件,这时我们就需要自定义组件。在Flutter中自定义组件有三种方式:通过组合其他组件自绘实现RenderObject

1. 组合多个Widget

这种方式是通过拼装多个组件来组合成一个新的组件。例如我们之前介绍的Container就是一个组合组件,它是由DecoratedBox、ConstrainedBox、Transform、Padding、Align等组件组成。

在Flutter中,组合的思想非常重要,Flutter提供了非常多的基础组件,而我们的界面开发其实就是按照需要组合这些组件来实现各种不同的布局而已。

2. 通过CustomPaint自绘

如果遇到无法通过现有的组件来实现需要的UI时,我们可以通过自绘组件的方式来实现,例如我们需要一个颜色渐变的圆形进度条,而Flutter提供的CircularProgressIndicator并不支持在显示精确进度时对进度条应用渐变色(其valueColor 属性只支持执行旋转动画时变化Indicator的颜色),这时最好的方法就是通过自定义组件来绘制出我们期望的外观。我们可以通过Flutter中提供的CustomPaintCanvas来实现UI自绘。

3. 通过RenderObject自绘

Flutter提供的自身具有UI外观的组件,如文本Text、Image都是通过相应的RenderObject渲染出来的,如Text是由RenderParagraph渲染;而Image是由RenderImage渲染。RenderObject是一个抽象类,它定义了一个抽象方法paint(...)

void paint(PaintingContext context, Offset offset)

PaintingContext代表组件的绘制上下文,通过PaintingContext.canvas可以获得Canvas,而绘制逻辑主要是通过Canvas API来实现。子类需要重写此方法以实现自身的绘制逻辑,如RenderParagraph需要实现文本绘制逻辑,而RenderImage需要实现图片绘制逻辑。

可以发现,RenderObject中最终也是通过Canvas API来绘制的,那么通过实现RenderObject的方式和上面介绍的通过CustomPaintCanvas自绘的方式有什么区别?其实答案很简单,CustomPaint只是为了方便开发者封装的一个代理类,它直接继承自SingleChildRenderObjectWidget,通过RenderCustomPaintpaint方法将Canvas和画笔Painter(需要开发者实现,后面介绍)连接起来实现了最终的绘制(绘制逻辑在Painter中)。

总结“组合”是自定义组件最简单的方法,在任何需要自定义组件的场景下,我们都应该优先考虑是否能够通过组合来实现。而通过CustomPaintRenderObject自绘的方式本质上是一样的,都需要开发者调用Canvas API手动去绘制UI,优点是强大灵活,理论上可以实现任何外观的UI,而缺点是必须了解Canvas API细节,并且得自己去实现绘制逻辑。

组合现有组件

实例:自定义渐变按钮

Flutter Material组件库中的按钮默认不支持渐变背景,为了实现渐变背景按钮,我们自定义一个GradientButton组件,它需要支持以下功能:

  1. 背景支持渐变色
  2. 手指按下时有涟漪效果
  3. 可以支持圆角

我们先来看看最终要实现的效果:

Flutter 笔记 | Flutter 自定义组件_第1张图片

DecoratedBox可以支持背景色渐变和圆角,InkWell在手指按下有涟漪效果,所以我们可以通过组合DecoratedBoxInkWell来实现GradientButton,代码如下:

import 'package:flutter/material.dart';

///组合方式定义一个渐变色按钮
///使用DecoratedBox、Padding、Center、InkWell等组合而成
class GradientButton extends StatelessWidget {
  const GradientButton({
    Key? key,
    this.colors,
    this.width,
    this.height,
    this.onPressed,
    this.borderRadius,
    required this.child,
  }): super(key: key);

  // 渐变色数组
  final List<Color>? colors;

  // 按钮宽高
  final double? width;
  final double? height;

  final Widget child;
  final BorderRadius? borderRadius;

  //点击回调
  final GestureTapCallback? onPressed;

  
  Widget build(BuildContext context) {
    ThemeData theme = Theme.of(context);

    //确保colors数组不空
    List<Color> _colors = colors ?? [theme.primaryColor, theme.primaryColorDark];

    return DecoratedBox(
      decoration: BoxDecoration(
        gradient: LinearGradient(colors: _colors), //渐变色
        borderRadius: borderRadius, //圆角
      ),
      child: Material(
        type: MaterialType.transparency,
        child: InkWell(
          splashColor: _colors.last,//水波颜色
          highlightColor: Colors.transparent,//高亮色
          borderRadius: borderRadius,//圆角
          onTap: onPressed,
          child: ConstrainedBox(
            constraints: BoxConstraints.tightFor(height: height, width: width),
            child: Center(
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: DefaultTextStyle(
                  style: const TextStyle(fontWeight: FontWeight.bold),
                  child: child,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

使用GradientButton:

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

  
  State createState() => _GradientButtonRouteState();
}

class _GradientButtonRouteState extends State<GradientButtonRoute> {
  
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text("组合方式自定义组件"),
        ),
        body: Column(
          children: <Widget>[
            GradientButton(
              colors: const [Colors.orange, Colors.red],
              height: 50.0,
              onPressed: onTap,
              child: const Text("Submit"),
            ),
            GradientButton(
              width: 300,
              height: 50.0,
              colors: [Colors.lightGreen, Colors.green[700]!],
              onPressed: onTap,
              child: const Text("Save"),
            ),
            Container(
              margin: const EdgeInsets.only(top: 20.0, left: 30.0, right: 30),
              child: GradientButton(
                width: 300,
                height: 50.0,
                colors: [Colors.lightBlue[300]!, Colors.blueAccent],
                borderRadius: const BorderRadius.all(Radius.circular(5)),
                onPressed: onTap,
                child: const Text("Delete"),
              ),
            ),
          ],
        ));
  }

  onTap() {
    print("button click");
  }
}

通过组合的方式定义组件和我们之前写界面并无差异,不过在抽离出单独的组件时我们要考虑代码规范性,如必要参数要用required关键词标注,对于可选参数在特定场景需要判空或设置默认值等。这是由于使用者大多时候可能不了解组件的内部细节,所以为了保证代码健壮性,我们需要在用户错误地使用组件时能够兼容或报错提示(使用assert断言函数)。

组合实例:TurnBox

我们之前已经介绍过RotatedBox,它可以旋转子组件,但是它有两个缺点:一是只能将其子节点以90度的倍数旋转;二是当旋转的角度发生变化时,旋转角度更新过程没有动画。

下面我们将实现一个TurnBox组件,它不仅可以以任意角度来旋转其子节点,而且可以在角度发生变化时执行一个动画以过渡到新状态,同时,我们可以手动指定动画速度。

TurnBox的完整代码如下:

import 'package:flutter/material.dart';
 
class TurnBox extends StatefulWidget {
  const TurnBox({
    Key? key,
    this.turns = .0, //旋转的“圈”数,一圈为360度,如0.25圈即90度
    this.duration = 200, //过渡动画执行的总时长
    required this.child
  }) :super(key: key);

  final double turns;
  final int duration;
  final Widget child;

  
  State createState() => _TurnBoxState();
}

class _TurnBoxState extends State<TurnBox> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
        vsync: this,
        lowerBound: -double.infinity,
        upperBound: double.infinity
    );
    _controller.value = widget.turns;
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    //旋转动画
    return RotationTransition(
      turns: _controller,
      child: widget.child,
    );
  }
 
  
  void didUpdateWidget(TurnBox oldWidget) {
    super.didUpdateWidget(oldWidget);
    //旋转角度发生变化时执行过渡动画
    if (oldWidget.turns != widget.turns) {
      _controller.animateTo(widget.turns,
        duration: Duration(milliseconds: widget.duration),
        curve: Curves.easeOut,
      );
    }
  }
}

上面代码中:

  1. 我们是通过组合RotationTransitionchild来实现的旋转效果。
  2. didUpdateWidget中,我们判断要旋转的角度是否发生了变化,如果变了,则执行一个过渡动画。

下面我们测试一下TurnBox的功能,测试代码如下:

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

  
  State createState() => _TurnBoxRouteState();
}

class _TurnBoxRouteState extends State<TurnBoxRoute> {
  double _turns = .0;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("任意角度旋转子组件"),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            TurnBox(
              turns: _turns,
              duration: 500,
              child: const Icon(
                Icons.refresh,
                size: 50,
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(50),
              child: TurnBox(
                turns: _turns,
                duration: 1000,
                child: Image.asset(
                  "images/ic_timg.jpg",
                  width: 100,
                ),
              ),
            ),
            ElevatedButton(
              child: const Text("顺时针旋转1/5圈"),
              onPressed: () {
                setState(() => _turns += .2);
              },
            ),
            ElevatedButton(
              child: const Text("逆时针旋转1/5圈"),
              onPressed: () {
                setState(() => _turns -= .2);
              },
            )
          ],
        ),
      ),
    );
  }
}

效果:

Flutter 笔记 | Flutter 自定义组件_第2张图片
当我们点击旋转按钮时,两个图标的旋转都会旋转1/5圈,但旋转的速度是不同的。

实际上本示例只组合了RotationTransition一个组件,它是一个最简的组合类组件示例。另外,如果我们封装的是StatefulWidget,那么一定要注意在组件更新时是否需要同步状态。比如我们要封装一个富文本展示组件MyRichText ,它可以自动处理url链接,定义如下:

class MyRichText extends StatefulWidget {
  MyRichText({
    Key key,
    this.text, // 文本字符串
    this.linkStyle, // url链接样式
  }) : super(key: key);

  final String text;
  final TextStyle linkStyle;

  
  _MyRichTextState createState() => _MyRichTextState();
}

接下来我们在_MyRichTextState中要实现的功能有两个:

  1. 解析文本字符串“text”,生成TextSpan缓存起来;
  2. build中返回最终的富文本样式;

_MyRichTextState 实现的代码大致如下:

class _MyRichTextState extends State<MyRichText> {

  TextSpan _textSpan;

  
  Widget build(BuildContext context) {
    return RichText(
      text: _textSpan,
    );
  }

  TextSpan parseText(String text) {
    // 耗时操作:解析文本字符串,构建出TextSpan。
    // 省略具体实现。
  }

  
  void initState() {
    _textSpan = parseText(widget.text)
    super.initState();
  }
}

由于解析文本字符串,构建出TextSpan是一个耗时操作,为了不在每次build的时候都解析一次,所以我们在initState中对解析的结果进行了缓存,然后再build中直接使用解析的结果_textSpan

这看起来很不错,但是上面的代码有一个严重的问题,就是父组件传入的text发生变化时(组件树结构不变),那么MyRichText显示的内容不会更新,原因就是initState只会在State创建时被调用,所以在text发生变化时,parseText没有重新执行,导致_textSpan任然是旧的解析值。

要解决这个问题也很简单,我们只需添加一个didUpdateWidget回调,然后再里面重新调用parseText即可:


void didUpdateWidget(MyRichText oldWidget) {
  if (widget.text != oldWidget.text) {
    _textSpan = parseText(widget.text);
  }
  super.didUpdateWidget(oldWidget);
}

有些开发者可能会觉得这个点也很简单,是的,的确很简单,之所以要在这里反复强调是因为这个点在实际开发中很容易被忽略,它虽然简单,但却很重要。总之,当我们在State中会缓存某些依赖Widget参数的数据时,一定要注意在组件更新时是否需要同步状态。

再次强调,自定义组件的一个重要点,就是在didUpdateWidget中根据新旧组件的状态值比较决定是否要重新构建UI。

CustomPaint 与 Canvas

对于一些复杂或不规则的UI,我们可能无法通过组合其他组件的方式来实现,比如我们需要一个正六边形、一个渐变的圆形进度条、一个棋盘等。当然,有时候我们可以使用图片来实现,但在一些需要动态交互的场景静态图片也是实现不了的,比如要实现一个手写输入面板,这时,我们就需要来自己绘制UI外观。

几乎所有的UI系统都会提供一个自绘UI的接口,这个接口通常会提供一块2D画布CanvasCanvas内部封装了一些基本绘制的API,开发者可以通过Canvas绘制各种自定义图形。在Flutter中,提供了一个CustomPaint 组件,它可以结合画笔CustomPainter来实现自定义图形绘制。

CustomPaint

我们看看CustomPaint构造函数:

CustomPaint({
  Key key,
  this.painter, 
  this.foregroundPainter,
  this.size = Size.zero, 
  this.isComplex = false, 
  this.willChange = false, 
  Widget child, //子节点,可以为空
})
  • painter: 背景画笔,会显示在子节点后面;
  • foregroundPainter: 前景画笔,会显示在子节点前面
  • size:当childnull时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现。
  • isComplex:是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。
  • willChange:和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。

可以看到,绘制时我们需要提供前景或背景画笔,两者也可以同时提供。我们的画笔需要继承CustomPainter类,我们在画笔类中实现真正的绘制逻辑。

1. 绘制边界 RepaintBoundary

如果CustomPaint有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary组件中,这样会在绘制时就会创建一个新的绘制层(Layer),其子组件将在新的Layer上绘制,而父组件将在原来Layer上绘制,也就是说RepaintBoundary 子组件的绘制将独立于父组件的绘制,RepaintBoundary会隔离其子节点和CustomPaint本身的绘制边界。

示例如下:

CustomPaint(
  size: Size(300, 300), //指定画布大小
  painter: MyPainter(),
  child: RepaintBoundary(
  		child: ...
  	)
  ), 
)

2. CustomPainter 与 Canvas

CustomPainter是一个抽象类,我们自定义的画笔需要实现CustomPainterCustomPainter 中定义了一个抽象方法 paint

void paint(Canvas canvas, Size size);

paint有两个参数:

  • canvas:一个画布,包括各种绘制方法
  • size:当前绘制区域大小。

下面是 Canvas 中常用的方法:

API名称 功能
drawLine 画线
drawPoint 画点
drawPath 画路径
drawImage 画图像
drawRect 画矩形
drawCircle 画圆
drawOval 画椭圆
drawArc 画圆弧

3. 画笔Paint

现在画布有了,我们最后还缺一个画笔,Flutter提供了Paint类来实现画笔。在Paint中,我们可以配置画笔的各种属性如粗细、颜色、样式等。如:

var paint = Paint() //创建一个画笔并配置其属性
  ..isAntiAlias = true //是否抗锯齿
  ..style = PaintingStyle.fill //画笔样式:填充
  ..color=Color(0x77cdb175);//画笔颜色

更多的配置属性可以参考Paint类定义。

实例:五子棋/盘

1. 绘制棋盘、棋子

下面我们通过一个五子棋游戏中棋盘和棋子的绘制来演示自绘UI的过程,首先我们看一下我们的目标效果,如图所示:
Flutter 笔记 | Flutter 自定义组件_第3张图片
代码:

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

class CustomPaintRoute extends StatelessWidget {
  const CustomPaintRoute({Key? key}) : super(key: key);
  
  
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        size: Size(300, 300), //指定画布大小
        painter: MyPainter(),
      ),
    );
  }
}

class MyPainter extends CustomPainter {

  
  void paint(Canvas canvas, Size size) {
    print('paint');
    var rect = Offset.zero & size;
    //画棋盘
    drawChessboard(canvas, rect);
    //画棋子
    drawPieces(canvas, rect);
  }

  // 返回false, 后面介绍
  
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

我们先实现棋盘绘制:

void drawChessboard(Canvas canvas, Rect rect) {
  //棋盘背景
  var paint = Paint()
    ..isAntiAlias = true
    ..style = PaintingStyle.fill //填充
    ..color = Color(0xFFDCC48C);
  canvas.drawRect(rect, paint);

  //画棋盘网格
  paint
    ..style = PaintingStyle.stroke //线
    ..color = Colors.black38
    ..strokeWidth = 1.0;

  //画横线
  for (int i = 0; i <= 15; ++i) {
    double dy = rect.top + rect.height / 15 * i;
    canvas.drawLine(Offset(rect.left, dy), Offset(rect.right, dy), paint);
  }

  for (int i = 0; i <= 15; ++i) {
    double dx = rect.left + rect.width / 15 * i;
    canvas.drawLine(Offset(dx, rect.top), Offset(dx, rect.bottom), paint);
  }
}

再实现棋子绘制:

//画棋子
void drawPieces(Canvas canvas, Rect rect) {
  double eWidth = rect.width / 15;
  double eHeight = rect.height / 15;
  //画一个黑子
  var paint = Paint()
    ..style = PaintingStyle.fill
    ..color = Colors.black;
  //画一个黑子
  canvas.drawCircle(
    Offset(rect.center.dx - eWidth / 2, rect.center.dy - eHeight / 2),
    min(eWidth / 2, eHeight / 2) - 2,
    paint,
  );
  //画一个白子
  paint.color = Colors.white;
  canvas.drawCircle(
    Offset(rect.center.dx + eWidth / 2, rect.center.dy - eHeight / 2),
    min(eWidth / 2, eHeight / 2) - 2,
    paint,
  );
}

2. 绘制性能

绘制是比较昂贵的操作,所以我们在实现自绘控件时应该考虑到性能开销,下面是两条关于性能优化的建议:

  1. 尽可能的利用好 shouldRepaint 返回值;在UI树重新build时,控件在绘制前都会先调用该方法以确定是否有必要重绘;

    假如我们绘制的UI不依赖外部状态,即外部状态改变不会影响我们的UI外观,那么就应该返回false;如果绘制依赖外部状态,那么我们就应该在shouldRepaint中判断依赖的状态是否改变,如果已改变则应返回true来重绘,反之则应返回false不需要重绘。

  2. 绘制尽可能多的分层

    在上面五子棋的示例中,我们将棋盘和棋子的绘制放在了一起,这样会有一个问题:由于棋盘始终是不变的,用户每次落子时变的只是棋子,但是如果按照上面的代码来实现,每次绘制棋子时都要重新绘制一次棋盘,这是没必要的。优化的方法就是将棋盘单独抽为一个组件,并设置其shouldRepaint回调值为false,然后将棋盘组件作为背景。然后将棋子的绘制放到另一个组件中,这样每次落子时只需要绘制棋子。

3. 防止意外重绘

我们在上例的基础上添加一个 ElevatedButton,点击后什么也不做:

class CustomPaintRoute extends StatelessWidget {
  const CustomPaintRoute({Key? key}) : super(key: key);
  
  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          CustomPaint(
            size: Size(300, 300), //指定画布大小
            painter: MyPainter(),
          ),
          // 添加一个刷新button
          ElevatedButton(onPressed: () {}, child: Text("刷新"))
        ],
      ),
    );
  }
}

运行后如图所示:

Flutter 笔记 | Flutter 自定义组件_第4张图片
运行后我们点击“刷新”按钮,发现日志面板输出了很多 “paint”,也就是说在点击按钮的时候发生了多次重绘。奇怪,shouldRepaint 我们返回的是false,并且点击刷新按钮也不会触发页面重新构建,那是什么导致的重绘呢? 刷新按钮的画布和CustomPaint的画布是同一个,刷新按钮点击时会执行一个水波动画,水波动画执行过程中画布会不停的刷新,所以就导致了CustomPaint 不停的重绘。要解决这个问题的方案很简单,给刷新按钮 或 CustomPaint 任意一个添加一个 RepaintBoundary 父组件即可,现在可以先简单认为这样做可以生成一个新的画布:

RepaintBoundary(
  child: CustomPaint(
    size: Size(300, 300), //指定画布大小
    painter: MyPainter(),
  ),
),
// 或者给刷新按钮添加RepaintBoundary
// RepaintBoundary(child: ElevatedButton(onPressed: () {}, child: Text("刷新")))

自绘实例:圆形背景渐变进度条

下面我们实现一个圆形背景渐变进度条,它支持:

  1. 支持多种背景渐变色。
  2. 任意弧度;进度条可以不是整圆。
  3. 可以自定义粗细、两端是否圆角等样式。

可以发现要实现这样的一个进度条是无法通过现有组件组合而成的,所以我们通过自绘方式实现,代码如下:

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

class GradientCircularProgressIndicator extends StatelessWidget {
  const GradientCircularProgressIndicator({
    Key? key,
    required this.radius,
    this.strokeWidth = 2.0,
    this.colors,
    this.stops,
    this.strokeCapRound = false,
    this.backgroundColor = const Color(0xFFEEEEEE),
    this.totalAngle = 2 * pi,
    this.value
  }) : super(key: key);

  ///粗细
  final double strokeWidth;

  /// 圆的半径
  final double radius;

  ///两端是否为圆角
  final bool strokeCapRound;

  /// 当前进度,取值范围 [0.0-1.0]
  final double? value;

  /// 进度条背景色
  final Color backgroundColor;

  /// 进度条的总弧度,2*PI为整圆,小于2*PI则不是整圆
  final double totalAngle;

  /// 渐变色数组
  final List<Color>? colors;

  /// 渐变色的终止点,对应colors属性
  final List<double>? stops;

  
  Widget build(BuildContext context) {
    double _offset = .0;
    // 如果两端为圆角,则需要对起始位置进行调整,否则圆角部分会偏离起始位置
    // 下面调整的角度的计算公式是通过数学几何知识得出,读者有兴趣可以研究一下为什么是这样
    if (strokeCapRound && totalAngle != 2 * pi) {
      _offset = asin(strokeWidth / (radius * 2 - strokeWidth));
    }
    var _colors = colors;
    if (_colors == null) {
      Color color = Theme.of(context).colorScheme.secondary;
      _colors = [color, color];
    }
    return Transform.rotate(
      angle: -pi / 2.0 - _offset,
      child: CustomPaint(
          size: Size.fromRadius(radius),
          painter: _GradientCircularProgressPainter(
            strokeWidth: strokeWidth,
            strokeCapRound: strokeCapRound,
            backgroundColor: backgroundColor,
            value: value,
            total: totalAngle,
            radius: radius,
            colors: _colors,
          )
      ),
    );
  }
}

//实现画笔
class _GradientCircularProgressPainter extends CustomPainter {
  const _GradientCircularProgressPainter({
    this.strokeWidth = 10.0,
    this.strokeCapRound = false,
    this.backgroundColor = const Color(0xFFEEEEEE),
    this.radius,
    this.total = 2 * pi,
    required this.colors,
    this.stops,
    this.value,
    this.fullColor,
  });

  final double strokeWidth;
  final bool strokeCapRound;
  final double? value;
  final Color backgroundColor;
  final List<Color> colors;
  final double total;
  final double? radius;
  final List<double>? stops;
  final Color? fullColor;

  
  void paint(Canvas canvas, Size size) {
    if (radius != null) {
      size = Size.fromRadius(radius!);
    }
    double _offset = strokeWidth / 2.0;
    double _value = (value ?? .0);
    //将_value控制在指定区间 大于最大值取最大值小于最小值取最小值
    _value = _value.clamp(.0, 1.0) * total;
    double _start = .0;

    if (strokeCapRound) {
      _start = asin(strokeWidth/ (size.width - strokeWidth));
    }

    Rect rect = Offset(_offset, _offset) & Size(
        size.width - strokeWidth,
        size.height - strokeWidth
    );

    var paint = Paint()
      ..strokeCap = strokeCapRound ? StrokeCap.round : StrokeCap.butt
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true
      ..strokeWidth = strokeWidth;

    // 先画背景
    if (backgroundColor != Colors.transparent) {
      paint.color = backgroundColor;
      canvas.drawArc(rect, _start, total, false, paint);
    }

    // 再画前景,应用渐变
    if (value == 1 && fullColor != null) {
      paint.color = fullColor!;
      canvas.drawArc(rect, _start, _value, false, paint);
    } else if (_value > 0) {
      // draw foreground arc and apply gradient
      paint.shader = SweepGradient(
        startAngle: 0.0,
        endAngle: _value,
        colors: colors,
        stops: stops,
      ).createShader(rect);
      canvas.drawArc(rect, _start, _value, false, paint);
    }
  }

  
  bool shouldRepaint(_GradientCircularProgressPainter old) {
    return old.strokeWidth != strokeWidth ||
        old.strokeCapRound != strokeCapRound ||
        old.backgroundColor != backgroundColor ||
        old.radius != radius ||
        old.value != value ||
        old.fullColor != fullColor ||
        old.colors.toString() != colors.toString() ||
        old.stops.toString() != stops.toString();
  }
}

下面我们来测试一下,为了尽可能多的展示GradientCircularProgressIndicator的不同外观和用途,这个示例代码会比较长,并且添加了动画。

示例代码:

import 'dart:math'; 
import 'package:flutter/material.dart';
import 'package:flutter_app_3_7_7/util/gradient_circular_progress_indicator.dart';
import 'package:flutter_app_3_7_7/util/turn_box.dart';
 
class GradientCircularProgressRoute extends StatefulWidget {
  const GradientCircularProgressRoute({Key? key}) : super(key: key);

  
  GradientCircularProgressRouteState createState() {
    return GradientCircularProgressRouteState();
  }
}

class GradientCircularProgressRouteState
    extends State<GradientCircularProgressRoute> with TickerProviderStateMixin {
  late AnimationController _animationController;

  
  void initState() {
    super.initState();
    _animationController =
        AnimationController(vsync: this, duration: const Duration(seconds: 3));
    bool isForward = true;
    _animationController.addStatusListener((status) {
      if (status == AnimationStatus.forward) {
        isForward = true;
      } else if (status == AnimationStatus.completed ||
          status == AnimationStatus.dismissed) {
        if (isForward) {
          _animationController.reverse();
        } else {
          _animationController.forward();
        }
      } else if (status == AnimationStatus.reverse) {
        isForward = false;
      }
    });
    _animationController.forward();
  }

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("GradientCircularProgress"),
      ),
      body: SingleChildScrollView(
        child: Center(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              AnimatedBuilder(
                animation: _animationController,
                builder: (BuildContext context, Widget? child) {
                  return Padding(
                    padding: const EdgeInsets.symmetric(vertical: 16.0),
                    child: Column(
                      children: <Widget>[
                        Wrap(
                          spacing: 10.0,
                          runSpacing: 16.0,
                          children: <Widget>[
                            GradientCircularProgressIndicator(
                              // No gradient
                              colors: const [Colors.blue, Colors.blue],
                              radius: 50.0,
                              strokeWidth: 3.0,
                              value: _animationController.value,
                            ),
                            GradientCircularProgressIndicator(
                              colors: const [Colors.red, Colors.orange],
                              radius: 50.0,
                              strokeWidth: 3.0,
                              value: _animationController.value,
                            ),
                            GradientCircularProgressIndicator(
                              colors: const [
                                Colors.red,
                                Colors.orange,
                                Colors.red
                              ],
                              radius: 50.0,
                              strokeWidth: 5.0,
                              value: _animationController.value,
                            ),
                            GradientCircularProgressIndicator(
                              colors: const [Colors.teal, Colors.cyan],
                              radius: 50.0,
                              strokeWidth: 5.0,
                              strokeCapRound: true,
                              value: CurvedAnimation(
                                      parent: _animationController,
                                      curve: Curves.decelerate)
                                  .value,
                            ),
                            TurnBox(
                              turns: 1 / 8,
                              child: GradientCircularProgressIndicator(
                                  colors: const [
                                    Colors.red,
                                    Colors.orange,
                                    Colors.red
                                  ],
                                  radius: 50.0,
                                  strokeWidth: 5.0,
                                  strokeCapRound: true,
                                  backgroundColor: Colors.red[50]!,
                                  totalAngle: 1.5 * pi,
                                  value: CurvedAnimation(
                                          parent: _animationController,
                                          curve: Curves.ease)
                                      .value),
                            ),
                            RotatedBox(
                              quarterTurns: 1,
                              child: GradientCircularProgressIndicator(
                                  colors: [
                                    Colors.blue[700]!,
                                    Colors.blue[200]!
                                  ],
                                  radius: 50.0,
                                  strokeWidth: 3.0,
                                  strokeCapRound: true,
                                  backgroundColor: Colors.transparent,
                                  value: _animationController.value),
                            ),
                            GradientCircularProgressIndicator(
                              colors: [
                                Colors.red,
                                Colors.amber,
                                Colors.cyan,
                                Colors.green[200]!,
                                Colors.blue,
                                Colors.red
                              ],
                              radius: 50.0,
                              strokeWidth: 5.0,
                              strokeCapRound: true,
                              value: _animationController.value,
                            ),
                          ],
                        ),
                        GradientCircularProgressIndicator(
                          colors: [Colors.blue[700]!, Colors.blue[200]!],
                          radius: 100.0,
                          strokeWidth: 20.0,
                          value: _animationController.value,
                        ),

                        Padding(
                          padding: const EdgeInsets.symmetric(vertical: 16.0),
                          child: GradientCircularProgressIndicator(
                            colors: [Colors.blue[700]!, Colors.blue[300]!],
                            radius: 100.0,
                            strokeWidth: 20.0,
                            value: _animationController.value,
                            strokeCapRound: true,
                          ),
                        ),
                        //剪裁半圆
                        ClipRect(
                          child: Align(
                            alignment: Alignment.topCenter,
                            heightFactor: .5,
                            child: Padding(
                              padding: const EdgeInsets.only(bottom: 8.0),
                              child: SizedBox(
                                //width: 100.0,
                                child: TurnBox(
                                  turns: .75,
                                  child: GradientCircularProgressIndicator(
                                    colors: [Colors.teal, Colors.cyan[500]!],
                                    radius: 100.0,
                                    strokeWidth: 8.0,
                                    value: _animationController.value,
                                    totalAngle: pi,
                                    strokeCapRound: true,
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ),
                        SizedBox(
                          height: 104.0,
                          width: 200.0,
                          child: Stack(
                            alignment: Alignment.center,
                            children: <Widget>[
                              Positioned(
                                height: 200.0,
                                top: .0,
                                child: TurnBox(
                                  turns: .75,
                                  child: GradientCircularProgressIndicator(
                                    colors: [Colors.teal, Colors.cyan[500]!],
                                    radius: 100.0,
                                    strokeWidth: 8.0,
                                    value: _animationController.value,
                                    totalAngle: pi,
                                    strokeCapRound: true,
                                  ),
                                ),
                              ),
                              Padding(
                                padding: const EdgeInsets.only(top: 10.0),
                                child: Text(
                                  "${(_animationController.value * 100).toInt()}%",
                                  style: const TextStyle(
                                    fontSize: 25.0,
                                    color: Colors.blueGrey,
                                  ),
                                ),
                              )
                            ],
                          ),
                        ),
                      ],
                    ),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

运行效果:

自绘组件:CustomCheckbox

Flutter 自带的 Checkbox 组件是不能自由指定大小的,下面我们通过自定义一个可以自由指定大小的 CustomCheckbox 组件来演示如何通过定义 RenderObject 的方式来自定义组件(而不是通过组合)。我们要实现的 CustomCheckbox 组件效果如图所示:

Flutter 笔记 | Flutter 自定义组件_第5张图片

  1. 有选中和未选中两种状态。
  2. 状态切换时要执行动画。
  3. 可以自定义外观。

CustomCheckbox 定义如下:

class CustomCheckbox extends LeafRenderObjectWidget {
  const CustomCheckbox({
    Key? key,
    this.strokeWidth = 2.0,
    this.value = false,
    this.strokeColor = Colors.white,
    this.fillColor = Colors.blue,
    this.radius = 2.0,
    this.onChanged,
  }) : super(key: key);

  final double strokeWidth; // “勾”的线条宽度
  final Color strokeColor; // “勾”的线条宽度
  final Color? fillColor; // 背景填充颜色
  final bool value; //选中状态
  final double radius; // 圆角
  final ValueChanged<bool>? onChanged; // 选中状态发生改变后的回调

  
  RenderObject createRenderObject(BuildContext context) {
    return RenderCustomCheckbox(
      strokeWidth,
      strokeColor,
      fillColor ?? Theme.of(context).primaryColor, // 填充颜色如果未指定则使用主题色
      value,
      radius,
      onChanged,
    );
  }

  
  void updateRenderObject(context, RenderCustomCheckbox renderObject) {
    if (renderObject.value != value) {
      // 选中状态发生了变化,则需要调整动画状态以执行过渡动画
      // 当从未选中切换为选中状态时,执行正向动画;当从选中状态切换为未选中状态时执行反向动画。
      renderObject.animationStatus =
          value ? AnimationStatus.forward : AnimationStatus.reverse;
    }
    renderObject
      ..strokeWidth = strokeWidth
      ..strokeColor = strokeColor
      ..fillColor = fillColor ?? Theme.of(context).primaryColor
      ..radius = radius
      ..value = value
      ..onChanged = onChanged;
  }
}

上面代码中唯一需要注意的就是 updateRenderObject 方法中当选中状态发生变化时,我们要更新RenderObject中的动画状态,具体逻辑是:当从未选中切换为选中状态时,执行正向动画;当从选中状态切换为未选中状态时执行反向动画。

接下来需要实现 RenderCustomCheckbox

class RenderCustomCheckbox extends RenderBox {
  bool value;
  int pointerId = -1;
  double strokeWidth;
  Color strokeColor;
  Color fillColor;
  double radius;
  ValueChanged<bool>? onChanged;

  // 下面的属性用于调度动画
  double progress = 0; // 动画当前进度
  int? _lastTimeStamp;//上一次绘制的时间
  //动画执行时长
  Duration get duration => const Duration(milliseconds: 150);
  //动画当前状态
  AnimationStatus _animationStatus = AnimationStatus.completed;
  set animationStatus(AnimationStatus v) {
    if (_animationStatus != v) {
      markNeedsPaint();
    }
    _animationStatus = v;
  }

  //背景动画时长占比(背景动画要在前40%的时间内执行完毕,之后执行打勾动画)
  final double bgAnimationInterval = .4;

  RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor,
      this.value, this.radius, this.onChanged)
      : progress = value ? 1 : 0;
  
  
  void performLayout() {}  //布局

  
  void paint(PaintingContext context, Offset offset) {
    Rect rect = offset & size;
    // 将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制'勾'
    _drawBackground(context, rect);
    _drawCheckMark(context, rect);
    // 调度动画
    _scheduleAnimation();
  }
  
  // 画背景
  void _drawBackground(PaintingContext context, Rect rect) {}

  //画 "勾"
  void _drawCheckMark(PaintingContext context, Rect rect) { }
  //调度动画
  void _scheduleAnimation() {}

  ... //响应点击事件
}

1. 实现布局算法

为了使用户可以自定义宽高,我们的布局策略是:如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25

  
  void performLayout() {
    // 如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25
    size = constraints.constrain(
      constraints.isTight ? Size.infinite : const Size(25, 25),
    );
  }

2. 绘制 CustomCheckbox

接下来重点就是绘制 CustomCheckbox 了,为了清晰起见,我们将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制’勾’,这里需要注意两点:

  1. 我们绘制的是动画执行过程中的一帧,所以需要通过动画执行的进度(progress)来计算每一帧要绘制的样子。
  2. CustomCheckbox 从未选中变为选中时,我们执行正向动画,progress 的值会从 0 逐渐变为 1,因为 CustomCheckbox 的背景和前景(‘勾’)的颜色要有对比,所以我们在背景绘制完之后再绘制前景。因此,我们将动画分割为两端,前 40% 的时间画背景,后 60%的时间画’勾’。

1)绘制背景

Flutter 笔记 | Flutter 自定义组件_第6张图片

结合上图,我们先看看如何绘制背景:

  1. 当状态切换为选中状态时,将矩形逐渐从边缘向中心收缩填充,直到填满 Checkbox 区域。
  2. 当状态切换为未选中状态时,填充从中间逐渐向边缘消散,直到只剩一个边框为止。

实现的思路是先将整个背景矩形区域全部填充满蓝色,然后在上面绘制一个白色背景的矩形,根据动画进度来动态改变白色矩形区域大小即可。幸运的是 Canvas API 中已经帮助我们实现了我们期望的功能,drawDRRect 可以指定内外两个矩形,然后画出不相交的部分,并且可以指定圆角,下面是具体实现:

void _drawBackground(PaintingContext context, Rect rect) {
  Color color = value ? fillColor : Colors.grey;
  var paint = Paint()
    ..isAntiAlias = true
    ..style = PaintingStyle.fill //填充
    ..strokeWidth
    ..color = color;
  
  // 我们需要算出每一帧里面矩形的大小,为此我们可以直接根据矩形插值方法来确定里面矩形
  final outer = RRect.fromRectXY(rect, radius, radius);
  var rects = [
    rect.inflate(-strokeWidth),
    Rect.fromCenter(center: rect.center, width: 0, height: 0)
  ];
  // 根据动画执行进度调整来确定里面矩形在每一帧的大小
  var rectProgress = Rect.lerp(
    rects[0],
    rects[1],
    // 背景动画的执行时长是前 40% 的时间
    min(progress, bgAnimationInterval) / bgAnimationInterval,
  )!;
  final inner = RRect.fromRectXY(rectProgress, 0, 0);
  // 绘制
  context.canvas.drawDRRect(outer, inner, paint);
}

2)绘制前景

前景是一个"勾",它有三个点的连线构成,为了简单起见,我们将起始点和中点拐点的位置根据 Checkbox 的大小算出固定的坐标,然后我们在每一帧中动态调整第三个点的位置就可以实现打勾动画:

//画 "勾"
void _drawCheckMark(PaintingContext context, Rect rect) {
  // 在画好背景后再画前景
  if (progress > bgAnimationInterval) {
    
    //确定中间拐点位置
    final secondOffset = Offset(
      rect.left + rect.width / 2.5,
      rect.bottom - rect.height / 4,
    );
    // 第三个点的位置
    final lastOffset = Offset(
      rect.right - rect.width / 6,
      rect.top + rect.height / 4,
    );

    // 我们只对第三个点的位置做插值
    final _lastOffset = Offset.lerp(
      secondOffset,
      lastOffset,
      (progress - bgAnimationInterval) / (1 - bgAnimationInterval),
    )!;

    // 将三个点连起来
    final path = Path()
      ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2)
      ..lineTo(secondOffset.dx, secondOffset.dy)
      ..lineTo(_lastOffset.dx, _lastOffset.dy);

    final paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.stroke
      ..color = strokeColor
      ..strokeWidth = strokeWidth;

    context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
  }
}

3. 实现动画

最后,我们需要让UI动起来,Flutter 的动画框架是依赖于 StatefulWidget 的,即当状态改变时显式或隐式的去调用 setState 触发更新。但是我们直接通过定义 RenderObject 的方式来实现的 CustomCheckbox,并不是基于 StatefulWidget ,那该怎么来调度动画呢?有两种办法:

  1. CustomCheckbox 用一个 StatefulWidget 包装起来,这样就可以复用之前介绍的执行动画的方法。
  2. 自定义动画调度。

第一种方法相信已经很熟悉了,不再赘述,下面我们演示一下第二种方法,我们的思路是:在一帧绘制结束后判断动画是否结束,如果动画未结束,则将将当前组件标记为”需要重绘“,然后等待下一帧即可

void _scheduleAnimation() {
  if (_animationStatus != AnimationStatus.completed) {
    // 需要在Flutter 当前frame 结束之前再执行,因为不能在绘制过程中又将组件标记为需要重绘
    SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
      if (_lastTimeStamp != null) {
        double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) /
          duration.inMilliseconds;
        // 如果是反向动画,则 progress值要逐渐减小
        if (_animationStatus == AnimationStatus.reverse) {
          delta = -delta;
        }
        //更新动画进度
        progress = progress + delta;
        
        if (progress >= 1 || progress <= 0) {
          //动画执行结束
          _animationStatus = AnimationStatus.completed;
          progress = progress.clamp(0, 1);
        }
      }
       //标记为需要重绘
      markNeedsPaint();
      _lastTimeStamp = timeStamp.inMilliseconds;
    });
  } else {
    _lastTimeStamp = null;
  }
}

4. 响应点击事件

根据之前事件处理相关的介绍,如果我们要让渲染对象能处理事件,则它必须能通过命中测试,之后才能在 handleEvent 方法中处理事件,所以我们需要添加如下代码:

// 必须置为true,确保能通过命中测试

bool hitTestSelf(Offset position) => true;

// 只有通过命中测试,才会调用本方法,我们在手指抬起时触发事件即可

void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
  if (event.down) {
    pointerId = event.pointer;
  } else if (pointerId == event.pointer) {
   	  // 判断手指抬起时是在组件范围内的话才触发onChange
      if (size.contains(event.localPosition)) {
        onChanged?.call(!value);
      }
  }
}

动画调度抽象 RenderObjectAnimationMixin

我们可以看到,在 RenderObject 中调度动画还是比较复杂的,为此我们抽象了一个 RenderObjectAnimationMixin,如果还有其他 RenderObject 中需要执行动画,可以直接复用。

import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

mixin RenderObjectAnimationMixin on RenderObject {
  double _progress = 0; // 动画当前进度
  int? _lastTimeStamp; // 上一次绘制的时间

  double get progress => _progress;

  // 动画执行时长,子类可以重写
  Duration get duration => const Duration(milliseconds: 200);
  // 动画当前状态
  AnimationStatus _animationStatus = AnimationStatus.completed;
  // 设置动画状态
  set animationStatus(AnimationStatus v) {
    if (_animationStatus != v) {
      markNeedsPaint();
    }
    _animationStatus = v;
  }

  set progress(double v) {
    _progress = v.clamp(0, 1);
  }

  
  void paint(PaintingContext context, Offset offset) {
    // 调用子类绘制逻辑
    doPaint(context, offset);
    // 调度动画
    _scheduleAnimation();
  } 
 
  void _scheduleAnimation() {
    //SchedulerBinding.instance.remo
    if (_animationStatus != AnimationStatus.completed) {
      // 需要在Flutter 当前frame 结束之前再执行,因为不能在绘制过程中又将组件标记为需要重绘
      SchedulerBinding.instance!.addPostFrameCallback((Duration timeStamp) {
        if (_lastTimeStamp != null) {
          double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) / duration.inMilliseconds;
          //在特定情况下,可能在一帧中连续的往frameCallback中添加了多次,导致两次回调时间间隔为0,
          //这种情况下应该继续请求重绘。
          if (delta == 0) {
            markNeedsPaint();
            return;
          }
          // 如果是反向动画,则 progress值要逐渐减小
          if (_animationStatus == AnimationStatus.reverse) {
            delta = -delta;
          }
          // 更新动画进度
          _progress = _progress + delta;
          if (_progress >= 1 || _progress <= 0) {
            // 动画执行结束
            _animationStatus = AnimationStatus.completed;
            _progress = _progress.clamp(0, 1);
          }
        }
        // 标记为需要重绘
        markNeedsPaint();
        _lastTimeStamp = timeStamp.inMilliseconds;
      });
    } else {
      _lastTimeStamp = null;
    }
  }

  // 子类实现绘制逻辑的地方
  void doPaint(PaintingContext context, Offset offset);
}

CustomCheckbox 的完整源码

最终 CustomCheckbox 的完整源码为:

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_app_3_7_7/util/render_object_animation_mixin.dart';

class CustomCheckbox extends LeafRenderObjectWidget {
  const CustomCheckbox({
    Key? key,
    this.strokeWidth = 2.0,
    this.value = false,
    this.strokeColor = Colors.white,
    this.fillColor = Colors.blue,
    this.radius = 2.0,
    this.onChanged,
  }) : super(key: key);

  final double strokeWidth; // “勾”的线条宽度
  final Color strokeColor; // “勾”的线条宽度
  final Color? fillColor; // 背景填充颜色
  final bool value; //选中状态
  final double radius; // 圆角
  final ValueChanged<bool>? onChanged; // 选中状态发生改变后的回调

  
  RenderObject createRenderObject(BuildContext context) {
    return RenderCustomCheckbox(
      strokeWidth,
      strokeColor,
      fillColor ?? Theme.of(context).primaryColor, // 填充颜色如果未指定则使用主题色
      value,
      radius,
      onChanged,
    );
  }

  
  void updateRenderObject(context, RenderCustomCheckbox renderObject) {
    if (renderObject.value != value) {
      // 选中状态发生了变化,则需要调整动画状态以执行过渡动画
      // 具体逻辑是:当从未选中切换为选中状态时,执行正向动画;当从选中状态切换为未选中状态时执行反向动画。
      renderObject.animationStatus =
          value ? AnimationStatus.forward : AnimationStatus.reverse;
    }
    renderObject
      ..strokeWidth = strokeWidth
      ..strokeColor = strokeColor
      ..fillColor = fillColor ?? Theme.of(context).primaryColor
      ..radius = radius
      ..value = value
      ..onChanged = onChanged;
  }
}

// 动画调度相关逻辑直接 with  RenderObjectAnimationMixin即可
class RenderCustomCheckbox extends RenderBox with RenderObjectAnimationMixin {
  bool value;
  int pointerId = -1;
  double strokeWidth;
  Color strokeColor;
  Color fillColor;
  double radius;
  ValueChanged<bool>? onChanged;

  RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor,
      this.value, this.radius, this.onChanged) {
    progress = value ? 1 : 0;
  }

  
  bool get isRepaintBoundary => true;

  //背景动画时长占比(背景动画要在前40%的时间内执行完毕,之后执行打勾动画)
  final double bgAnimationInterval = .4;

  /// 我们将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制'勾',这里需要注意两点:
  /// 1.我们绘制的是动画执行过程中的一帧,所以需要通过动画执行的进度(progress)来计算每一帧要绘制的样子。
  /// 2.当 CustomCheckbox 从未选中变为选中时,我们执行正向动画,progress 的值会从 0 逐渐变为 1,
  ///   因为 CustomCheckbox 的背景和前景('勾')的颜色要有对比,所以我们在背景绘制完之后再绘制前景。
  ///   因此,我们将动画分割为两端,前 40% 的时间画背景,后 60%的时间画'勾'。
  
  void doPaint(PaintingContext context, Offset offset) {
    Rect rect = offset & size;
    // 将绘制分为背景(矩形)和 前景(打勾)两部分,先画背景,再绘制'勾'
    _drawBackground(context, rect);
    _drawCheckMark(context, rect);
  }

  // 画背景
  // 1.当状态切换为选中状态时,将矩形逐渐从边缘向中心收缩填充,直到填满 Checkbox 区域。
  // 2.当状态切换为未选中状态时,填充从中间逐渐向边缘消散,直到只剩一个边框为止。 
  // 实现的思路是先将整个背景矩形区域全部填充满蓝色,然后在上面绘制一个白色背景的矩形,
  // 根据动画进度来动态改变白色矩形区域大小即可。 
  void _drawBackground(PaintingContext context, Rect rect) {
    Color color = value ? fillColor : Colors.grey;
    var paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill //填充
      ..strokeWidth
      ..color = color;

    // 我们对矩形做插值
    // 我们需要算出每一帧里面矩形的大小,为此我们可以直接根据矩形插值方法来确定里面矩形
    final outer = RRect.fromRectXY(rect, radius, radius);
    var rects = [
      rect.inflate(-strokeWidth),
      Rect.fromCenter(center: rect.center, width: 0, height: 0)
    ];
    // 根据动画执行进度调整来确定里面矩形在每一帧的大小
    var rectProgress = Rect.lerp(
      rects[0],
      rects[1],
      // 背景动画的执行时长是前 40% 的时间
      min(progress, bgAnimationInterval) / bgAnimationInterval,
    )!;

    final inner = RRect.fromRectXY(rectProgress, 0, 0);
    // 绘制 drawDRRect 可以指定内外两个矩形,然后画出不相交的部分,并且可以指定圆角
    context.canvas.drawDRRect(outer, inner, paint);
  }

  // 画 "勾"
  // 它有三个点的连线构成,为了简单起见,我们将起始点和中点拐点的位置根据 Checkbox 的大小
  // 算出固定的坐标,然后我们在每一帧中动态调整第三个点的位置就可以实现打勾动画
  void _drawCheckMark(PaintingContext context, Rect rect) {
    // 在画好背景后再画前景
    if (progress > bgAnimationInterval) {
      //确定中间拐点位置
      final secondOffset = Offset(
        rect.left + rect.width / 2.5,
        rect.bottom - rect.height / 4,
      );
      // 第三个点的位置
      final lastOffset = Offset(
        rect.right - rect.width / 6,
        rect.top + rect.height / 4,
      );

      // 我们只对第三个点的位置做插值
      final _lastOffset = Offset.lerp(
        secondOffset,
        lastOffset,
        (progress - bgAnimationInterval) / (1 - bgAnimationInterval),
      )!;

      // 将三个点连起来
      final path = Path()
        ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2)
        ..lineTo(secondOffset.dx, secondOffset.dy)
        ..lineTo(_lastOffset.dx, _lastOffset.dy);

      final paint = Paint()
        ..isAntiAlias = true
        ..style = PaintingStyle.stroke
        ..color = strokeColor
        ..strokeWidth = strokeWidth;

      context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
    }
  }

  
  void performLayout() {
    // 如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25
    size = constraints.constrain(
      constraints.isTight ? Size.infinite : const Size(25, 25),
    );
  }

  /// 如果我们要让渲染对象能处理事件,则它必须能通过命中测试,之后才能在 handleEvent 方法中处理事件
  // 必须置为true,否则不可以响应事件
  
  bool hitTestSelf(Offset position) => true;

  // 只有通过命中测试,才会调用本方法,我们在手指抬起时触发事件即可
  
  void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
    if (event.down) {
      pointerId = event.pointer;
    } else if (pointerId == event.pointer) {
      // 判断手指抬起时是在组件范围内的话才触发onChange
      if (size.contains(event.localPosition)) {
        onChanged?.call(!value);
      }
    }
  }
}

测试代码如下:我们创建三个大小不同的复选框,点击其中任意一个,另外两个复选框的状态也会跟着联动:

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

  
  State<CustomCheckboxTest> createState() => _CustomCheckboxTestState();
}

class _CustomCheckboxTestState extends State<CustomCheckboxTest> {
  bool _checked = false;

  
  Widget build(BuildContext context) {
    return Center(
      child: Column(mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CustomCheckbox2(
            value: _checked,
            onChanged: _onChange,
          ),
          Padding(
            padding: const EdgeInsets.all(18.0),
            child: SizedBox(
              width: 16,
              height: 16,
              child: CustomCheckbox(
                strokeWidth: 1,
                radius: 1,
                value: _checked,
                onChanged: _onChange,
              ),
            ),
          ),
          SizedBox(
            width: 30,
            height: 30,
            child: CustomCheckbox(
              strokeWidth: 3,
              radius: 3,
              value: _checked,
              onChanged: _onChange,
            ),
          ),
        ],
      ),
    );
  }

  void _onChange(value) {
    setState(() => _checked = value);
  }
}

可以看到通过 RenderObject 来自定义组件会比组合的方式更复杂一些,但这种方式会更接近 Flutter 组件的本质。

自绘组件: DoneWidget

下面我们将实现一个 DoneWidget,它可以在创建时执行一个打勾动画,效果如图:

Flutter 笔记 | Flutter 自定义组件_第7张图片

实现代码如下:

class DoneWidget extends LeafRenderObjectWidget {
  const DoneWidget({
    Key? key,
    this.strokeWidth = 2.0,
    this.color = Colors.green,
    this.outline = false,
  }) : super(key: key);

  //线条宽度
  final double strokeWidth;
  //轮廓颜色或填充色
  final Color color;
  //如果为true,则没有填充色,color代表轮廓的颜色;如果为false,则color为填充色
  final bool outline;

  
  RenderObject createRenderObject(BuildContext context) {
    return RenderDoneObject(
      strokeWidth,
      color,
      outline,
    )..animationStatus = AnimationStatus.forward; // 创建时执行正向动画
  }

  
  void updateRenderObject(context, RenderDoneObject renderObject) {
    renderObject
      ..strokeWidth = strokeWidth
      ..outline = outline
      ..color = color;
  }
}

DoneWidget 有两种模式,一种是 outline 模式,该模式背景没有填充色,此时 color 表示的是轮廓线条的颜色;如果是非 outline 模式,则 color 表示填充的背景色,此时 “勾” 的颜色简单设置为白色。

接下来需要实现 RenderDoneObject,由于组件不需要响应事件,所以我们可以不用添加事件相关的处理代码;但是组件需要执行动画,因此我们可以直接使用上一节中封装的 RenderObjectAnimationMixin,具体实现代码如下:

class RenderDoneObject extends RenderBox with RenderObjectAnimationMixin {
  double strokeWidth;
  Color color;
  bool outline;

  ValueChanged<bool>? onChanged;

  RenderDoneObject(
    this.strokeWidth,
    this.color,
    this.outline,
  );

  // 动画执行时间为 300ms
  
  Duration get duration => const Duration(milliseconds: 300);

  
  void doPaint(PaintingContext context, Offset offset) {
    // 可以对动画运用曲线
    Curve curve = Curves.easeIn;
    final _progress = curve.transform(progress);

    Rect rect = offset & size;
    final paint = Paint()
      ..isAntiAlias = true
      ..style = outline ? PaintingStyle.stroke : PaintingStyle.fill //填充
      ..color = color;

    if (outline) {
      paint.strokeWidth = strokeWidth;
      rect = rect.deflate(strokeWidth / 2);
    }

    // 画背景圆
    context.canvas.drawCircle(rect.center, rect.shortestSide / 2, paint);

    paint
      ..style = PaintingStyle.stroke
      ..color = outline ? color : Colors.white
      ..strokeWidth = strokeWidth;

    final path = Path();

    //接下来画 "勾"
    Offset firstOffset =
        Offset(rect.left + rect.width / 6, rect.top + rect.height / 2.1);

    final secondOffset = Offset(
      rect.left + rect.width / 2.5,
      rect.bottom - rect.height / 3.3,
    );

    path.moveTo(firstOffset.dx, firstOffset.dy);

    // adjustProgress 的作用主要是将“打勾”动画氛围两部分,第一部分是第一个点和第二个点的连线动画,
    // 这部分动画站总动画时长的 前 60%; 第二部分是第二点和第三个点的连线动画,该部分动画占总时长的后 40%。
    const adjustProgress = .6;
    //画 "勾"
    if (_progress < adjustProgress) {
      //第一个点到第二个点的连线做动画(第二个点不停的变)
      Offset _secondOffset = Offset.lerp(
        firstOffset,
        secondOffset,
        _progress / adjustProgress,
      )!;
      path.lineTo(_secondOffset.dx, _secondOffset.dy);
    } else {
      //连接第一个点和第二个点
      path.lineTo(secondOffset.dx, secondOffset.dy);
      //第三个点位置随着动画变,做动画
      final lastOffset = Offset(
        rect.right - rect.width / 5,
        rect.top + rect.height / 3.5,
      );
      Offset _lastOffset = Offset.lerp(
        secondOffset,
        lastOffset,
        (progress - adjustProgress) / (1 - adjustProgress),
      )!;
      path.lineTo(_lastOffset.dx, _lastOffset.dy);
    }
    context.canvas.drawPath(path, paint..style = PaintingStyle.stroke);
  }

  
  void performLayout() {
    // 如果父组件指定了固定宽高,则使用父组件指定的,否则宽高默认置为 25
    size = constraints.constrain(
      constraints.isTight ? Size.infinite : const Size(25, 25),
    );
  }
}

上面代码很简单,但需要注意三点:

  1. 我们对动画应用了easeIn 曲线,可以看到如何在 RenderObject 中对动画应用曲线,曲线的本质其实就是对动画的进度加了一层映射,通过不同的映射规则就可以控制动画在不同阶段的快慢。

  2. 我们重写了 RenderObjectAnimationMixin 中的 duration,该参数用于指定动画时长。

  3. adjustProgress 的作用主要是将“打勾”动画分为两部分,第一部分是第一个点和第二个点的连线动画,这部分动画站总动画时长的前 60%; 第二部分是第二点和第三个点的连线动画,该部分动画占总时长的后 40%

水印实例: 文本绘制与离屏渲染

下面将通过实现一个水印组件来介绍一下如何绘制文本以及如何进行离屏渲染。

在实际场景中,大多数情况下水印是要铺满整个屏幕的,如果不需要铺满屏幕,通常直接用组件组合即可实现,本节我们主要讨论的是需要铺满屏幕的水印。

水印组件 WaterMark

我们可以通过绘制一个“单元水印”,然后让它在整个水印组件的背景中重复即可实现我们期望的功能,因此我们可以直接使用 DecoratedBox ,它拥有背景图重复功能。重复的问题解决后,那么主要的问题便是如何绘制单元水印,为了灵活好扩展,我们定义一个水印画笔接口,这样一来我们可以预置一些常用的画笔实现来满足大多数场景,同时如果开发者有自定义需求的话也可以通过自定义画笔来实现。

下面是水印组件 WaterMark 的定义:

class WaterMark extends StatefulWidget {
  const WaterMark({
    Key? key,
    this.repeat = ImageRepeat.repeat,
    required this.painter,
  }) : super(key: key);

  /// 单元水印画笔
  final WaterMarkPainter painter;

  /// 单元水印的重复方式
  final ImageRepeat repeat;

  
  State<WaterMark> createState() => _WaterMarkState();
}

下面看一下 State 实现:

class _WaterMarkState extends State<WaterMark> {
  late Future<MemoryImage> _memoryImageFuture;

  
  void initState() {
    // 缓存的是promise
    _memoryImageFuture = _getWaterMarkImage();
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return SizedBox.expand( // 水印尽可能大 
      child: FutureBuilder(
        future: _memoryImageFuture,
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            // 如果单元水印还没有绘制好先返回一个空的Container
            return Container();
          } else {
            // 如果单元水印已经绘制好,则渲染水印
            return DecoratedBox(
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: snapshot.data, // 背景图,即我们绘制的单元水印图片
                  repeat: widget.repeat,
                  alignment: Alignment.topLeft, // 指定重复方式
                  scale: MediaQuery.of(context).devicePixelRatio, // 很重要
                ),
              ),
            );
          }
        },
      ),
    );
  }

  
  void didUpdateWidget(WaterMark oldWidget) {
   ... //待实现
  }

  // 离屏绘制单元水印并将绘制结果转为图片缓存起来
  Future<MemoryImage> _getWaterMarkImage() async {
    ... //待实现
  }

  
  void dispose() {
   ...// 待实现
  }
}	

我们通过 DecoratedBox 来实现背景图重复,同时我们在组件初始化时开始进行离屏绘制单元水印,并将结果缓存在 MemoryImage 中,因为离屏绘制是一个异步任务,所以直接缓存 Future 即可。这里需要注意,当组件重新build时,如果画笔配置发生变化,则我们需要重新绘制单元水印并缓存新的绘制结果:

 
  void didUpdateWidget(WaterMark oldWidget) {
     // 如果画笔发生了变化(类型或者配置)则重新绘制水印
    if (widget.painter.runtimeType != oldWidget.painter.runtimeType ||
        widget.painter.shouldRepaint(oldWidget.painter)) {
      //先释放之前的缓存
      _memoryImageFuture.then((value) => value.evict());
      //重新绘制并缓存
      _memoryImageFuture = _getWaterMarkImage();
    }
    super.didUpdateWidget(oldWidget);  
  }

注意,在重新绘制单元水印之前要先将旧单元水印的缓存清理掉,清理缓存可以通过调用 MemoryImageevict 方法。同时,当组件卸载时,我们也要释放缓存:

  
  void dispose() {
    //释放图片缓存
    _memoryImageFuture.then((value) => value.evict());
    super.dispose();
  }

接下来就需要重新绘制单元水印了,调用 _getWaterMarkImage() 方法即可,该方法的功能是离屏绘制单元水印并将绘制结果转为图片缓存起来,下面我们看一下它的实现。

离屏绘制

离屏绘制的代码如下:

  // 离屏绘制单元水印并将绘制结果保存为图片缓存起来
  Future<MemoryImage> _getWaterMarkImage() async {
    // 创建一个 Canvas 进行离屏绘制 
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);
    // 绘制单元水印并获取其大小
    final size = widget.painter.paintUnit(
      canvas,
      MediaQueryData.fromView(ui.window).devicePixelRatio,
    );
    final picture = recorder.endRecording();
    //将单元水印导为图片并缓存起来
    final img = await picture.toImage(size.width.ceil(), size.height.ceil());
    picture.dispose();
    final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
    img.dispose();
    final pngBytes = byteData!.buffer.asUint8List();
    return MemoryImage(pngBytes);
  }

我们通过手动创建了一个 Canvas 和一个 PictureRecorder 来实现离屏绘制,PictureRecorder 的功能简单来说:调用 Canvas API 后,实际上产生的是一系列绘制指令,这些绘制指令执行后才能获取绘制结果,而PictureRecorder 就是一个绘制指令记录器,它可以记录一段时间内所有绘制指令,我们可以通过调用 recorder.endRecording() 方法来获取记录的绘制指令,该方法返回一个 Picture 对象,它是绘制指令的载体,它有一个 toImage 方法,调用后会执行绘制指令获得绘制的像素结果(ui.Image 对象),之后我们就可以将像素结果转为 png 格式的数据并缓存在MemoryImage 中。

单元水印画笔

现在我们看一下如何绘制单元水印,我们先看一下水印画笔接口的定义:

/// 定义水印画笔
abstract class WaterMarkPainter {
  /// 绘制"单元水印",完整的水印是由单元水印重复平铺组成,返回值为"单元水印"占用空间的大小。
  /// [devicePixelRatio]: 因为最终要将绘制内容保存为图片,所以在绘制时需要根据屏幕的DPR来放大,以防止失真 
  Size paintUnit(Canvas canvas, double devicePixelRatio);

  /// 是否需要重绘
  bool shouldRepaint(covariant WaterMarkPainter oldPainter) => true;
}

定义很简单,就两个函数:

  • paintUnit 用于绘制单元水印,这里需要注意一点,因为很多 UI 元素的大小只能在绘制时获取,无法提前知道大小,所以 paintUnit 在完成绘制单元水印任务的同时,最后得返回单元水印的大小信息,它在导为图片时要用到。
  • shouldRepaint:当画笔状态发生变化且会影响单元水印的外观时返回 true,否则返回 false,返回 true 后重绘单元水印。它是在 _WaterMarkStatedidUpdateWidget 方法中调用,可以结合源码理解。

文本水印画笔

下面我们实现一个文本水印画笔,它可以绘制一段文本,我们可以指定文本的样式和旋转角度。

/// 文本水印画笔
class TextWaterMarkPainter extends WaterMarkPainter {
  TextWaterMarkPainter({
    Key? key,
    double? rotate,
    EdgeInsets? padding,
    TextStyle? textStyle,
    required this.text,
  })  : assert(rotate == null || rotate >= -90 && rotate <= 90),
        rotate = rotate ?? 0,
        padding = padding ?? const EdgeInsets.all(10.0),
        textStyle = textStyle ??
            TextStyle(
              color: Color.fromARGB(20, 0, 0, 0),
              fontSize: 14,
            );

  double rotate; // 文本旋转的度数,是角度不是弧度
  TextStyle textStyle; // 文本样式
  EdgeInsets padding; // 文本的 padding
  String text; // 文本

  
  Size paintUnit(Canvas canvas,double devicePixelRatio) {
   // 1. 先绘制文本
   // 2. 应用旋转和padding 
  }

  
  bool shouldRepaint(TextWaterMarkPainter oldPainter) {
   ...// 待实现
  }
}

paintUnit 的绘制分两步:

  1. 绘制文本
  2. 应用旋转和padding

绘制文本

  1. 创建一个 ParagraphBuilder,记为 builder
  2. 调用 builder.add 添加要绘制的字符串。
  3. 构建文本并进行 layout,因为在 layout 后才能知道文本所占用的空间。
  4. 调用 canvas.drawParagraph 绘制。

具体代码如下:

import 'dart:ui' as ui;
...
 
  Size paintUnit(Canvas canvas,double devicePixelRatio) {
    //根据屏幕 devicePixelRatio 对文本样式中长度相关的一些值乘以devicePixelRatio
    final _textStyle = _handleTextStyle(textStyle, devicePixelRatio);
    final _padding = padding * devicePixelRatio;
  
    //构建文本段落
    final builder = ui.ParagraphBuilder(_textStyle.getParagraphStyle(
      textDirection: textDirection,
      textAlign: TextAlign.start,
      textScaleFactor: devicePixelRatio,
    ));

    //添加要绘制的文本及样式
    builder
      ..pushStyle(_textStyle.getTextStyle()) // textStyle 为 ui.TextStyle
      ..addText(text);

    //layout 后我们才能知道文本占用的空间
    ui.Paragraph paragraph = builder.build()
      ..layout(ui.ParagraphConstraints(width: double.infinity));

    //文本占用的真实宽度
    final textWidth = paragraph.longestLine.ceilToDouble();
    //文本占用的真实高度
    final fontSize = paragraph.height;
    
    ...//省略应用旋转和 padding 的相关代码

    //绘制文本
    canvas.drawParagraph(paragraph, Offset.zero);

  }

  TextStyle _handleTextStyle(double devicePixelRatio) {
    var style = textStyle;
    double _scale(attr) => attr == null ? 1.0 : devicePixelRatio;
    return style.apply(
      decorationThicknessFactor: _scale(style.decorationThickness),
      letterSpacingFactor: _scale(style.letterSpacing),
      wordSpacingFactor: _scale(style.wordSpacing),
      heightFactor: _scale(style.height),
    );
  }

可以看到绘制文本的过程还是比较复杂的,为此 Flutter 提供了一个专门用于绘制文本的画笔 TextPainter,我们用 TextPainter 改造上面代码:

//构建文本画笔
TextPainter painter = TextPainter(
  textDirection: TextDirection.ltr,
  textScaleFactor: devicePixelRatio,
);
//添加文本和样式
painter.text = TextSpan(text: text, style: _textStyle);
//对文本进行布局
painter.layout();

//文本占用的真实宽度
final textWidth = painter.width;
//文本占用的真实高度
final textHeight = painter.height;

 ...//省略应用旋转和 padding 的相关代码
   
// 绘制文本
painter.paint(canvas, Offset.zero);

可以看到,代码实际上少不了多少,但是清晰了一些。

另外 TextPainter 在实战中还有一个用处就是我们想提前知道 Text 组件的宽高时,可以通过 TextPainter 来提前测量,比如:

Widget wTextPainterTest() {
    // 我们想提前知道 Text 组件的大小
    Text text = Text('flutter', style: TextStyle(fontSize: 18));
    // 使用 TextPainter 来测量
    TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
    // 将 Text 组件文本和样式透传给TextPainter
    painter.text = TextSpan(text: text.data,style:text.style);
    // 开始布局测量,调用 layout 后就能获取文本大小了
    painter.layout();
    // 自定义组件 AfterLayout 可以在布局结束后获取子组件的大小,我们用它来验证一下
    // TextPainter 测量的宽高是否正确
    return AfterLayout(
      callback: (RenderAfterLayout value) {
        // 输出日志
        print('text size(painter): ${painter.size}');
        print('text size(after layout): ${value.size}');
      },
      child: text,
    );
  }

应用旋转和 padding

应用旋转效果本身比较简单,但难的是文本旋转后它占用的空间大小会发生变化,所以我们得动态计算旋转后文本所占用空间的大小,假设沿顺时针方向旋转了了 rotate 角度,画出布局图:

Flutter 笔记 | Flutter 自定义组件_第8张图片

我们可以根据上面公式求出最终的宽度和高度,是不是感觉高中学的三角函数终于派上用场了!注意,上面的公式中并没有考虑paddingpadding 的处理比较简单,不赘述,看代码:

 
  Size paintUnit(Canvas canvas, double devicePixelRatio) {
    ... // 省略
    //文本占用的真实宽度
    final textWidth = painter.width;
    //文本占用的真实高度
    final textHeight = painter.height;

    // 将弧度转化为度数
    final radians = math.pi * rotate / 180;

    //通过三角函数计算旋转后的位置和size
    final orgSin = math.sin(radians);
    final sin = orgSin.abs();
    final cos = math.cos(radians).abs();

    final width = textWidth * cos;
    final height = textWidth * sin;
    final adjustWidth = fontSize * sin;
    final adjustHeight = fontSize * cos;

    // 为什么要平移?下面解释
    if (orgSin >= 0) { // 旋转角度为正
      canvas.translate(
        adjustWidth + padding.left,
        padding.top,
      );
    } else { // 旋转角度为负
      canvas.translate(
        padding.left,
        height + padding.top,
      );
    }
    canvas.rotate(radians);
    // 绘制文本
    painter.paint(canvas, Offset.zero);
    // 返回水印单元所占的真实空间大小(需要加上padding)
    return Size(
      width + adjustWidth + padding.horizontal,
      height + adjustHeight + padding.vertical,
    );
  }

注意,在旋转前我们对 canvas 进行了平移操作,如果不限平移,就会导致旋转之后一部分内容的位置跑在画布之外了,如图:

Flutter 笔记 | Flutter 自定义组件_第9张图片

接下来实现 shouldRepaint 方法:


bool shouldRepaint(TextWaterMarkPainter oldPainter) {
  return oldPainter.rotate != rotate ||
      oldPainter.text != text ||
      oldPainter.padding != padding ||
      oldPainter.textDirection != textDirection ||
      oldPainter.textStyle != textStyle;
}

上面这些属性发生变化时都会导致水印 UI 发生变化,所以需要重绘。

完整代码:

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

import 'package:flutter/widgets.dart';

class WaterMark extends StatefulWidget {
  const WaterMark({
    Key? key,
    this.repeat = ImageRepeat.repeat,
    required this.painter,
  }) : super(key: key);

  /// 单元水印画笔
  final WaterMarkPainter painter;

  /// 单元水印的重复方式
  final ImageRepeat repeat;

  
  State<WaterMark> createState() => _WaterMarkState();
}

class _WaterMarkState extends State<WaterMark> {
  late Future<MemoryImage> _memoryImageFuture;

  
  void initState() {
    // 缓存的是promise
    _memoryImageFuture = _getWaterMarkImage();
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return SizedBox.expand(
      // 水印尽可能大
      child: FutureBuilder(
        future: _memoryImageFuture,
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            // 如果单元水印还没有绘制好先返回一个空的Container
            return Container();
          } else {
            // 如果单元水印已经绘制好,则渲染水印
            return DecoratedBox(
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: snapshot.data, // 背景图,即我们绘制的单元水印图片
                  repeat: widget.repeat,
                  alignment: Alignment.topLeft, // 指定重复方式
                  scale: MediaQuery.of(context).devicePixelRatio, // 很重要
                ),
              ),
            );
          }
        },
      ),
    );
  }

  
  void didUpdateWidget(WaterMark oldWidget) {
    // 如果画笔发生了变化(类型或者配置)则重新绘制水印
    if (widget.painter.runtimeType != oldWidget.painter.runtimeType ||
        widget.painter.shouldRepaint(oldWidget.painter)) {
      //先释放之前的缓存
      _memoryImageFuture.then((value) => value.evict());
      //重新绘制并缓存
      _memoryImageFuture = _getWaterMarkImage();
    }
    super.didUpdateWidget(oldWidget);
  }

  // 离屏绘制单元水印并将绘制结果保存为图片缓存起来
  Future<MemoryImage> _getWaterMarkImage() async {
    // 创建一个 Canvas 进行离屏绘制 
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);
    // 绘制单元水印并获取其大小
    final size = widget.painter.paintUnit(
      canvas,
      MediaQueryData.fromView(ui.window).devicePixelRatio,
    ); 
    final picture = recorder.endRecording();
    //将单元水印导为图片并缓存起来
    final img = await picture.toImage(size.width.ceil(), size.height.ceil());
    picture.dispose();
    final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
    img.dispose();
    final pngBytes = byteData!.buffer.asUint8List();
    return MemoryImage(pngBytes);
  }

  
  void dispose() {
    // 释放图片缓存
    _memoryImageFuture.then((value) => value.evict());
    super.dispose();
  }
}

/// 定义水印画笔
abstract class WaterMarkPainter {
  /// 绘制"单元水印",完整的水印是由单元水印重复平铺组成,返回值为"单元水印"占用空间的大小。
  /// [devicePixelRatio]: 因为最终要将绘制内容保存为图片,所以在绘制时需要根据屏幕的
  /// DPR来放大,以防止失真
  Size paintUnit(Canvas canvas, double devicePixelRatio);

  /// 是否需要重绘
  bool shouldRepaint(covariant WaterMarkPainter oldPainter) => true;
}

/// 文本水印画笔
class TextWaterMarkPainter extends WaterMarkPainter {
  TextWaterMarkPainter(
      {Key? key,
      double? rotate,
      EdgeInsets? padding,
      TextStyle? textStyle,
      required this.text,
      this.textDirection = TextDirection.ltr})
      : assert(rotate == null || rotate >= -90 && rotate <= 90),
        rotate = rotate ?? 0,
        padding = padding ?? const EdgeInsets.all(10.0),
        textStyle = textStyle ??
            const TextStyle(
              color: Color.fromARGB(30, 0, 0, 0),
              fontSize: 14,
            );

  double rotate; // 文本旋转的度数,是角度不是弧度
  TextStyle textStyle; // 文本样式
  EdgeInsets padding; // 文本的 padding
  String text; // 文本
  TextDirection textDirection;
 
  // Flutter 提供了一个专门用于绘制文本的画笔 TextPainter 
  
  Size paintUnit(Canvas canvas, double devicePixelRatio) {
    //根据屏幕 devicePixelRatio 对文本样式中长度相关的一些值乘以devicePixelRatio
    final _textStyle = _handleTextStyle(devicePixelRatio);
    final _padding = padding * devicePixelRatio;

    //构建文本画笔
    TextPainter painter = TextPainter(
      textDirection: TextDirection.ltr,
      textScaleFactor: devicePixelRatio,
    );
    //添加文本和样式
    painter.text = TextSpan(text: text, style: _textStyle);
    //对文本进行布局
    painter.layout();

    //文本占用的真实宽度
    final textWidth = painter.width;
    //文本占用的真实高度
    final textHeight = painter.height;

    // 将弧度转化为度数
    final radians = math.pi * rotate / 180;

    //通过三角函数计算旋转后的位置和size
    final orgSin = math.sin(radians);
    final sin = orgSin.abs();
    final cos = math.cos(radians).abs();

    final width = textWidth * cos;
    final height = textWidth * sin;
    final adjustWidth = textHeight * sin;
    final adjustHeight = textHeight * cos;

    // 为什么要平移? 
    // 如果不限平移,就会导致旋转之后一部分内容的位置跑在画布之外了
    if (orgSin >= 0) {
      // 旋转角度为正
      canvas.translate(
        adjustWidth + _padding.left,
        _padding.top,
      );
    } else {
      // 旋转角度为负
      canvas.translate(
        _padding.left,
        height + _padding.top,
      );
    }
    canvas.rotate(radians);
    // 绘制文本
    painter.paint(canvas, Offset.zero);
    // 返回水印单元所占的真实空间大小(需要加上padding)
    return Size(
      width + adjustWidth + _padding.horizontal,
      height + adjustHeight + _padding.vertical,
    );
  } 
 
  TextStyle _handleTextStyle(double devicePixelRatio) {
    var style = textStyle;
    double _scale(attr) => attr == null ? 1.0 : devicePixelRatio;
    return style.apply(
      decorationThicknessFactor: _scale(style.decorationThickness),
      letterSpacingFactor: _scale(style.letterSpacing),
      wordSpacingFactor: _scale(style.wordSpacing),
      heightFactor: _scale(style.height),
    );
  }

  
  bool shouldRepaint(TextWaterMarkPainter oldPainter) {
    return oldPainter.rotate != rotate ||
        oldPainter.text != text ||
        oldPainter.padding != padding ||
        oldPainter.textDirection != textDirection ||
        oldPainter.textStyle != textStyle;
  }
}

测试代码:


Widget build(BuildContext context) {
  return wTextWaterMark();
}

Widget wTextWaterMark() {
  return Stack(
    children: [
      wPage(),
      IgnorePointer(
        child: WaterMark(
          painter: TextWaterMarkPainter(
            text: 'Flutter 中国 @wendux',
            textStyle: TextStyle(
              fontSize: 15,
              fontWeight: FontWeight.w200,
              color: Colors.black38, //为了水印能更清晰一些,颜色深一点
            ),
            rotate: -20, // 旋转 -20 度
          ),
        ),
      ),
    ],
  );
}

Widget wPage() {
  return Center(
    child: ElevatedButton(
      child: const Text('按钮'),
      onPressed: () => print('tab'),
    ),
  );
}
... //省略无关代码

运行效果:

Flutter 笔记 | Flutter 自定义组件_第10张图片

单元水印画笔—交错文本水印

拥有交错效果的文本水印比较常见,效果如图:

Flutter 笔记 | Flutter 自定义组件_第11张图片

要实现这样的效果按照之前思路,我们只需要将单元水印绘制为图中红色框圈出来的部分即可,可以看到这个单元水印和之前TextWaterMarkPainter 有一点不同,即 TextWaterMarkPainter 只能绘制单个文本,而现在我们需要绘制两个问文本,且两个文本沿竖直方向排列,且两个文本左边起始位置有偏移。

我们想想如何实现?直接能想到的是继续在 TextWaterMarkPainterpaintUnit 方法后面加逻辑,但这样会带来两个问题:

  1. TextWaterMarkPainter 的配置参数会变多。
  2. TextWaterMarkPainterpaintUnit 已经很复杂了,如果再往里面加代码,后期的理解成本和维护成本会比较大,心智负担重。

不能直接修改 TextWaterMarkPainter 实现,但我们又想复用 TextWaterMarkPainter 的逻辑,这时可以使用代理模式,即我们新建一个WaterMarkPainter,在里面来调用 TextWaterMarkPainter 方法即可。

/// 交错文本水印画笔,可以在水平或垂直方向上组合两个文本水印,
/// 通过给第二个文本水印指定不同的 padding 来实现交错效果。
class StaggerTextWaterMarkPainter extends WaterMarkPainter {
  StaggerTextWaterMarkPainter({
    required this.text,
    this.padding1,
    this.padding2 = const EdgeInsets.all(30),
    this.rotate,
    this.textStyle,
    this.staggerAxis = Axis.vertical, 
    String? text2,
  }) : text2 = text2 ?? text;
  //第一个文本
  String text;
  //第二个文本,如果不指定则和第二个文本相同
  String text2;
  //我们限制两个文本的旋转角度和文本样式必须相同,否则显得太乱了
  double? rotate;
  ui.TextStyle? textStyle;
  //第一个文本的padding
  EdgeInsets? padding1;
  //第二个文本的padding
  EdgeInsets padding2;
  // 两个文本沿哪个方向排列
  Axis staggerAxis;

  
  Size paintUnit(Canvas canvas, double devicePixelRatio) {
    final TextWaterMarkPainter painter = TextWaterMarkPainter(
      text: text,
      padding: padding1,
      rotate: rotate ?? 0,
      textStyle: textStyle,
    );
    // 绘制第一个文本水印前保存画布状态,因为在绘制过程中可能会平移或旋转画布
    canvas.save();
    // 绘制第一个文本水印
    final size1 = painter.paintUnit(canvas, devicePixelRatio);
    // 绘制完毕后恢复画布状态。
    canvas.restore();
    // 确定交错方向
    bool vertical = staggerAxis == Axis.vertical;
    // 将 Canvas平移至第二个文本水印的起始绘制点
    canvas.translate(vertical ? 0 : size1.width, vertical ? size1.height : 0);
    // 设置第二个文本水印的 padding 和 text2
    painter
      ..padding = padding2
      ..text = text2;
    // 绘制第二个文本水印
    final size2 = painter.paintUnit(canvas, devicePixelRatio);
    // 返回两个文本水印所占用的总大小
    return Size(
      vertical ? math.max(size1.width, size2.width) : size1.width + size2.width,
      vertical
          ? size1.height + size2.height
          : math.max(size1.height, size2.height),
    );
  }

  
  bool shouldRepaint(StaggerTextWaterMarkPainter oldPainter) {
    return oldPainter.rotate != rotate ||
        oldPainter.text != text ||
        oldPainter.text2 != text2 ||
        oldPainter.staggerAxis != staggerAxis ||
        oldPainter.padding1 != padding1 ||
        oldPainter.padding2 != padding2 ||
        oldPainter.textDirection != textDirection ||      
        oldPainter.textStyle != textStyle;
  }
}

上面代码有三点需要注意:

  1. 在绘制第一个文本之前需要调用 canvas.save 保存画布状态,因为在绘制过程中可能会平移或旋转画布,在绘制第二个文本之前恢复画布状态,并需要将 Canvas平移至第二个文本水印的起始绘制点。
  2. 两个文本可以沿水平方向排列,也可以沿竖直方向排列,不同的排列规则会影响最终水印单元的大小。
  3. 交错的偏移通过 padding2 来指定。

测试代码:

Widget wStaggerTextWaterMark() {
  return Stack(
    children: [
      wPage(),
      IgnorePointer(
        child: WaterMark(
          painter: StaggerTextWaterMarkPainter(
            text: '《Flutter实战》',
            text2: 'wendux',
            textStyle: TextStyle(
              color: Colors.black38,
            ),
            padding2: EdgeInsets.only(left: 40), // 第二个文本左边向右偏移 40
            rotate: -10,
          ),
        ),
      ),
    ],
  );
}

对水印应用偏移

我们实现的两个文本水印画笔能对单元水印指定padding,但是如果我们需要对整个水印组件应用偏移效果呢?比如期望下图所示的效果:让 WaterMark 的整个背景向左平移了30像素,可以看到第一列的水印文本只显示了一部分。

Flutter 笔记 | Flutter 自定义组件_第12张图片

首先,我们不能在文本水印画笔中应用偏移,因为水印画笔画的是单元水印,如果我们绘制的单元水印只显示了部分文本,则单元水印重复时每个重复区域也都只显示部分文本。所以我们得对 WaterMark 的背景整体做一个偏移,这时想必读者应该想到了 Transform 组件,OK,那我们先用 Transform 组件来试试。

Transform.translate(
  offset: Offset(-30,0), //向做偏移30像素
  child: WaterMark(
    painter: TextWaterMarkPainter(
      text: 'Flutter 中国 @wendux',
      textStyle: TextStyle(
        color: Colors.black38,
      ),
      rotate: -20,
    ),
  ),
),

运行效果:

Flutter 笔记 | Flutter 自定义组件_第13张图片

可以发现虽然整体向做偏移了,但是右边出现了空白,这时因为 WaterMark 占用的空间本来就是和屏幕等宽的,所以它绘制时的区域也就和屏幕一样大,而Transform.translate 的作用相当于是在绘制时将绘制的原点向做平移了 30 像素,所以右边就出现了空白。

既然如此,那如果能让 WaterMark 的绘制区域超过屏幕宽度 30 像素,这样平移后不就可以了么?这个思路是对的,我们知道 WaterMark 中是通过 DecoratedBox 去绘制的背景,但我们不能去修改 DecoratedBox 的绘制逻辑,如果将 DecoratedBox 相关代码拷贝一份出来修改,这样后期的维护成本就很大,所以直接修改 DecoratedBox 的方法不可取。

1. 方案一:使用可滚动组件来应用偏移

我们知道大多数组件的绘制区域是和自身布局大小是相同的,那么我们能不能强制让 WaterMark 的宽度超出屏幕宽度30 像素呢?当然可以,可滚动组件不都是这个原理么!那么肯定有一个方法能行的通,即强制指定WaterMark的宽度比屏幕宽度大30,然后用一个 SingleChildScrollView包裹:

Widget wTextWaterMarkWithOffset() {
  return Stack(
    children: [
      wPage(),
      IgnorePointer(
        child: LayoutBuilder(builder: (context, constraints) {
          print(constraints);
          return SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Transform.translate(
              offset: Offset(-30, 0),
              child: SizedBox(
                // constraints.maxWidth 为屏幕宽度,+30 像素
                width: constraints.maxWidth + 30,
                height: constraints.maxHeight,
                child: WaterMark(
                  painter: TextWaterMarkPainter(
                    text: 'Flutter 中国 @wendux',
                    textStyle: TextStyle(
                      color: Colors.black38,
                    ),
                    rotate: -20,
                  ),
                ),
              ),
            ),
          );
        }),
      ),
    ],
  );
}

上面的代码可以实现我们期望的效果。

需要说明的是因为 SingleChildScrollViewIgnorePointer 包裹着,所以它是接收不到事件的,所以不会受用户滑动的干扰。

我们知道 SingleChildScrollView 内部要创建ScrollableViewport 对象,而在这个场景下 SingleChildScrollView 是不会响应事件的,所以创建 Scrollable 就属于多余的开销,我们需要探索一种更优的方案。

2. 方案二:使用 FittedBox 来应用偏移

我们能否先通过 UnconstrainedBox 取消父组件对子组件大小的约束,然后通过 SizedBox 指定 WaterMark 宽度比屏幕长 30 像素 来实现,比如:

LayoutBuilder(
  builder: (_, constraints) {
    return UnconstrainedBox( // 取消父组件对子组件大小的约束
      alignment: Alignment.topRight,
      child: SizedBox(
        //指定 WaterMark 宽度比屏幕长 30 像素
        width: constraints.maxWidth + 30,
        height: constraints.maxHeight,
        child: WaterMark(...),
      ),
    );
  },
),

运行效果:

Flutter 笔记 | Flutter 自定义组件_第14张图片

我们看到,左边出现了一个溢出提示条,这是因为 UnconstrainedBox 虽然在其子组件布局时可以取消约束(子组件可以为无限大),但是 UnconstrainedBox 自身是受其父组件约束的,所以当 UnconstrainedBox 随着其子组件变大后,如果 UnconstrainedBox 的大小超过它父组件大小时,就导致了溢出。

如果没有这个溢出提示条,则我们想要的偏移效果实际上已经实现了!偏移的实现原理是我们指定了屏幕右对齐,因为子组件的右边界和父组件右边界对齐时,超出的 30 像素宽度就会在父组件的左边界之外,从而就实现了我们期望的效果。我们知道在 Release 模式下是不会绘制溢出提示条的,因为溢出条的绘制逻辑是在 assert 函数中,比如:

// Display the overflow indicator.
assert(() {
  paintOverflowIndicator(context, offset, _overflowContainerRect, _overflowChildRect);
  return true;
}());

所以在 Release 模式下上面代码也不会有问题,但是我们还是不应该使用这种方法,因为既然有提示,则这就代表 UnconstrainedBox 子元素溢出是不被预期的行为。

原因搞清楚后,我们解决思路就是:在取消约束的同时不要让组件大小超出父组件的空间即可。而我们之前章节介绍的 FittedBox 组件,它可以取消父组件对子组件的约束并同时可以让其子组件适配 FittedBox 父组件的大小,正好符合我们的要求,下面我们修改一下代码:

 LayoutBuilder(
  builder: (_, constraints) {
    return FittedBox( //FittedBox会取消父组件对子组件的约束
      alignment: Alignment.topRight, // 通过对齐方式来实现平移效果
      fit: BoxFit.none,//不进行任何适配处理
      child: SizedBox(
        //指定 WaterMark 宽度比屏幕长 30 像素
        width: constraints.maxWidth + 30,
        height: constraints.maxHeight,
        child: WaterMark(
          painter: TextWaterMarkPainter(
            text: 'Flutter 中国 @wendux',
            textStyle: TextStyle(
              color: Colors.black38,
            ),
            rotate: -20,
          ),
        ),
      ),
    );
  },
),

运行后,能实现我们预期的效果。

FittedBox 主要的使用场景是对子组件进行一些缩放、拉升等以适配父组件的空间,而在本例的场景中我们并没有用到这个功能(适配方式指定了 BoxFit.none ),还是有点杀鸡用牛刀的感觉,那还有其他更合适的组件来解决这个问题吗?答案是有,OverflowBox

方案三:使用 OverflowBox 来应用偏移

OverflowBoxUnconstrainedBox 相同的是可以取消父组件对子组件的约束,但不同的是 OverflowBox 自身大小不会随着子组件大小而变化,它的大小只取决于其父组件的约束(约束为 constraints.biggest),即在满足父组件约束的前提下会尽可能大。我们封装一个 TranslateWithExpandedPaintingArea 组件来包裹 WaterMark 组件:

class TranslateWithExpandedPaintingArea extends StatelessWidget {
  const TranslateWithExpandedPaintingArea({
    Key? key,
    required this.offset,
    this.clipBehavior = Clip.none,
    this.child,
  }) : super(key: key);
  final Widget? child;
  final Offset offset;
  final Clip clipBehavior;

  
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final dx = offset.dx.abs();
        final dy = offset.dy.abs();

        Widget widget = OverflowBox(
          //平移多少,则子组件相应轴的长度增加多少
          minWidth: constraints.minWidth + dx,
          maxWidth: constraints.maxWidth + dx,
          minHeight: constraints.minHeight + dy,
          maxHeight: constraints.maxHeight + dy,
          alignment: Alignment(
            // 不同方向的平移,要指定不同的对齐方式
            offset.dx <= 0 ? 1 : -1,
            offset.dy <= 0 ? 1 : -1,
          ),
          child: child,
        );
        //超出组件布局空间的部分要剪裁掉
        if (clipBehavior != Clip.none) {
          widget = ClipRect(clipBehavior: clipBehavior, child: widget);
        }
        return widget;
      },
    );
  }
}

上面代码有三点需要说明:

  1. 会根据用户指定的偏移来动态给子组件宽高增加相应的值。
  2. 我们需要根据用户指定的偏移来动态调整 OverflowBox 的对齐方式,比如要向左平移时,OverflowBox 就必须右对齐,因为右对齐后超出父容器的部分会在左边界之外,这就是我们想要的效果,如果我们没有右对齐而是左对齐,则超出屏幕的部分本来就在右边界之外,这不符合预期。
  3. 超出边界的内容默认会显示,当然本例中水印组件大小和屏幕剩余显示空间一样大,所以超出后就不会显示,但如果我们给水印组件指定一个较小的大小,就可以看到超出之后的内容了,因此,我们定义了一个剪裁的配置参数,使用者可以根据实际情况决定是否进行剪裁。

所以最终的调用代码就是:

Widget wTextWaterMarkWithOffset2() {
  return Stack(
    children: [
      wPage(),
      IgnorePointer(
        child: TranslateWithExpandedPaintingArea(
          offset: Offset(-30, 0),
          child: WaterMark(
            painter: TextWaterMarkPainter(
              text: 'Flutter 中国 @wendux',
              textStyle: TextStyle(
                color: Colors.black38,
              ),
              rotate: -20,
            ),
          ),
        ),
      ),
    ],
  );
}

运行后,能实现我们预期的效果。


参考:《Flutter实战·第二版》

你可能感兴趣的:(Flutter,flutter,Flutter自定义组件)