[Flutter] 惯性滚动(一)ScrollActivity与ScrollActivityDelegate

一、什么是惯性滚动

[Flutter] 多级嵌套滚动一文中,我们虽然介绍了一种让滚动量在Element树上传递,来实现多层级的嵌套滚动的方案,但是它在实际的使用过程中,存在一个比较明显的问题,就是虽然我们这样实现之后可以成功地将滚动量在不同的层级去传递,但是手指离开屏幕之后的惯性滚动却消失了:

而理想情况下,这种滚动应该是什么样的呢?

当我们在滚动的过程中,在手指完成滚动,离开屏幕之后,也就对应着一个ScrollDragEnd回调之后,一般的视图就会开始进行惯性滚动,也就是让列表再往指定方向上滚动一段距离之后才停止,这背后主要起作用的就是ScrollActivity,惯性滚动也就是BallisticScrollActivity,Ballistic本意是「弹道学的」,延伸出来的解释就是按照一定的策略来运动轨迹。

在认识惯性滚动之前,我们需要认识一下Flutter Scrollable组件的滚动实现,即手指触摸到屏幕再到内容滚动是如何发生的。

一、ScrollActivity

ScrollActivity是一个抽象类,现实中的含义是表示当前可滚动控件的滚动活动类型,比如在一次手指滚动过程中,会创建如下的几种实例对象:

HoldScrollActivity
HoldScrollActivity
HoldScrollActivity
IdleScrollActivity
IdleScrollActivity
DragScrollActivity
BallisticScrollActivity
IdleScrollActivity

以手指在ListView上滚动为例:

  • HoldScrollActivity对应手指按下时刻ListView的滚动活动;
  • IdleScrollActivity则对应停止滚动的ListView的滚动活动;
  • DragScrollActivity意指手指开始拖动时的ListView的滚动活动;
  • **BallisticScrollActivity**则对应惯性滚动开始时ListView的滚动活动;

这其中IdleScrollActivity我们可以猜到它代表的就是ListView的禁止状态;而DragScrollActivity代表的就是手指滚动状态,BallisticScrollActivity则是惯性滚动状态,从这些现实意义进行切入,我们就可以去看看它们的实现了。

注意,我们暂时不太需要关注它们的实现细节,更需要关注它们的现实意义,Activity对应的就是一种造成滑动的场景。

1.1 IdleScrollActivityHoldScrollActivity

IdleScrollActivity的注释就说明了一切:

A scroll activity that does nothing.

它就是一个什么也不做的ScrollActivity,它对所有参数、方法的实现也没有任何复杂的逻辑:

scala 复制代码
class IdleScrollActivity extends ScrollActivity {
 IdleScrollActivity( super .delegate);

  @override
  void applyNewDimensions() {
   delegate.goBallistic( 0.0 );
  }

  @override
bool get  shouldIgnorePointer  = > false ;

  @override
bool get  isScrolling  = > false ;

  @override
 double  get  velocity  = > 0.0 ;
}

而对比之下,HoldScrollActivity则比IdleScrollActivity多了一个感知HoldEvent的能力,当手指Hold在一个Scrollable上时,该Scrollable便会切换到HoldScrollActivity状态,代码就不贴出来了。

1.2 DragScrollActivity

The activity a scroll view performs when the user drags their finger across the screen.

当用户在屏幕上拖动手指时,滚动视图所执行的活动。

通过注释我们就可以看出来用户手指在屏幕上滚动时就会切换到DragScrollActivity,其中主要是对一些滚动相关的事件做了分发处理:

dart 复制代码
@override
void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
  final dynamic lastDetails = _controller!.lastDetails;
  assert(lastDetails is DragStartDetails);
  ScrollStartNotification(metrics: metrics, context: context, dragDetails: lastDetails as DragStartDetails).dispatch(context);
}

@override
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);
}

