跳至主要內容

Flutter 滑动分析之 NestedScrollView

JI,XIAOYONG...大约 22 分钟

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

  • 基于 RenderBox 的 box protocol 实现的,主要基于 Size 实现布局。常见的有SingleChildScrollViewopen in new window
  • 基于 RenderSliver 的 sliver protocol 实现的,主要基于 SliverGeometry 实现布局。比如 CustomScrollView 及其子类 ListView、GridView 等继承自ScrollViewopen in new window的 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 效果。

本文只对 NestedScrollView 的源码实现做一简单分析:它是如何实现联动滚动效果,有什么优势和限制。

官方对其定义是:“A scrolling view inside of which can be nested other scrolling views, with their scroll positions being intrinsically linked.”。

顾名思义,NestedScrollView 是一个可以在内部嵌套其他 scrolling views 的滑动 View,按照所处位置的不同,使用headerSliverBuilder提供 header 部分的 scrolling views(限制只能是可以产生 RenderSliver 的 widget),而使用body提供在填充 header 之下所有区域的 widget(限制只能是产生 RenderBox 的 widget)。

用例

下面是一个 NestedScrollView 经典的使用方式:

  Widget build(BuildContext context) {
    var _tabBar = TabBar(
      isScrollable: true,
      tabs: tabs
          .map((e) => Tab(
                child: Container(
                  height: double.infinity,
                  child: Center(child: Text("Tab$e")),
                ),
              ))
          .toList(),
    );
    return Scaffold(
      body: DefaultTabController(// 此处的 controller 是给 TabBar 和 TabBarView 使用的
          length: tabs.length,
          child: NestedScrollView(
            headerSliverBuilder: (context, innerScroll) {
              return [// 必须是能够产生 RenderSliver 的 Widget
                SliverAppBar(
                  pinned: true,
                  toolbarHeight: 0,
                  expandedHeight: 200,
                  bottom: _tabBar,//在这里传入 TabBar
                ),
              ];
            },
            body: TabBarView(// body 必须是能产生 RenderBox 的 widget
                children: tabs
                    .map((e) => ListView(// 这里的列表滑动和 header 的滑动联动
                          children: List.generate(
                              100,
                              (index) => SizedBox(
                                  height: 80,
                                  child: Text("Hello TabBarView $e   $index"))),
                        ))
                    .toList()),
          )),
    );
  }

在这个例子中,NestedScrollView 包括了 headerSliverBuilder 创建的 header 部分,以及 header 下面的 body 部分,二者的滑动效果联动在一起,好像是同一个 scrolling view。比如,当向上滑动 TabBarView 中列表时,会先向上滑动 header 内容,等到 header 无需再滑动才会向上滑动列表。而如果没有 NestedScrollView 的话,ListView 和 header 的滑动是独立的两个事件。

源码分析

NestedScrollView 本质上还是对 CustomScrollView(的子类_NestedScrollViewCustomScrollView)的进一步封装。

它借助于_NestedScrollCoordinator 的_outerController_innerController 这两个分别传入_NestedScrollViewCustomScrollView(header 和 body 其实是他的 slivers,其最大滑动范围为 header 的 scrollExtent)和 body 中的 scrolling view(其最大滑动范围为内部滑动视图最大滑动范围之和)的 ScrollController,创建并应用_NestedScrollPosition;当用户滑动等事件发生,通过_NestedScrollViewCustomScrollView 的_NestedScrollPosition 接收外部所有的滑动事件全部归集到_NestedScrollCoordinator(比如 applyUserOffset 方法)统一处理,按照 ScrollPhysics 等分别修改 header 和 body 的 ScrollPosition,从而实现了这两处滑动事件的联动。

所以,在分析 NestedScrollView 的时候,主要涉及到以下类:

  • NestedScrollViewState:是 NestedScrollView 真正执行逻辑的类,将_NestedScrollCoordinator、_NestedScrollViewCustomScrollView、ScrollController 等组装在一起,对外暴露操纵_NestedScrollCoordinator 的方法
  • _NestedScrollViewCustomScrollView:继承自 CustomScrollView,主要作用是创建自定义的 NestedScrollViewViewport,后者又创建了 RenderNestedScrollViewViewport 主要用途是更新 SliverOverlapAbsorberHandle
  • _NestedScrollCoordinator:处理_NestedScrollPosition 转发过来的滑动事件,将其分发给 header(其实是容纳 header 和 body 的_NestedScrollViewCustomScrollView)和 body。
  • _NestedScrollController:给_NestedScrollCoordinator 的 inner 和 outer 的 ScrollController,内部创建_NestedScrollPosition。
  • _NestedScrollPosition:给_NestedScrollCoordinator 的 inner 和 outer 的 ScrollPosition,会将 animateTo、jumpTo、pointerScroll、updateCanDrag、hold、drag 等和滑动有关的事件转发给_NestedScrollCoordinator 统一处理。
  • 其余辅助类

下面对这些类逐一分析:

NestedScrollViewState

NestedScrollView 是 StatefulWidget,其主要逻辑都在创建的 State——NestedScrollViewState 中。

class NestedScrollView extends StatefulWidget {

