美文网首页
Flutter Framework怎么自定义渲染型 Widget

Flutter Framework怎么自定义渲染型 Widget

作者: 笨笨11 | 来源:发表于2021-09-27 20:10 被阅读0次

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

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

    Custom Leaf Render Widget


    首先,我们来实现评分组件里的五星部分 (ScoreStar 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

    如下代码:

    • socre setter 中调用了markNeedsPaint方法,以便在score变化后及时 re-paint (由于 socre 变化不会引起 layout 变化,故此处只需调用markNeedsPaint,若会引起 layout 变化,则需要调用markNeedsLayout);
    • 关于sizedByParent,在该例子中设为true or false都可以,因为RenderScoreStar#size完全由constraints决定:
      • 从性能角度考虑,sizedByParent应设为true,以便满足RepaintBoundary的条件
      • sizedByParent设为true,需要重写performResize方法来计算 size,由于RenderScoreStar没有 layout 操作需要执行,故不需要重写performLayout
      • sizedByParent设为false,则需要重写performLayout,并在该方法中完成 size 的计算;
      • 22~3032~40两个代码片段随便使用哪个都可以。
    • 关于IntrinsicWidth/Height,若重写了performLayout方法,则进而需要重写以下四个方法:
      • double computeMaxIntrinsicWidth(double height):用于计算一个最小宽度(没错,是最小宽度),在最终 size.width 超过该宽度时,也不会减少 size.height (如,对 text 排版,将 text 排成一行需要的最小宽度就是这里的 MaxIntrinsicWidth,因为再增加宽度也不会减少 text 的高度);
      • double computeMinIntrinsicWidth(double height):排版需要的最小宽度,若小于这个宽度内容就会被裁剪;
      • computeMinIntrinsicHeightcomputeMaxIntrinsicHeight与上面介绍的computeMinIntrinsicWidthcomputeMaxIntrinsicWidth类似,不再赘述;
      • 在一些特殊 RenderObject 排版时才会用到这些方法,在此我们根据 constraints 简单计算了一下。
    • 为了响应点击事件,重写hitTestSelf方法,并返回true,表示该 Render Object 需要响应用户事件;
    • 关于paint方法中五角星 ★★★★★ 的绘制:
      • 对于背景 ★★★★★,设置好 path 后,直接通过context.canvas.drawPath绘制即可;
      • 对于前景 ★★★★★,先通过context.pushClipRect对画布进行裁剪 ( rect.width 由 score 决定 ),再行绘制。
    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: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

    对于含有子节点的 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。

    这个算法有点小问题:

    由于 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 操作。

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

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

    作者:峰之巅
    链接:https://juejin.cn/post/7004305315814440968/
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    相关文章

      网友评论

          本文标题:Flutter Framework怎么自定义渲染型 Widget

          本文链接:https://www.haomeiwen.com/subject/wfhknltx.html