作为系列文章的第十八篇,本篇将通过 ScrollPhysics 和 Simulation ,带你深入走进 Flutter 的滑动新世界,为你打开 Flutter 滑动操作的另一扇窗。

文章汇总地址:

Flutter 完整实战实战系列文章专栏

Flutter 番外的世界系列文章专栏

一、前言

如下图所示是Flutter 默认的可滑动 Widget 效果,在 Android 和 iOS 上出现了不同的 滑动速度与边缘拖拽效果 ,这是因为在不同平台上,默认使用了不同的 ScrollPhysicsSimulation ,后面我们将逐步介绍这两大主角的实现原理,最终让你对 Flutter 世界的滑动拖拽进阶到 “为所欲为” 的境界。

十八、 神奇的ScrollPhysics与Simulation - 图1

下方开始高能干货,请自带茶水食用。

二、 ScrollPhysics

首先介绍 ScrollPhysics ,在 Flutter 官方的介绍中,ScrollPhysics 的作用是 确定可滚动控件的物理特性, 常见的有以下四大金刚:

  • BouncingScrollPhysics :允许滚动超出边界,但之后内容会反弹回来。
  • ClampingScrollPhysics : 防止滚动超出边界,夹住
  • AlwaysScrollableScrollPhysics :始终响应用户的滚动。
  • NeverScrollableScrollPhysics不响应用户的滚动。

在开发过程中,一般会通过如下代码进行设置:

  1. CustomScrollView(physics: const BouncingScrollPhysics())
  2. ListView.builder(physics: const AlwaysScrollableScrollPhysics())
  3. GridView.count(physics: NeverScrollableScrollPhysics())

但在一般我们都不会主动去设置 physics 属性, 那么默认情况下,为什么在 Flutter 中的 ListViewCustomScrollViewScrollable 控件中,在 Android 和 iOS 平台的滚动和边界拖拽效果,会出现如下图所示的平台区别呢?

十八、 神奇的ScrollPhysics与Simulation - 图2

这里的关键就在于 ScrollConfigurationScrollBehavior

2.1、ScrollConfiguration 和 ScrollBehavior

我们知道所有的滑动控件都是通过 Scrollable 对触摸进行响应从而进行滑动的。

如下代码所示,在 Scrollable_updatePosition 方法内,当 widget.physics == null 时,_physics 默认是从 ScrollConfiguration.of(context)getScrollPhysics(context) 方法获取 ,而 ScrollConfiguration.of(context) 返回的是一个 ScrollBehavior 对象。

  1. // Only call this from places that will definitely trigger a rebuild.
  2. void _updatePosition() {
  3. _configuration = ScrollConfiguration.of(context);
  4. _physics = _configuration.getScrollPhysics(context);
  5. if (widget.physics != null)
  6. _physics = widget.physics.applyTo(_physics);
  7. final ScrollController controller = widget.controller;
  8. final ScrollPosition oldPosition = position;
  9. if (oldPosition != null) {
  10. controller?.detach(oldPosition);
  11. scheduleMicrotask(oldPosition.dispose);
  12. }
  13. _position = controller?.createScrollPosition(_physics, this, oldPosition)
  14. ?? ScrollPositionWithSingleContext(physics: _physics, context: this, oldPosition: oldPosition);
  15. assert(position != null);
  16. controller?.attach(position);
  17. }

所以默认情况下 ,ScrollPhysics 是和 ScrollConfigurationScrollBehavior 有关系。

那么 ScrollBehavior 是这么工作的?

查看 ScrollBehavior 的源码可知,它的 getScrollPhysics 方法中,默认实现了平台返回了不同的 ScrollPhysics ,所以默认情况下,在不同平台上的滚动和边缘推拽,会出现不一样的效果:

  1. ScrollPhysics getScrollPhysics(BuildContext context) {
  2. switch (getPlatform(context)) {
  3. case TargetPlatform.iOS:
  4. return const BouncingScrollPhysics();
  5. case TargetPlatform.android:
  6. case TargetPlatform.fuchsia:
  7. return const ClampingScrollPhysics();
  8. }
  9. return null;
  10. }

前面说过, ScrollPhysics 是确定可滚动控件的物理特性 ,那么如上图所示,Android 平台上拖拽溢出的蓝色半圆的怎么来的?ScrollConfigurationScrollBehavior 是在什么时候被设置的?

查看 ScrollConfiguration 的源码我们得知, ScrollConfigurationThemeLocalizations 等一样是 InheritedWidget,那么它应该是从上层往下共享的。

