Flutter 完整开发实战详解(十6、详解自定义布局实战)

本篇将解析 Flutter 中自定义布局的原理,并带你深刻实战自定义布局的流程,利用两种自定义布局的实现方式,完成以下图所示的界面效果,看完这一篇你将能够更轻松的对 Flutter 随心所欲。git

前文:github

1、前言

在以前的篇章咱们讲过 WidgetElementRenderObject 之间的关系,所谓的 自定义布局,事实上就是自定义 RenderObjectchild 的大小和位置 ,而在这点上和其余框架不一样的是,在 Flutter 中布局的核心并非嵌套堆叠,Flutter 布局的核心是在于 Canvas ,咱们所使用的 Widget ,仅仅是为了简化 RenderObject 的操做。数组

《9、 深刻绘制原理》的测试绘制 中咱们知道, 对于 Flutter 而言,整个屏幕都是一块画布,咱们经过各类 OffsetRect 肯定了位置,而后经过 Canvas 绘制 UI,而整个屏幕区域都是绘制目标,若是在 child 中咱们 “不按照套路出牌” ,咱们甚至能够无论 parent 的大小和位置随意绘制。bash

2、MultiChildRenderObjectWidget

了解基本概念后,咱们知道 自定义 Widget 布局的核心在于自定义 RenderObject ,而在官方默认提供的布局控件里,大部分的布局控件都是经过继承 MultiChildRenderObjectWidget 实现,那么通常状况下自定义布局时,咱们须要作什么呢?框架

如上图所示,通常状况下实现自定义布局,咱们会经过继承 MultiChildRenderObjectWidgetRenderBox 这两个 abstract 类实现,而 MultiChildRenderObjectElement 则负责关联起它们, 除了此以外,还有有几个关键的类 : ContainerRenderObjectMixinRenderBoxContainerDefaultsMixinContainerBoxParentDataless

RenderBox 咱们知道是 RenderObject 的子类封装,也是咱们自定义 RenderObject 时常常须要继承的,那么其余的类分别是什么含义呢?ide

一、ContainerRenderObjectMixin

故名思义,这是一个 mixin 类,ContainerRenderObjectMixin 的做用,主要是维护提供了一个双链表的 children RenderObject布局

经过在 RenderBox 里混入 ContainerRenderObjectMixin , 咱们就能够获得一个双链表的 children ,方便在咱们布局时,能够正向或者反向去获取和管理 RenderObject 们 。post

二、RenderBoxContainerDefaultsMixin

RenderBoxContainerDefaultsMixin 主要是对 ContainerRenderObjectMixin 的拓展,是对 ContainerRenderObjectMixin 内的 children 提供经常使用的默认行为和管理,接口以下所示:学习

/// 计算返回第一个 child 的基线 ,经常使用于 child 的位置顺序有关
	double defaultComputeDistanceToFirstActualBaseline(TextBaseline baseline)
	
	/// 计算返回全部 child 中最小的基线,经常使用于 child 的位置顺序无关
	double defaultComputeDistanceToHighestActualBaseline(TextBaseline baseline)
	
	/// 触摸碰撞测试
	bool defaultHitTestChildren(BoxHitTestResult result, { Offset position })
	
	/// 默认绘制
	void defaultPaint(PaintingContext context, Offset offset)
	
	/// 以数组方式返回 child 链表
	List<ChildType> getChildrenAsList()

复制代码

三、ContainerBoxParentData

ContainerBoxParentDataBoxParentData 的子类,主要是关联了 ContainerDefaultsMixinBoxParentDataBoxParentDataRenderBox 绘制时所需的位置类。

经过 ContainerBoxParentData ,咱们能够将 RenderBox 须要的 BoxParentData 和上面的 ContainerParentDataMixin 组合起来,事实上咱们获得的 children 双链表就是以 ParentData 的形式呈现出来的。

abstract class ContainerBoxParentData<ChildType extends RenderObject> extends BoxParentData with ContainerParentDataMixin<ChildType> { }
复制代码

四、MultiChildRenderObjectWidget

