Flutter滑动分析之SingleChildScrollView

JI,XIAOYONG...大约 13 分钟flutter

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年7月16日
written by human, not by AI
本文地址: https://jixiaoyong.github.io/blog/posts/d3bdcb53.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 许可协议。转载请注明出处!
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.1