Flutter notes | Flutter custom components

Several ways to customize components in Flutter

When the existing components provided by Flutter cannot meet our needs, or we need to encapsulate some common components in order to share code, then we need custom components. There are three ways to customize components in Flutter: by combining other components , self-drawing and implementationRenderObject .

1. Combining multiple widgets

This method is to combine multiple components into a new component. For example, what we introduced before Containeris a composite component, which is DecoratedBox、ConstrainedBox、Transform、Padding、Aligncomposed of other components.

In Flutter, the idea of ​​combination is very important. Flutter provides a lot of basic components, and our interface development is actually to combine these components according to needs to achieve various layouts.

2. Self-drawing through CustomPaint

If the required UI cannot be achieved through the existing components, we can achieve it by self-drawing components. For example, we need a circular progress bar with a color gradient, and Flutter does not support accurate progress display CircularProgressIndicator. When applying a gradient color to the progress bar (its valueColorproperty only supports Indicatorthe color that changes when the rotation animation is performed), the best way at this time is to draw the appearance we expect by customizing the component. CustomPaintWe can implement UI self-drawing through the sum provided in Flutter Canvas.

3. Self-drawing through RenderObject

The components provided by Flutter with their own UI appearance, such as text, are rendered Text、Imageby corresponding rendering, such as by rendering; but by rendering. is an abstract class that defines an abstract method :RenderObjectTextRenderParagraphImageRenderImageRenderObjectpaint(...)

void paint(PaintingContext context, Offset offset)

PaintingContextRepresents the drawing context of the component, which PaintingContext.canvascan be obtained through Canvasthe API, and the drawing logic is mainly Canvasimplemented through the API. Subclasses need to override this method to implement their own drawing logic, such as RenderParagraphtext drawing logic and RenderImagepicture drawing logic.

It can be found that RenderObjectin the end, it is also Canvasdrawn through the API, so what is the difference between the way of realization and the way of passing and self-drawing RenderObjectintroduced above ? In fact, the answer is very simple. It is just a proxy class encapsulated for the convenience of developers. It directly inherits from the passed method and connects with the brush (which needs to be implemented by the developer, which will be introduced later) to achieve the final drawing (the drawing logic is in ).CustomPaintCanvasCustomPaintSingleChildRenderObjectWidgetRenderCustomPaintpaintCanvasPainterPainter

Summary : "Combination" is the easiest way to customize components. In any scenario that requires custom components, we should give priority to whether it can be achieved through composition . CustomPaintThe RenderObjectself-drawing method is essentially the same as the self-drawing method, which requires developers to call the CanvasAPI to manually draw the UI. The advantage is that it is powerful and flexible. In theory, any appearance of the UI can be realized. The disadvantage is that you must understand the details of the CanvasAPI and have to do it yourself. Implement drawing logic.

Combine existing components

Example: Custom Gradient Button

The buttons in the Flutter Material component library do not support gradient backgrounds by default. In order to implement gradient background buttons, we customize a GradientButtoncomponent that needs to support the following functions:

  1. Background supports gradient colors
  2. There is a ripple effect when the finger is pressed
  3. Can support rounded corners

Let's take a look at the final effect:

insert image description here

DecoratedBoxIt can support background color gradient and rounded corners, and InkWellthere is a ripple effect when the finger is pressed, so we can achieve it by combining DecoratedBoxand , the code is as follows:InkWellGradientButton

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,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

use 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");
  }
}

Defining components through combination is no different from the interface we wrote before, but we need to consider code standardization when extracting individual components. For example, necessary parameters should be marked with keywords, and optional parameters need to be judged empty in specific scenarios required. Or set defaults etc. assertThis is because users may not understand the internal details of the component most of the time, so in order to ensure the robustness of the code, we need to be compatible or report an error prompt (using the assertion function) when the user uses the component incorrectly.

Combination example: TurnBox

