跳至主要內容

Flutter 滑动分析之 SingleChildScrollView

JI,XIAOYONG...大约 13 分钟

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 的源码实现做一简单分析:它是如何实现滚动效果,有什么优势和限制。

官方对其定义是:“A box in which a single widget can be scrolled”。明确表明,SingleChildScrollView 是遵守 box protocol 的 widget,在其内部也只能有一个box widget

用例

下面是一个 SingleChildScrollView 的简单使用:

SingleChildScrollView(
          child: Column(
            children: List.generate(
                20,
                (index) => SizedBox(
                      height: 50,
                      child: Center(child: Text("item $index")),
                    )),
          ),
        )

在这个例子中,SingleChildScrollView 中容纳了一个叫 Column 的 child,如果 Column 的高度无法在屏幕中完全展示,就 SingleChildScrollView 就会保证用户可以上下滑动,从而展示对应的内容;否则如果能够完全显示,则内容无法滑动。

源码分析

SingleChildScrollView

class SingleChildScrollView extends StatelessWidget {}

作为一个 StatelessWidget,SingleChildScrollView 的主要逻辑在他的build()方法中:

  Widget build(BuildContext context) {
    final AxisDirection axisDirection = _getDirection(context);
    Widget? contents = child;
    if (padding != null)
      contents = Padding(padding: padding!, child: contents);
    // 这里 scrollController 如果没有指定或者 primary 为 true 的话会使用上级最近的
    // PrimaryScrollController
    final ScrollController? scrollController = primary
        ? PrimaryScrollController.of(context)
        : controller;
    // 正如我们之前所说,SingleChildScrollView 实现其实也就是对 Scrollable 的
    // 进一步封装,提供一些自己特有的内容,比如_SingleChildViewport
    Widget scrollable = Scrollable(
      dragStartBehavior: dragStartBehavior,
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      restorationId: restorationId,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        // 这里使用了自定义的 Viewport 来实现布局逻辑
        return _SingleChildViewport(
          axisDirection: axisDirection,
          offset: offset,// offset 就是 Scrollable 处理的 ScrollPosition
          clipBehavior: clipBehavior,
          child: contents,// 就是我们传入的 child
        );
      },
    );

    // 这里处理了滑动时键盘隐藏的问题
    if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
      scrollable = NotificationListener<ScrollUpdateNotification>(
        child: scrollable,
        onNotification: (ScrollUpdateNotification notification) {
          final FocusScopeNode focusNode = FocusScope.of(context);
          if (notification.dragDetails != null && focusNode.hasFocus) {
            focusNode.unfocus();
          }
          return false;
        },
      );
    }

    return primary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;
  }

可以看到,正如之前所言,SingleChildScrollView 是依赖于封装 Scrollable 实现滑动效果。我们注意到在 Scrollable.viewportBuilder 中传入的是_SingleChildViewport,这个类处理了 Scrollable 传入的 ScrollPosition 也即这里的 ViewportOffset:

_SingleChildViewport

_SingleChildViewport 继承自 SingleChildRenderObjectWidget,主要逻辑是创建和更新 RenderObject——_RenderSingleChildViewport。

class _SingleChildViewport extends SingleChildRenderObjectWidget {
  
  _RenderSingleChildViewport createRenderObject(BuildContext context) {
    return _RenderSingleChildViewport(
      axisDirection: axisDirection,
      offset: offset,// 此处的 offset 是来自于 Scrollable 的 ScrollPosition
      clipBehavior: clipBehavior,
    );
  }

  
  void updateRenderObject(BuildContext context, _RenderSingleChildViewport renderObject) {
    // Order dependency: The offset setter reads the axis direction.
    renderObject
      ..axisDirection = axisDirection
      ..offset = offset// 此处的 offset 是来自于 Scrollable 的 ScrollPosition
      ..clipBehavior = clipBehavior;
  }
}

可见处理 offset 以便更新 content 实现滑动效果的主要逻辑在_RenderSingleChildViewport 这个 RenderObject 中。

_RenderSingleChildViewport