所以查看 MaterialApp 的源码,得到如下代码,可以看到 ScrollConfiguration 是在 MaterialApp 内默认嵌套的,并且通过 _MaterialScrollBehavior 设置了 ScrollBehavior, 其 override 的buildViewportChrome 方法,就是实现了Android 上溢出拖拽的半圆效果, 其中 GlowingOverscrollIndicator 就是半圆效果的绘制控件。

  1. @override
  2. Widget build(BuildContext context) {
  3. ····
  4. return ScrollConfiguration(
  5. behavior: _MaterialScrollBehavior(),
  6. child: result,
  7. );
  8. }
  9. class _MaterialScrollBehavior extends ScrollBehavior {
  10. @override
  11. TargetPlatform getPlatform(BuildContext context) {
  12. return Theme.of(context).platform;
  13. }
  14. @override
  15. Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) {
  16. switch (getPlatform(context)) {
  17. case TargetPlatform.iOS:
  18. return child;
  19. case TargetPlatform.android:
  20. case TargetPlatform.fuchsia:
  21. return GlowingOverscrollIndicator(
  22. child: child,
  23. axisDirection: axisDirection,
  24. color: Theme.of(context).accentColor,
  25. );
  26. }
  27. return null;
  28. }
  29. }

到这里我们就知道了,在默认情况下可滑动控件的 ScrollPhysics 是如何配置的:

  • 1、ScrollConfiguration 是一个 InheritedWidget
  • 2、MaterialApp 内部利用 ScrollConfiguration 并共享了一个 ScrollBehavior 的子类 _MaterialScrollBehavior
  • 3、ScrollBehavior 默认根据平台返回了特定的 BouncingScrollPhysicsClampingScrollPhysics 效果。
  • 4、_MaterialScrollBehavior 中针对 Android 平台实现了 buildViewportChrome 的蓝色半球拖拽溢出效果。

ps :我们可以通过实现自己的 ScrollBehavior , 实现自定义的拖拽溢出效果。

三、ScrollPhysics 工作原理

那么 ScrollPhysics 是怎么实现滚动和边缘拖拽的呢? ScrollPhysics 默认是没有什么代码逻辑的,它的主要定义方法如下所示:

  1. /// [position] 当前的位置, [offset] 用户拖拽距离
  2. /// 将用户拖拽距离 offset 转为需要移动的 pixels
  3. double applyPhysicsToUserOffset(ScrollMetrics position, double offset)
  4. /// 返回 overscroll ,如果返回 0 ,overscroll 就一直是0
  5. /// 返回边界条件
  6. double applyBoundaryConditions(ScrollMetrics position, double value)
  7. ///创建一个滚动的模拟器
  8. Simulation createBallisticSimulation(ScrollMetrics position, double velocity)
  9. ///最小滚动数据
  10. double get minFlingVelocity
  11. ///传输动量,返回重复滚动时的速度
  12. double carriedMomentum(double existingVelocity)
  13. ///最小的开始拖拽距离
  14. double get dragStartDistanceMotionThreshold
  15. ///滚动模拟的公差
  16. ///指定距离、持续时间和速度差应视为平等的差异的结构。
  17. Tolerance get tolerance

上方代码标注了 ScrollPhysics 各个方法的大致作用,而在前面 《十三、全面深入触摸和滑动原理》 中,我们深入解析过触摸和滑动的原理,大致流程从触摸开始往下传递, 最终触发 layout 实现滑动的现象:

十八、 神奇的ScrollPhysics与Simulation - 图3

ScrollPhysics 的工作原理就穿插在其中,其流程如下图所示, 主要的逻辑在于红色标注的的三个方法:

  • applyPhysicsToUserOffset :通过 physics 将用户拖拽距离 offset 转化为 setPixels(滚动) 的增量。
  • applyBoundaryConditions :通过 physics 计算当前滚动的边界条件。
  • createBallisticSimulation : 创建自动滑动的模拟器。

十八、 神奇的ScrollPhysics与Simulation - 图4

这三个方法的触发时机在于 _handleDragUpdate_handleDragCancel_handleDragEnd ,也就是拖动过程和拖动结束的时机:

  • applyPhysicsToUserOffsetapplyBoundaryConditions 是在 _handleDragUpdate 时被触发的。
  • createBallisticSimulation 是在 _handleDragCancel_handleDragEnd 时被触发的。

所以默认的 BouncingScrollPhysicsClampingScrollPhysics 最大的差异也在这个三个方法。

3.1、applyPhysicsToUserOffset

ClampingScrollPhysics 默认是没有重载 applyPhysicsToUserOffset 方法的,parent == null 时,用户的滑动 offset 是什么就返回什么:

  1. double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
  2. if (parent == null)
  3. return offset;
  4. return parent.applyPhysicsToUserOffset(position, offset);
  5. }