As we have introduced before RotatedBox, it can rotate sub-components, but it has two disadvantages: one is that it can only rotate its sub-nodes in multiples of degrees 90; the other is that when the rotation angle changes, there is no animation during the rotation angle update process.

Below we will implement a TurnBoxcomponent that can not only rotate its child nodes at any angle, but also perform an animation to transition to a new state when the angle changes. At the same time, we can manually specify the animation speed.

TurnBoxThe full code is as follows:

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,
      );
    }
  }
}

In the above code:

  1. We achieve the rotation effect by combining RotationTransitionand .child
  2. In didUpdateWidget, we judge whether the angle to be rotated has changed, and if so, perform a transition animation.

Let's test TurnBoxthe function below, the test code is as follows:

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);
              },
            )
          ],
        ),
      ),
    );
  }
}

Effect:

insert image description here
When we click the rotate button, the rotation of both icons will rotate 1/5 of a circle, but the rotation speed is different.

In fact, this example only combines RotationTransitionone component, which is the simplest example of a combined class component. In addition, if we are encapsulating StatefulWidget, we must pay attention to whether we need to synchronize the state when the component is updated. For example, we want to encapsulate a rich text display component MyRichText, which can automatically handle urllinks, defined as follows:

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

  final String text;
  final TextStyle linkStyle;

  
  _MyRichTextState createState() => _MyRichTextState();
}

Next, we _MyRichTextStatehave two functions to implement in:

  1. Parse the text string " text" and generate TextSpana cache;
  2. buildReturn the final rich text style in ;

_MyRichTextStateThe implemented code is roughly as follows:

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();
  }
}

Since parsing a text string TextSpanis a time-consuming operation, in order not to buildparse it every time, we initStatecache the parsed results in , and then builduse the parsed results directly in _textSpan.

This looks good, but the above code has a serious problem, that is, textwhen the input of the parent component changes (the component tree structure remains unchanged), MyRichTextthe displayed content will not be updated, because it initStatewill only Statebe called when it is created , so when texta change occurs, parseTextthere is no re-execution, resulting _textSpanin the old parsed value.

To solve this problem is also very simple, we only need to add a didUpdateWidgetcallback, and then call it again parseText:


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

Some developers may think that this point is also very simple. Yes, it is indeed very simple. The reason why it is repeatedly emphasized here is that this point is easily overlooked in actual development. Although it is simple, it is very important. In short, when we Statecache some Widgetdata that depends on parameters in , we must pay attention to whether we need to synchronize the state when the component is updated.

Again, an important point of custom components is to didUpdateWidgetdecide whether to rebuild the UI based on the comparison of the status values ​​of the old and new components.

CustomPaint and Canvas

For some complex or irregular UI, we may not be able to achieve it by combining other components, for example, we need a regular hexagon, a gradient circular progress bar, a chessboard, etc. Of course, sometimes we can use pictures to achieve this, but in some scenes that require dynamic interaction, static pictures cannot be realized. For example, to realize a handwriting input panel, at this time, we need to draw the UI appearance by ourselves.

Almost all UI systems provide a self-drawing UI interface. This interface usually provides a 2D canvas Canvas, Canvaswhich encapsulates some basic drawing APIs, and developers can Canvasdraw various custom graphics. In Flutter, a CustomPaintcomponent is provided, which can be combined with brushes CustomPainterto realize custom graphics drawing.

CustomPaint

Let's look at CustomPaintthe constructor:

CustomPaint({
    
    
  Key key,
  this.painter, 
  this.foregroundPainter,
  this.size = Size.zero, 
  this.isComplex = false, 
  this.willChange = false, 
  Widget child, //子节点,可以为空
})
  • painter: Background brush, which will be displayed behind the child nodes;
  • foregroundPainter: foreground brush, will be displayed in front of child nodes
  • size: When childisnull , it represents the default drawing area size. If there is, childthis parameter is ignored, and the canvas size is childthe size. If you have childbut want to specify the canvas to be a specific size, you can use SizeBoxthe package CustomPaintto achieve it.
  • isComplex: Whether it is complex drawing, if so, Flutter will apply some caching strategies to reduce the overhead of repeated rendering.
  • willChange: isComplexUsed in conjunction with it, when caching is enabled, this property represents whether the drawing will change in the next frame.