先看一下_RenderSingleChildViewport 的继承关系:
class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMixin<RenderBox> implements RenderAbstractViewport{}

由上述代码可知,_RenderSingleChildViewport:

  • 是 RenderBox,也就是说其内部 lay out 遵守 box protocol
  • RenderObjectWithChildMixin<RenderBox>,RenderObjectWithChildMixin 为 RenderObject 提供一套管理单个 child 的模式,它的泛型指定了 child 的类型只能是 RenderBox,这也就是为什么我们之前说 SingleChildScrollView 的 child 只能是 box widget。
  • 实现了 RenderAbstractViewport 接口,这个接口表示 render object 是内部比实际要大,提供了一些方法供 ScrollPosition 和其他 viewport 调用,来获取一些使此 viewport 在屏幕上可见的信息。

在修改 axisDirection、offset、cacheExtent 等三个属性的时候会触发 markNeedsLayout() 方法重新进行 lay out;
在修改 clipBehavior 属性的时候只会触发 markNeedsPaint() 和 markNeedsSemanticsUpdate() 方法。

此外,在每次设置 offset 的时候,都会对齐添加监听,这样当 Scrollable 中由于用户手势或者通过 ScrollController 调用 jumpTo/animateTo 等方法修改了 ScrollPosition 的时候,都会使得 Scrollab 的 viewport 也就是我们这里的_RenderSingleChildViewport 收到通知、从而进行对应处理:

  set offset(ViewportOffset value) {
    assert(value != null);
    if (value == _offset)
      return;
    if (attached)
    // 先移除已有的监听
      _offset.removeListener(_hasScrolled);
    _offset = value;
    if (attached)
    // 再为新的 offset 添加监听
      _offset.addListener(_hasScrolled);
    markNeedsLayout();
  }

  void _hasScrolled() {
    markNeedsPaint();
    markNeedsSemanticsUpdate();
  }

除了上述在修改 offset 的时候添加/移除监听,在 attach/detach 方法中也有对应操作:

  
  void attach(PipelineOwner owner) {
    super.attach(owner);
    _offset.addListener(_hasScrolled);
  }

  
  void detach() {
    _offset.removeListener(_hasScrolled);
    super.detach();
  }

从上面的分析我们也可以看出,除了设置修改 axisDirection、offset、cacheExtent 等属性的时候会触发 layout 外,其余时候只会触发重新 paint。

layout

一般来说 Flutter Widget 要展示在屏幕上需要经历 build、layout、paint 三步,在分析 SingleChildScrollView 如何根据 offset 的变化实现 scroll 效果之前,我们先看一下他是如何实现 layout 的。

  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    if (child == null) {
      size = constraints.smallest;// 如果 child 为空,则按照父级的最小尺寸来
    } else {
      // 如果有 child,就不限制主轴方向的尺寸,让 child 进行 layout(会得到最大的主轴尺寸)
      child!.layout(_getInnerConstraints(constraints), parentUsesSize: true);
      // 在父级约束范围内尽可能满足 child 的尺寸
      size = constraints.constrain(child!.size);
    }

    // 使用_viewportExtent 作为 offset 的 viewport 范围
    offset.applyViewportDimension(_viewportExtent);
    // 更新 viewport 的内容 content 的大小范围
    offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
  }

// 只有横轴方向的约束,没有主轴方向的约束
BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
    switch (axis) {
      case Axis.horizontal:
        // 如果是水平布局,就只限制高度,不限制宽度
        return constraints.heightConstraints();
      case Axis.vertical:
        // 如果是垂直布局,就只限制宽度,不限制高度
        return constraints.widthConstraints();
    }
  }

可以看到在 SingleChildScrollView 先让 child 在主轴方向尽可能自由布局,取得其最大值,然后自身在满足父级约束的情况下应用 child 的 size:如果 child.size 在父级约束内就直接应用,负责采用父级的约束。