  List<Widget> _buildSlivers(BuildContext context, ScrollController innerController, bool bodyIsScrolled) {
    // _buildSlivers 的主轴尺寸为 header 的 scrollExtent+viewport 主轴尺寸,所以创建好的 viewport 滑动范围
    // 为 header 的滑动 scrollExtent
    return <Widget>[
      ...headerSliverBuilder(context, bodyIsScrolled),// header 部分
      SliverFillRemaining(//body 部分,其尺寸为所处的 viewport 的主轴尺寸
        child: PrimaryScrollController(
          controller: innerController,
          child: body,// SliverFillRemaining 只能容纳可以产生 RenderBox 的 widget
        ),
      ),
    ];
  }

  
  NestedScrollViewState createState() => NestedScrollViewState();
}

NestedScrollView._buildSlivers 方法将 headerSliverBuilder 创建的 header 和 body 放到一个列表中,会被 NestedScrollViewState 传入到自定义的 CustomScrollView——_NestedScrollViewCustomScrollView 中。

需要注意 SliverFillRemaining 默认会创建_SliverFillRemainingWithScrollable,后者创建的 RenderObject 是_SliverFillRemainingWithScrollable。在 RenderSliverFillRemainingWithScrollable.performLayout 方法会使用他所处 viewport 主轴方向的尺寸作为自己的 scrollExtent。

  void performLayout() {
    final SliverConstraints constraints = this.constraints;
    final double extent = constraints.remainingPaintExtent - math.min(constraints.overlap, 0.0);

    if (child != null)
      child!.layout(constraints.asBoxConstraints(
        minExtent: extent,
        maxExtent: extent,
      ));

    final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: extent);
    assert(paintedChildSize.isFinite);
    assert(paintedChildSize >= 0.0);
    geometry = SliverGeometry(
      scrollExtent: constraints.viewportMainAxisExtent,// 这里使用的是 viewport 的主轴尺寸
      paintExtent: paintedChildSize,
      maxPaintExtent: paintedChildSize,
      hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
    );
    if (child != null)
      setChildParentData(child!, constraints, geometry!);
  }

也就是说无论 inner scrolling view 的尺寸如何,它(下面称其为 body)占用的 scrollExtent 都是所处的 viewport 的主轴尺寸 mainAxisExtent;再加上 headerSliverBuilder 方法创建的 header,导致_NestedScrollViewCustomScrollView 所创建的 viewport 的最大可滑动范围_maxScrollExtent(其值等于 header+body 的 scrollExtent)一定大于 viewport 的主轴方向尺寸 mainAxisExtent,从而计算出_NestedScrollViewCustomScrollView 的 ScrollPosition 的最大滑动范围(maxScrollExtent)为:

_outScrollPosition.maxScrollExtent = viewport._maxScrollExtent - viewport.mainAxisExtent
                = (body.scrollExtent + header.scrollExtent) - viewport.mainAxisExtent
                = (viewport.mainAxisExtent + header.scrollExtent) - viewport.mainAxisExtent
                = header.scrollExtent

所以,无论 NestedScrollView 的 body 内容尺寸如何,它为 header+body 分配的尺寸只比 viewport 的尺寸多出一个 header 的尺寸。这个也是 NestedScrollView 实现协调 header 和 body 滑动的基础。

让我们再看一下 NestedScrollViewState 的实现:

NestedScrollViewState 中一个重要的属性就是_NestedScrollCoordinator? _coordinator,它在initState()方法中初始化。

class NestedScrollViewState extends State<NestedScrollView> {

  // inner 和 outer controller 都来自_coordinator
  ScrollController get innerController => _coordinator!._innerController;
  ScrollController get outerController => _coordinator!._outerController;

  _NestedScrollCoordinator? _coordinator;

  void initState() {
    super.initState();
    _coordinator = _NestedScrollCoordinator(
      this,
      widget.controller,// 注意这里传入了 widget 处获取的 controller
      _handleHasScrolledBodyChanged,
      widget.floatHeaderSlivers,
    );
  }

}

能注意到,_NestedScrollCoordinator 中持有了 widget.controller,并且还会在 didChangeDependencies、didUpdateWidget 方法被调用时通过_NestedScrollCoordinator.setParent 方法更新,主要有两个作用:1. 获取 initialScrollOffset;2. 通过_outerPosition?.setParent 使得 widget.controller 可以监听 outerPosition 的变化。