As you can see, we need to provide a foreground or background brush when drawing, and both can be provided at the same time. Our brush needs to inherit CustomPainterthe class, and we implement the real drawing logic in the brush class.

1. Draw the boundary RepaintBoundary

If CustomPaintthere are child nodes, in order to avoid unnecessary redrawing of the child nodes and improve performance, the child nodes are usually wrapped in components RepaintBoundary, so that a new drawing layer ( Layer) will be created when drawing, and its child components will be Draw on the new one Layer, while the parent component will Layerdraw on the original one, that is to say, RepaintBoundarythe drawing of the child component will be independent of the drawing of the parent component, which RepaintBoundarywill isolate its child nodes and CustomPaintits own drawing boundary.

Examples are as follows:

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

2. Custom Painter and Canvas

CustomPainteris an abstract class, our custom brush needs to be implemented CustomPainter, and CustomPainteran abstract method is defined in it paint:

void paint(Canvas canvas, Size size);

paintThere are two parameters:

  • canvas: a canvas, including various drawing methods
  • size: The size of the current drawing area.

The following Canvasmethods are commonly used in :

API name Function
drawLine draw a line
drawPoint draw dots
drawPath draw path
drawImage draw image
drawRect draw a rectangle
drawCircle draw a circle
drawOval draw an ellipse
drawArc Draw an arc

3. Brush Paint

Now that the canvas is available, we still lack a brush. Flutter provides Paintclasses to implement brushes. In Paint, we can configure various attributes of the brush such as thickness, color, style, etc. like:

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

More configuration properties can refer to Paintthe class definition.

Example: backgammon/board

1. Draw the chessboard and chess pieces

Next, we demonstrate the process of self-drawing UI by drawing the board and pieces in a backgammon game. First, let's take a look at our target effect, as shown in the figure:
insert image description here
Code:

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;
}

Let's implement the chessboard drawing first:

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);
  }
}

Realize chess piece drawing again:

//画棋子
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. Drawing performance

Drawing is a relatively expensive operation, so we should consider performance overhead when implementing self-drawing controls. Here are two suggestions for performance optimization:

  1. Make good use of the return value as much as possible shouldRepaint; when the UI tree is redrawn build, the control will call this method before drawing to determine whether it is necessary to redraw;

    If the UI we draw does not depend on the external state, that is, the change of the external state will not affect the appearance of our UI, then we should return; falseif the drawing depends on the external state, then we should shouldRepaintjudge whether the dependent state has changed in , if it has changed, then Should be returned trueto redraw, otherwise it should be returned falsenot to redraw.

  2. Draw as many layers as possible ;

    In the example of backgammon above, we put the drawing of the chessboard and the chess pieces together, so there will be a problem: since the chessboard is always the same, the user only changes the chess pieces every time, but if you follow the above code to Realize, it is unnecessary to redraw the board every time a chess piece is drawn. The optimized method is to separate the chessboard as a component, set its shouldRepaintcallback value false, and then use the chessboard component as the background. Then put the drawing of the chess pieces into another component, so that only the chess pieces need to be drawn every time a piece is dropped.

3. Prevent accidental redrawing

Let's add one based on the above example ElevatedButton, and do nothing after clicking:

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("刷新"))
        ],
      ),
    );
  }
}

After running, as shown in the figure:

insert image description here
After running, we clicked the "Refresh" button and found that the log panel output a lot of " paint", that is to say, multiple redraws occurred when the button was clicked. Strange, shouldRepaintwhat we return is false, and clicking the refresh button does not trigger the page to rebuild, so what caused the redraw? The canvas of the refresh button CustomPaintis the same as the canvas of . When the refresh button is clicked, a water wave animation will be executed. During the execution of the water wave animation, the canvas will be refreshed continuously, which leads to CustomPaintcontinuous redrawing. The solution to this problem is very simple, just CustomPaintadd a parent component to the refresh button or any one RepaintBoundary, and now you can simply think that doing so can generate a new canvas:

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

Self-drawn example: circular background gradient progress bar

Below we implement a circular background gradient progress bar, which supports:

  1. Supports multiple background gradient colors.
  2. Arbitrary arc; the progress bar may not be a full circle.
  3. You can customize the thickness, whether the ends are rounded or not, and other styles.

It can be found that to achieve such a progress bar cannot be combined through existing components, so we realize it by self-drawing, the code is as follows:

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();
  }
}

Next, let's test it. In order to show as many GradientCircularProgressIndicatordifferent appearances and uses as possible, this sample code will be relatively long and animations will be added.

Sample code:

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,
                                  ),
                                ),
                              )
                            ],
                          ),
                        ),
                      ],
                    ),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

running result:

insert image description here

Self-drawn component: CustomCheckbox

Flutter's built-in Checkboxcomponents cannot be freely specified in size. Next, we will customize a CustomCheckboxcomponent that can be freely specified in size to demonstrate how to RenderObjectcustomize components by definition (rather than by combination). The component effect we want to achieve CustomCheckboxis shown in the figure:

insert image description here

  1. There are two states: selected and unselected.
  2. An animation is to be performed when the state is switched.
  3. The appearance can be customized.

CustomCheckboxIt is defined as follows:

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;
  }
}

The only thing to note in the above code is that updateRenderObjectwhen the selected state changes in the method, we need to update RenderObjectthe animation state in progress. The specific logic is: when switching from unselected to selected state, perform forward animation; when switching from selected state to Perform reverse animation when unchecked state.

Next you need to implement 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. Implement the layout algorithm

In order to allow users to customize the width and height, our layout strategy is: if the parent component specifies a fixed width and height, use the parent component specified, otherwise the width and height are set to 25:

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

2. Draw CustomCheckbox

The next point is to draw CustomCheckbox. For the sake of clarity, we divide the drawing into two parts: the background (rectangle) and the foreground (tick). First draw the background, and then draw the 'tick'. Here are two points to note:

  1. progressWhat we draw is a frame during the animation execution, so we need to calculate the appearance of each frame through the animation execution progress ( ).
  2. When CustomCheckboxfrom unselected to selected, we perform a forward animation, progressand the value of will 0gradually change from to 1, because CustomCheckboxthe background and foreground ('tick') colors of the should contrast, so we draw the foreground after the background is drawn. Therefore, we split the animation into two ends, the front 40%time draws the background, and the second 60% of the time draws the 'tick'.

1) Draw the background

insert image description here

Combined with the above picture, let's take a look at how to draw the background:

  1. When the state is switched to the selected state, the rectangle is gradually shrunk and filled from the edge to the center until Checkboxthe area is filled.
  2. When the state is switched to unselected, the padding gradually fades from the center to the edges until only a border remains.

The idea of ​​realization is to first fill the entire background rectangle area with blue, then draw a rectangle with a white background on it, and dynamically change the size of the white rectangle area according to the progress of the animation. Fortunately, Canvasthe API has helped us realize the functions we expect. drawDRRectYou can specify two rectangles inside and outside, and then draw the disjoint parts, and you can specify rounded corners. The following is the specific implementation:

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) Draw the foreground

The foreground is a "hook", which is composed of three points. For the sake of simplicity, we calculate the Checkboxfixed coordinates based on the position of the starting point and the midpoint inflection point, and then we dynamically adjust the third one in each frame. The position of the point can realize the tick animation:

//画 "勾"
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. Realize the animation

