欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Flutter 自定义控件之RenderObject

程序员文章站 2024-03-23 21:57:52
...

博主相关文章列表
Flutter 框架实现原理
Flutter 框架层启动源码剖析
Flutter 页面更新流程剖析
Flutter 事件处理源码剖析
Flutter 路由源码剖析
Flutter 安卓平台源码剖析(一)
Flutter 自定义控件之RenderObject

使用RenderObject 自定义控件

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

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

布局原理

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

Flutter 自定义控件之RenderObject

Flutter 自定义控件之RenderObject

过程如下:

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

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

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

Flutter 自定义控件之RenderObject

这表示子级可以取得图中绿色范围内的值。 即介于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 自定义控件之RenderObject

Flutter 全栈式开发指南

Flutter 自定义控件之RenderObject