跳至主要內容

Flutter 滑动分析之 Scrollview

JI,XIAOYONG...大约 8 分钟

Flutter 中的 scrollable widget 根据实现方式的不同,可以分为两大类:

  • 基于 RenderBox 的 box protocol 实现的,主要基于 Size 实现布局。常见的有 SingleChildScrollView。
  • 基于 RenderSliver 的 sliver protocol 实现的,主要基于 SliverGeometry 实现布局。比如 CustomScrollView 及其子类 ListView、GridView 等继承自 ScrollView 的 Widget,以及基于 CustomScrollView 的 NestedScrollView、基于 Viewport 等的 PageView、TabBarView 等直接对 SliverFillViewport 等进行封装的 Widget。

上述所有的 scrollable widget 其底层逻辑依然是对 Scrollable 的封装,其内部处理了 ScrollController、ScrollPosition(viewport 的 offset)、ViewportBuilder(容纳滚动内容的容器)、ScrollPhysics(管理 scrollable view 的物理属性,比如是否可以滚动或弹性滚动等)、ScrollActivity(对外发出 ScrollNotification)、RawGestureDetector(手势识别)等等一系列与 scroll 有关的逻辑,从而使得其他 scrollable view 能够比较方便的实现 scroll 效果。


上回我们对 SingleChildScrollView 的实现做了简单分析(见《Flutter 滑动分析之 SingleChildScrollViewopen in new window》),本文将对另外一种遵循 sliver protocol 的 ScrollView 做一分析。

官方对 ScrollView 的定义是:“A widget that scrolls”。

其主要由三部分组成:

  • 一个ScrollWidget,监听用户手势,实现 scrolling 的交互设计

  • 一个viewport widget,根据传入的 shrinkwrap 值的 true/false 分别会是 ShrinkWrappingViewport 或者 Viewport。通过根据传入的 ViewportOffset 不同而值展示 slivers 的一部分内容来实现滑动的视觉设计效果。

  • 一个或多个slivers,可以被组合起来创建各种 scrolling effects(比如 list,grids,expanding header 等)的 widget,是真正显示在屏幕上的 widget。

    由于默认的 ScrollView 创建的 viewport 的 slivers 属性只接受能创建 RenderSliver 的 Widget,所以 ScrollView 的List<Widget> buildSlivers(BuildContext context)方法只能返回 SliverXXX 之类(比如 SliverList)可以创建 RenderSliver 的 Widget。


ScrollView 是一个抽象类,它主要的作用是将上面提到的三部分组合起来,为(遵从 sliver protocol 的)scrollable widget 封装屏蔽掉滑动底层细节,提供像buildSlivers之类的方法方便子类能够快速实现一个 scrollable widget。

源码分析

abstract class ScrollView extends StatelessWidget{}

ScrollView 继承自 StatelessWidget,他的主要逻辑在 build 方法中:

  Widget build(BuildContext context) {
    // 这里创建 slivers
    final List<Widget> slivers = buildSlivers(context);
    final AxisDirection axisDirection = getDirection(context);
    // scrollController 要么使用最近的 PrimaryScrollController,要么使用自己的 controller
    final ScrollController? scrollController =
        primary ? PrimaryScrollController.of(context) : controller;
    // 这里是主要创建 Scrollable 的地方
    final Scrollable scrollable = Scrollable(
      dragStartBehavior: dragStartBehavior,
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      scrollBehavior: scrollBehavior,
      semanticChildCount: semanticChildCount,
      restorationId: restorationId,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        // 在这里创建 viewport,使用我们创建好的 slivers 填充 viewport
        // 并传入 Scrollable.ScrollPosition 作为这里的入参 offset,在 viewport 中
        // 会监听 offset 的变化来重新绘制 slivers 从而实现滑动效果
        return buildViewport(context, offset, axisDirection, slivers);
      },
    );
    final Widget scrollableResult = primary && scrollController != null
        ? PrimaryScrollController.none(child: scrollable)
        : scrollable;

    // 这里是处理当 scrollable view 滑动时隐藏键盘的逻辑
    if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
      return NotificationListener<ScrollUpdateNotification>(
        child: scrollableResult,
        onNotification: (ScrollUpdateNotification notification) {
          final FocusScopeNode focusScope = FocusScope.of(context);
          if (notification.dragDetails != null && focusScope.hasFocus) {
            focusScope.unfocus();
          }
          return false;
        },
      );
    } else {
      return scrollableResult;
    }
  }

通过上述代码,我们可以验证之前的判断:ScrollView 本身是对 Scrollable、viewport、slivers 的封装。具体的处理滑动手势、更新 ScrollPosition、发送 ScrollNotification 等等都在 Scrollable 中处理了,ScrollView 的子类只需要按照要求提供 slivers(通过 buildSlivers 方法)和其他一些必须的信息即可。

上面的代码中还分别调用了 buildViewport 和 buildSlivers 方法,接下来我们逐一分析一下他们的源码。

Widget buildViewport()