Finally, we need to animate the UI. Flutter's animation framework depends on StatefulWidget, that is, when the state changes, it is called explicitly or implicitly to trigger setStatean update. RenderObjectBut what we achieve directly by defining CustomCheckboxis not based on StatefulWidget, so how to schedule animation? There are two ways:

  1. Wrap the with CustomCheckboxa StatefulWidgetso that you can reuse the animation method described earlier.
  2. Custom animation scheduler.

I believe the first method is already familiar, so I won’t go into details here. Let’s demonstrate the second method. Our idea is: judge whether the animation is over after drawing a frame. If the animation is not over, mark the current component Set it to "need to redraw", and then wait for the next frame :

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. Respond to the click event

According to the previous introduction about event handling, if we want the rendering object to be able to handle events, it must pass the hit test before we can handleEventhandle events in the method, so we need to add the following code:

// 必须置为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);
      }
  }
}

Animation scheduling abstract RenderObjectAnimationMixin

We can see that RenderObjectit is quite complicated to schedule animations in , so we abstracted one RenderObjectAnimationMixin. If there are other RenderObjectanimations that need to be executed, they can be reused directly.

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);
}

Full source code of CustomCheckbox

The final CustomCheckboxcomplete source code is:

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);
      }
    }
  }
}

The test code is as follows: We create three check boxes of different sizes, click any one of them, and the states of the other two check boxes will also be linked accordingly:

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);
  }
}

RenderObjectIt can be seen that customizing components through will be more complicated than combining, but this method will be closer to the essence of Flutter components.

Self-drawing component: DoneWidget

Below we will implement one DoneWidgetthat can perform a tick animation when it is created, as shown in the figure:

insert image description here

The implementation code is as follows:

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;
  }
}

DoneWidgetThere are two modes, one is outlinethe mode, the background of this mode has no fill color, and at this time colorrepresents the color of the outline line; if it is not outlinethe mode, then colorrepresents the background color of the fill, at this time the color of the "tick" is simply set to white.

Next, it needs to be implemented RenderDoneObject. Since the component does not need to respond to events, we don’t need to add event-related processing code; but the component needs to perform animation, so we can directly use the one encapsulated in the previous section. The specific implementation code is as follows 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),
    );
  }
}

The above code is very simple, but you need to pay attention to three points:

  1. We have applied easeInthe curve to the animation, and you can see how to RenderObjectapply the curve to the animation in . The essence of the curve is to add a layer of mapping to the progress of the animation. Through different mapping rules, the speed of the animation at different stages can be controlled.

  2. We override RenderObjectAnimationMixinin duration, which is used to specify the duration of the animation.

  3. adjustProgressThe main function is to divide the "tick" animation into two parts. The first part is the connection animation between the first point and the second point. This part of the animation stands before the total animation time; the second part is the second point 60%and The connection animation of the third point, this part of the animation takes the last part of the total duration 40%.

Watermark example: text drawing and off-screen rendering

The following will introduce how to draw text and how to perform off-screen rendering by implementing a watermark component.

In actual scenarios, in most cases, the watermark needs to cover the entire screen. If it does not need to cover the screen, it can usually be achieved directly by combining components. In this section, we mainly discuss the watermark that needs to cover the screen.

Watermark component WaterMark

We can achieve our desired function by drawing a "unit watermark" and then let it repeat in the background of the entire watermark component, so we can use it directly DecoratedBox, it has the background image repeat function. After the repeated problem is solved, the main problem is how to draw the unit watermark. In order to be flexible and easy to expand, we define a watermark brush interface, so that we can preset some commonly used brush implementations to meet most scenarios. At the same time, if the development If the user has custom requirements, it can also be realized by custom brushes.

Here is WaterMarkthe definition of the watermark component:

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();
}

Let's take a look at Statethe implementation:

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() {
    
    
   ...// 待实现
  }
}	