@override
void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
  final dynamic lastDetails = _controller!.lastDetails;
  assert(lastDetails is DragUpdateDetails);
  OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
}

@override
void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
  // We might not have DragEndDetails yet if we're being called from beginActivity.
  final dynamic lastDetails = _controller!.lastDetails;
  ScrollEndNotification(
    metrics: metrics,
    context: context,
    dragDetails: lastDetails is DragEndDetails ? lastDetails : null,
  ).dispatch(context);
}

例如:

php 复制代码
ScrollStartNotification(metrics: metrics, context: context, dragDetails: lastDetails as DragStartDetails).dispatch(context); 

就是从当前Scrollable组件对应的BuildContext向外派发了一个ScrollStartNotification事件。

再温习一下ListView和它背后的Scrollable是如何被滚动的。

除此之外,ScrollDragActivity会创建一个非常重要的对象:ScrollDragController

它是手指在滚动Scrollable时,不断产生Update事件的控制器,一旦Scrollable开始一次Drag行为,一个ScrollDragController对象就会被创建,然后关联到ScrollableState上,后续这个State对应的Element在不断地接收到来自系统的滚动事件时,就直接会派发到这个ScrollDragController之上,而ScrollDragController会调用相关的方法来设置ScrollPosition的偏移数值,然后更新RenderObject:

一旦一个DragScrollActivity完成了它的使命之后,随之而来的就是这个实例被废弃,比如在DragScrollActivity变更到IdleScrollActivity时,在ScrollPositionWithSingleContext中beginActivity的实现中就可以看到对Drag行为的置空操作:

总结一下

DragScrollActivity两个作用:

  1. 派发滚动Notification

  2. 创建ScrollDragController对象,并交给ScrollableState所引用,然后ScrollableState收到滚动事件时传递给ScrollDragController,调用ScrollPosition的pixels变更,然后通知RenderObject更新视图的偏移量。

  3. 在新的ScrollActivity被begin之后,Scrollable的_drag会立即被置为null,并dispose掉既有的ScrollActivity。

1.3 BallisticScrollActivity

An activity that animates a scroll view based on a physics [Simulation].

一项基于物理 [模拟] 使滚动视图具有动画效果的活动。

Simulation,也就是模拟,在我们处理惯性滚动时,我们需要根据手指Drag的时长距离预设的阻尼系数等参数来估算接下来惯性滚动的速度,说人话就是一个基于时间的动画数值计算函数,基于时间t产生deltaX的一个函数:

ini 复制代码
deltaX = simulation(t) 

惯性滚动,很大程度上就是根据动画自动地去决定手指离开屏幕之后接下来Scrollable的变更偏移量,因此,BallisticScrollActivity比其他的ScrollActivity会多一个AnimationController,需要实现动画所以它需要感知系统的Vsync信号,自然它的构造函数:

kotlin 复制代码
BallisticScrollActivity(
  super.delegate,
  Simulation simulation,
  TickerProvider vsync,
  this.shouldIgnorePointer,
) {
   _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
}

其次,它多了一个_tick方法,结合对Vsync信号的理解,和上面BallisticScrollActivity构造函数中对于AnmationController的创建,我们可以猜测,AnimationController最终会通过这个方法引用来将最新的动画数据,也就是最新时刻对应的t生成的deltaX交付给BallisticScrollActivity来产生动画位移:

scss 复制代码
void _tick() {
  if (!applyMoveTo(_controller.value)) {
    delegate.goIdle();
  }
}

而applyMoveTo则就是拿着ScrollPosition去设置位移量了:

arduino 复制代码
@protected
bool applyMoveTo(double value) {
  return delegate.setPixels(value).abs() < precisionErrorTolerance;
}

这里的value,是BallisticScrollActivity对应的AnimationController动画中ClampingScrollSimulation基于当前时间t生成的动画数值。

ClampingScrollSimulation数值生成由两个部分构成:

