Flutter滑动分析之NestedScrollView
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 效果。
本文只对 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 经典的使用方式:
1 | Widget build(BuildContext context) { |
在这个例子中,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 中。
1 | class NestedScrollView extends StatefulWidget { |
NestedScrollView._buildSlivers 方法将 headerSliverBuilder 创建的 header 和 body 放到一个列表中,会被 NestedScrollViewState 传入到自定义的 CustomScrollView——_NestedScrollViewCustomScrollView 中。
需要注意 SliverFillRemaining 默认会创建_SliverFillRemainingWithScrollable,后者创建的 RenderObject 是_SliverFillRemainingWithScrollable。在 RenderSliverFillRemainingWithScrollable.performLayout 方法会使用他所处 viewport 主轴方向的尺寸作为自己的 scrollExtent。
1 | void performLayout() { |
也就是说无论 inner scrolling view 的尺寸如何,它(下面称其为 body)占用的 scrollExtent 都是所处的 viewport 的主轴尺寸 mainAxisExtent;再加上 headerSliverBuilder 方法创建的 header,导致_NestedScrollViewCustomScrollView 所创建的 viewport 的最大可滑动范围_maxScrollExtent(其值等于 header+body 的 scrollExtent)一定大于 viewport 的主轴方向尺寸 mainAxisExtent,从而计算出_NestedScrollViewCustomScrollView 的 ScrollPosition 的最大滑动范围(maxScrollExtent)为:
1 | _outScrollPosition.maxScrollExtent = viewport._maxScrollExtent - viewport.mainAxisExtent |
所以,无论 NestedScrollView 的 body 内容尺寸如何,它为 header+body 分配的尺寸只比 viewport 的尺寸多出一个 header 的尺寸。这个也是 NestedScrollView 实现协调 header 和 body 滑动的基础。
让我们再看一下 NestedScrollViewState 的实现:
NestedScrollViewState 中一个重要的属性就是_NestedScrollCoordinator? _coordinator
,它在initState()
方法中初始化。
1 | class NestedScrollViewState extends State<NestedScrollView> { |
能注意到,_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的原因。
1 |
|
_NestedScrollViewCustomScrollView 继承自 CustomScrollView,主要作用是创建继承自 Viewport 的 NestedScrollViewViewport,而后者又主要负责创建和更新继承自 RenderViewport 的 RenderNestedScrollViewViewport——其在内部更新和维护 SliverOverlapAbsorberHandle。
SliverOverlapAbsorberHandle
: Handle to provide to aSliverOverlapAbsorber
, aSliverOverlapInjector
, and anNestedScrollViewViewport
, to shift overlap in aNestedScrollView
.
到目前位置,UI 展示部分的内容已经完成,我们的 NestedScrollView 可以将 header 和 body 显示在屏幕上面,但是如果要联动处理在 header 和 body 上面的滑动事件,还需要_NestedScrollCoordinator、_NestedScrollController 和_NestedScrollPosition 的配合。
_NestedScrollController
_NestedScrollController 继承自 ScrollController,其逻辑比较简单,主要添加了两项功能:
创建_NestedScrollPosition
创建_NestedScrollPosition 的逻辑比较简单,主要是将 coordinator 也一并传入。
1 | ScrollPosition createScrollPosition( |
在 ScrollPosition 变化时通知 coordinator
在 attach(ScrollPosition position)中调用_scheduleUpdateShadow()和_NestedScrollCoordinator 的 updateParent、updateCanDrag,对传入的 ScrollPosition 添加回调_scheduleUpdateShadow()。
在 detach(ScrollPosition position)中调用_scheduleUpdateShadow(),对传入的 ScrollPosition 移除回调_scheduleUpdateShadow()。
而这个_scheduleUpdateShadow()方法主要作用是异步执行 coordinator.updateShadow()更新 NestedScrollView,实现滑动效果。
1 | void _scheduleUpdateShadow() { |
_NestedScrollPosition
在 inner scrolling widget 和 outer viewport 都使用_NestedScrollPosition,它追踪这些 viewport 使用的 offset,并且内部持有_NestedScrollCoordinator,所以此 class 上触发 activities 时,可以推迟或者影响 coordinator。
1 | class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDelegate { |
setParent
_NestedScrollPosition.setParent 中,将自己和传入的 ScrollController 绑定在一起:
- 将自身加入 ScrollController._positions
- ScrollController 监听自身变化时执行 notifyListeners 通知监听者
absorb
在 absorb 方法中将 activity 的 delegate 更新为当前 ScrollPosition:
1 |
|
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 了)。
1 | // Returns the amount of delta that was not used. |
applyFullDragUpdate
此方法在满足 overscroll 条件时,会应用 overscroll,并发出 OverscrollNotification 通知。
1 | double applyFullDragUpdate(double delta) { |
applyClampedPointerSignalUpdate
applyClampedPointerSignalUpdate 方法返回未使用的 delta,不考虑 ScrollPhysics 的影响。
applyNewDimensions()
此方法是_outerScrollPosition 接管 body 滑动事件的关键,也是body 中 scrolling view 使用了自己的 ScrollController 之后 NestedScrollView 就无法协调 header 和 body 滑动的原因。
在默认的 ScrollController 中,createScrollPosition()方法创建的是 ScrollPositionWithSingleContext,当 content 或者 viewport 的尺寸变化之后会调用其 applyNewDimensions()方法:
1 | // ScrollPositionWithSingleContext类 |
最后会调用 ScrollableState 的 setCanDrag 方法:
1 | // ScrollableState类 |
可见_gestureRecognizers 默认为空,只有主动调用 ScrollableState.setCanDrag(true)之后滑动视图中的 Scrollable 才能识别手势并处理。
而在_NesetedScrollPosition 的方法中,并没有调用,而是:
1 | // _NestedScrollPosition类 |
从上述代码分析可知,如果使用默认的_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 接口:
1 | class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { |
beginActivity
beginActivity 用来对 outer 和 inner 应用 ScrollActivity,在 goIdle/goBallistic/animateTo/jumpTo/pointerScroll/drag/hold 等与滑动有关的方法中都有直接或间接的调用。
其中 outer activity 是直接指定的,而 inner activity 则是根据 innerActivityGetter 和 inner position 动态计算。
1 | void beginActivity(ScrollActivity newOuterActivity, _NestedScrollActivityGetter innerActivityGetter) { |
此方法的一种使用方式如下:
1 | void goBallistic(double velocity) { |
创建 outer scroll activity 的方法:
1 | ScrollActivity createOuterBallisticScrollActivity(double velocity) { |
可见在计算 outer scroll activity 的时候,需判断 body 内是不是有 inner scrolling view:
- 没有,按照正常创建 BallisticScrollActivity 的流程创建
- 有,将 inner 的 space 也计入,然后以此计算 BallisticScrollActivity
创建 inner scroll activity 的方法:
1 | ScrollActivity createInnerBallisticScrollActivity(_NestedScrollPosition position, double velocity) { |
applyUserOffset
applyUserOffset() 是_NestedScrollCoordinator 的重点,也是 NestedScrollView 能够实现协调 inner 和 outer 滑动事件的关键。
在看 applyUserOffset() 方法之前,先看一下 drag()方法,在此方法中创建 ScrollDragController 时 delegate 传入的是_NestedScrollCoordinator。
当用户操作屏幕发生 drag 事件时,手势事件会被 ScrollableState 中的 RawGestureDetector 识别到:
- drag 开始时调用
_handleDragStart
,通过_NestedScrollPosition 转发调用_NestedScrollCoordinator.drag
方法创建了ScrollDragController drag
1 | // 此方法在ScrollableState中被RawGestureDetector通过 |
- drag 开始时更新时
_handleDragUpdate
,内部调用ScrollDragController.update
,在 update 方法内部执行了delegate.applyUserOffset
,此处的delegate
就是我们之前传入的_NestedScrollCoordinator
根据上述分析,在用户滑动屏幕时,会执行_NestedScrollCoordinator.applyUserOffset
方法:
1 |
|
分析为何 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 的滑动联动。