MultiChildRenderObjectWidget 的实现很简单 ,它仅仅只是继承了 RenderObjectWidget,而后提供了 children 数组,并建立了 MultiChildRenderObjectElement

上面的 RenderObjectWidget 顾名思义,它是提供 RenderObjectWidget ,那有不存在 RenderObjectWidget 吗?

有的,好比咱们常见的 StatefulWidgetStatelessWidgetContainer 等,它们的 Element 都是 ComponentElementComponentElement 仅仅起到容器的做用,而它的 get renderObject 须要来自它的 child

五、MultiChildRenderObjectElement

前面的篇章咱们说过 ElementBuildContext 的实现, 内部通常持有 WidgetRenderObject 并做为两者沟通的桥梁,那么 MultiChildRenderObjectElement 就是咱们自定义布局时的桥梁了, 以下代码所示,MultiChildRenderObjectElement 主要实现了以下接口,其主要功能是对内部 childrenRenderObject ,实现了插入、移除、访问、更新等逻辑:

/// 下面三个方法都是利用 ContainerRenderObjectMixin 的 insert/move/remove 去操做
	/// ContainerRenderObjectMixin<RenderObject, ContainerParentDataMixin<RenderObject> 
	void insertChildRenderObject(RenderObject child, Element slot) 
	void moveChildRenderObject(RenderObject child, dynamic slot)         
	void removeChildRenderObject(RenderObject child)
	
	/// visitChildren 是经过 Element 中的 ElementVisitor 去迭代的
	/// 通常在 RenderObject get renderObject 会调用
	void visitChildren(ElementVisitor visitor)
	
	/// 添加忽略child _forgottenChildren.add(child);
	void forgetChild(Element child) 
	
	/// 经过 inflateWidget , 把 children 中 List<Widget> 对应的 List<Element>
	void mount(Element parent, dynamic newSlot)
	
	/// 经过 updateChildren 方法去更新获得  List<Element>
	void update(MultiChildRenderObjectWidget newWidget)
	
复制代码

因此 MultiChildRenderObjectElement 利用 ContainerRenderObjectMixin 最终将咱们自定义的 RenderBoxWidget 关联起来。

六、自定义流程

上述主要描述了 MultiChildRenderObjectWidgetMultiChildRenderObjectElement 和其余三个辅助类ContainerRenderObjectMixinRenderBoxContainerDefaultsMixinContainerBoxParentData 之间的关系。

了解几个关键类以后,咱们看通常状况下,实现自定义布局的简化流程是:

  • 一、自定义 ParentData 继承 ContainerBoxParentData
  • 二、继承 RenderBox ,同时混入 ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin 实现自定义RenderObject
  • 三、继承 MultiChildRenderObjectWidget,实现 createRenderObjectupdateRenderObject 方法,关联咱们自定义的 RenderBox
  • 四、override RenderBoxperformLayoutsetupParentData 方法,实现自定义布局。

固然咱们能够利用官方的 CustomMultiChildLayout 实现自定义布局,这个后面也会讲到,如今让咱们先从基础开始, 而上述流程中混入的 ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin ,在 RenderFlexRenderWrapRenderStack 等官方实现的布局里,也都会混入它们。

3、自定义布局

自定义布局就是在 performLayout 中实现的 child.layout 大小和 child.ParentData.offset 位置的赋值。

首先咱们要实现相似如图效果,咱们须要自定义 RenderCloudParentData 继承 ContainerBoxParentData ,用于记录宽高和内容区域 :

class RenderCloudParentData extends ContainerBoxParentData<RenderBox> {
  double width;
  double height;

  Rect get content => Rect.fromLTWH(
        offset.dx,
        offset.dy,
        width,
        height,
      );
}

复制代码

而后自定义 RenderCloudWidget 继承 RenderBox ,并混入 ContainerRenderObjectMixinRenderBoxContainerDefaultsMixin 实现 RenderBox 自定义的简化。

