Flutter Framework怎么自定义渲染型 Widget
本文将对Framework 的 Widget 、BuildOwner 、Element 、PaintingContext 、Layer 、PipelineOwner 、RenderObejct 进行总结,并动手实现一个渲染型 Widget (Render-Widget)。
Render-Widget 大致有三类:
- 作为『 Widget Tree 』的叶节点,也是最小的 UI 表达单元,一般继承自
LeafRenderObjectWidget
; - 有一个子节点 ( Single Child ),一般继承自
SingleChildRenderObjectWidget
; - 有多个子节点 ( Multi Child ),一般继承自
MultiChildRenderObjectWidget
。
Widget 间的继承关系如下图:
Widget、Element、RenderObject 间的对应关系如下:
其中,Element 与 RenderObject 间用的是虚线,因为它们间的对应关系是基于 RenderBox 系列下的一种建议 (不是强制)。
Sliver 系列就不是基于
RenderBox
,而是RenderSliver
。通过
Render-Widget#createRenderObject
方法可以返回任意 RenderObject (如果你愿意)。
对于RenderBox
系列来说,如果要自定义子类,根据自定义子类子节点模型的不同需要有不同的处理:
- 自定义子类本身是『 Render Tree 』的叶子节点,一般直接继承自
RenderBox
; - 有一个子节点 (Single Child),且子节点属于
RenderBox
系列:- 如果其自身的 size 完全 match 子节点的 size,则可以选择继承自
RenderProxyBox
(如:RenderOffstage
); - 如果其自身的 size 大于子节点的 size,则可以选择继承自
RenderShiftedBox
(如:RenderPadding
);
- 如果其自身的 size 完全 match 子节点的 size,则可以选择继承自
- 有一个子节点 (Single Child),但子节点不属于
RenderBox
系列,自定义子类可以 mixinRenderObjectWithChildMixin
,其提供了管理一个子节点的模型; - 有多个子节点 (Multi Child),自定义子类可以 mixin
ContainerRenderObjectMixin
、RenderBoxContainerDefaultsMixin
,前者提供了管理多个子节点的模型,后者提供了基于ContainerRenderObjectMixin
的一些默认实现。
下面,我们一步步地来实现上面提到的评分组件。
Custom Leaf Render Widget
首先,我们来实现评分组件里的五星部分 (ScoreStar Widget):
LeafRenderObjectWidget
ScoreStar
作为叶子节点,继承自LeafRenderObjectWidget
,并实现了2个重要方法:createRenderObject
、updateRenderObject
:
1 class ScoreStar extends LeafRenderObjectWidget {
2 final Color backgroundColor;
3 final Color foregroundColor;
4 final double score;
5
6 ScoreStar(this.backgroundColor, this.foregroundColor, this.score);
7
8 @override
9 RenderObject createRenderObject(BuildContext context) {
10 return RenderScoreStar(backgroundColor, foregroundColor, score);
11 }
12
13 @override
14 void updateRenderObject(BuildContext context, covariant RenderScoreStar renderObject) {
15 renderObject
16 ..backgroundColor = backgroundColor
17 ..foregroundColor = foregroundColor
18 ..score = score;
19 }
20 }
复制代码
其中,updateRenderObject
方法会在 Widget re-build 时调用,用于更新复用的 Render Object 的属性。
在本例中,score
会随着用户点击不同的区域而变化,就需要通过updateRenderObject
方法来更新RenderScoreStar#score
,以便刷新 UI。
Leaf Render Object
从上一小节ScoreStar#createRenderObject
可知,ScoreStar
对应的 Render Object 是RenderScoreStar
。
RenderScoreStar
继承自RenderBox
。
如下代码:
- 在
socre setter
中调用了markNeedsPaint
方法,以便在score
变化后及时 re-paint (由于 socre 变化不会引起 layout 变化,故此处只需调用markNeedsPaint
,若会引起 layout 变化,则需要调用markNeedsLayout
); - 关于
sizedByParent
,在该例子中设为true
orfalse
都可以,因为RenderScoreStar#size
完全由constraints
决定:- 从性能角度考虑,
sizedByParent
应设为true
,以便满足RepaintBoundary
的条件 - 若
sizedByParent
设为true
,需要重写performResize
方法来计算 size,由于RenderScoreStar
没有 layout 操作需要执行,故不需要重写performLayout
; - 若
sizedByParent
设为false
,则需要重写performLayout
,并在该方法中完成 size 的计算; -
22~30
、32~40
两个代码片段随便使用哪个都可以。
- 从性能角度考虑,
- 关于IntrinsicWidth/Height,若重写了
performLayout
方法,则进而需要重写以下四个方法:-
double computeMaxIntrinsicWidth(double height)
:用于计算一个最小宽度(没错,是最小宽度),在最终 size.width 超过该宽度时,也不会减少 size.height (如,对 text 排版,将 text 排成一行需要的最小宽度就是这里的 MaxIntrinsicWidth,因为再增加宽度也不会减少 text 的高度); -
double computeMinIntrinsicWidth(double height)
:排版需要的最小宽度,若小于这个宽度内容就会被裁剪; -
computeMinIntrinsicHeight
、computeMaxIntrinsicHeight
与上面介绍的computeMinIntrinsicWidth
、computeMaxIntrinsicWidth
类似,不再赘述; - 在一些特殊 RenderObject 排版时才会用到这些方法,在此我们根据 constraints 简单计算了一下。
-
- 为了响应点击事件,重写
hitTestSelf
方法,并返回true
,表示该 Render Object 需要响应用户事件; - 关于
paint
方法中五角星 ★★★★★ 的绘制:- 对于背景 ★★★★★,设置好 path 后,直接通过
context.canvas.drawPath
绘制即可; - 对于前景 ★★★★★,先通过
context.pushClipRect
对画布进行裁剪 ( rect.width 由 score 决定 ),再行绘制。
- 对于背景 ★★★★★,设置好 path 后,直接通过
1 // 为了缩减篇幅,精简了部分代码
2 //
3 class RenderScoreStar extends RenderBox {
4 Color _backgroundColor;
5 ...
6
7 Color _foregroundColor;
8 ...
9
10 double _score;
11 double get score => _score;
12 set score(double value) {
13 _score = value;
14
15 // score 变化时需要re-paint
16 //
17 markNeedsPaint();
18 }
19
20 RenderScoreStar(this._backgroundColor, this._foregroundColor, this._score);
21
22 @override
23 bool get sizedByParent => false;
24
25 @override
26 void performLayout() {
27 double height = min(constraints.biggest.height, constraints.biggest.width / 5);
28 height = max(height, constraints.smallest.height);
29 size = Size(constraints.biggest.width, height);
30 }
31
32 // @override
33 // bool get sizedByParent => true;
34 //
35 // @override
36 // void performResize() {
37 // double height = min(constraints.biggest.height, constraints.biggest.width / 5);
38 // height = max(height, constraints.smallest.height);
39 // size = Size(constraints.biggest.width, height);
40 // }
41
42 @override
43 double computeMaxIntrinsicWidth(double height) {
44 return constraints.biggest.width;
45 }
46
47 @override
48 double computeMaxIntrinsicHeight(double width) {
49 double height = min(constraints.biggest.height, constraints.biggest.width / 5);
50 height = max(height, constraints.smallest.height);
51
52 return height;
53 }
54
55 @override
56 bool hitTestSelf(Offset position) {
57 return true;
58 }
59
60 @override
61 void paint(PaintingContext context, Offset offset) {
62 void _backgroundStarPainter(PaintingContext context, Offset offset) {
63 _starPainter(context, offset, backgroundColor);
64 }
65
66 void _foregroundStarPainter(PaintingContext context, Offset offset) {
67 _starPainter(context, offset, foregroundColor);
68 }
69
70 _backgroundStarPainter(context, offset);
71 context.pushClipRect(
72 needsCompositing,
73 offset,
74 Rect.fromLTRB(0, 0, size.width * score / 5, size.height),
75 _foregroundStarPainter
76 );
77 }
78
79 void _starPainter(PaintingContext context, Offset offset, Color color) {
80 Paint paint = Paint();
81 paint.color = color;
82 paint.style = PaintingStyle.fill;
83
84 double radius = min(size.height / 2, size.width/ (2 * 5));
85
86 Path path = Path();
87 _addStarLine(radius, path);
88 for (int i = 0; i < 4; i++) {
89 path = path.shift(Offset(radius * 2, 0.0));
90 _addStarLine(radius, path);
91 }
92
93 path = path.shift(offset);
94 path.close();
95
96 context.canvas.drawPath(path, paint);
97 }
98
99 void _addStarLine(double radius, Path path) {
100 ...
101 }
102 }
复制代码
至此,RenderScoreStar
基本完成,完整代码请参见 [ Github:Score ]
动态评分
如下图,我们希望评分组件不仅能展示分数,还能评分:
在 Flutter UI 中,一个重要的思想就是:『 组合 』。
为了实现上图所示效果,只需组合StatefulWidget
+ScoreStar
即可:
1 typedef ScoreCallback = void Function(double score);
2
3 class Score extends StatefulWidget {
4 final double score;
5 final ScoreCallback callback;
6
7 const Score({Key key, this.score = 0, this.callback}) : super(key: key);
8
9 @override
10 _ScoreState createState() => _ScoreState();
11 }
12
13 class _ScoreState extends State<Score> {
14
15 double score;
16
17 @override
18 void initState() {
19 super.initState();
20
21 score = widget.score ?? 0;
22 }
23
24 @override
25 void didUpdateWidget(Score oldWidget) {
26 super.didUpdateWidget(oldWidget);
27
28 score = widget.score ?? 0;
29 }
30
31 @override
32 Widget build(BuildContext context) {
33 void _changeScore(Offset offset) {
34 Size _size = context.size;
35 double offsetX = min(offset.dx, _size.width);
36 offsetX = max(0, offsetX);
37
38 setState(() {
39 score = double.parse(((offsetX / _size.width) * 5).toStringAsFixed(1));
40 });
41
42 if (widget.callback != null) {
43 widget.callback(score);
44 }
45 }
46
47 return GestureDetector(
48 child: ScoreStar(Colors.grey, Colors.amber, score),
49 onTapDown: (TapDownDetails details) {
50 _changeScore(details.localPosition);
51 },
52 onLongPressMoveUpdate:(LongPressMoveUpdateDetails details) {
53 _changeScore(details.localPosition);
54 },
55 );
56 }
57 }
复制代码
代码比较简单,就不赘述了。
其中的关键还是上节介绍的RenderScoreStar#hitTestSelf
需要返回true
。
Custom MultiChild RenderObject Widget
我们希望通过自定义 MultiChild RenderObject Widget 实现如上图所示的效果。
没错,就是加了一个显示分数的 Text。
本来,这完全没必要通过自定义 MultiChild RenderObject Widget 来实现,一般的 Widget 组合即可。
我们只是为了实践自定义 MultiChild RenderObject Widget 才这么做的。
MultiChildRenderObjectWidget
RichScore
继承自MultiChildRenderObjectWidget
。
在其初始化方法中,向父类传递了2个 children:Score
、Text
。
重写了createRenderObject
方法,以便返回RenderRichScore
实例。 由于RenderRichScore
没有属性,故无需重写updateRenderObject
方法。
1 class RichScore extends MultiChildRenderObjectWidget {
2 RichScore({
3 Key key,
4 double score,
5 ScoreCallback callback,
6 }) : super(
7 key: key,
8 children: [
9 Score(score: score, callback: callback),
10 Text('$score分', style: TextStyle(fontSize: 28)),
11 ]
12 );
13
14 @override
15 RenderObject createRenderObject(BuildContext context) {
16 return RenderRichScore();
17 }
18 }
复制代码
RichScoreParentData
对于含有子节点的 RenderObject,一般都需要自定义自己的 ParentData 子类,用于辅助 layout。
class RichScoreParentData extends ContainerBoxParentData<RenderBox> {
double scoreTextWidth;
}
复制代码
RichScoreParentData
继承自ContainerBoxParentData
:
/// Abstract ParentData subclass for RenderBox subclasses that want the
/// ContainerRenderObjectMixin.
///
/// This is a convenience class that mixes in the relevant classes with
/// the relevant type arguments.
abstract class ContainerBoxParentData<ChildType extends RenderObject> extends BoxParentData with ContainerParentDataMixin<ChildType> { }
复制代码
ContainerBoxParentData
是抽象类,但其 mixinContainerParentDataMixin
:
/// Parent data to support a doubly-linked list of children.
mixin ContainerParentDataMixin<ChildType extends RenderObject> on ParentData {
/// The previous sibling in the parent's child list.
ChildType previousSibling;
/// The next sibling in the parent's child list.
ChildType nextSibling;
}
复制代码
ContainerParentDataMixin
在子节点间提供了双向链接的支持。
在RichScoreParentData
中定义了唯一一个属性:scoreTextWidth
,其作用在后面再介绍。
MultiChild RenderObject
RenderRichScore
继承自RenderBox
并 minix 了ContainerRenderObjectMixin
以及RenderBoxContainerDefaultsMixin
:
- 由于
RenderRichScore#size
受子节点的影响,即不完全由 Constraints 决定,故sizedByParent
设为false
,同时在调用子节点的layout
方法时parentUsesSize
参数需设为true
(下面代码第40
、55
行); - 由于其子节点 (
RenderScoreStar
)需要响应用户事件,故重写了hitTestChildren
方法; - 在
performLayout
方法中,完成了所有子节点的排版、设置相应的 ParentData 并计算出了 size; - 对于有子节点的 RenderObject 需要重写
computeDistanceToActualBaseline
方法,这里我们用了RenderBoxContainerDefaultsMixin
提供的默认实现; -
paint
方法的功能很简单,依次绘制每个子节点(defaultPaint
由RenderBoxContainerDefaultsMixin
提供); -
setupParentData
用于给子节点设置parentData
。
1 class RenderRichScore extends RenderBox with ContainerRenderObjectMixin<RenderBox, RichScoreParentData>,
2 RenderBoxContainerDefaultsMixin<RenderBox, RichScoreParentData>,
3 DebugOverflowIndicatorMixin {
4
5 RenderRichScore({
6 List<RenderBox> children,
7 }) {
8 addAll(children);
9 }
10
11 @override
12 bool get sizedByParent => false;
13
14 final double horizontalSpace = 10;
15 final double scoreTextWidthDifference = 10;
16
17 @override
18 bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
19 assert(childCount == 2);
20
21 RenderBox scoreChild = firstChild;
22 return scoreChild?.hitTest(result, position: position) ?? false;
23 }
24
25 @override
26 void performLayout() {
27 assert(childCount == 2);
28
29 RenderBox scoreStarChild = firstChild;
30 RenderBox scoreTextChild = lastChild;
31
32 if (scoreStarChild == null || scoreTextChild == null) {
33 size = constraints.smallest;
34 return;
35 }
36
37 // infinity constraints
38 //
39 BoxConstraints descConstraints = BoxConstraints();
40 scoreTextChild.layout(descConstraints, parentUsesSize: true);
41
42 final RichScoreParentData descChildParentData = scoreTextChild.parentData as RichScoreParentData;
43 double descWidth = descChildParentData.scoreTextWidth;
44 if (descWidth == null) {
45 descWidth = scoreTextChild.size.width + scoreTextWidthDifference;
46 descChildParentData.scoreTextWidth = descWidth;
47 }
48
49 BoxConstraints scoreConstraints = BoxConstraints(
50 minWidth: 0,
51 maxWidth: max(constraints.maxWidth - descWidth - horizontalSpace, 0),
52 minHeight: 0,
53 maxHeight: constraints.maxHeight
54 );
55 scoreStarChild.layout(scoreConstraints, parentUsesSize: true);
56
57 descChildParentData.offset = Offset(
58 scoreStarChild.size.width + horizontalSpace,
59 (scoreStarChild.size.height - scoreTextChild.size.height) / 2
60 );
61
62 if (constraints.isTight) {
63 size = constraints.biggest;
64 }
65 else {
66 double width = min(constraints.biggest.width, scoreStarChild.size.width + descWidth + horizontalSpace);
67 width = max(constraints.smallest.width, width);
68
69 double height = max(scoreStarChild.size.height, scoreTextChild.size.height);
70 height = min(constraints.biggest.height, height);
71 height = max(constraints.smallest.height, height);
72
73 size = Size(width, height);
74 }
75 }
76
77 ...
78
79 @override
80 double computeDistanceToActualBaseline(TextBaseline baseline) {
81 return defaultComputeDistanceToFirstActualBaseline(baseline);
82 }
83
84 @override
85 void paint(PaintingContext context, Offset offset) {
86 assert(childCount == 2);
87
88 if (childCount != 2) {
89 return;
90 }
91
92 defaultPaint(context, offset);
93 }
94
95 @override
96 void setupParentData(RenderObject child) {
97 if (child.parentData is! RichScoreParentData) {
98 child.parentData = RichScoreParentData();
99 }
100 }
101 }
复制代码
RichScoreParentData#scoreTextWidth
上面我们提到RichScoreParentData
有唯一一个属性:scoreTextWidth
。 那么它的作用是啥呢?
根据RenderRichScore
的排版算法,先计算 text 的宽度,★★★★★ 的宽度等于 constraints.biggest.width - textWidth。
这个算法有点小问题:
由于 textWidth 会因分数的不同,而有细微的差异,最终导致 ★★★★★ 有点闪烁。
为了解决这个问题,我们将 textWidth 的宽度固定为首次计算的 text 宽度+10,并将其存储在RichScoreParentData
中(上述代码第42~47
行)。
这种解决方法不一定是最好的,这里主要是演示一下 ParentData 的作用。
至此,自定义 MultiChild RenderObject 基本完成了。
小结
本文通过实现评分组件,逐步实践了如何自定义 Leaf Render Widget 以及 MultiChild Render Widget。
在这过程中,自定义了 Widget 以及 Render Object,但并没有涉及 Element。
原因是 Element 作为 Widget 与 Render Object 间的桥梁,逻辑相对内聚、独立。 当自定义 Widget 继承自LeafRenderObjectWidget
、SingleChildRenderObjectWidget
或MultiChildRenderObjectWidget
时,一般不用自定义 Element。
自定义 Leaf Render Widget,一般需要以下步骤:
- 自定义 Widget 继承自
LeafRenderObjectWidget
,并重写createRenderObject
、updateRenderObject
方法; - 自定义 Render Object 继承自
RenderBox
:- 确定
sizedByParent
为true
orfalse
; - 为
false
,重写performLayout
方法,执行 layout 并计算 size; - 为
true
,重写performResize
方法计算 size、重写performLayout
方法执行 layout (若需要); - 如果重写了
performLayout
方法,则需进一步重写computeMax/MinIntrinsicWidth/Height
系列方法; - 如需处理用户事件,重写
hitTestSelf
方法; - 重写
paint
方法,完成最终的绘制。
- 确定
自定义 MultiChild Render Widget,一般需要以下步骤:
- 自定义 Widget 继承自
MultiChildRenderObjectWidget
,并重写createRenderObject
、updateRenderObject
方法; - 自定义 Render Object 继承自
RenderBox
,并 minixContainerRenderObjectMixin
、RenderBoxContainerDefaultsMixin
:-
确定
sizedByParent
为true
orfalse
; -
为
false
,重写performLayout
方法,对子节点逐个执行 layout 操作并计算 size; -
为
true
,重写performResize
方法计算 size、重写performLayout
方法执行 layout; -
如果重写了
performLayout
方法,则需进一步重写computeMax/MinIntrinsicWidth/Height
系列方法; -
重写
computeDistanceToActualBaseline
方法计算 baseline; -
如需处理用户事件,重写
hitTestSelf
或/和hitTestChildren
方法; -
自定义 ContainerBoxParentData 子类,用于存储 layout 过程中需要的辅助信息;
-
重写
setupParentData
方法,为子节点设置 ParentData; -
重写
paint
方法,对子节点逐个执行 paint 操作。
-
写该文章围绕 Widget、Element 以及 RenderObject 展开讨论,对 Flutter Framework 有了一个简单的认识。
在此过程中对相关的 BuildOwner、PaintingContext、Layer 以及 PipelineOwner 等也进行了一定的讨论。
作者:峰之巅
链接:https://juejin.cn/post/7004305315814440968/
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。