阅读 162

Flutter 自定义控件之RenderObject

博主相关文章列表

Flutter 框架实现原理

Flutter 框架层启动源码剖析

Flutter 页面更新流程剖析

Flutter 事件处理源码剖析

Flutter 路由源码剖析

Flutter 安卓平台源码剖析

Flutter 自定义控件之RenderObject

使用RenderObject 自定义控件

前面课程已经讲了使用Canvas自绘控件,为什么还需要了解使用RenderObject 自定义控件呢?两种有什么区别?

Canvas主要是进行底层绘制的,是最基础的一环。有时候一个控件除了绘制,还需要处理布局和事件,我们如果直接使用Canvas,就需要自己处理这些异常麻烦的事情,而Flutter的控件体系正是实现了这样一套机制,我们使用RenderObject 去自定义控件就能复用这套体系。另外,通过学习使用RenderObject,也能加深我们对Flutter的控件、元素、渲染对象三者之间关系的理解。

布局原理

在Flutter中,布局阶段由两个线性传递构成:约束沿树向下传递,以及布局细节沿树向上传递。

过程如下:

  1. 父级给每个子级传递某些“约束”。这些约束是子级布置自己时必须遵守的一组规则。约束的一个简单示例是最大宽度约束。父级可以将允许渲染的最大宽度传递给其子级。当子级收到这些约束时,它知道不能超过父级约束的最大宽度。
  2. 接着,子级生成新的约束,并将其向下传递给自己的子级,这种情况一直持续到没有子级的叶子节点为止。
  3. 然后,此叶子节点控件根据传递给它的约束条件确定其“布局细节”。例如,如果其父级传递给它的最大宽度限制为500像素。它可以选择全部用光或只使用100像素。之后,叶子节点控件将确定的“布局细节”返回父级。
  4. 父级反过来也是这样做的。它利用子级返回的细节来确定自己的细节是什么,然后把它们传到渲染树上,一直沿着树往上传,要么传到根,要么达到某些限制为止。

至于 "约束 "和 "布局细节 "是什么,要看使用的布局协议。在Flutter中,主要有两种布局协议:box协议,和sliver协议。box协议用于在简单的二维笛卡尔坐标系中显示对象,而sliver协议用于显示对滚动有反应的对象。

在box协议中,父代传递给子代的约束称为BoxConstraints。这些约束决定了每个子代允许的最大和最小宽度和高度。例如,父代可能会将以下BoxConstraintsMinWidth=150,MaxWidth=300,MinHeight=100)传给它的子级。

这表示子级可以取得图中绿色范围内的值。 即介于150到300之间的任何宽度,大于100的任何高度(此处maxHeight为无穷大)。 由此,子级决定在这些限制条件下要拥有多大的尺寸,并将其决定通知父级。所以,“布局细节”是指子级选择的大小。

Sliver协议中,情况会更复杂。 父级向下传递SliverConstraints到其子级,其中包含滚动信息和约束,例如滚动偏移量,重叠部分等。子级又将SliverGeometry返回其父级。 Sliver协议非常复杂,本篇不涉及。

一旦父级知道其子级的所有布局细节,它就可以继续绘制自己和子级。Flutter会传递给它一个PaintingContext,其中包含一个Canvas,它可以在上面绘制。

关于布局约束的深入理解,请阅读官方文档的解释 《深入理解布局约束》

自定义示例

渲染对象RenderObject是一个抽象类。我们需要继承它来完成自定义控件的渲染。它有两个重要的子类RenderBoxRenderSliver。这两个类分别实现box协议和sliver协议,这两个类还被其他几十个类继承,这些子类分别处理特定的场景,并实现渲染过程的细节。

如果我们直接从RenderObject继承,就无法复用已有的布局协议,通常来说,应该从它的子类RenderBox类去派生自定义类。但是直接继承RenderBox仍然会有些细节处理,较为繁琐,通常我们可以去继承RenderBox的两个子类RenderShiftedBoxRenderProxyBox

自定义RenderObject

这里继承自RenderShiftedBox

/// 自定义用于对齐布局的渲染对象
class MyAlignRenderBox extends RenderShiftedBox {

  AlignmentGeometry alignment;

  MyAlignRenderBox({
    this.alignment = Alignment.center,
    RenderBox child,
  }) : super(child);

