likes
comments
collection
share

深入浅出 Flutter Framework 之自定义渲染型 Widget

作者站长头像
站长
· 阅读数 6

本文是『 深入浅出 Flutter Framework 』系列文章的第八篇,也是收官之作。通过自定义渲染型 Widget,我们一步步地实现了一个评分组件。

本文同时发表于我的个人博客

本系列文章将深入 Flutter Framework 内部逐步去分析其核心概念和流程,主要包括:

Overview


本文作为『 深入浅出 Flutter Framework 』系列文章的收官之作,为了对本系列文章所涉重点内容的回顾和总结,动手实现一个渲染型 Widget (Render-Widget)。

如下图,最终成品是一个评分组件 (源码已上传 Github: Score):

深入浅出 Flutter Framework 之自定义渲染型 Widget

通过前面系列文章的介绍,我们知道 Render-Widget 大致有三类:

  • 作为『 Widget Tree 』的叶节点,也是最小的 UI 表达单元,一般继承自LeafRenderObjectWidget
  • 有一个子节点 ( Single Child ),一般继承自SingleChildRenderObjectWidget
  • 有多个子节点 ( Multi Child ),一般继承自MultiChildRenderObjectWidget

Widget 间的继承关系如下图: 深入浅出 Flutter Framework 之自定义渲染型 Widget

Widget、Element、RenderObject 间的对应关系如下: 深入浅出 Flutter Framework 之自定义渲染型 Widget

其中,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);
  • 有一个子节点 (Single Child),但子节点不属于RenderBox系列,自定义子类可以 mixin RenderObjectWithChildMixin,其提供了管理一个子节点的模型;
  • 有多个子节点 (Multi Child),自定义子类可以 mixin ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin,前者提供了管理多个子节点的模型,后者提供了基于ContainerRenderObjectMixin的一些默认实现。

下面,我们一步步地来实现上面提到的评分组件。

Custom Leaf Render Widget


首先,我们来实现评分组件里的五星部分 (ScoreStar Widget):

深入浅出 Flutter Framework 之自定义渲染型 Widget

LeafRenderObjectWidget

ScoreStar作为叶子节点,继承自LeafRenderObjectWidget,并实现了2个重要方法:createRenderObjectupdateRenderObject

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

如下代码:

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 Framework 之自定义渲染型 Widget

在 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


深入浅出 Flutter Framework 之自定义渲染型 Widget

我们希望通过自定义 MultiChild RenderObject Widget 实现如上图所示的效果。

没错,就是加了一个显示分数的 Text。

本来,这完全没必要通过自定义 MultiChild RenderObject Widget 来实现,一般的 Widget 组合即可。

我们只是为了实践自定义 MultiChild RenderObject Widget 才这么做的。

MultiChildRenderObjectWidget

RichScore继承自MultiChildRenderObjectWidget

在其初始化方法中,向父类传递了2个 children:ScoreText

重写了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

还记得 ParentData 吗?

对于含有子节点的 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 (下面代码第4055行);
  • 由于其子节点 (RenderScoreStar)需要响应用户事件,故重写了hitTestChildren方法;
  • performLayout方法中,完成了所有子节点的排版、设置相应的 ParentData 并计算出了 size;
  • 对于有子节点的 RenderObject 需要重写computeDistanceToActualBaseline方法,这里我们用了RenderBoxContainerDefaultsMixin提供的默认实现;
  • paint方法的功能很简单,依次绘制每个子节点(defaultPaintRenderBoxContainerDefaultsMixin提供);
  • 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。

这个算法有点小问题:

深入浅出 Flutter Framework 之自定义渲染型 Widget

由于 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 继承自LeafRenderObjectWidgetSingleChildRenderObjectWidgetMultiChildRenderObjectWidget时,一般不用自定义 Element。

自定义 Leaf Render Widget,一般需要以下步骤:

  • 自定义 Widget 继承自LeafRenderObjectWidget,并重写createRenderObjectupdateRenderObject方法;
  • 自定义 Render Object 继承自RenderBox
    • 确定sizedByParenttrue or false
    • false,重写performLayout方法,执行 layout 并计算 size;
    • true,重写performResize方法计算 size、重写performLayout方法执行 layout (若需要);
    • 如果重写了performLayout方法,则需进一步重写computeMax/MinIntrinsicWidth/Height系列方法;
    • 如需处理用户事件,重写hitTestSelf方法;
    • 重写paint方法,完成最终的绘制。

自定义 MultiChild Render Widget,一般需要以下步骤:

  • 自定义 Widget 继承自MultiChildRenderObjectWidget,并重写createRenderObjectupdateRenderObject方法;
  • 自定义 Render Object 继承自RenderBox,并 minix ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin
    • 确定sizedByParenttrue or false

    • false,重写performLayout方法,对子节点逐个执行 layout 操作并计算 size;

    • true,重写performResize方法计算 size、重写performLayout方法执行 layout;

    • 如果重写了performLayout方法,则需进一步重写computeMax/MinIntrinsicWidth/Height系列方法;

    • 重写computeDistanceToActualBaseline方法计算 baseline;

    • 如需处理用户事件,重写hitTestSelf或/和hitTestChildren方法;

    • 自定义 ContainerBoxParentData 子类,用于存储 layout 过程中需要的辅助信息;

    • 重写setupParentData方法,为子节点设置 ParentData;

    • 重写paint方法,对子节点逐个执行 paint 操作。

『 深入浅出 Flutter Framework 』系列文章至此全部完成!

这一系列文章围绕 Widget、Element 以及 RenderObject 展开讨论,对 Flutter Framework 有了一个简单的认识。

在此过程中对相关的 BuildOwner、PaintingContext、Layer 以及 PipelineOwner 等也进行了一定的讨论。