Flutter 滑动体系

2,810

ScrollView

可滑动视图的父类,ListView,CustomScrollView 和 GridView 都是它的子类,它们通过实现 buildSlivers 函数为 ScrollView 提供子视图,同时将 ScrollController,ScrollPhysics,ViewportBuilder 和 children 等传递给 Scrollable。

在 ScrollView 的 build 中,一些 ScrollView 的参数,如 dragStartBehavior,controller 以及 buildSlivers 这些函数,会用来生成一个 Scrollable,它对 ScrollView 的一些东西进行收拢(比如 ScrollView 不同子类的实现),然后专注于实现滑动这一功能。

Viewport

Viewport 负责计算 ScrollView 的大小,一般有两种,ShrinkWrappingViewport 和 Viewport,它们的区别在于 ScrollView 大小的计算方式,Viewport 在 performResize 阶段就可以确定自己的大小,即父 widget 提供的最大空间,而 ShrinkWrappingViewport 要在 performLayout 阶段才能确定,因为它的大小依赖于自己的子 widget,需要先统计子 view 的大小,再确定自身的大小。

所以,当我们使用 ScrollView 时,一般我们需要给它一定有限大小的 constraints,它才能正确计算自己的大小,当我们无法提供这样一个环境,就可以将它的 shrinkWrap 设置为 true,这样它会给自己计算一个合适的大小。

ScrollConfiguration

这是一个 InheritedWidget,它的作用是给 Scrollable 传递 ScrollBehavior,而它的确定,在很早之前就确定了,且一般一个 app 只有一个(自己单独声明使用的另算),比如在 _CupertinoAppState 中:

Widget build(BuildContext context) {
  final CupertinoThemeData effectiveThemeData = widget.theme ?? const CupertinoThemeData();
  return ScrollConfiguration(
    behavior: _AlwaysCupertinoScrollBehavior(),
    child: CupertinoUserInterfaceLevel(
      data: CupertinoUserInterfaceLevelData.base,
      child: CupertinoTheme(
        data: effectiveThemeData,
        child: HeroControllerScope(
          controller: _heroController,
          child: Builder(
            builder: _buildWidgetApp,
          ),
        ),
      ),
    ),
  );
}

给整个 app 指定了一个全局的 ScrollBehavior _AlwaysCupertinoScrollBehavior,这个会在 Scrollable 执行滑动的时候用到。

ScrollBehavior

ScrollBehavior 本身在 flutter 的设计中是一个平台相关的 Widget,它会根据当前的平台,选择一个合适的 ScrollPhysics,如下:

ScrollPhysics getScrollPhysics(BuildContext context) {
  switch (getPlatform(context)) {
    case TargetPlatform.iOS:
    case TargetPlatform.macOS:
      return _bouncingPhysics;
    case TargetPlatform.android:
    case TargetPlatform.fuchsia:
    case TargetPlatform.linux:
    case TargetPlatform.windows:
      return _clampingPhysics;
  }
}

而 ScrollPhysics 的定位,可以从名字上理解,控制滑动过程的物理特性,定义了如当滑动到顶部的时候的表现、滑过头了之后的回弹方式等。

Scrollable

ScrollView 中 build 主要返回的 widget,一个通用的滑动模型,它是滑动功能的载体,与 ScrollController、ScrollPhysics 一起实现了一个可滑动的控件。而它也只算是一个载体,一个中介,它最主要的作用,就是利用身为 widget 的优势,从整个视图体系中拿到触摸事件,而剩下的功能,交给其他人就好。

Viewport 负责决定滑动视图的大小,ScrollPosition 决定滑动的位置,ScrollPhysics 决定滑动的物理属性,ScrollController 可以支持外部使用者控制滑动过程。还有其他的一些,比如 ScrollActivity 表示了滑动过程中的某一阶段等。在这样一个体系中,ScrollPosition 更像是一个 controller,它直接从 Scrollable 中拿到未处理的触摸事件,根据事件类型计算出自己当前的状态。