We use DecoratedBoxto implement the background image repetition, and we start to draw the unit watermark off-screen when the component is initialized, and cache the result in MemoryImage, because off-screen drawing is an asynchronous task, so just cache directly Future. It should be noted here that when the component is restarted build, if the brush configuration changes, we need to redraw the unit watermark and cache the new drawing result:

 
  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);  
  }

Note that before redrawing the unit watermark, the cache of the old unit watermark must be cleared, and the cache can be cleared by calling the method MemoryImageof evict. At the same time, when the component is unmounted, we also need to release the cache:

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

Next, you need to redraw the unit watermark, _getWaterMarkImage()just call the method. The function of this method is to draw the unit watermark off-screen and convert the drawing result into an image cache. Let's take a look at its implementation.

off-screen drawing

The code for off-screen drawing is as follows:

  // 离屏绘制单元水印并将绘制结果保存为图片缓存起来
  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);
  }

We manually created one Canvasand one PictureRecorderto achieve off-screen drawing. PictureRecorderThe function is simple: Canvasafter calling the API, a series of drawing instructions are actually generated, and the drawing results can only be obtained after these drawing instructions are executed, which PictureRecorderis just a drawing instruction record The controller, which can record all drawing instructions in a period of time, we can recorder.endRecording()get the recorded drawing instructions by calling the method, which returns an Pictureobject, which is the carrier of the drawing instructions, and it has a toImagemethod, which will execute the drawing instructions after calling to get drawing The pixel result ( ui.Imageobject), then we can convert the pixel result to pngformat data and cache it in MemoryImage.

Cell Watermark Brush

Now let's take a look at how to draw a unit watermark. Let's first look at the definition of the watermark brush interface:

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

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

The definition is very simple, just two functions:

  • paintUnitIt is used to draw the unit watermark, here you need to pay attention, because the size of many UI elements can only be obtained when drawing, and the size cannot be known in advance, so paintUnitwhen the task of drawing the unit watermark is completed, the size information of the unit watermark must be returned at the end. It is used when exporting to a picture.
  • shouldRepaint: Return when the state of the brush changes and will affect the appearance of the unit watermark true, otherwise return , and redraw the unit watermark after falsereturning . trueIt is called _WaterMarkStatein didUpdateWidgetthe method and can be understood in combination with the source code.

Text Watermark Brush

Below we implement a text watermark brush, which can draw a piece of text, and we can specify the style and rotation angle of the text.

/// 文本水印画笔
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) {
    
    
   ...// 待实现
  }
}

paintUnitis drawn in two steps:

  1. draw text
  2. apply rotation andpadding

draw text

  1. Create one ParagraphBuilder, denoted as builder.
  2. Call builder.addto add the string to draw.
  3. Build the text and proceed layout, because layoutthe space occupied by the text is not known until after .
  4. Call canvas.drawParagraphdraw.

The specific code is as follows:

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),
    );
  }

It can be seen that the process of drawing text is quite complicated. For this reason, Flutter provides a brush specially used for drawing text TextPainter. We use to TextPaintertransform the above code:

//构建文本画笔
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);

It can be seen that the code is actually not much less, but it is clearer.

In addition, TextPainterthere is another usefulness in actual combat. TextWhen we want to know the width and height of the component in advance, we can use to TextPaintermeasure in advance, such as:

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,
    );
  }

Apply rotation and padding

Applying the rotation effect itself is relatively simple, but the difficulty is that the size of the space it occupies will change after the text is rotated, so we have to dynamically calculate the size of the space occupied by the rotated text. Assuming that the angle is rotated clockwise, draw the rotatelayout picture:

insert image description here

We can calculate the final width and height according to the above formula. Do you feel that the trigonometric functions learned in high school have finally come in handy! Note that the above formula does not take into account padding, paddingthe processing is relatively simple, do not go into details, see the code:

 
  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,
    );
  }

Note that we have canvasperformed a translation operation on before the rotation. If there is no limit to the translation, it will cause the position of some content to run outside the canvas after the rotation, as shown in the figure:

insert image description here

Next implement shouldRepaintthe method:


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

When the above properties change, the watermark UI will change, so it needs to be redrawn.

Full code:

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;
  }
}

Test code:


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'),
    ),
  );
}
... //省略无关代码

running result:

insert image description here

Cell Watermark Brush—Interleaved Text Watermark

Text watermarks with interlaced effects are more common, as shown in the figure:

insert image description here

To achieve this effect, according to the previous idea, we only need to draw the unit watermark as the part circled by the red frame in the figure. You can see that this unit watermark is a little different from the previous one, that is, only a single text can be drawn, TextWaterMarkPainterand TextWaterMarkPainternow we Two texts need to be drawn, and the two texts are arranged in the vertical direction, and the left starting position of the two texts is offset.

Let's think about how to achieve it? What can be directly thought of is to continue TextWaterMarkPainterto paintUnitadd logic after the method, but this will bring two problems:

  1. TextWaterMarkPainterThere will be more configuration parameters.
  2. TextWaterMarkPainterThe is paintUnitalready very complicated. If you add code to it, the later understanding cost and maintenance cost will be relatively large, and the mental burden will be heavy.

The implementation of cannot be modified directly TextWaterMarkPainter, but we want to reuse TextWaterMarkPainterthe logic of , then we can use the proxy mode, that is, we create a new one WaterMarkPainterand call the method in it 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;
  }
}

There are three things to note about the above code:

  1. It is necessary to call to save the canvas state before drawing the first text canvas.save, because the canvas may be translated or rotated during the drawing process, and the canvas state is restored before the second text is drawn, and the translation needs to be translated to the start drawing Canvasof the second text watermark point.
  2. The two texts can be arranged horizontally or vertically, and different arrangement rules will affect the size of the final watermark unit.
  3. The offset for the stagger is padding2specified by .

Test code:

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,
          ),
        ),
      ),
    ],
  );
}

apply an offset to the watermark

The two text watermark brushes we implemented can be specified for unit watermarks padding, but what if we need to apply offset effects to the entire watermark component? For example, expect the effect shown in the figure below: let WaterMarkthe entire background of the pixel be shifted to the left 30, and you can see that only a part of the watermark text in the first column is displayed.

insert image description here

First of all, we cannot apply offset in the text watermark brush, because the watermark brush draws the unit watermark, if the unit watermark we draw only shows part of the text, when the unit watermark is repeated, each repeated area will only display part of the text. So we have to WaterMarkmake an offset to the background of as a whole. At this time, the reader must have thought of Transformthe component. OK, let's Transformtry it with the component first.

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

running result:

insert image description here

It can be found that although the overall direction is offset, there is a blank space on the right. At this time, because the WaterMarkspace occupied by is originally the same width as the screen, the area it draws is also as large as the screen, and Transform.translatethe effect of drawing is equivalent to drawing The origin of the drawing is shifted by 30pixels, so there is a blank space on the right.

That being the case, if WaterMarkthe drawing area can be made to exceed the width of the screen 30by pixels, wouldn't it work after panning? This idea is correct. We know that the background WaterMarkis drawn through in DecoratedBox, but we cannot modify DecoratedBoxthe drawing logic of . If we DecoratedBoxcopy the relevant code of and modify it, the later maintenance cost will be very high, so DecoratedBoxthe method of direct modification is not possible. Pick.

1. Solution 1: Use scrollable components to apply offsets

We know that the drawing area of ​​most components is the same size as its own layout, so can we force WaterMarkthe width of to exceed the screen width 30by pixels? Of course you can, isn't this the principle of scrollable components? Then there must be a way that will work, that is, to force the specified WaterMarkwidth to be larger than the screen width 30, and then use a SingleChildScrollViewpackage:

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,
                  ),
                ),
              ),
            ),
          );
        }),
      ),
    ],
  );
}