buildViewport 方法顾名思义,是用来创建 viewport 的。在 ScrollView 中默认会按照 shrinkWrap 的不同创建两种 viewport,他的子类也可以根据需要重写此方法以返回自己的 viewport。

  Widget buildViewport(
    BuildContext context,
    ViewportOffset offset,
    AxisDirection axisDirection,
    List<Widget> slivers,
  ) {
    assert(() {
      switch (axisDirection) {
        case AxisDirection.up:
        case AxisDirection.down:
          // 如果是上述两种 AxisDirection 说明 Axis 是 Axis.vertical 的,那么就
          // 需要判断是否此 widget 是否有 Directionality 可以用来判断文本布局方向
          // 如果没法判断则抛出 FlutterError,否则返回 true
          return debugCheckHasDirectionality(
            context,
            why: 'to determine the cross-axis direction of the scroll view',
            hint: 'Vertical scroll views create Viewport widgets that try to determine their cross axis direction '
                  'from the ambient Directionality.',
          );
        // 如果 getDirection() 能得出下面两种 AxisDirection 说明 Axis 是
        // Axis.horizontal 的,并且已经得知文本方向,所以直接返回 true
        case AxisDirection.left:
        case AxisDirection.right:
          return true;
      }
    }());
    // 经过上述检查,到这里 widget 的文本方向(TextDirection)一定已经确定了
    // 这里根据 shrinkWrap 的不同分别创建两种 viewport
    if (shrinkWrap) {
      return ShrinkWrappingViewport(
        axisDirection: axisDirection,
        offset: offset,
        slivers: slivers,
        clipBehavior: clipBehavior,
      );
    }
    return Viewport(
      axisDirection: axisDirection,
      offset: offset,
      slivers: slivers,
      cacheExtent: cacheExtent,
      center: center,
      anchor: anchor,
      clipBehavior: clipBehavior,
    );
  }

可以看到,在 buildViewport() 方法中,显示在 debug 模式下检查确保 widget 已经确定了文本方向(TextDirection 是 rtl 还是 ltr);然后根据 shrinkWrap 的不同分别创建 ShrinkWrappingViewport 或者 Viewport,他们会根据 offset 的变化展示不同部分的 slivers。

ShrinkWrappingViewport 和 Viewport 都是继承自 MultiChildRenderObjectWidget 的 widget,主要逻辑是分别创建对应的 RenderObject:RenderShrinkWrappingViewport 和 RenderViewport,而这两者又都继承自 RenderViewportBase。

class Viewport extends MultiChildRenderObjectWidget {

  
  RenderViewport createRenderObject(BuildContext context) {
    return RenderViewport(...);
  }

  
  void updateRenderObject(BuildContext context, RenderViewport renderObject) {
    renderObject...;
  }

  
  MultiChildRenderObjectElement createElement() => _ViewportElement(this);
}
class ShrinkWrappingViewport extends MultiChildRenderObjectWidget {

  
  RenderShrinkWrappingViewport createRenderObject(BuildContext context) {
    return RenderShrinkWrappingViewport(...);
  }

  
  void updateRenderObject(BuildContext context, RenderShrinkWrappingViewport renderObject) {
    renderObject...;
  }
}

RenderViewportBase

abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMixin<RenderSliver>> extends RenderBox with ContainerRenderObjectMixin<RenderSliver, ParentDataClass> implements RenderAbstractViewport {}

RenderViewportBase 继承自 RenderBox,混入了 ContainerRenderObjectMixin<RenderSliver, ParentDataClass>类,本身不持有 children,但提供了在 RenderBox 中容纳 RenderSliver 的一些通用方法:

  • 自动添加监听_offset 的方法,在其变化时执行 markNeedsLayout() 方法实现滑动效果
  • 提供按照_offset 的值 layout、paint 持有的 children 的方法
  • 通过 hitTestChildren 实现 children 的 hit test
校验 children 类型是否为 RenderSliver

在 RenderViewportBase 混入的 ContainerRenderObjectMixin<RenderSliver, ParentDataClass>的 debugValidateChild() 方法中会检验 child 的类型是否为指定的 ChildType(在 RenderViewportBase 中 ChildType 为 RenderSliver),如果不是则会抛出 FlutterError,这也是 ScrollView 默认 Viewport 只支持可以创建 RenderSliver 的 Widget 的原因。

mixin ContainerRenderObjectMixin<ChildType extends RenderObject, ParentDataType extends ContainerParentDataMixin<ChildType>> on RenderObject {
  bool debugValidateChild(RenderObject child) {
    assert(() {
      if (child is! ChildType) {// 此处会校验 child 的类型
        throw FlutterError.fromParts(<DiagnosticsNode>[
          ...
        ]);
      }
      return true;
    }());
    return true;
  }
}

可见 ContainerRenderObjectMixin 提供了检测 child 类型的方法,那么它是在什么时候被调用的呢?

无论是 Viewport 还是 ShrinkWrappingViewport 都继承自 MultiChildRenderObjectWidget,其会创建 MultiChildRenderObjectElement。

MultiChildRenderObjectElement.insertRenderObjectChild() 添加 child 中的 RenderObject 时都会先检查一下 child 的类型:

class MultiChildRenderObjectElement extends RenderObjectElement {
  
  void insertRenderObjectChild(RenderObject child, IndexedSlot<Element?> slot) {
    final ContainerRenderObjectMixin<RenderObject, ContainerParentDataMixin<RenderObject>> renderObject = this.renderObject;
    // 注意这里调用 MultiChildRenderObjectElement 持有的 RenderObject 的
    // debugValidateChild 方法校验 child 类型
    assert(renderObject.debugValidateChild(child));
    renderObject.insert(child, after: slot.value?.renderObject);
    assert(renderObject == this.renderObject);
  }
}

而因为不管是 RenderShrinkWrappingViewport 还是 RenderViewport 都是继承自 RenderViewportBase,也就会执行ContainerRenderObjectMixin<RenderSliver, ParentDataClass>.debugValidateChild(child)方法,校验 child 类型是否为 RenderSliver,所以会在其slivers中直接传入 box widget 则会报错“A RenderViewport expected a child of type RenderSliver but received a child of type RenderXXX.”

RenderViewport

class RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData> {}

RenderViewport 是 Flutter 滑动机制的主力,他通过监听offset的变化展示children的一部分来实现滑动的视觉效果,他会占据父级给的最大空间(大小由父级指定)。

其内部持有一个双向的 slivers 列表children,以在 zero scroll offset 的center为锚点:

  • slivers 列表中在 center 之前的 Slivers 按照列表反方向,沿着 axisDirection 的反方向展示。
  • slivers 列表中在 center 之后的 Slivers 按照列表的方向,沿着 axisDirection 的方向展示。

比如一个 axisDirection 为 AxisDirection.down,children 列表为["1", "2", "3", "center", "5", "6", "7"],center 为“center”,那么默认会展示["center", "5", "6", "7"],当手指向下滑动的时候,会依次展示出“3”、“2”、“1”,等完全下拉之后,展示内容为:["1", "2", "3", "center", "5", "6", "7"]。

RenderShrinkWrappingViewport

class RenderShrinkWrappingViewport extends RenderViewportBase<SliverLogicalContainerParentData>{}

RenderShrinkWrappingViewport 通过监听offset的变化展示children的一部分来实现滑动的视觉效果,与 Viewport 不同的是,他会 shrinkWrap(收缩包装)自己以便在主轴上匹配 children 的 size(大小由 RenderShrinkWrappingViewport 根据 children 计算而来),比较耗费性能(特别是当 item 可能会通过折叠展开等方式改变尺寸时)。

List<Widget> buildSlivers()

ScrollView 的 buildSlivers 方法是抽象方法,由子类根据需要实现,一般子类也只需要重写此方法即可

  /// Build the list of widgets to place inside the viewport.
  ///
  /// Subclasses should override this method to build the slivers for the inside
  /// of the viewport.
  
  List<Widget> buildSlivers(BuildContext context);

使用示例

CustomScrollView 就是继承自 ScrollView:

class CustomScrollView extends ScrollView {
  /// Creates a [ScrollView] that creates custom scroll effects using slivers.
  ///
  /// See the [ScrollView] constructor for more details on these arguments.
  const CustomScrollView({
    ...
  }) : super(
    ...
  );

  /// The slivers to place inside the viewport.
  final List<Widget> slivers;

  
  List<Widget> buildSlivers(BuildContext context) => slivers;
}

可以看到 CustomScrollView 的实现比较简单,主要逻辑是将传入的参数slivers作为List<Widget> buildSlivers(BuildContext context)的返回值。这导致我们在使用 CustomScrollView 的时候,需要传入 SliverList、SliverAppBar 等这些继承自 SliverMultiBoxAdaptorWidget 能创建 RenderSliver 的 Widget,而不是普通的 box widget。

总结

ScrollView 的子类借助 SliverMultiBoxAdaptorWidget 及其子类可以实现对 item 的懒加载从而避免创建无法通过 viewport 可见的 children(这种类型的传参一般都需传入 SliverChildDelegate 的子类),从而优化性能。

而根据 shrinkWrap 的不同,分别使用 Viewport 和 ShrinkWrappingViewport 创建 viewport,从而分别实现按照父级指定 size 或按照子级计算 size(比较耗性能)。

ScrollView 是 Flutter 中基于 sliver protocol 的 scrollable widget 的父类,因为 viewport 的限制只接受创建 RenderSliver 的 widget 作为其直接子类。其子类则通过 SliverMultiBoxAdaptorWidget 及其子类实现加载 box widget。

以下类都是基于 ScrollView 实现的 scrollable widget:

参考资料

ScrollView_api.flutter.devopen in new window

文章标题:《Flutter 滑动分析之 Scrollview》
本文作者: JI,XIAOYONG
发布时间: 2022/07/16 10:14:01 UTC+8
更新时间: 2023/12/30 16:17:02 UTC+8
written by human, not by AI
本文地址: https://jixiaoyong.github.io/blog/posts/60f8d92f.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 许可协议。转载请注明出处!
你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.8