Scrollable 对应的 state 为 ScrollableState,在它的 build 中,返回的 child 中,有 Listener、RawGestureDetector 和 Viewport 等,Listener 用于监听 PointerScrollEvent 事件,一般这个事件应该是在滚动滑动条时触发的,此时它会计算出一个滑动位置,并直接调用 ScrollPosition.jumpTo 滚动到对应位置。

而 RawGestureDetector 会监听一些滑动手势,比如 dragDown、dragStart 等,ScrollPosition 根据这些手势信息更新状态,计算滑动。

Viewport 就是在 ScrollView 中生成的。

ScrollController

ScrollController 可以从 ScrollView 中设置,并一路传递到 Scrollable 中,它虽然名为 controller,但并不是一个 center controller,而是一个 user controller,即给用户提供控制滑动状态的一种途径,但本身在滑动体系中作用不大,只是一个将用户的命令传达到 ScrollPosition 中的角色,不过它还有一点权利,就是可能决定创建的 ScrollPosition,当然这个最终还是将权利传递到外部用户的手上而已。

它有诸如 adjumpTo、animateTo 等函数,通过调用 position 的同名函数实现。另外,ScrollController 可以绑定多个 ScrollPosition,可以据此实现多个视图同步滑动的能力。

ScrollPosition

ScrollPosition 承担着滑动过程中的主要责任,上承 ScrollController,Scrollable,下启 ScrollActivity、ScrollPhysics 等。

首先,ScrollPosition 是 ViewportOffset 的一个子类,这是一个 widget 向概念,它表示的是 Viewport 这个 widget 的偏移量,由于 Viewport 本身就是用于承载滑动视图的 widget,在很多情况下,它的 children 的整体长度要大于它自身,所以就需要有一个 offset 属性,控制当前应该显示的内容。另外,ViewportOffset 中也通过 applyViewportDimension 等函数,接收来自 widget 的信息,及时根据当前的布局,更改显示内容。

其次,ScrollPosition 作为接收触摸事件者,它还完成了对触摸事件的分发功能,以及进一步,将处理过的触摸事件转换成视图滑动(其中有一些复杂操作,比如需要考虑视图滑动范围、滑动物理属性等),最终视图更新。

举个例子,当一个滑动事件发生时,它会创建一个Drag 处理后续的滑动事件,Drag 后续对原始的滑动事件进行第一次加工之后,再给到 ScrollPosition,然后 ScrollPosition 还会再将这个数据拿给 ScrollPhysics 进行一些类似边界问题的判断,完了之后,将最终结果给到 ViewportOffset 的 pixels 属性,最后通知 Viewport 进行重新 layout,由此完成一次滑动。更具体的流程,在最后详细说明。

ScrollPyhsics

ScrollPhysic 描述的是一个滑动视图,也就是 Viewport 的内容,在执行滑动过程中的一些物理属性,比如是否可以 overscroll,在一个给定的 ScrollMetrics 和理论偏移值下计算一个实际的偏移值等。再看下它的一些成员变量:

  • spring,SpringDescription,描述了滑动的一些物理特性,会在创建 Simulation 时传递过去

  • tolerance,Tolerance,定义了一些可忽略的距离、速度、时间等

  • flingDistance,定义了最小的可被认定为 fling 手势的距离

  • flingVelocity,定义了最小的可被认定为 fling 手势的速度,和最大的 fling 速度

  • dragStartDistanceMotionThreshold,定义了开始滑动时,可被认定为是滑动手势的最小距离

  • allowImplicitScrolling,这是一个来自 ViewportOffset 的变量

ScrollActivity