The above code can achieve the desired effect.

It should be noted that because SingleChildScrollViewit is IgnorePointerwrapped, it cannot receive events, so it will not be disturbed by the user's sliding.

We know that and objects SingleChildScrollVieware created internally , and in this scenario will not respond to events, so creating is redundant overhead, and we need to explore a better solution.ScrollableViewportSingleChildScrollViewScrollable

2. Solution 2: Use FittedBox to apply offset

Can we first UnconstrainedBoxcancel the constraint on the size of the child component by the parent component, and then achieve it by SizedBoxspecifying that WaterMarkthe width is longer than the screen by pixels, for example:30

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

running result:

insert image description here

We can see that there is an overflow prompt bar on the left. This is because UnconstrainedBoxalthough the constraints can be canceled during the layout of its subcomponents (subcomponents can be infinite), but UnconstrainedBoxitself is constrained by its parent component, so when UnconstrainedBoxit follows its subcomponent After the component grows, if UnconstrainedBoxthe size of the exceeds the size of its parent component, it will cause overflow.

Without this overflow tooltip, the offset effect we want has actually been achieved! The realization principle of the offset is that we specify the right alignment of the screen, because when the right border of the child component is aligned with the right border of the parent component, the excess 30pixel width will be outside the left border of the parent component, thus achieving our desired effect . We know that Releasethe overflow prompt bar will not be drawn in the mode, because the drawing logic of the overflow bar is in assertthe function, for example:

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

So Releasethere is no problem with the above code in the mode, but we still should not use this method, because since there is a hint, it means that the UnconstrainedBoxchild element overflow is an unexpected behavior.

After the reason is clarified, our solution is: while canceling the constraint, do not let the component size exceed the space of the parent component. The component we introduced in the previous chapter FittedBoxcan cancel the constraint of the parent component on the child component and at the same time allow its child component to adapt to FittedBoxthe size of the parent component, which just meets our requirements. Let’s modify the code below:

 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,
          ),
        ),
      ),
    );
  },
),

After running, we can achieve the desired effect.

FittedBoxThe main usage scenario is to zoom, lift, etc. the child component to fit the space of the parent component, but in this example scenario we did not use this function (the adaptation method is specified), it is still a bit of a BoxFit.nonekiller Feeling like a sledgehammer, are there other more suitable components to solve this problem? The answer is yes, OverflowBox!

Solution 3: Use OverflowBox to apply the offset

OverflowBoxThe UnconstrainedBoxsame as that can cancel the constraint of the parent component on the child component, but the difference is that OverflowBoxits own size will not change with the size of the child component , its size only depends on the constraints of its parent component (the constraint is constraints.biggest), that is, when the parent component is satisfied As large as possible subject to component constraints. We encapsulate a TranslateWithExpandedPaintingAreacomponent to wrap WaterMarkthe component:

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;
      },
    );
  }
}

There are three points to note about the above code:

  1. The width and height of the child component will be dynamically increased by the corresponding value according to the offset specified by the user.
  2. OverflowBoxWe need to dynamically adjust the alignment according to the offset specified by the user . For example, when we want to pan to the left, OverflowBoxwe must right-align, because the part beyond the parent container after right-alignment will be outside the left boundary, which is the effect we want , if instead of right-aligning we left-aligned, the part that goes off the screen would already be outside the right border, which is not as expected.
  3. The content beyond the boundary will be displayed by default. Of course, the size of the watermark component in this example is as large as the remaining display space of the screen, so it will not be displayed after exceeding the limit. But if we specify a smaller size for the watermark component, we can see it Therefore, we define a pruning configuration parameter, and users can decide whether to pruning according to the actual situation.

So the final calling code is:

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,
            ),
          ),
        ),
      ),
    ],
  );
}

After running, we can achieve the desired effect.


Reference: "Flutter Combat Second Edition"

Guess you like

Origin blog.csdn.net/lyabc123456/article/details/130916352