class RenderCloudWidget extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, RenderCloudParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, RenderCloudParentData> {
  RenderCloudWidget({
    List<RenderBox> children,
    Overflow overflow = Overflow.visible,
    double ratio,
  })  : _ratio = ratio,
        _overflow = overflow {
   ///添加全部 child 
    addAll(children);
  }
复制代码

以下代码所示,接下来主要看 RenderCloudWidgetoverride performLayout 中的实现,这里咱们只放关键代码:

  • 一、咱们首先拿到 ContainerRenderObjectMixin 链表中的 firstChild ,而后从头到位读取整个链表。
  • 二、对于每一个 child 首先经过 child.layout 设置他们的大小,而后记录下大小以后。
  • 三、以容器控件的中心为起点,从内到外设置布局,这是设置的时候,须要经过记录的 Rect 判断是否会重复,每次布局都须要计算位置,直到当前 child 不在重复区域内。
  • 四、获得最终布局内大小,而后设置总体居中。
///设置为咱们的数据
@override
void setupParentData(RenderBox child) {
  if (child.parentData is! RenderCloudParentData)
    child.parentData = RenderCloudParentData();
}

@override
  void performLayout() {
    ///默认不须要裁剪
    _needClip = false;

    ///没有 childCount 不玩
    if (childCount == 0) {
      size = constraints.smallest;
      return;
    }

    ///初始化区域
    var recordRect = Rect.zero;
    var previousChildRect = Rect.zero;

    RenderBox child = firstChild;

    while (child != null) {
      var curIndex = -1;

      ///提出数据
      final RenderCloudParentData childParentData = child.parentData;

      child.layout(constraints, parentUsesSize: true);

      var childSize = child.size;

      ///记录大小
      childParentData.width = childSize.width;
      childParentData.height = childSize.height;

      do {
        ///设置 xy 轴的比例
        var rX = ratio >= 1 ? ratio : 1.0;
        var rY = ratio <= 1 ? ratio : 1.0;

        ///调整位置
        var step = 0.02 * _mathPi;
        var rotation = 0.0;
        var angle = curIndex * step;
        var angleRadius = 5 + 5 * angle;
        var x = rX * angleRadius * math.cos(angle + rotation);
        var y = rY * angleRadius * math.sin(angle + rotation);
        var position = Offset(x, y);

        ///计算获得绝对偏移
        var childOffset = position - Alignment.center.alongSize(childSize);

        ++curIndex;

        ///设置为遏制
        childParentData.offset = childOffset;

        ///判处是否交叠
      } while (overlaps(childParentData));

      ///记录区域
      previousChildRect = childParentData.content;
      recordRect = recordRect.expandToInclude(previousChildRect);

      ///下一个
      child = childParentData.nextSibling;
    }

    ///调整布局大小
    size = constraints
        .tighten(
          height: recordRect.height,
          width: recordRect.width,
        )
        .smallest;

    ///居中
    var contentCenter = size.center(Offset.zero);
    var recordRectCenter = recordRect.center;
    var transCenter = contentCenter - recordRectCenter;
    child = firstChild;
    while (child != null) {
      final RenderCloudParentData childParentData = child.parentData;
      childParentData.offset += transCenter;
      child = childParentData.nextSibling;
    }

    ///超过了嘛?
    _needClip =
        size.width < recordRect.width || size.height < recordRect.height;
  }
复制代码

其实看完代码能够发现,关键就在于你怎么设置 child.parentDataoffset ,来控制其位置。

最后经过 CloudWidget 加载咱们的 RenderCloudWidget 便可, 固然完整代码还须要结合 FittedBoxRotatedBox 简化完成,具体可见 :GSYFlutterDemo

class CloudWidget extends MultiChildRenderObjectWidget {
  final Overflow overflow;
  final double ratio;

  CloudWidget({
    Key key,
    this.ratio = 1,
    this.overflow = Overflow.clip,
    List<Widget> children = const <Widget>[],
  }) : super(key: key, children: children);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderCloudWidget(
      ratio: ratio,
      overflow: overflow,
    );
  }

  @override
  void updateRenderObject(
      BuildContext context, RenderCloudWidget renderObject) {
    renderObject
      ..ratio = ratio
      ..overflow = overflow;
  }
}
复制代码