ScrollActivity 可以表示滑动过程中的一个阶段,只是记录了当前的状态,比如是否是滑动中、当前的滑动速度等。它的几个基本参数分别为:

  • delegate,ScrollActivityDelegate,有着更新滑动位置的实现,一般就是 ScrollPosition 及其子类
  • shouldIgnorePointer,是否忽略触摸事件,这里的主体,是 Scrollable 的子 widget,也就是 Viewport,而在 Scrollable 中用于接收手势滑动的 RawGestureDetector 在它之上,也就是说,这个参数并不是控制是否检测滑动手势,而是待滑动的内容是否可以接收事件,所以,在众多 ScrollActivity 中,只有 HoldScrollActivity 和 IdleScrollActivity 中它的值才为 true
  • isScrolling,当前是否处于滑动状态
  • velocity,如果是滑动状态,当前的滑动速度,当然,这个值也只有在 BallisticScrollActivity 和 DrivenScrollActivity 中才不为 0

它大致可以分为两种类型,滑动和不滑动。

non-scroll

其中表示不滑动的有两个 ScrollActivity,HoldScrollActivity 和 IdleScrollActivity。

HoldScrollActivity

HoldScrollActivity 会在手指按下的瞬间生成,它有表示一种蓄势待发的状态,是为了下一刻的滑动,所以在启动 HoldScrollActivity 的时候,会保存下来当前的滑动速度,然后在开始滑动时,会在一个初始速度上接着滑动。

ScrollHoldController hold(VoidCallback holdCancelCallback) {
  final double previousVelocity = activity!.velocity;
  // ...
}
Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
  final ScrollDragController drag = ScrollDragController(
    delegate: this,
    details: details,
    onDragCanceled: dragCancelCallback,
    carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
    motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
  );
  // ...
}
IdleScrollActivity

而 IdleScrollActivity 只是表示这是一个静止状态,此时 ScrollPosition 不进行滑动,也基本不处理事件。不过换句话说,ScrollPosition 也只是处理两种事件,在 dragDown 时将状态切换至 HoldScrollActivity,当 dargStart 时,生成 Drag 并将状态切换至 DragScrollActivity,至于 dragUpdate 事件,则是直接交给 Drag 来处理的。

scroll

表示滑动状态的 ScrollActivity 有三种,分别是事件驱动、速度驱动和动画驱动。

driven by draging - DragScrollActivity

所谓事件驱动,就是滑动过程是根据外部传进来的滑动事件,来决定是否以及如何更新视图。这个就是在基本的滑动过程中,ScrollPosition 接收到 dragStart 事件时,进入的滑动状态,与之关联的 ScrollDragController,它会被传递回 Scrollable,在 dragUpdate 事件到来时直接处理事件。

void _handleDragStart(DragStartDetails details) {
  // It's possible for _hold to become null between _handleDragDown and
  // _handleDragStart, for example if some user code calls jumpTo or otherwise
  // triggers a new activity to begin.
  assert(_drag == null);
  _drag = position.drag(details, _disposeDrag);
  assert(_drag != null);
  assert(_hold == null);
}
void _handleDragUpdate(DragUpdateDetails details) {
  // _drag might be null if the drag activity ended and called _disposeDrag.
  assert(_hold == null || _drag == null);
  _drag?.update(details);
}
driven by velocity - BallisticScrollActivity

当 drag 系列事件结束后,会留下一个滑动速度,此时滑动并不会停止,而是在基于这个速度下,做减速滑动,直到速度为 0,或者滑动到边界,这个阶段,对应的就是 BallisticScrollActivity。

deiven by animation - DrivenScrollActivity

当我们直接通过 ScrollController 控制 Scrollable 进行滑动时,一般就是调用 animateTo,会创建一个 DrivenScrollActivity,根据当前给出的 duration、curve 等,创建一个动画并执行。

Simulation

在 BallisticScrollActivity 执行过程中,用于决定滑动位置的就是 Simulation,

void goBallistic(double velocity) {
  assert(hasPixels);
  final Simulation? simulation = physics.createBallisticSimulation(this, velocity);
  if (simulation != null) {
    beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
  } else {
    goIdle();
  }
}