然后,在 NestedScrollViewState.build 方法中,会创建_NestedScrollViewCustomScrollView 对象:

  • 将_coordinator!._outerController 作为其 controller,这样会创建,_outerPosition,后者会将_NestedScrollViewCustomScrollView 的事件转发给_coordinator,这样其接管了外层的滑动事件;

  • 此外在 NestedScrollView._buildSlivers 方法中创建的 header 和 body 作为_NestedScrollViewCustomScrollView 也就是 CustomScrollView 的 slivers。

    这也是创建 header 的NestedScrollView.headerSliverBuilder 只接受可以创建 RenderSliver 的 widget的原因。

  
  Widget build(BuildContext context) {
    final ScrollPhysics scrollPhysics = widget.physics?.applyTo(const ClampingScrollPhysics())
      ?? widget.scrollBehavior?.getScrollPhysics(context).applyTo(const ClampingScrollPhysics())
      ?? const ClampingScrollPhysics();

    return _InheritedNestedScrollView(
      state: this,
      child: Builder(
        builder: (BuildContext context) {
          _lastHasScrolledBody = _coordinator!.hasScrolledBody;
          return _NestedScrollViewCustomScrollView(
            dragStartBehavior: widget.dragStartBehavior,
            scrollDirection: widget.scrollDirection,
            reverse: widget.reverse,
            physics: scrollPhysics,
            scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false),
            // 注意这里使用的从_coordinator 获取的_outerController
            controller: _coordinator!._outerController,
            // 这里将 header 和 body 传入 slivers,
            // _NestedScrollViewCustomScrollView 创建的 viewport 是继承自
            // Viewport 的 NestedScrollViewViewport,其只接受可以创建
            // RenderSliver 的 widget
            slivers: widget._buildSlivers(
              context,
              _coordinator!._innerController,
              _lastHasScrolledBody!,
            ),
            handle: _absorberHandle,
            clipBehavior: widget.clipBehavior,
            restorationId: widget.restorationId,
          );
        },
      ),
    );
  }

_NestedScrollViewCustomScrollView 继承自 CustomScrollView,主要作用是创建继承自 Viewport 的 NestedScrollViewViewport,而后者又主要负责创建和更新继承自 RenderViewport 的 RenderNestedScrollViewViewport——其在内部更新和维护 SliverOverlapAbsorberHandle。

SliverOverlapAbsorberHandle: Handle to provide to a SliverOverlapAbsorber, a SliverOverlapInjector, and an NestedScrollViewViewport, to shift overlap in a NestedScrollView.

到目前位置,UI 展示部分的内容已经完成,我们的 NestedScrollView 可以将 header 和 body 显示在屏幕上面,但是如果要联动处理在 header 和 body 上面的滑动事件,还需要_NestedScrollCoordinator、_NestedScrollController 和_NestedScrollPosition 的配合。

_NestedScrollController

_NestedScrollController 继承自 ScrollController,其逻辑比较简单,主要添加了两项功能:

创建_NestedScrollPosition