最后咱们总结,实现自定义布局的流程就是,实现自定义 RenderBoxperformLayout child 的 offset

4、CustomMultiChildLayout

CustomMultiChildLayout 是 Flutter 为咱们封装的简化自定义布局实现,它的内部一样是经过 MultiChildRenderObjectWidget 实现,可是它为咱们封装了 RenderCustomMultiChildLayoutBoxMultiChildLayoutParentData ,并经过 MultiChildLayoutDelegate 暴露出须要自定义的地方。

使用 CustomMultiChildLayout 你只须要继承 MultiChildLayoutDelegate ,并实现以下方法便可:

void performLayout(Size size);

  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate);

复制代码

经过继承 MultiChildLayoutDelegate,而且实现 performLayout 方法,咱们能够快速自定义咱们须要的控件,固然便捷的封装也表明了灵活性的丧失,能够看到 performLayout 方法中只有布局自身的 Size 参数,因此完成上图需求时,咱们还须要 child 的大小和位置 ,也就是 childSizechildId

childSize 相信你们都能故名思义,那 childId 是什么呢?

这就要从 MultiChildLayoutDelegate 的实现提及,MultiChildLayoutDelegate 内部会有一个 Map<Object, RenderBox> _idToChild; 对象,这个 Map 对象保存着 Object idRenderBox 的映射关系,而在 MultiChildLayoutDelegate 中获取 RenderBox 都须要经过 id 获取。

_idToChild 这个 Map 是在 RenderBox performLayout 时,在 delegate._callPerformLayout 方法内建立的,建立后所用的 idMultiChildLayoutParentData 中的 id, MultiChildLayoutParentData 的 id ,能够经过 LayoutId 嵌套时自定义指定赋值。

而完成上述布局,咱们须要知道每一个 child 的 index ,因此咱们能够把 index 做为 id 设置给每一个 child 的 LayoutId

因此咱们能够经过 LayoutId 指定 id 为数字 index , 同时告知 delegate ,这样咱们就知道 child 顺序和位置啦。

这个 id 是 Object 类型 ,因此你懂得,你能够赋予不少属性进去。

以下代码所示,这样在自定义的 CircleLayoutDelegate 中,就知道每一个控件的 index 位置,也就是知道了,圆形布局中每一个 item 须要的位置。

咱们只须要经过 index ,计算出 child 所在的角度,而后利用 layoutChildpositionChild 对每一个item进行布局便可,完整代码:GSYFlutterDemo

///自定义实现圆形布局
class CircleLayoutDelegate extends MultiChildLayoutDelegate {
  final List<String> customLayoutId;

  final Offset center;

  Size childSize;

  CircleLayoutDelegate(this.customLayoutId,
      {this.center = Offset.zero, this.childSize});

  @override
  void performLayout(Size size) {
    for (var item in customLayoutId) {
      if (hasChild(item)) {
        double r = 100;

        int index = int.parse(item);

        double step = 360 / customLayoutId.length;

        double hd = (2 * math.pi / 360) * step * index;

        var x = center.dx + math.sin(hd) * r;

        var y = center.dy - math.cos(hd) * r;

        childSize ??= Size(size.width / customLayoutId.length,
            size.height / customLayoutId.length);

        ///设置 child 大小
        layoutChild(item, BoxConstraints.loose(childSize));

        final double centerX = childSize.width / 2.0;

        final double centerY = childSize.height / 2.0;

        var result = new Offset(x - centerX, y - centerY);

        ///设置 child 位置
        positionChild(item, result);
      }
    }
  }

  @override
  bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
}
复制代码

总的来讲,第二种实现方式相对简单,可是也丧失了必定的灵活性,可自定义控制程度更低,可是也更加规范与间接,同时咱们本身实现 RenderBox 时,也能够用相似的 delegate 的方式作二次封装,这样的自定义布局会更行规范可控。

自此,第十六篇终于结束了!(///▽///)

资源推荐

文章

《Flutter完整开发实战详解系列》

《移动端跨平台开发的深度解析》

相关文章
相关标签/搜索