Simulation 由 ScrollPhysics 创建,在一定程度上是平台相关的,本身也算是 ScrollPhysics 功能组成的一部分,主要是用于控制拖拽滑动结束后的过程,比如在 ios 中默认使用的 BouncingScrollPhysics 会创建一个 BouncingScrollSimulation,创建 BouncingScrollSimulation 的时候,给了它一个初速度、滑动范围等,然后就由它来确定滑动的距离以及停止的时间。

完整的滑动过程

下面从一次完整的滑动过程再次分析下 flutter 中 Scrollable 的滑动体系,以 ScrollPositionWithSingleContext 和 BouncingScrollPhysics 为例。

drag

首先,当 Scrollable 创建完成之后,它会利用 RawGestureDetector 监听当前的手势操作,主要监听的操作就是 drag 事件相关的,比如 dragDown、dragStart、dragUpdat、dragCancel 等,在这些过程中,主要涉及的只有 DragScrollActivity 这个。

onDown

onDown 用于处理 dragDown 事件,

void _handleDragDown(DragDownDetails details) {
  assert(_drag == null);
  assert(_hold == null);
  _hold = position.hold(_disposeHold);
}

很简单,这里只是调用 ScrollPosition 的 hold,创建一个 HoldScrollActivity,如上所介绍的,为下一步的滑动作准备。这里有一点,就是在创建 HoldScrollActivity 的时候,同时传进去了一个 dispose 回调,在这个回调中,会将 _hold 置空,当然这里的考虑并不是释放空间这么简单,_hold 本身还是一种状态,当它不为空的时候,就意味着当前处于 HoldScrollActivity 所管辖的状态,_drag 也是同理,会有 assert 对当前的状态进行判断。

onStart

onStart 处理 dragStart 事件,

void _handleDragStart(DragStartDetails details) {
  // It's possible for _hold to become null between _handleDragDown and
  // _handleDragStart, for example if some user code calls jumpTo or otherwise
  // triggers a new activity to begin.
  assert(_drag == null);
  _drag = position.drag(details, _disposeDrag);
  assert(_drag != null);
  assert(_hold == null);
}

跟 onDown 类似,此时创建了 _drag,在 position.drag 中,

Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
  final ScrollDragController drag = ScrollDragController(
    delegate: this,
    details: details,
    onDragCanceled: dragCancelCallback,
    carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
    motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
  );
  beginActivity(DragScrollActivity(this, drag));
  assert(_currentDrag == null);
  _currentDrag = drag;
  return drag;
}

ScrollDragController 会被创建并进入到 DragScrollActivity 状态。在 ScrollPosition 中,每次启用一个 ScrollActivity 时,都是使用 beginActivity 进行状态切换的。

void beginActivity(ScrollActivity? newActivity) {
  if (newActivity == null)
    return;
  bool wasScrolling, oldIgnorePointer;
  if (_activity != null) {
    oldIgnorePointer = _activity!.shouldIgnorePointer;
    wasScrolling = _activity!.isScrolling;
    if (wasScrolling && !newActivity.isScrolling)
      didEndScroll(); // notifies and then saves the scroll offset
    _activity!.dispose();
  } else {
    oldIgnorePointer = false;
    wasScrolling = false;
  }
  _activity = newActivity;
  if (oldIgnorePointer != activity!.shouldIgnorePointer)
    context.setIgnorePointer(activity!.shouldIgnorePointer);
  isScrollingNotifier.value = activity!.isScrolling;
  if (!wasScrolling && _activity!.isScrolling)
    didStartScroll();
}

一个比较常规的切换逻辑,有一点就是,在 beginActivity 执行时,会判断一下切换前后的滑动状态和是否可以接收事件,并产生相应的通知。

比如 didStartScroll,

void didStartScroll() {
  activity!.dispatchScrollStartNotification(copyWith(), context.notificationContext);
}