BouncingScrollPhysics 中对 applyPhysicsToUserOffset 方法进行了 override ,其中 用户没有达到边界前,依旧返回默认的 offset,当用户到达边界时,通过算法来达到模拟溢出阻尼效果。

  1. ///摩擦因子
  2. double frictionFactor(double overscrollFraction) => 0.52 * math.pow(1 - overscrollFraction, 2);
  3. @override
  4. double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
  5. assert(offset != 0.0);
  6. assert(position.minScrollExtent <= position.maxScrollExtent);
  7. if (!position.outOfRange)
  8. return offset;
  9. final double overscrollPastStart = math.max(position.minScrollExtent - position.pixels, 0.0);
  10. final double overscrollPastEnd = math.max(position.pixels - position.maxScrollExtent, 0.0);
  11. final double overscrollPast = math.max(overscrollPastStart, overscrollPastEnd);
  12. final bool easing = (overscrollPastStart > 0.0 && offset < 0.0)
  13. || (overscrollPastEnd > 0.0 && offset > 0.0);
  14. final double friction = easing
  15. // Apply less resistance when easing the overscroll vs tensioning.
  16. ? frictionFactor((overscrollPast - offset.abs()) / position.viewportDimension)
  17. : frictionFactor(overscrollPast / position.viewportDimension);
  18. final double direction = offset.sign;
  19. return direction * _applyFriction(overscrollPast, offset.abs(), friction);
  20. }

3.2、applyBoundaryConditions

ClampingScrollPhysicsapplyBoundaryConditions 方法中,在计算边界条件值的时候,滑动值会和边界值相减得到相反的数据,使得滑动边界相对静止,从而达到“夹住”的作用 ,也就是动态边界 ,所以默认请下 Android 上滚动到了边界就会停止响应。

  1. @override
  2. double applyBoundaryConditions(ScrollMetrics position, double value) {
  3. if (value < position.pixels && position.pixels <= position.minScrollExtent) // underscroll
  4. return value - position.pixels;
  5. if (position.maxScrollExtent <= position.pixels && position.pixels < value) // overscroll
  6. return value - position.pixels;
  7. if (value < position.minScrollExtent && position.minScrollExtent < position.pixels) // hit top edge
  8. return value - position.minScrollExtent;
  9. if (position.pixels < position.maxScrollExtent && position.maxScrollExtent < value) // hit bottom edge
  10. return value - position.maxScrollExtent;
  11. return 0.0;
  12. }

ps: 前面说过蓝色的半圆是默认的 ScrollBehaviorbuildViewportChrome 方法实现的。

BouncingScrollPhysicsapplyBoundaryConditions 直接返回 0 ,也就是达到 0 是就边界,过了 0 的就是边界外的拖拽效果了。

  1. @override
  2. double applyBoundaryConditions(ScrollMetrics position, double value) => 0.0;

3.3、createBallisticSimulation

因为 createBallisticSimulation 是在 _handleDragCancel_handleDragEnd 时触发的,其实就是停止触摸的时候,createBallisticSimulation 返回 null 时,Scrllable 将进入 IdleScrollActivity ,也就是停止滚动的状态。

如下图所示,完全没有 Simulation 的列表滚动,是不会连续滚动的。

十八、 神奇的ScrollPhysics与Simulation - 图5

ClampingScrollPhysicscreateBallisticSimulation 方法中,使用了 ClampingScrollSimulation(固定) 和 ScrollSpringSimulation(弹性) 两种 Simulation ,如下代码所示,理论上只有 position.outOfRange 才会触发弹性的回弹效果,但 ScrollPhysics 采用了类似 双亲代理模型 ,其 parent 可能会触发 position.outOfRange ,所以推测这里才会有 ScrollSpringSimulation 补充的判断。

如下代码可以看出,只有在 velocity 速度大于默认加速度,并且是可滑动范围内,才返回 ClampingScrollPhysics 模拟滑动,否则返回 null 进入前面所说的 Idle 停止滑动,这也是为什么普通慢速拖动,不会触发自动滚动的原因。

  1. @override
  2. Simulation createBallisticSimulation(
  3. ScrollMetrics position, double velocity) {
  4. final Tolerance tolerance = this.tolerance;
  5. if (position.outOfRange) {
  6. double end;
  7. if (position.pixels > position.maxScrollExtent)
  8. end = position.maxScrollExtent;
  9. if (position.pixels < position.minScrollExtent)
  10. end = position.minScrollExtent;
  11. assert(end != null);
  12. return ScrollSpringSimulation(
  13. spring,
  14. position.pixels,
  15. end,
  16. math.min(0.0, velocity),
  17. tolerance: tolerance,
  18. );
  19. }
  20. if (velocity.abs() < tolerance.velocity) return null;
  21. if (velocity > 0.0 && position.pixels >= position.maxScrollExtent)
  22. return null;
  23. if (velocity < 0.0 && position.pixels <= position.minScrollExtent)
  24. return null;
  25. return ClampingScrollSimulation(
  26. position: position.pixels,
  27. velocity: velocity,
  28. tolerance: tolerance,
  29. );
  30. }