这样最终的效果就是我们的 SingleChildScrollView 在 child 不超过父级约束的时候只占据 child 的内容,当 child 的内容大于父级约束时,SingleChildScrollView 自身的尺寸是父级给定的最大尺寸,而 child 本身在主轴方向上的尺寸是大于 SingleChildScrollView 的尺寸。这样也为我们后续通过监听 offset 修改显示部分 child 的内容实现滑动效果提供了可能。

这也告诉我们 SingleChildScrollView 的父级需要指定指定主轴方向约束,否则会出现异常。
比如在 Column 中直接使用 SingleChildScrollView 就会在内容过长的时候发生overflowed错误并且无法滑动 SingleChildScrollView,这是因为 SingleChildScrollView 和 child 都按照最长的尺寸布局,并且这个尺寸超过了父级约束。
在 SingleChildScrollView 外层添加 Expanded 作为父级,相当于给他指定了一个约束(占据剩余空间),所以可以解决这个问题。

之后,又根据_viewportExtent 以及_minScrollExtent/_maxScrollExtent 分别设置了 viewport 和 content 的范围,让我们看一下这三个值的来历:

  double get _viewportExtent {
    assert(hasSize);
    switch (axis) {
      case Axis.horizontal:
        return size.width;
      case Axis.vertical:
        return size.height;
    }
  }

可以看到,_viewportExtent 是取值主轴方向的 size 大小,也就是 SingleChildScrollView 的尺寸。

  double get _minScrollExtent {
    assert(hasSize);
    return 0.0;
  }

  double get _maxScrollExtent {
    assert(hasSize);
    if (child == null)
      return 0.0;
    switch (axis) {
      case Axis.horizontal:
        return math.max(0.0, child!.size.width - size.width);
      case Axis.vertical:
        return math.max(0.0, child!.size.height - size.height);
    }
  }

_minScrollExtent 默认返回 0.0;
_maxScrollExtent 返回的是主轴方向上 child 减去 SingleChildScrollView 之后的尺寸和 0.0 之间的最大值,换言之,如果 child 比 SingleChildScrollView 尺寸大,_maxScrollExtent 就是多出来的那一部分,也就是我们可以滑动的范围,否则为 0.0,也就是 SingleChildScrollView 不可滑动。

paint

到目前为止,我们的 SingleChildScrollView 顺利得到了尺寸,假设 child 尺寸大于 SingleChildScrollView 的最大尺寸,那么当用户滑动屏幕导致 offset 改变的时候,又是如何实现滑动效果的呢?

先看一个属性:

  // offset.pixels 表示 child 沿着与轴方向 axis direction 相反的方法 offset 的 pixels
  // 比如 axis direction 是 down 的话,手指向上滑动屏幕此值增大,否则减小
  Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);

  // 根据 position 计算出 child 实际在 SingleChildScrollView 中的 offset
  // 以 child 的左上角在 SingleChildScrollView 左上角为 0.0,向上为负,向下为正
  Offset _paintOffsetForPosition(double position) {
    assert(axisDirection != null);
    switch (axisDirection) {
      case AxisDirection.up:
        return Offset(0.0, position - child!.size.height + size.height);
      case AxisDirection.down:
        return Offset(0.0, -position);
      case AxisDirection.left:
        return Offset(position - child!.size.width + size.width, 0.0);
      case AxisDirection.right:
        return Offset(-position, 0.0);
    }
  }

可以看出,_paintOffset 是根据 ScrollPosition 计算出来的真正的 child 和 SingleChildScrollView 的偏移 offset。

  
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      final Offset paintOffset = _paintOffset;

      void paintContents(PaintingContext context, Offset offset) {
        // 可以看到这里,除了父级传入的 offset 外,还应用了 ScrollPosition 改变而变化的
        // paintOffset。这样每次 Scrollable 修改 ScrollPosition 之后都会触发 paint
        // 方法,使用新的 paintOffset 绘制 child
        context.paintChild(child!, offset + paintOffset);
      }

      if (_shouldClipAtPaintOffset(paintOffset) && clipBehavior != Clip.none) {
        _clipRectLayer.layer = context.pushClipRect(
          needsCompositing,
          offset,
          Offset.zero & size,
          paintContents,
          clipBehavior: clipBehavior,
          oldLayer: _clipRectLayer.layer,
        );
      } else {
        _clipRectLayer.layer = null;
        paintContents(context, offset);
      }
    }
  }