void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
  ScrollStartNotification(metrics: metrics, context: context).dispatch(context);
}

void dispatch(BuildContext? target) {
  // The `target` may be null if the subtree the notification is supposed to be
  // dispatched in is in the process of being disposed.
  target?.visitAncestorElements(visitAncestor);
}

bool visitAncestor(Element element) {
  if (element is StatelessElement) {
    final StatelessWidget widget = element.widget;
    if (widget is NotificationListener<Notification>) {
      if (widget._dispatch(this, element)) // that function checks the type dynamically
        return false;
    }
  }
  return true;
}

这个过程会创建一个 ScrollStartNotification,并沿着 widget 树向上传递,通过 visitAncestor,传递给上层第一个接收消费掉此通知的 NotificationListener。(只有当 NotificationListener 的 NotificationListenerCallback 返回 true 才是消费此通知,否则通知会一直向上传递)

onUpdate

onUpdate 会处理 dragUpdate 事件,也就是手指滑动时的事件,此时这个事件会直接交给 ScrollDragController 处理,

void _handleDragUpdate(DragUpdateDetails details) {
  // _drag might be null if the drag activity ended and called _disposeDrag.
  assert(_hold == null || _drag == null);
  _drag?.update(details);
}

void update(DragUpdateDetails details) {
  assert(details.primaryDelta != null);
  _lastDetails = details;
  double offset = details.primaryDelta!;
  if (offset != 0.0) {
    _lastNonStationaryTimestamp = details.sourceTimeStamp;
  }
  // By default, iOS platforms carries momentum and has a start threshold
  // (configured in [BouncingScrollPhysics]). The 2 operations below are
  // no-ops on Android.
  _maybeLoseMomentum(offset, details.sourceTimeStamp);
  offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
  if (offset == 0.0) {
    return;
  }
  if (_reversed) // e.g. an AxisDirection.up scrollable
    offset = -offset;
  delegate.applyUserOffset(offset);
}

在 update 中,保存下来的 _lastDetails 是为了在之后发送通知的时候,加上这个滑动事件信息,比如 dispatchScrollUpdateNotification,

void dispatchScrollUpdateNotification(ScrollMetrics metrics, BuildContext context, double scrollDelta) {
  final dynamic lastDetails = _controller!.lastDetails;
  assert(lastDetails is DragUpdateDetails);
  ScrollUpdateNotification(metrics: metrics, context: context, scrollDelta: scrollDelta, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
}

然后就是一个关于是否损失动量的判断,

void _maybeLoseMomentum(double offset, Duration? timestamp) {
  if (_retainMomentum &&
      offset == 0.0 &&
      (timestamp == null || // If drag event has no timestamp, we lose momentum.
       timestamp - _lastNonStationaryTimestamp! > momentumRetainStationaryDurationThreshold)) {
    // If pointer is stationary for too long, we lose momentum.
    _retainMomentum = false;
  }
}

这个过程的目的,是为了判断是否损失动量,我们知道,一般在 ios 的滑动中,连续快速滑动的时候,速度是会积累的,所以后面会越滑越快,而 flutter 为了保持这一特性,就有了动量积累这样一个功能,目前也只在 BouncingScrollPhysics 中才有。关于这个就要从 HoldScrollActivity 开始说起,之前 HoldScrollActivity 有提到,当 dragDown 事件发生时,ScrollPosition 会记录下当前的滑动速度(如果当前还在滑动中),然后在 dragStart 时,将之前的滑动速度传递给 ScrollDragController,不过需要经过 ScrollPhysics 再过滤,而只有 BouncingScrollPhysics 才会提供这个初速度,不过也是经过计算的:

double carriedMomentum(double existingVelocity) {
  return existingVelocity.sign *
      math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0);
}