其中的position数值是当前ScrollActivity对应的可滚动组件(Scrollable)的偏移量,也就是对应的ScrollPosition的pixels变量;而绿色部分则是delta,即根据t simulate出来的数值simulate出来的数值,这俩个数值相加返回的数值其实是ScrollPosition在当前惯性滚动动画下的newPixels。

所以在applyMoveTo方法中,直接将返回的double类型数值调用delegate.setPixels(double)赋值给了当前的可滚动视图,也就是当前视图的最新偏移量。

二、ScrollActivityDelegate与ScrollPosition

2.1 ScrollPosition

ScrollPosition其实我们之前已经有过介绍了,他就是用来维护当前Scrollable组件偏移量的一个代理对象,目前来说我们需要知道它有三个非常重要的数值:

  1. pixels:即当前视图的偏移量,如果说offset可能刚好理解;
  2. [minScrollExtent,maxScrollExtent]:当前Scrollable对应的最小可滚动距离和最大可滚动距离,也就是pixels参数的取值范围;
  3. viewportDimension:对应Viewport在主轴方向上的尺寸

它直接维护着一个可滚动视图的滚动状态,这与我们之前介绍的ScrollActivity其实是密不可分的,因为ScrollActivity在被begin之后,如果需要修改可滚动视图的偏移量就必然需要操作ScrollPosition。

而Flutter使用了一种前后端分离的模型来处理这二者之间的关系,以适配不同的ScrollActivity和不同的ScrollPosition之间的关系。

这就是ScrollActivity的分离模型。

2.2 ScrollActivityDelegate与前后端分离模型

ScrollActivity作为前端。

而ScrollActivity需要操作ScrollPosition来变更可滚动视图的偏移量,这个操作可能是直接的,也可能是间接的。

比如ListView的ScrollActivity作为前端,就会直接操作ScrollPostition来完成偏移量的修改,进而完成滚动;

而NestedScrollView产生的ScrollActivity并不会直接操作ScrollPosition。

为什么呢?

因为NestedScrollView会有两个ScrollPosition,外层的CustomScrollView对应着outScrollPosition,而内层的PrimaryScrollController对应着另一个innerScrollPosition

因此,ScrollActivity作为前端,不能直接将ScrollPosition作为后端,而是需要找到各个接受ScrollActivity对象之间的最大公约数,因此ScrollActivityDelegate就诞生了:

这里的delegate的实现类就是用来实际控制视图位移的类,一般是ScrollPosition,而注释中把ScrollActivityDelegate这一类东西称为ScrollActivity的后端(backend),我们就可以大致上确定Scrollable在滚动时大致上的模型就是:

  1. 创建后端:创建视图(可滚动视图)的ScrollPosition作为后端;
  2. 创建前端:根据外部事件(比如手指滚动事件)产生滚动事件(DragScrollActivity或者是BallisticScrollActivity)作为前端;
  3. 前端和后端进行连接:前端ScrollActivity后端ScrollPosition进行连接;
  4. 产生滚动:滚动事件控制ScrollPosition进行偏移量变更;

ScrollActivityDelegate本身实现了多种不同事件的灵活切换。

比如BallisticScrollActivity生效时,这时手指突然按在屏幕上,此时会用一个HoldScrollAtivity去替换掉生效的activity,能够立即快速地终止BallisticScrollActivity的惯性滚动效果。

2.3 ScrollActivityDelegate

对于ScrollActivityDelegate来说,他就约定了前后端之间需要交流的几件事情:

csharp 复制代码
abstract class ScrollActivityDelegate {
AxisDirection get axisDirection;
double setPixels(double pixels);
void applyUserOffset(double delta);
void goIdle();
void goBallistic(double velocity);
}