  @override
  void performLayout() {
    /// 测量
    /// 父级向子级传递约束,子级必须服从给定的约束。
    /// parentUsesSize为true,表示父级依赖于子级的布局,子级布局改变,父级也要重新布局
    /// 反之,子级发生改变,不会通知父级。即父级不依赖子级
    child.layout(BoxConstraints(
        minHeight: 0.0,
        maxHeight: constraints.maxHeight,
        minWidth: 0.0,
        maxWidth: constraints.maxWidth
    ), parentUsesSize: true);

    /// 对子级进行布局
    /// 经过测量后,可通过 child.size 拿到 child 测量后的大小
    /// 这里parentData即负责存储父节点所需要的子节点的布局信息
    final BoxParentData childParentData = child.parentData;
    if(alignment == Alignment.center){
      // offset属性即用来设置子节点相对于父节点的位置
      childParentData.offset = Offset((this.constraints.maxWidth - child.size.width)/2,
          (this.constraints.maxHeight - child.size.height)/2);
    }else{
      childParentData.offset = Offset(0,0);
    }

    /// 确定自己的“布局细节”
    size = Size(this.constraints.maxWidth, constraints.maxHeight);
  }
}
复制代码

上面关于方位的计算,可以直接利用Alignment已经封装的功能,无需使用if判断

childParentData.offset = (alignment as Alignment).alongOffset(Size(constraints.maxWidth, constraints.maxHeight)-child.size);
复制代码

自定义Widget

有了渲染对象,还需要一个与之对应的Widget,用于插入控件树中。自定义的Widget中需要实现两个方法,用于创建与之相关的ElementRenderObject。为了简单,这里继承自SingleChildRenderObjectWidget,因为它内部会帮我们创建上下文Element,这样我们只需把精力放在RenderObject

/// 自定义对齐布局Widget
class MyAlignWidget extends SingleChildRenderObjectWidget{

  MyAlignWidget({this.alignment=Alignment.center,Widget child}):super(child:child);

  final AlignmentGeometry alignment;

  @override
  SingleChildRenderObjectElement createElement() {
      return super.createElement();
  }

  @override
  RenderObject createRenderObject(BuildContext context) {
     // 创建我们自定义的渲染对象
     return MyAlignRenderBox(alignment: alignment);
  }
}
复制代码

使用自定义布局

Widget build(BuildContext context) {
  return Scaffold(
    body: MyAlignWidget(
      alignment: Alignment.center,
      child: Container(
        width: 100,
        height: 100,
        color: Colors.blue,
      ),
    ),
  );
}
复制代码

其他示例

当我们的控件不想进行布局,而是交给它的子级去处理,而我们只是想改变某些行为时,可以继承一个RenderObject的代理类RenderProxyBox

以下是一个仅处理触摸事件的自定义控件示例,而RenderConstrainedBox正是一个RenderProxyBox 的子类

class TouchHighlightRender extends RenderConstrainedBox {
  TouchHighlightRender() : super(additionalConstraints: const BoxConstraints.expand());

  // 自身是否可进行命中检测
  @override
  bool hitTestSelf(Offset position) => true;

  final Map<int, Offset> _dots = <int, Offset>{};

  // 实现该方法用于处理事件
  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    if (event is PointerDownEvent || event is PointerMoveEvent) {
      _dots[event.pointer] = event.position;
      markNeedsPaint();
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      _dots.remove(event.pointer);
      markNeedsPaint();
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;
    canvas.drawRect(offset & size, Paint()..color = const Color(0xFFE6E6FA));

    final Paint paint = Paint()..color = const Color(0xFFFFFF00);
    for (Offset point in _dots.values)
      canvas.drawCircle(point, 50.0, paint);

    super.paint(context, offset);
  }
}

///
/// 触摸高亮控件
/// 
class TouchHighlight extends SingleChildRenderObjectWidget {
  const TouchHighlight({ Key key, Widget child }) : super(key: key, child: child);

  @override
  TouchHighlightRender createRenderObject(BuildContext context) => TouchHighlightRender();
}
复制代码

使用控件。当我们触摸屏幕时,触摸点会形成一个圆圈高亮效果

Widget build(BuildContext context) {
  return Scaffold(
    body: TouchHighlight(
      child: Center(
        child: Text("Hello"),
      ),
    ),
  );
}
复制代码

总结

当我们使用这种方式自定义控件时,至少需要自定义一个Widget和一个RenderObject 。通常,我们的Widget可以继承自以下三种类

  • SingleChildRenderObjectWidgetRenderObject只有一个 child
  • MultiChildRenderObjectWidget:可以有多个 child
  • LeafRenderObjectWidgetRenderObject是一个叶子节点,没有child

而我们的自定义的RenderObject 通常可以从RenderShiftedBoxRenderProxyBox 及其子类派生。

当然,并不推荐实际开发中直接使用这种方式去自定义布局,总体来说仍然显得繁琐。Flutter已为开发者提供了两个控件用于自定义布局

  • CustomSingleChildLayout 处理包含单个child 的布局
  • CustomMultiChildLayout 处理包含多个child的布局

视频课程

本篇博客视频内容可访问B站链接 使用RenderObject 自定义控件 您觉得有帮助,别忘了点赞哦

如需要获取完整的Flutter全栈式开发课程,请访问以下地址 Flutter全栈式开发之Dart 编程指南 二维码

Flutter 全栈式开发指南