然后就是在 ScrollDragController 结束时,它会在滑动速度的基础上,再把初速度加上去,构成滑动后的速度。而在滑动后是否加上初速度也是需要判断的,就是通过 _maybeLoseMomentum,如果是滑动太慢或者有悬停的话,就认为这不能进行动量积累,也就不会把初速度再加上去。

然后,_adjustForScrollStartThreshold 会就开始滑动的距离做些处理,大体就是,当本次滑动距离超过某个阈值的时候,才真正开始滑动,否则就当作误差忽略掉。当然这个逻辑也是可以通过 ScrollPhysics 控制的,就是它的 dragStartDistanceMotionThreshold,目前也只是 BouncingScrollPhysics 才有。

double _adjustForScrollStartThreshold(double offset, Duration? timestamp) {
  if (timestamp == null) {
    // If we can't track time, we can't apply thresholds.
    // May be null for proxied drags like via accessibility.
    return offset;
  }
  if (offset == 0.0) {
    if (motionStartDistanceThreshold != null &&
        _offsetSinceLastStop == null &&
        timestamp - _lastNonStationaryTimestamp! > motionStoppedDurationThreshold) {
      // Enforce a new threshold.
      _offsetSinceLastStop = 0.0;
    }
    // Not moving can't break threshold.
    return 0.0;
  } else {
    if (_offsetSinceLastStop == null) {
      // Already in motion or no threshold behavior configured such as for
      // Android. Allow transparent offset transmission.
      return offset;
    } else {
      _offsetSinceLastStop = _offsetSinceLastStop! + offset;
      if (_offsetSinceLastStop!.abs() > motionStartDistanceThreshold!) {
        // Threshold broken.
        _offsetSinceLastStop = null;
        if (offset.abs() > _bigThresholdBreakDistance) {
          // This is heuristically a very deliberate fling. Leave the motion
          // unaffected.
          return offset;
        } else {
          // This is a normal speed threshold break.
          return math.min(
            // Ease into the motion when the threshold is initially broken
            // to avoid a visible jump.
            motionStartDistanceThreshold! / 3.0,
            offset.abs(),
          ) * offset.sign;
        }
      } else {
        return 0.0;
      }
    }
  }
}

在这个函数中,大体分为了几种判断标准:

  • 有没有时间信息
  • 滑动距离为不为 0
  • 是否有 motionStartDistanceThreshold

当没有时间信息和 motionStartDistanceThreshold 没有值的时候,这个函数可以认为不工作状态,都是直接返回原滑动距离就完了。主要还是看 motionStartDistanceThreshold 不为空的情况。

首先,当已经开始滑动,但滑动过程中有悬停时,_offsetSinceLastStop 会归零,重新开始计算。当开始滑动时,会逐渐积累 _offsetSinceLastStop,这个过程中不会有实际滑动,直到它大于 motionStartDistanceThreshold 时,阈值到达,此时 _offsetSinceLastStop 置空,开始实际滑动。

不过这还只是从 ScrollDragController 的角度,认为可以滑动的距离,但真正反馈到 Viewport 之前,ScrollPhysics 也要来表现一下,

void applyUserOffset(double delta) {
  updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
  setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
}

首先就是 applyPhysicsToUserOffset,也是只有 BouncingScrollPhysics 有实现,原因是因为它是一个允许 overscroll 的 ScrollPhysics,在这个函数中,主要是就 overscroll 的情况,通过改变实际移动的距离,添加一种类似“阻力”的概念,即在 overscroll 状态下,实际的滑动距离要小于手势滑动距离。

double setPixels(double newPixels) {
  if (newPixels != pixels) {
    final double overscroll = applyBoundaryConditions(newPixels);
    final double oldPixels = pixels;
    _pixels = newPixels - overscroll;
    if (_pixels != oldPixels) {
      notifyListeners();
      didUpdateScrollPositionBy(pixels - oldPixels);
    }
    if (overscroll != 0.0) {
      didOverscrollBy(overscroll);
      return overscroll;
    }
  }
  return 0.0;
}