它的实现类:ScrollPosition要对如上的内容进行实现:

  1. axisDirection: 滚动轴方向,这里是一个get方法,会根据具体的逻辑去进行自定,比如前文我们介绍的_NestedScrollView中,就直接返回了 _outerPosition!.axisDirection
  2. setPixels: 设置当前Scroll组件滚动像素的数值 ,返回值表示overscroll,即超量滑动数值;
  3. applyUserOffset:接收用户输入的入口;
  4. goIdle: 启动一个IdleScrollActivity;
  5. goBallistic(double velocity) :即终止当前滚动,并以给定的 速度开始一个惯性滚动;

这五个内容都是要由ScrollActivityDelegate去重写的,我们今天的主要内容其实就是惯性滚动,例如ScrollPositionWithSingleContext对goBallistic的实现如下:

java 复制代码
@override
void goBallistic(double velocity) {
  assert (hasPixels);
  final Simulation? simulation = physics.createBallisticSimulation( this , velocity);
  if (simulation != null) {
    beginActivity(BallisticScrollActivity(
 this ,
simulation,
context.vsync,
activity?.shouldIgnorePointer ?? true ,
));
  } else {
    goIdle();
  }
}

主要的内容就两件事情:

  1. 通过physics创建了对应的BallisticSimulation,physics是外部传入的,用来告知ScrollPosition如何处理用户的输入,主要就是用于控制动画的类型。
  2. 根据Simulation,开始BallisticScrollActivity;

在BallisticScrollActivity开始之后,BallisticScrollActivity所创建的AnimationController就开始工作了,然后根据当前事件t,不断地输出当前的动画数值,然后回调BallisticScrollActivity#_tick->applyMoveTo方法,再调用后端ScrollPositionWithSingleContext设置pixels的数值:

2.4 NestedScrollView的ScrollActivityDelegate

NestedScrollView对应的NestedScrollCoordinator会直接配合ScrollActivity来消费滚动活动,因此它也实现elScrollActivityDelegate:

这就意味着它重写的setPixels方法的返回值永远会返回0,因为Coordinator本身并不维护偏移量,直接做滚动没有任何意义:

java 复制代码
@override
double setPixels(double newPixels) {
 assert ( false );
  return 0.0;
}

理论上永远调用不到setPixels方法,因为applyUserOffset中,就将具体的滚动量分发到outScrollPosition和innerScrollPostion中去了:

scss 复制代码
@override
void applyUserOffset(double delta) {
  updateUserScrollDirection(
    delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse,
  );
  assert(delta != 0.0);
  if (_innerPositions.isEmpty) {
    _outerPosition!.applyFullDragUpdate(delta);
    // 此处省略一万个字

而其他ScrollActivityDelegate的实现类,比如ScrollPositionWithSingleContext,就会直接在ScrollActivityDelegate#applyUserOffset中调用setPixels来处理偏移量的变更和根据delta的正负来改变滚动轴的方向记录

scss 复制代码
@override
void applyUserOffset(double delta) {
  updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
  setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
}
相关推荐
恋猫de小郭6 分钟前
什么?Flutter 可能会被 SwiftUI/ArkUI 化?全新的 Flutter Roadmap
flutter·ios·swiftui
sunly_1 天前
Flutter:导航,tab切换,顶部固定,列表分页滚动
开发语言·javascript·flutter
敲代码的小强1 天前
Flutter项目兼容鸿蒙Next系统
flutter·华为·harmonyos
Zh-jie1 天前
flutter 快速实现侧边栏
前端·javascript·flutter
truemi.732 天前
flutter --no-color pub get 超时解决方法
android·flutter
王家视频教程图书馆2 天前
flutter 使用dio 请求go语言后台数据接口展示瀑布流图片
flutter
迷雾漫步者2 天前
Flutter组件————AppBar
flutter·跨平台·dart
AiFlutter2 天前
Flutter 开关属性
flutter
迷雾漫步者2 天前
Flutter组件————Scaffold
flutter·dart
ELI_He9992 天前
[flutter] 容器组件
flutter