到此为止,我们可以得出以下结论:

_RenderSingleChildViewport 接收传入的 child,并监听传入的 Offset,当其变化时执行 markNeedPaint();
其先让 child 在主轴方向尽可能大的进行 layout,然后自身在父级约束条件下尽可能满足 child size,这样当 child 比父级给的约束大时,child 保持自身大小,而 viewport 的 size 则在父级给的最大尺寸内展示一部分 child 内容;
当 Offset 变化时,按照 Offset.pixels 计算出对应的 paintOffset,重新绘制 child,展示另外一部分 child 的内容,从而实现滑动效果。

hitTest

_RenderSingleChildViewport 将 hitTest 直接转发给了 child:

  
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    if (child != null) {
      return result.addWithPaintOffset(
        offset: _paintOffset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          assert(transformed == position + -_paintOffset);
          return child!.hitTest(result, position: transformed);
        },
      );
    }
    return false;
  }

至此,SingleChildScrollView 基于 Scrollable、ScrollPosition 和_RenderSingleChildView 完成了支持内部单个 box widget 的滑动效果。

优劣对比

相对于使用 Sliver 实现滑动效果的 Widget 来说,SingleChildScrollView 使用简单,使用的是 box protocol,适用于 child 通常是完全可见的,但是在某些特殊场景(比如竖屏变为横屏等)下可能显示不全的情况,SingleChildScrollView 可以保证在父级无法完整显示 child 的时候使其支持滑动。
SingleChildScrollView 使用起来也比较方便。

但是,正如上面分析的,无论 content 是否可见,SingleChildScrollView 都会将其 layout/paint(也就是说会将所有内容全部加载),这样如果 content 超出 viewport 的部分比较多就会非常耗费性能

对于这种情况,就应该考虑使用 ListView/GridView/CustomScrollView 等基于 sliver protocol 的 scrollable widget。在 shrinkWrap 属性为 false 的情况下,viewport 会只创建屏幕可见部分 + viewport 前后缓存区域的内容,在 content 滑出这部分区域时 dispose,当其再次滑入时再 recreate,从而保证性能。

进阶使用

为 Column 的 children 安全应用 spacedAround,center 等效果

想要给 Column 的 children 设置 spacedAround 效果,又需要保证在父级空间不足时能够完整显示所有 children 的内容的话,就需要结合 SingleChildScrollView(空间不足时可滑动)、LayoutBuilder(获取父级约束信息)、ConstrainedBox(设置 Column 约束)来实现:

child: LayoutBuilder(// 获取父级约束信息
        builder: (BuildContext context, BoxConstraints viewportConstraints) {
          return SingleChildScrollView(// 父级空间不足时可以滚动
            child: ConstrainedBox(
              constraints: BoxConstraints(
        // 这里指定最小高度为父级高度,所以空间足够时 Column 可以按需分布 children,
        // 空间不足时则将 children 一个个依次排列(互相之间 space 为 0)
                minHeight: viewportConstraints.maxHeight,
              ),
              child: Column(
                mainAxisSize: MainAxisSize.min,// 默认主轴尺寸尽可能的小
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: <Widget>[
                  Container(
                    // A fixed-height child.
                    color: const Color(0xffeeee00), // Yellow
                    height: 120.0,
                    alignment: Alignment.center,
                    child: const Text('Fixed Height Content'),
                  ),
                  Container(
                    // Another fixed-height child.
                    color: const Color(0xff008000), // Green
                    height: 120.0,
                    alignment: Alignment.center,
                    child: const Text('Fixed Height Content'),
                  ),
                ],
              ),
            ),
          );
        },
      )