double applyBoundaryConditions(double value) {
  final double result = physics.applyBoundaryConditions(this, value);
  return result;
}

然后在 setPixels 中,继续调用 ScrollPhysics 的 applyBoundaryConditions 判断当前的 overscroll,当然这里的 overscroll 与上面 applyPhysicsToUserOffset 中所说的不太一样,上面说的 overscroll 是指用户所观察到的,如果要用语言简单区分,它们可以是:

  1. 在 applyPhysicsToUserOffset 中:用户所观察到的 overscroll,即需要展示给用户的
  2. 在 applyBoundaryConditions 中:ScrollPosition 所观察到的 overscroll,是不能让用户看到的

所以可以看到,只有 ClampingScrollPhysics 对其有实现,而因为 BouncingScrollPhysics 是始终可以滑动的状态(通过阻力表达滑动到边界),所以它在这里的 overscroll 是始终为 0。

完了之后才能得到真正的需要偏移的数值,此时一次 dragUpdate 完成。

onEnd

接下来就是 dragEnd,滑动手势结束的时候触发。

void end(DragEndDetails details) {
  assert(details.primaryVelocity != null);
  // We negate the velocity here because if the touch is moving downwards,
  // the scroll has to move upwards. It's the same reason that update()
  // above negates the delta before applying it to the scroll offset.
  double velocity = -details.primaryVelocity!;
  if (_reversed) // e.g. an AxisDirection.up scrollable
    velocity = -velocity;
  _lastDetails = details;
  // Build momentum only if dragging in the same direction.
  if (_retainMomentum && velocity.sign == carriedVelocity!.sign)
    velocity += carriedVelocity!;
  delegate.goBallistic(velocity);
}

此时就是计算一下当前的滑动速度,以便后面进入 BallisticScrollActivity 阶段,也就是 goBallistic 调用。

void goBallistic(double velocity) {
  assert(hasPixels);
  final Simulation? simulation = physics.createBallisticSimulation(this, velocity);
  if (simulation != null) {
    beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
  } else {
    goIdle();
  }
}

BallisticScrollActivity(
  ScrollActivityDelegate delegate,
  Simulation simulation,
  TickerProvider vsync,
) : super(delegate) {
  _controller = AnimationController.unbounded(
    debugLabel: kDebugMode ? objectRuntimeType(this, 'BallisticScrollActivity') : null,
    vsync: vsync,
  )
    ..addListener(_tick)
    ..animateWith(simulation)
     .whenComplete(_end); // won't trigger if we dispose _controller first
}

函数本身很简单,首先通过 ScrollPhysics 创建一个 Simulation,然后将其传给 BallisticScrollActivity。从 BallisticScrollActivity 的构造函数可以看到,本质上我们也可以将其看作是一个由动画驱动的滑动过程,只不过这个动画是根据一个给定的初始速度创建的。

BallisticScrollActivity 与 DrivenScrollActivity 的相似度很高,它们都是在构造函数中先根据提供的信息(simulation,duration、curve等)创建一个 AnimationController,然后监听更新和结束事件,在 _tick 中更新偏移值,在 _end 中结束自己。

ballistic

当滑动手势结束时,远不意味着整个滑动的结束,为了用户体验,我们赋予滑动速度的概念,那它的滑动也就有动量,所以停止不能只是戛然而止,需要一个慢慢停下来的过程,所以就有了 BallisticScrollActivity 所代表的减速过程,而这个过程的主要控制者,实际为 ScrollPhysics 所生成的 Simulation,不同的 Simulation 相距甚远。这里就以较为复杂的 BouncingScrollSimulation 为例说明。

