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 Container
is a composite component, which is DecoratedBox、ConstrainedBox、Transform、Padding、Align
composed 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 valueColor
property only supports Indicator
the 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. CustomPaint
We 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、Image
by corresponding rendering, such as by rendering; but by rendering. is an abstract class that defines an abstract method :RenderObject
Text
RenderParagraph
Image
RenderImage
RenderObject
paint(...)
void paint(PaintingContext context, Offset offset)
PaintingContext
Represents the drawing context of the component, which PaintingContext.canvas
can be obtained through Canvas
the API, and the drawing logic is mainly Canvas
implemented through the API. Subclasses need to override this method to implement their own drawing logic, such as RenderParagraph
text drawing logic and RenderImage
picture drawing logic.
It can be found that RenderObject
in the end, it is also Canvas
drawn through the API, so what is the difference between the way of realization and the way of passing and self-drawing RenderObject
introduced 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 ).CustomPaint
Canvas
CustomPaint
SingleChildRenderObjectWidget
RenderCustomPaint
paint
Canvas
Painter
Painter
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 . CustomPaint
The RenderObject
self-drawing method is essentially the same as the self-drawing method, which requires developers to call the Canvas
API 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 Canvas
API 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 GradientButton
component that needs to support the following functions:
- Background supports gradient colors
- There is a ripple effect when the finger is pressed
- Can support rounded corners
Let's take a look at the final effect:
DecoratedBox
It can support background color gradient and rounded corners, and InkWell
there is a ripple effect when the finger is pressed, so we can achieve it by combining DecoratedBox
and , the code is as follows:InkWell
GradientButton
import 'package:flutter/material.dart';
///组合方式定义一个渐变色按钮
///使用DecoratedBox、Padding、Center、InkWell等组合而成
class GradientButton extends StatelessWidget {
const GradientButton({
Key? key,
this.colors,
this.width,
this.height,
this.onPressed,
this.borderRadius,
required this.child,
}): super(key: key);
// 渐变色数组
final List<Color>? colors;
// 按钮宽高
final double? width;
final double? height;
final Widget child;
final BorderRadius? borderRadius;
//点击回调
final GestureTapCallback? onPressed;
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
//确保colors数组不空
List<Color> _colors = colors ?? [theme.primaryColor, theme.primaryColorDark];
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: _colors), //渐变色
borderRadius: borderRadius, //圆角
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
splashColor: _colors.last,//水波颜色
highlightColor: Colors.transparent,//高亮色
borderRadius: borderRadius,//圆角
onTap: onPressed,
child: ConstrainedBox(
constraints: BoxConstraints.tightFor(height: height, width: width),
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DefaultTextStyle(
style: const TextStyle(fontWeight: FontWeight.bold),
child: child,
),
),
),
),
),
),
);
}
}
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. assert
This 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 TurnBox
component 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.
TurnBox
The 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:
- We achieve the rotation effect by combining
RotationTransition
and .child
- In
didUpdateWidget
, we judge whether the angle to be rotated has changed, and if so, perform a transition animation.
Let's test TurnBox
the 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:
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 RotationTransition
one 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 url
links, 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 _MyRichTextState
have two functions to implement in:
- Parse the text string "
text
" and generateTextSpan
a cache; build
Return the final rich text style in ;
_MyRichTextState
The 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 TextSpan
is a time-consuming operation, in order not to build
parse it every time, we initState
cache the parsed results in , and then build
use the parsed results directly in _textSpan
.
This looks good, but the above code has a serious problem, that is, text
when the input of the parent component changes (the component tree structure remains unchanged), MyRichText
the displayed content will not be updated, because it initState
will only State
be called when it is created , so when text
a change occurs, parseText
there is no re-execution, resulting _textSpan
in the old parsed value.
To solve this problem is also very simple, we only need to add a didUpdateWidget
callback, 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 State
cache some Widget
data 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
didUpdateWidget
decide 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
, Canvas
which encapsulates some basic drawing APIs, and developers can Canvas
draw various custom graphics. In Flutter, a CustomPaint
component is provided, which can be combined with brushes CustomPainter
to realize custom graphics drawing.
CustomPaint
Let's look at CustomPaint
the 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 nodessize
: Whenchild
isnull
, it represents the default drawing area size. If there is,child
this parameter is ignored, and the canvas size ischild
the size. If you havechild
but want to specify the canvas to be a specific size, you can useSizeBox
the packageCustomPaint
to achieve it.isComplex
: Whether it is complex drawing, if so, Flutter will apply some caching strategies to reduce the overhead of repeated rendering.willChange
:isComplex
Used 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 CustomPainter
the class, and we implement the real drawing logic in the brush class.
1. Draw the boundary RepaintBoundary
If CustomPaint
there 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 Layer
draw on the original one, that is to say, RepaintBoundary
the drawing of the child component will be independent of the drawing of the parent component, which RepaintBoundary
will isolate its child nodes and CustomPaint
its own drawing boundary.
Examples are as follows:
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
child: RepaintBoundary(
child: ...
)
),
)
2. Custom Painter and Canvas
CustomPainter
is an abstract class, our custom brush needs to be implemented CustomPainter
, and CustomPainter
an abstract method is defined in it paint
:
void paint(Canvas canvas, Size size);
paint
There are two parameters:
canvas
: a canvas, including various drawing methodssize
: The size of the current drawing area.
The following Canvas
methods 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 Paint
classes 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 Paint
the 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:
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:
-
Make good use of the return value as much as possible
shouldRepaint
; when the UI tree is redrawnbuild
, 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;
false
if the drawing depends on the external state, then we shouldshouldRepaint
judge whether the dependent state has changed in , if it has changed, then Should be returnedtrue
to redraw, otherwise it should be returnedfalse
not to redraw. -
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
shouldRepaint
callback valuefalse
, 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:
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, shouldRepaint
what 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 CustomPaint
is 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 CustomPaint
continuous redrawing. The solution to this problem is very simple, just CustomPaint
add 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:
- Supports multiple background gradient colors.
- Arbitrary arc; the progress bar may not be a full circle.
- 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 GradientCircularProgressIndicator
different 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:
Self-drawn component: CustomCheckbox
Flutter's built-in Checkbox
components cannot be freely specified in size. Next, we will customize a CustomCheckbox
component that can be freely specified in size to demonstrate how to RenderObject
customize components by definition (rather than by combination). The component effect we want to achieve CustomCheckbox
is shown in the figure:
- There are two states: selected and unselected.
- An animation is to be performed when the state is switched.
- The appearance can be customized.
CustomCheckbox
It 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 updateRenderObject
when the selected state changes in the method, we need to update RenderObject
the 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:
progress
What we draw is a frame during the animation execution, so we need to calculate the appearance of each frame through the animation execution progress ( ).- When
CustomCheckbox
from unselected to selected, we perform a forward animation,progress
and the value of will0
gradually change from to1
, becauseCustomCheckbox
the 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 front40%
time draws the background, and the second 60% of the time draws the 'tick'.
1) Draw the background
Combined with the above picture, let's take a look at how to draw the background:
- When the state is switched to the selected state, the rectangle is gradually shrunk and filled from the edge to the center until
Checkbox
the area is filled. - 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, Canvas
the API has helped us realize the functions we expect. drawDRRect
You 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 Checkbox
fixed 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 setState
an update. RenderObject
But what we achieve directly by defining CustomCheckbox
is not based on StatefulWidget
, so how to schedule animation? There are two ways:
- Wrap the with
CustomCheckbox
aStatefulWidget
so that you can reuse the animation method described earlier. - 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 handleEvent
handle 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 RenderObject
it is quite complicated to schedule animations in , so we abstracted one RenderObjectAnimationMixin
. If there are other RenderObject
animations 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 CustomCheckbox
complete 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);
}
}
RenderObject
It 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 DoneWidget
that can perform a tick animation when it is created, as shown in the figure:
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;
}
}
DoneWidget
There are two modes, one is outline
the mode, the background of this mode has no fill color, and at this time color
represents the color of the outline line; if it is not outline
the mode, then color
represents 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:
-
We have applied
easeIn
the curve to the animation, and you can see how toRenderObject
apply 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. -
We override
RenderObjectAnimationMixin
induration
, which is used to specify the duration of the animation. -
adjustProgress
The 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 point60%
and The connection animation of the third point, this part of the animation takes the last part of the total duration40%
.
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 WaterMark
the 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 State
the 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 DecoratedBox
to 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 MemoryImage
of 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 Canvas
and one PictureRecorder
to achieve off-screen drawing. PictureRecorder
The function is simple: Canvas
after 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 PictureRecorder
is 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 Picture
object, which is the carrier of the drawing instructions, and it has a toImage
method, which will execute the drawing instructions after calling to get drawing The pixel result ( ui.Image
object), then we can convert the pixel result to png
format 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:
paintUnit
It 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, sopaintUnit
when 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 watermarktrue
, otherwise return , and redraw the unit watermark afterfalse
returning .true
It is called_WaterMarkState
indidUpdateWidget
the 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) {
...// 待实现
}
}
paintUnit
is drawn in two steps:
- draw text
- apply rotation and
padding
draw text
- Create one
ParagraphBuilder
, denoted asbuilder
. - Call
builder.add
to add the string to draw. - Build the text and proceed
layout
, becauselayout
the space occupied by the text is not known until after . - Call
canvas.drawParagraph
draw.
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 TextPainter
transform 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, TextPainter
there is another usefulness in actual combat. Text
When we want to know the width and height of the component in advance, we can use to TextPainter
measure 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 rotate
layout picture:
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
, padding
the 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 canvas
performed 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:
Next implement shouldRepaint
the method:
shouldRepaint(TextWaterMarkPainter oldPainter) {
return oldPainter.rotate != rotate ||
oldPainter.text != text ||
oldPainter.padding != padding ||
oldPainter.textDirection != textDirection ||
oldPainter.textStyle != textStyle;
}
bool
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:
Cell Watermark Brush—Interleaved Text Watermark
Text watermarks with interlaced effects are more common, as shown in the figure:
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, TextWaterMarkPainter
and TextWaterMarkPainter
now 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 TextWaterMarkPainter
to paintUnit
add logic after the method, but this will bring two problems:
TextWaterMarkPainter
There will be more configuration parameters.TextWaterMarkPainter
The ispaintUnit
already 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 TextWaterMarkPainter
the logic of , then we can use the proxy mode, that is, we create a new one WaterMarkPainter
and 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:
- 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 drawingCanvas
of the second text watermark point. - The two texts can be arranged horizontally or vertically, and different arrangement rules will affect the size of the final watermark unit.
- The offset for the stagger is
padding2
specified 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 WaterMark
the 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.
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 WaterMark
make an offset to the background of as a whole. At this time, the reader must have thought of Transform
the component. OK, let's Transform
try 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:
It can be found that although the overall direction is offset, there is a blank space on the right. At this time, because the WaterMark
space occupied by is originally the same width as the screen, the area it draws is also as large as the screen, and Transform.translate
the effect of drawing is equivalent to drawing The origin of the drawing is shifted by 30
pixels, so there is a blank space on the right.
That being the case, if WaterMark
the drawing area can be made to exceed the width of the screen 30
by pixels, wouldn't it work after panning? This idea is correct. We know that the background WaterMark
is drawn through in DecoratedBox
, but we cannot modify DecoratedBox
the drawing logic of . If we DecoratedBox
copy the relevant code of and modify it, the later maintenance cost will be very high, so DecoratedBox
the 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 WaterMark
the width of to exceed the screen width 30
by 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 WaterMark
width to be larger than the screen width 30
, and then use a SingleChildScrollView
package:
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 SingleChildScrollView
it is IgnorePointer
wrapped, it cannot receive events, so it will not be disturbed by the user's sliding.
We know that and objects SingleChildScrollView
are created internally , and in this scenario will not respond to events, so creating is redundant overhead, and we need to explore a better solution.Scrollable
Viewport
SingleChildScrollView
Scrollable
2. Solution 2: Use FittedBox to apply offset
Can we first UnconstrainedBox
cancel the constraint on the size of the child component by the parent component, and then achieve it by SizedBox
specifying that WaterMark
the 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:
We can see that there is an overflow prompt bar on the left. This is because UnconstrainedBox
although the constraints can be canceled during the layout of its subcomponents (subcomponents can be infinite), but UnconstrainedBox
itself is constrained by its parent component, so when UnconstrainedBox
it follows its subcomponent After the component grows, if UnconstrainedBox
the 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 30
pixel width will be outside the left border of the parent component, thus achieving our desired effect . We know that Release
the overflow prompt bar will not be drawn in the mode, because the drawing logic of the overflow bar is in assert
the function, for example:
// Display the overflow indicator.
assert(() {
paintOverflowIndicator(context, offset, _overflowContainerRect, _overflowChildRect);
return true;
}());
So Release
there 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 UnconstrainedBox
child 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 FittedBox
can cancel the constraint of the parent component on the child component and at the same time allow its child component to adapt to FittedBox
the 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.
FittedBox
The 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.none
killer 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
OverflowBox
The UnconstrainedBox
same as that can cancel the constraint of the parent component on the child component, but the difference is that OverflowBox
its 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 TranslateWithExpandedPaintingArea
component to wrap WaterMark
the 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:
- The width and height of the child component will be dynamically increased by the corresponding value according to the offset specified by the user.
OverflowBox
We need to dynamically adjust the alignment according to the offset specified by the user . For example, when we want to pan to the left,OverflowBox
we 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.- 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"