创建_NestedScrollPosition 的逻辑比较简单,主要是将 coordinator 也一并传入。

  ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition? oldPosition,
  ) {
    return _NestedScrollPosition(
      coordinator: coordinator,
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

在 ScrollPosition 变化时通知 coordinator

在 attach(ScrollPosition position) 中调用_scheduleUpdateShadow() 和_NestedScrollCoordinator 的 updateParent、updateCanDrag,对传入的 ScrollPosition 添加回调_scheduleUpdateShadow()。

在 detach(ScrollPosition position) 中调用_scheduleUpdateShadow(),对传入的 ScrollPosition 移除回调_scheduleUpdateShadow()。

而这个_scheduleUpdateShadow() 方法主要作用是异步执行 coordinator.updateShadow() 更新 NestedScrollView,实现滑动效果。

  void _scheduleUpdateShadow() {
    // We do this asynchronously for attach() so that the new position has had
    // time to be initialized, and we do it asynchronously for detach() and from
    // the position change notifications because those happen synchronously
    // during a frame, at a time where it's too late to call setState. Since the
    // result is usually animated, the lag incurred is no big deal.
    SchedulerBinding.instance.addPostFrameCallback(
      (Duration timeStamp) {
        coordinator.updateShadow();
      },
    );
  }

_NestedScrollPosition

在 inner scrolling widget 和 outer viewport 都使用_NestedScrollPosition,它追踪这些 viewport 使用的 offset,并且内部持有_NestedScrollCoordinator,所以此 class 上触发 activities 时,可以推迟或者影响 coordinator。

class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDelegate {
  final _NestedScrollCoordinator coordinator; // 协调 inner 和 outer 滑动事件
  // 是在 NestedScrollView 中传给_NestedScrollViewCustomScrollView 的 ScrollController
  ScrollController? _parent;

  void setParent(ScrollController? value) {
    _parent?.detach(this);
    _parent = value;
    _parent?.attach(this);// 将此 ScrollPosition 和_parent 绑定
  }
}

setParent

_NestedScrollPosition.setParent 中,将自己和传入的 ScrollController 绑定在一起:

  • 将自身加入 ScrollController._positions
  • ScrollController 监听自身变化时执行 notifyListeners 通知监听者

absorb

在 absorb 方法中将 activity 的 delegate 更新为当前 ScrollPosition:

  
  void absorb(ScrollPosition other) {
    super.absorb(other);
    // 部分 activity 会使用此来操作 scroll view
    activity!.updateDelegate(this);
  }

applyClampedDragUpdate

此方法返回的是没有使用的 delta,此方法不会主动创建 overscroll/underscroll,如果当前 ScrollPosition 在范围内,则不会发送 overscroll/underscroll;如果已经超出范围,则只会“减轻”这种情况,而不会“加重”。

之所以不会 overscroll,是因为 min 和 max 的取值限定了他们的范围,以一个垂直方向向下布局的滑动列表为例:

  • delat < 0,即向上滑动,范围是 min:-double.infinity ~ max:0(overscroll 时)或者 maxScrollExtent 和 pixels 中最大值(只能滑到最大范围)。

    也就是说,向上滑动时,如果已经在顶部出现 overscroll(此时 pixels 应该为负值),那么最多滑动到 0(也就是恢复到初始位置),没有顶部 overscroll 时(此时 pixels 为正值,可能在 maxScrollExtent 范围内,也可能超出范围,即底部出现 overscroll),那么此时最多向上滑动 maxScrollExtent 和 pixels,也就是说要么不能超范围,要是超了范围,就不能再超了。

    而最小滑动范围为-double.infinity,无论 pixels 正负,当其 delta 为负时,其值都只会增大,取值-double.infinity 是为了将 pixels 包含在内。

  • delta > 0,即向下滑动,范围是 min:minScrollExtent 和 pixels 最小值 ~ double.infinity。

    也就是说,向下滑动最小到初始位置,最大值不限定(因为此时可能 offset 已经由于某种原因超过 maxScrollExtent 了)。

  // Returns the amount of delta that was not used.
  // Positive delta means going down (exposing stuff above), negative delta
  // going up (exposing stuff below).
  double applyClampedDragUpdate(double delta) {
    assert(delta != 0.0);
    // If we are going towards the maxScrollExtent (negative scroll offset),
    // then the furthest we can be in the minScrollExtent direction is negative
    // infinity. For example, if we are already overscrolled, then scrolling to
    // reduce the overscroll should not disallow the overscroll.
    //
    // If we are going towards the minScrollExtent (positive scroll offset),
    // then the furthest we can be in the minScrollExtent direction is wherever
    // we are now, if we are already overscrolled (in which case pixels is less
    // than the minScrollExtent), or the minScrollExtent if we are not.
    //
    // In other words, we cannot, via applyClampedDragUpdate, _enter_ an
    // overscroll situation.
    //
    // An overscroll situation might be nonetheless entered via several means.
    // One is if the physics allow it, via applyFullDragUpdate (see below). An
    // overscroll situation can also be forced, e.g. if the scroll position is
    // artificially set using the scroll controller.

    // delat < 0,即向上滑动,范围是 min:-double.infinity ~ max:0
    //(overscroll 时)或者 maxScrollExtent 和 pixels 中最大值(只能滑到最大范围)
    // delta > 0,即向下滑动,范围是 min:minScrollExtent 和 pixels 最小值 ~
    // double.infinity(也就是说,向下滑动最小到初始位置,最大值不限定
    // [因为此时可能 offset 已经由于某种原因超过 maxScrollExtent 了])
    final double min = delta < 0.0
      ? -double.infinity// 向上滑动
      : math.min(minScrollExtent, pixels);// 向下滑动
    // The logic for max is equivalent but on the other side.
    // 这里的逻辑是,如果向下滑动,那么 max 为无限大;
    // 如果向上滑动并且已经 overscroll 了,那么 max 是 0(即恢复初始位置),否则为 maxScrollExtent 即最大滑动范围
    final double max = delta > 0.0
      ? double.infinity// 向下滑动
      // If pixels < 0.0, then we are currently in overscroll. The max should be
      // 0.0, representing the end of the overscrolled portion.
      // pixels 比 maxScrollExtent 大可能是由于 jumpTo 等情况,此时 max 为 pixels 表示不能继续滑动超出此值
      : pixels < 0.0 ? 0.0 : math.max(maxScrollExtent, pixels);// 向上滑动
    final double oldPixels = pixels;
    //newPixels 是可以应用到 ScrollPosition 的 pixels,其范围:
    // 1. delta 为负,即向上滑动,pixels - delta = pixels + |delta| > pixels,

    // 1.1 当 pixels 小于 0 也就是存在 overscroll 时,其范围是 pixels + |delta|~0,
    // 此时 overscroll 偏移量为 pixels + |delta|,newPixels 在 pixels + |delta|~0 之间,【不会再加深越界】
    // 1.2 当 pixels 大于等于 0 也就是不存在 overscroll 时,其范围是 pixels + |delta|~maxScrollExtent
    // 此时,newPixels 在 pixels + |delta|~maxScrollExtent 之间,最大为 maxScrollExtent【newPixels 不会越界】

    // 2. delta 为正,即向下滑动,pixels - delta = pixels - |delta| < pixels

    // 2.1 当 pixels 小于 0 也就是存在 overscroll 时,pixels - delta = pixels - |delta| < pixels,
    // 其范围是(pixels 和 minScrollExtent 较小值)~double.infinity,也就是 delta 不会
    // 被应用,newPixels 会等于 pixels,如果已经越界了,【不会再加深越界】
    // 2.2 当 pixels 大于等于 0 也就是不存在 overscroll 时,其范围是 minScrollExtent~double.infinity,
    // newPixels 会在 minScrollExtent 和 pixels 之间,【newPixels 的值不会越界】
    final double newPixels = (pixels - delta).clamp(min, max);
    final double clampedDelta = newPixels - pixels;// 对比 ScrollPosition 变化的值
    // position 的 pixels 为 0 且向下滑动时这里 clampedDelta 为 0,不执行剩余步骤
    if (clampedDelta == 0.0)
      return delta;
    // 返回超出界限的值 overscroll,如果为 0 表示可以任意超出界限,不为 0 表示不可以应用到
    //ScrollPosition 上的值,根据 physics 而不同
    final double overscroll = physics.applyBoundaryConditions(this, newPixels);
    // 减去了 overscroll,所以这里 actualNewPixels 是真正可以应用的 pixels
    final double actualNewPixels = newPixels - overscroll;
    // offset 表示经过上述计算之后,ScrollPosition 实际将要产生的变化
    final double offset = actualNewPixels - oldPixels;
    if (offset != 0.0) {
      // 根据 physics 的不同,这里 offset 可能会导致 ScrollPosition 内部视觉上出现越界现象,此时 overscroll 为 0,
      // 或者没有越界内容,overscroll 为 0 或者应用了 delta 之后会出现的越界值
      forcePixels(actualNewPixels);//更新 pixels
      didUpdateScrollPositionBy(offset);// 发出 ScrollUpdateNotification 通知
    }
    // delta 为负时,offset 为正值;delta 为正值时,offset 为负值。总之 delta 绝对值减少了。
    return delta + offset;
  }

applyFullDragUpdate

此方法在满足 overscroll 条件时,会应用 overscroll,并发出 OverscrollNotification 通知。

  double applyFullDragUpdate(double delta) {
    assert(delta != 0.0);
    final double oldPixels = pixels;
    // Apply friction:
    final double newPixels = pixels - physics.applyPhysicsToUserOffset(
      this,
      delta,
    );
    if (oldPixels == newPixels)// 应用 delta 之后没有变化,返回
      return 0.0; // delta must have been so small we dropped it during floating point addition
    // Check for overscroll:
    // 按照 physics 的规则,如果可以 overscroll 则返回 0,下面的 actualNewPixels 会展示出越界的效果
    // 否则返回不能消耗的 delta,会发出 overscroll 通知
    final double overscroll = physics.applyBoundaryConditions(this, newPixels);
    // 如果 physics 允许越界返回 overscroll 是 0,则这里 actualNewPixels 最终是越界的 pixels
    final double actualNewPixels = newPixels - overscroll;
    if (actualNewPixels != oldPixels) {
      forcePixels(actualNewPixels);// 更新当前 ScrollPosition 的 pixels 值
      didUpdateScrollPositionBy(actualNewPixels - oldPixels);
    }
    if (overscroll != 0.0) {
      // 发出 overscroll 的 OverscrollNotification 通知,然后会有地方处理 overscroll
      // 比如 Android 会触发在 ScrollableState.build 方法中的_configuration.buildOverscrollIndicator
      // 对应的 ScrollBehavior.buildViewportChrome 创建蓝色波纹效果
      didOverscrollBy(overscroll);
      return overscroll;
    }
    return 0.0;
  }

applyClampedPointerSignalUpdate

applyClampedPointerSignalUpdate 方法返回未使用的 delta,不考虑 ScrollPhysics 的影响。

applyNewDimensions()

此方法是_outerScrollPosition 接管 body 滑动事件的关键,也是body 中 scrolling view 使用了自己的 ScrollController 之后 NestedScrollView 就无法协调 header 和 body 滑动的原因。

在默认的 ScrollController 中,createScrollPosition() 方法创建的是 ScrollPositionWithSingleContext,当 content 或者 viewport 的尺寸变化之后会调用其 applyNewDimensions() 方法:

  // ScrollPositionWithSingleContext 类
  void applyNewDimensions() {
    super.applyNewDimensions();
    // 此处的 context 一般是 ScrollableState
    context.setCanDrag(physics.shouldAcceptUserOffset(this));
  }

最后会调用 ScrollableState 的 setCanDrag 方法:

  // ScrollableState 类

  // 识别用户手势的属性,用于 RawGestureDetector.gestures
  Map<Type, GestureRecognizerFactory> _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
  
  
  void setCanDrag(bool value) {
    if (value == _lastCanDrag && (!value || widget.axis == _lastAxisDirection))
      return;
    if (!value) {
      _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
      // 其他方法
    } else {
      // 更新_gestureRecognizers 的方法
    }
  }

可见_gestureRecognizers 默认为空,只有主动调用 ScrollableState.setCanDrag(true) 之后滑动视图中的 Scrollable 才能识别手势并处理。

而在_NesetedScrollPosition 的方法中,并没有调用,而是:

  // _NestedScrollPosition 类
  
  void applyNewDimensions() {
    super.applyNewDimensions();
    coordinator.updateCanDrag();
  }

  // _NestedScrollCoordinator 类
  void updateCanDrag() {
    if (!_outerPosition!.haveDimensions) return;
    double maxInnerExtent = 0.0;
    for (final _NestedScrollPosition position in _innerPositions) {
      if (!position.haveDimensions) return;
      maxInnerExtent = math.max(
        maxInnerExtent,
        position.maxScrollExtent - position.minScrollExtent,
      );
    }
    // 注意这里只给_outerPosition 调用了 updateCanDrag 方法
    _outerPosition!.updateCanDrag(maxInnerExtent);
  }

从上述代码分析可知,如果使用默认的_NestedScrollController 创建的_NestedScrollPosition,最后只有_outerPosition 更新了_gestureRecognizers 可以识别手势,而使用_innerScrollPosition 的 body 内部的 scrolling view 无法识别手势。

所以,当没有给 body 中的 scrolling view 主动设置 ScrollController 时,无论是在 header 还是 body 的手势事件都会由 ScrollPosition 来转发给_NestedScrollCoordinator 统一协调处理;而如果给 body 中的 scrolling view 主动设置 ScrollController,由于 ScrollController 默认创建的 ScrollPositionWithSingleContext 会按照实际情况更新_gestureRecognizers,从而当用户手势在 body 中 scrolling view 的范围时,手势事件会被其捕获并内部消耗,而非转发到_NestedScrollCoordinator 处理,所以就会使 NestedScrollView 失效


此外还持有了_NestedScrollCoordinator,在 animateTo/jumpTo/pointerScroll/applyNewDimensions/hold/drag 等与滑动相关的方法被调用时执行_NestedScrollCoordinator 中对应的方法,这样就将 outer viewport 和 inner scrolling view 的滑动事件都归集到_NestedScrollCoordinator 统一处理。

_NestedScrollCoordinator

为了与_NestedScrollPosition 保持一致,方便接收其转发的事件,_NestedScrollCoordinator 也实现了 ScrollActivityDelegate 接口:

class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController {

  final NestedScrollViewState _state;// 用于获取 NestedScrollView 的 ScrollController
  ScrollController? _parent;// 用户传入的 NestedScrollView 的 ScrollController
  final bool _floatHeaderSlivers;// header 是否悬浮,是的话向“下”滑动时会先将 header 滑动出来

  // 分别应用于 outer(即_NestedScrollViewCustomScrollView)和 inner(即 body 中的
  // scrolling view)的 ScrollController
  late _NestedScrollController _outerController;
  late _NestedScrollController _innerController;
}

beginActivity

beginActivity 用来对 outer 和 inner 应用 ScrollActivity,在 goIdle/goBallistic/animateTo/jumpTo/pointerScroll/drag/hold 等与滑动有关的方法中都有直接或间接的调用。

其中 outer activity 是直接指定的,而 inner activity 则是根据 innerActivityGetter 和 inner position 动态计算。

  void beginActivity(ScrollActivity newOuterActivity, _NestedScrollActivityGetter innerActivityGetter) {
    _outerPosition!.beginActivity(newOuterActivity);// outer 直接应用 ScrollActivity
    bool scrolling = newOuterActivity.isScrolling;
    for (final _NestedScrollPosition position in _innerPositions) {
      // 依次遍历 inner scrolling view 计算对应的 newInnerActivity
      final ScrollActivity newInnerActivity = innerActivityGetter(position);
      position.beginActivity(newInnerActivity);
      scrolling = scrolling && newInnerActivity.isScrolling;
    }
    _currentDrag?.dispose();
    _currentDrag = null;
    if (!scrolling)
      // 如果都没有滑动,就表示当前 NestedScrollView 停止
      updateUserScrollDirection(ScrollDirection.idle);
  }

此方法的一种使用方式如下:

  void goBallistic(double velocity) {
    beginActivity(
      createOuterBallisticScrollActivity(velocity),// 创建 outer activity
      (_NestedScrollPosition position) {// 根据 position 创建 inner activity
        return createInnerBallisticScrollActivity(
          position,
          velocity,
        );
      },
    );
  }

创建 outer scroll activity 的方法:

  ScrollActivity createOuterBallisticScrollActivity(double velocity) {
    // This function creates a ballistic scroll for the outer scrollable.
    //
    // It assumes that the outer scrollable can't be overscrolled, and sets up a
    // ballistic scroll over the combined space of the innerPositions and the
    // outerPosition.

    // First we must pick a representative inner position that we will care
    // about. This is somewhat arbitrary. Ideally we'd pick the one that is "in
    // the center" but there isn't currently a good way to do that so we
    // arbitrarily pick the one that is the furthest away from the infinity we
    // are heading towards.
    _NestedScrollPosition? innerPosition;
    if (velocity != 0.0) {// 选择在正方向上离我们最远的 inner position
      for (final _NestedScrollPosition position in _innerPositions) {
        if (innerPosition != null) {
          if (velocity > 0.0) {
            if (innerPosition.pixels < position.pixels)
              continue;
          } else {
            assert(velocity < 0.0);
            if (innerPosition.pixels > position.pixels)
              continue;
          }
        }
        innerPosition = position;
      }
    }


    if (innerPosition == null) {// 这里表示只有 outer 或者 velocity 为 0
      // It's either just us or a velocity=0 situation.
      return _outerPosition!.createBallisticScrollActivity(
        _outerPosition!.physics.createBallisticSimulation(
          _outerPosition!,
          velocity,
        ),
        mode: _NestedBallisticScrollActivityMode.independent,
      );
    }

    // 这里表示 NestedScrollView 中存在 inner 和 outer scrolling view,且 velocity 不为 0
    // 在 innerPosition 和 outerPosition 组合的 space 之上设置 overscroll
    final _NestedScrollMetrics metrics = _getMetrics(innerPosition, velocity);

    return _outerPosition!.createBallisticScrollActivity(
      _outerPosition!.physics.createBallisticSimulation(metrics, velocity),
      mode: _NestedBallisticScrollActivityMode.outer,
      metrics: metrics,
    );
  }

可见在计算 outer scroll activity 的时候,需判断 body 内是不是有 inner scrolling view:

  • 没有,按照正常创建 BallisticScrollActivity 的流程创建
  • 有,将 inner 的 space 也计入,然后以此计算 BallisticScrollActivity

创建 inner scroll activity 的方法:

  ScrollActivity createInnerBallisticScrollActivity(_NestedScrollPosition position, double velocity) {
    return position.createBallisticScrollActivity(
      position.physics.createBallisticSimulation(
        _getMetrics(position, velocity),
        velocity,
      ),
      mode: _NestedBallisticScrollActivityMode.inner,
    );
  }

applyUserOffset

applyUserOffset() 是_NestedScrollCoordinator 的重点,也是 NestedScrollView 能够实现协调 inner 和 outer 滑动事件的关键。

在看 applyUserOffset() 方法之前,先看一下 drag() 方法,在此方法中创建 ScrollDragController 时 delegate 传入的是_NestedScrollCoordinator。

当用户操作屏幕发生 drag 事件时,手势事件会被 ScrollableState 中的 RawGestureDetector 识别到:

  • drag 开始时调用_handleDragStart,通过_NestedScrollPosition 转发调用_NestedScrollCoordinator.drag方法创建了ScrollDragController drag
  // 此方法在 ScrollableState 中被 RawGestureDetector 通过
  // ScrollableState._handleDragStart -> _NestedScrollPosition.drag
  // -> _NestedScrollCoordinator.drag 链路调用
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    final ScrollDragController drag = ScrollDragController(
      delegate: this,
      details: details,
      onDragCanceled: dragCancelCallback,
    );
    beginActivity(
      DragScrollActivity(_outerPosition!, drag),
      (_NestedScrollPosition position) => DragScrollActivity(position, drag),
    );
    assert(_currentDrag == null);
    _currentDrag = drag;
    return drag;
  }
  • drag 开始时更新时_handleDragUpdate,内部调用ScrollDragController.update,在 update 方法内部执行了delegate.applyUserOffset,此处的delegate就是我们之前传入的_NestedScrollCoordinator

根据上述分析,在用户滑动屏幕时,会执行_NestedScrollCoordinator.applyUserOffset方法:

  
  void applyUserOffset(double delta) {
    // 更新 scroll 方向
    updateUserScrollDirection(
      delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse,
    );
    assert(delta != 0.0);
    if (_innerPositions.isEmpty) {
      // 如果没有 inner(body 内部没有 scrolling view),就由 outer 完全处理滑动事件
      _outerPosition!.applyFullDragUpdate(delta);
    } else if (delta < 0.0) {
      // Dragging "up"
      // 先恢复 inner overscroll,然后是 outer view,以便 header 内容尽快 scroll out
      double outerDelta = delta;
      for (final _NestedScrollPosition position in _innerPositions) {
        if (position.pixels < 0.0) { // This inner position is in overscroll.
          // 先从 overscrolled 恢复并返回剩余没有使用的 delta
          // 因为 delta 是负值,如果“消耗”掉了一部分,那么 potentialOuterDelta 会比 delta 大
          final double potentialOuterDelta = position.applyClampedDragUpdate(delta);
          // In case there are multiple positions in varying states of
          // overscroll, the first to 'reach' the outer view above takes
          // precedence.此处 outerDelta 为剩余没有消耗的 delta
          outerDelta = math.max(outerDelta, potentialOuterDelta);
        }
      }
      if (outerDelta != 0.0) {
        // 如果还有剩下的,让 outer view 消耗
        // delta < 0;所以如果 outer 有 underscroll 则会先恢复到 0 然后返回(现有限制下不会出现
        // 此情况),否则最多可以向上滑动到 maxScrollExtent
        final double innerDelta = _outerPosition!.applyClampedDragUpdate(
          outerDelta,
        );
        if (innerDelta != 0.0) {
          // 还有剩下的,让 inner 开始滑动
          // 这里吧剩下的 innerDelta 完全给了 inner scroll position 的 applyFullDragUpdate 方法
          // inner 会先向上滑动,如果 physics 支持 underscroll 会执行 underscroll,否则最多滑动
          // 到 maxScrollExtent,然后发出 overscroll 的通知,让 Scrollable 绘制蓝色波纹(Android)
          for (final _NestedScrollPosition position in _innerPositions)
            position.applyFullDragUpdate(innerDelta);
        }
      }
    } else {
      // Dragging "down" - delta is positive
      double innerDelta = delta;
      // Apply delta to the outer header first if it is configured to float.
      if (_floatHeaderSlivers)
      // _floatHeaderSlivers 为 true,先让 outer 复现出来,最多向下滑动到 minScrollExtent
        // 也就是恢复原位
        innerDelta = _outerPosition!.applyClampedDragUpdate(delta);

      if (innerDelta != 0.0) {
        // Apply the innerDelta, if we have not floated in the outer scrollable,
        // any leftover delta after this will be passed on to the outer
        // scrollable by the outerDelta.
        double outerDelta = 0.0; // it will go positive if it changes
        final List<double> overscrolls = <double>[];
        final List<_NestedScrollPosition> innerPositions = _innerPositions.toList();
        // inner scrolling view 先消耗 delta
        for (final _NestedScrollPosition position in innerPositions) {
          // 向下滑动 inner scrolling view
          // 如果 inner physics 不支持 overscroll,则执行完 innerDelta 之后,最多会返回未执行的 overscroll
          // 如果支持,则会消耗完 innerDelta,这里的 overscroll 为 0
          final double overscroll = position.applyClampedDragUpdate(innerDelta);
          outerDelta = math.max(outerDelta, overscroll);
          overscrolls.add(overscroll);// 保存没有被使用的 overscroll
        }
        if (outerDelta != 0.0)
          // 在此处,即使设置了 outer 的 physics 为 BouncingScrollPhysics,因为当 ScrollPosition 的
          // offset 为 0 时,applyClampedDragUpdate 不会主动从 0 变为负值,所以无法应用 underscroll 效果
          // 此处 outerDelta-=overscroll 的结果是 outerDelta 是 outer 消耗的那一部分内容
          outerDelta -= _outerPosition!.applyClampedDragUpdate(outerDelta);
          // 这里 outerDelta 是剩下的 delta

        // Now deal with any overscroll 最后交给 inner 处理 overscroll
        for (int i = 0; i < innerPositions.length; ++i) {
          // 此处的 remainingDelta 是未执行的 overscroll 减去了 outer 消耗的内容
          final double remainingDelta = overscrolls[i] - outerDelta;
          if (remainingDelta > 0.0)
            // 如果还有剩下的 overscroll,与 physics 等结合计算之后,继续消耗(之前在
            // applyClampedDragUpdate 无法消耗,在这里也消耗不了,不过可以发送 overscroll
            // 的通知,让 Scrollable 知道之后做出蓝色波纹(Android 机型)等效果)
            innerPositions[i].applyFullDragUpdate(remainingDelta);
        }
      }
    }
  }

分析为何 inner 有 scrolling view 时,NestedScrollView.physics 为 BouncingScrollPhysics() 不生效:

从上述代码我们看到,可以产生 overscroll 效果的 applyFullDragUpdate 只有在 inner 中没有 scrolling view 的时候才会被_outerPosition 应用,其他两个场景都只有 inner position 应用。

而其余场景中,_outerPosition 和 inner position 都应用的是 applyClampedDragUpdate 方法:

  • 向下滑动 delta 大于 0,代码会执行到outerDelta -= _outerPosition!.applyClampedDragUpdate(outerDelta),因为此时限制了 applyClampedDragUpdate 中的 newPixels 范围为(当 ScrollPosition 的 pixels 等于 0 时)minScrollExtent~double.infinity,所以 clampedDelta = newPixels - pixels 等于 minScrollExtent(也就是 0),跳过剩余步骤直接返回了 delta。所以没有执行 BouncingScrollPhysics() 逻辑
  • 向上滑动 delta 小于 0,代码会执行final double innerDelta = _outerPosition!.applyClampedDragUpdate(outerDelta,);,在此方法中,如果有 overscroll 则会先恢复到 0,否则最多上划到 maxScrollExtent,所以也不会执行 BouncingScrollPhysics() 逻辑

通过上述步骤,NestedScrollView 将 header 和 body 的滚动事件进行组合、分发。

优劣对比

NestedScrollView 将 header 和 body 中可滑动 view(inner)的滑动事件组合起来:向上滑动时,先等达到 header 最大滑动范围之后,再将滑动分配给 inner 消耗;当向下滑动时,一般先恢复 inner 的 overscroll(如果_floatHeaderSlivers 为 true,会先尝试下滑 header),尝试将其恢复至 offset 为 0 的状态,再尝试将 header 向下滑动到初始位置,最后如果有 overscroll,会尝试应用到 inner 上面。

CustomScrollView 也支持在同一个页面内嵌套多个滑动列表并关联(在其 slivers 中传入多个 SliverList,SliverGrid 等),但是 CustomScrollView 不支持普通的滑动 view,比如 ListView 等,这些滑动布局会内部消耗掉滑动事件,从而无法与 CustomScrollView 内其余 sliver 正常联动。

总结

NestedScrollView 内部通过 NestedScrollViewState.build() 创建继承自 CustomScrollView 的_NestedScrollViewCustomScrollView。

通过 NestedScrollView._buildSlivers() 将 NestedScrollView.headerSliverBuilder 返回的 sliver 列表(下称 header)和被 SliverFillRemaining 包裹的 body 组合在一起,使得在_NestedScrollViewCustomScrollView 中创建的 viewport 的创建的_NestedScrollCoordinator.outerPosition 的_maxScrollExtent 为 NestedScrollView 的 header 的主轴尺寸,而_NestedScrollCoordinator._innerPositions 的_maxScrollExtent 则是与 body 实际内容一致。

_NestedScrollViewCustomScrollView 的 ScrollController 是_NestedScrollCoordinator._outerController,其创建了_NestedScrollCoordinator.outerPosition,所以整个 NestedScrollView 的滑动事件都会通过_NestedScrollCoordinator._outerController 转到给_NestedScrollCoordinator.applyUserOffset 方法。

在_NestedScrollCoordinator.applyUserOffset 方法中,根据滑动方向的不同,依次协调_NestedScrollCoordinator.outerPosition 和_NestedScrollCoordinator._innerPositions 处理用户 drag 等产生的 delta,修改这两个 ScrollPosition 的值,从而实现 header 和 body 的滑动联动。

参考资料

NestedScrollView_api.flutter.devopen in new window

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