BouncingScrollSimulation({
  required double position,
  required double velocity,
  required this.leadingExtent,
  required this.trailingExtent,
  required this.spring,
  Tolerance tolerance = Tolerance.defaultTolerance,
}) : assert(position != null),
     assert(velocity != null),
     assert(leadingExtent != null),
     assert(trailingExtent != null),
     assert(leadingExtent <= trailingExtent),
     assert(spring != null),
     super(tolerance: tolerance) {
  if (position < leadingExtent) {
    _springSimulation = _underscrollSimulation(position, velocity);
    _springTime = double.negativeInfinity;
  } else if (position > trailingExtent) {
    _springSimulation = _overscrollSimulation(position, velocity);
    _springTime = double.negativeInfinity;
  } else {
    // Taken from UIScrollView.decelerationRate (.normal = 0.998)
    // 0.998^1000 = ~0.135
    _frictionSimulation = FrictionSimulation(0.135, position, velocity);
    final double finalX = _frictionSimulation.finalX;
    if (velocity > 0.0 && finalX > trailingExtent) {
      _springTime = _frictionSimulation.timeAtX(trailingExtent);
      _springSimulation = _overscrollSimulation(
        trailingExtent,
        math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
      );
      assert(_springTime.isFinite);
    } else if (velocity < 0.0 && finalX < leadingExtent) {
      _springTime = _frictionSimulation.timeAtX(leadingExtent);
      _springSimulation = _underscrollSimulation(
        leadingExtent,
        math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
      );
      assert(_springTime.isFinite);
    } else {
      _springTime = double.infinity;
    }
  }
  assert(_springTime != null);
}

首先看它的构造函数,从参数来看,有滑动速度、当前位置、滑动范围和 spring 信息(质量、刚度、摩擦等),然后,在初始化的时候,又分三种情况,underscroll、overscroll 和其他。三种情况对应的三种不同的滑动方式。

首先,整体来说,在 BouncingScrollSimulation 中滑动也是分阶段的,因为它对应的 BouncingScrollPhysics 是一个允许 overscroll 的 ScrollPhysics,所以这就导致它的减速过程也变得复杂,需要考虑是否是 overscroll 状态下的减速,以及减速过程中是否会产生 overscroll。所以在 BouncingScrollSimulation 中,_springSimulation 负责由 overscroll 状态下回滚到边界的过程,_frictionSimulation 才是负责减速过程。

下面就直接看先减速后回弹的情况,首先,是根据当前速度创建 _frictionSimulation 并判断它是否会 overscroll,如果会,就再计算到达边界的时间,然后再约过边界的瞬间,启用 _springSimulation。而 _springTime 就是区分的中界线,

double x(double time) => _simulation(time).x(time - _timeOffset);

Simulation _simulation(double time) {
  final Simulation simulation;
  if (time > _springTime) {
    _timeOffset = _springTime.isFinite ? _springTime : 0.0;
    simulation = _springSimulation;
  } else {
    _timeOffset = 0.0;
    simulation = _frictionSimulation;
  }
  return simulation..tolerance = tolerance;
}

对于任何一个函数,它都是需要通过 _simulation 先拿到当前使用的 Simulation 再计算。从这个角度上看,BouncingScrollSimulation 只是一个代理,它的所有实现都是通过 _springSimulation 和 _frictionSimulation 完成的。

直到动画结束,一个完整的滑动过程也基本结束了。

扩展

上面介绍的还只是滑动体系的一部分,除此之外,还有更多不同的 ScrollPhysics,不同的 ScrollPosition,当然基本的逻辑都是如此。

不同的 ScrollPhysics 代表着不同的滑动方式,比如 NeverScrollableScrollPhysics,表示不可滑动,比如 PageScrollPhysics,将滑动固定在页与页之间。又比如 _NestedScrollPosition,专门用于控制 NestedScrollView 中,多层 view 同时滑动的逻辑,比如 _PagePosition,为 PageView 细化了一些滑动规则等等,这些都是基于当前所描述的 ScrollPosition 规则,但是在一些函数上有了新的实现,从而胜任不同的目标,这些都值得去看。