BouncingScrollPhysicscreateBallisticSimulation 则简单一些,只有在结束触摸时,初始速度大于默认加速度或者超出区域,才会返回 BouncingScrollSimulation 进行模拟滑动计算,否则经进入前面所说的 Idle 停止滑动。

  1. @override
  2. Simulation createBallisticSimulation(ScrollMetrics position, double velocity) {
  3. final Tolerance tolerance = this.tolerance;
  4. if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
  5. return BouncingScrollSimulation(
  6. spring: spring,
  7. position: position.pixels,
  8. velocity: velocity * 0.91, // TODO(abarth): We should move this constant closer to the drag end.
  9. leadingExtent: position.minScrollExtent,
  10. trailingExtent: position.maxScrollExtent,
  11. tolerance: tolerance,
  12. );
  13. }
  14. return null;
  15. }

可以看出,在停止触摸时,列表是否会继续模拟滑动是和 velocitytolerance.velocity 有关,也就是速度大于指定的加速度时才会继续滑动 ,并且在可滑动区域内 ClampingScrollSimulationBouncingScrollSimulation 呈现的效果也不一样。

如下图所示,第一页面的 ScrollSpringSimulation 在停止滚动前是有一定的减速效果的;而第二个页面 ClampingScrollSimulation 是直接快速滑动到边界。

十八、 神奇的ScrollPhysics与Simulation - 图6

事实上,通过选择或者调整 Simulation ,就可以对列表滑动的速度、阻尼、回弹效果等实现灵活的自定义。

四、Simulation

前面最后说到了,利用 Simulation 实现对列表的滑动、阻尼、回弹效果的实现处理,那么 Simulation 是如何工作的呢?

十八、 神奇的ScrollPhysics与Simulation - 图7

如上图所示,Simulation 的创建是在 ScrollPositionWithSingleContextgoBallistic 方法中被调用的 ,然后通过 BallisticScrollActivity 去触发执行。

  1. @override
  2. void goBallistic(double velocity) {
  3. assert(pixels != null);
  4. final Simulation simulation = physics.createBallisticSimulation(this, velocity);
  5. if (simulation != null) {
  6. beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
  7. } else {
  8. goIdle();
  9. }
  10. }

BallisticScrollActivity 状态中,Simulation 被用于驱动 AnimationControllervalue ,然后在动画的回调中获取 Simulation 计算后得到的 value 进行 setPixels(value) 实现滚动。

这里又涉及到了动画的绘制机制,动画的机制等新篇再详细说明,简单来说就是 当系统 drawFramevsync 信号到来时,会执行到 AnimationController 内部的 _tick 方法,从而触发 _value = _simulation.x(elapsedInSeconds).clamp(lowerBound, upperBound); 改变和 notifyListeners(); 通知更新。

对于 Simulation 的内部计算逻辑这里就不展开了,大致上可知 ClampingScrollSimulation 的摩擦因子是固定的,而 BouncingScrollSimulation 内部的摩擦因子和计算,是和传递的位置有关系。

这里需要着重提及的就是,为什么 BouncingScrollPhysics 会自动回弹呢?

其实也是 BouncingScrollSimulation 的功劳,因为 BouncingScrollSimulation 构建时,会传递有 leadingExtent:position.minScrollExtenttrailingExtent: position.maxScrollExtent 两个参数,在 underscroll 和 overscroll 的情况下,会利用 ScrollSpringSimulation 实现弹性的回滚到 leadingExtenttrailingExtent 的动画,从而达到如下图的效果:

十八、 神奇的ScrollPhysics与Simulation - 图8

最后

到这里 Flutter 的 ScrollPhysicsSimulation 就基本分析完了,严格意义上, Simulation 应该是属于动画的部分,但是这里因为ScrollPhysics 也放到了一起。

总结起来就是 ScrollPhysics 中控制了用户触摸转化和边界条件,并且在用户停止触摸时,利用 Simulation 实现了自动滚动与溢出回弹的动画效果。

自此,第十八篇终于结束了!(///▽///)

资源推荐

十八、 神奇的ScrollPhysics与Simulation - 图9