这里使用 ConstrainedBox 确保了 Column 主轴方向最小尺寸是父级大小:

  • 当父级尺寸大于 Column 的 children 尺寸时,多出的空隙由 Column 按照 MainAxisAlignment.spaceAround 原则分配,由于 SingleChildScrollView 的 child 尺寸和父级一致,所需不会滑动;
  • 当父级尺寸小于 Column 的 children 尺寸时,Column 的尺寸为 children 的尺寸之和(相互之间没有间隙),此时 SingleChildScrollView 的 child 尺寸大于父级尺寸,所以可以上下滑动,保证了 Column 的 children 可以完全显示。

为 Column 的 children 安全应用 Expanded、Space 等效果

在一些场景下,需要用到 Expanded、Space 等填充 Column 剩余的空间以展示某些内容,比如一直位于屏幕下方的版权信息,但是当 Column 的 children 尺寸大于父级尺寸时,又会导致 children 内容无法完整显示,如果直接在 Column 上加一个 SingleChildScrollView 作为父级,又会因为 SingleChildScrollView 给 child 在主轴方向的尺寸无限制,而 Expanded 又要求占据所有剩余空间从而导致出错。

此时可以在上面例子的基础上增加 IntrinsicHeight/InstrinsicWidth 来解决此问题:

LayoutBuilder(// 获取父级约束信息
        builder: (BuildContext context, BoxConstraints viewportConstraints) {
          return SingleChildScrollView(// 保证 child 超出父级限制时可以滑动
            child: ConstrainedBox(
              constraints: BoxConstraints(
        // 这里指定最小高度为父级高度,所以空间足够时 Column 可以按需分布 children,
        // 空间不足时则将 children 一个个依次排列(互相之间 space 为 0)
                minHeight: viewportConstraints.maxHeight,
              ),
              child: IntrinsicHeight(
        // 当 minHeight:viewportConstraints.maxHeight 比 Column 想要的大时,
        // 那么 Column 采用 viewportConstraints.maxHeight 的值
        // 否则 Column 按照自己的内容大小来
                child: Column(
                  children: <Widget>[
                    Container(
                      // A fixed-height child.
                      color: const Color(0xffeeee00), // Yellow
                      height: 320.0,
                      alignment: Alignment.center,
                      child: const Text('Fixed Height Content'),
                    ),
                    Expanded(
                      // A flexible child that will grow to fit the viewport but
                      // still be at least as big as necessary to fit its contents.
                      child: Container(
                        color: const Color(0xffee0000), // Red
                        height: 120.0,
                        alignment: Alignment.center,
                        child: const Text('Flexible Content'),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      )

在上面例子中,作为 SingleChildScrollView 子级的 Column 内部能够使用 Expanded 的关键在于 InstrinsicHeight:它的定义是“一个将 child 调整为 child 固有高度的 widget”,也就是说,当child 可能有无限的高度时,与其无限拓展,它更希望将自己 size 定位一个更加合理的固有高度(Expanded、Spacer 等非 RenderObjectWidget 本身没有高度,所以在这里不会被计算)。

那么,当父级指定的最小约束 minHeight 大于 InstrinsicHeight.child 的最大固有高度时,child 将按照父级的最小高度设置;
当父级指定的最大约束是 double.infinity 无限大时,InstrinsicHeight 会强制其 child 的大小为固有高度。

但是需要注意的是,IntrinsicHeight/InstrinsicWidth 因为至少需要对 child 进行两次 layout(一次获取 intrinsic dimensions,一次真正的执行 layout),所以会比较耗费性能。因此应当保证 Column 子级数量尽可能少,并且可以使用 SizeBox 给 child 指定大小以减轻计算 intrinsic dimensions 的压力。

总结

SingleChildScrollView 作为遵守 box protocol 的 scrollable widget,使用简单,适用于页面内容通常为全部可见,但特殊情况下可能无法完整显示因而需要支持滚动的情况。

其 child 只支持可以生成 RenderBox 的 Widget,会一次性创建所有 child 内容,在其内部使用 ListView 等时需要开启 shrinkWrap 从而导致其懒创建 item 失效,比较耗费性能。

因此,如果是大量 item、child 内容超出 viewport 部分时,应当考虑使用基于 Sliver 的 ListView/GridView/CustomScrollView 等。

参考资料

SingleChildScrollView_api.flutter.devopen in new window

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