[Flutter] 嵌套滚动与弹性嵌套滚动

274 阅读10分钟

如果你真的按照上一篇文章的内容:[Flutter] 多层级嵌套滚动

中,那样去实现了这样的多层级嵌套滑动的视图,你会发现当你手指一直按着屏幕的时候,滚动是正常的,每个层级都可以被很好地协调,并且正常地滚动,但是如果你仔细看第二段,你就会发现滚动整体非常「涩」,因为虽然手指在ListView3的滚动确实可以很好地传递到CustomScrollView3/2/1上,但是,当你手指离开屏幕之后,ListView3会触发一个「惯性滚动」,惯性滚动只会发生在ListView3上,一旦ListView3滚动到顶,惯性滚动就停了,即使这个惯性滚动还有很大的余量,他也不会导致上层的CustomScrollView3被惯性滚动所推动。

原因很简单------惯性滚动丢失了。

一、Flutter中的惯性滚动

1. 什么是惯性滚动

在 Flutter 中,惯性滚动是指当用户在可滚动组件(如 ListView、GridView 等)上进行快速滑动操作后,即使手指已经离开屏幕,组件仍会基于之前的滑动速度继续滚动一段距离,然后逐渐减速直至停止的效果。

划重点:手指离开屏幕、继续滚动。

既然手指离开了屏幕需要如何继续滚动呢?滚动量从哪里来?答案是动画。惯性滚动本质上是在滚动后,手指离开屏幕之后,根据此前的速度(velocity),生成一段动画,动画会模拟阻尼,计算当前的速度在多久之后会停止,就好比你在一个桌子上往前推一个方块,由于摩擦力的原因,他可能会在1~3秒之内停止滑动,这个过程中方块可能会移动1米的距离。

这个1~3秒和1米就是惯性滚动动画的两个非常重要的属性:distance和duration,前者描述了本次惯性滚动一共要滚多远,后者描述了多久会停止。如果是线性动画,那么1米的距离1秒内要完成,那么这就意味着动画每次触发的时候会对单位时间生成均匀的偏移量,然后在某个时间节点上附加到可滚动视图上。

这个过程就是动画生成的,在Flutter中,Simulation就是用来生成偏移量的模拟类,然后将生成的偏移量或者动画值交给AnimationController,例如ClampingScrollSimulation,它会在传入的position数值的基础上生成新的position数值:


@override
double x(double time) {
final double t = clampDouble (time / _duration, 0.0 , 1.0 );
  return position + _distance * ( 1.0 - math. pow ( 1.0 - t,  _kDecelerationRate ));
}

然后交给AnimationController,在每次tick时,AnimationController会访问Simulation,取出最新的数值:


void _tick(Duration elapsed) {
  ...
  _value = clampDouble(_simulation!.x(elapsedInSeconds), lowerBound, upperBound);
  ...
}

2. Flutter中的惯性滚动

在手指停止操作视图之后,Scrollable会处理DragEndDetails事件(scrollable.dart),然后会调用drag.end的一个回调,而这个end方法的实现最终可以追溯到:Drag的实现类:ScrollDragController中,其中的end方法中调用了delegate.goBallistic(velocity);,这行调用的含义是:我滚动完成了,然后告诉我对应的ScrollActivityDelegate,你将要开始一个BallisticScroll的流程,也就是开始惯性滚动。

对于goBallistic比较常见的实现,可以参考ScrollPositionWithSingleContext:

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

其中,创建了Simulation,然后开始滚动,前面我们有提到,Simulation其实是惯性滚动动画的数值生成器,本质上是在处理时刻t和视图最新偏移量的关系。

BallisticScrollActivity中,有对应的AnimationController,然后再动画开始之后,会不断地去执行_tick回调,这里面其实就是在不断地去操作视图滚动了:

void _tick() {
  if (!delegate.setPixels(value).abs() < precisionErrorTolerance) {
    delegate.goIdle();
  }
}

这个_tick会随着动画的执行和剩余滚动量的消耗有两种结果:

1. 动画时间执行完了

此时AnimationController会走对应的_end回调:

void _end() {
delegate. goBallistic ( 0.0 );
}

这个0.0的Velocity,最终会走到ClampingScrollPhysics的createBallisticSimulation,然后认为速度太小了,就返回一个null,所以最终这里创建出来的Simulation就是null,走了goIdle逻辑。

goIdle会用一个IdleScrollActivity替换掉现在正在执行的BallisticScrollActivity,BallisticScrollActivity会立即被dispose掉,对应的AnimationController也会dispose,因此惯性滚动动画也会停止。

类似直接替换ScrollActivity来改变滚动行为的操作,在惯性滚动,然后手指突然按住屏幕停止惯性滚动的地方也有用到。

2.滚动量走完了

此时会命中tick方法的if内部,最终也是利用一个goIdle去停止惯性滚动。

由此我们得到了一个重要的参考,**goIdle**来停止一个惯性滚动动画。

在后续的实践过程中,我们其实会发现Scroll体系内有大量的断言会校验当前的组件或者ScrollActivity状态,例如你调用滚动方法的时候,可能会校验当前的ScrollActivity是否是处于isScrolling状态;这就要求我们不仅仅要维护滚动量,我还需要正确地去维护对应的ScrollActivity。

二. 问题与思考

究其根本,只是惯性滚动量(BallisticScroll)它没有像我们之前实现的手指滚动(FingerScroll)那样,将盈余的滚动量传递出去, 我们要解决的正是这个问题。

2.1 两个问题

惯性滚动量的传递

如果你参考当前惯性滚动事件,你会发现这里的BallisticScrollActivity、AnimationController、Simulation和ScrollView是一对一处理的。

要传递滚动量不是一件简单的事情,前面我们提到了,如果在当前组件A处理完了自己需要的滚动量之后,BallsticScrollActivity会被一个IdleScrollActivity替代掉。AnimationController会立即被dispose,以至于动画终止。

因此,我们自然要保证动画在此时不停止,我们就要维持着ScrollView对应BallisticScrollActivity状态,然后动画仍然在运行,对应的动画偏移量的应用要替换到新的目标View上,假设此时惯性滚动量需要从ListView3向上传递,传递到CustomScrollView3上,这个流程会是:

  1. ListView3在goBallistic开始消费动画,ListView3滚完了,BallisticScrollActivity被IdleScrollActivity替换;
  2. CustomScrollView3接手ListView3对应的BallisticScrollActivity,继续滚动。

这个过程中存在的问题:

  1. BallisticScrollActivity如何被CustomScrollView3接手,如何处理已经经过的时间?
  2. BallisticScrollActivity本身是基于ListView3的偏移量来生成ListView3偏移的,如果BallisticScrollActivity实例平移到CustomScrollView3上,偏移量发生了变化要怎么处理?

本质上还是因为滚动滚动的动画与ListView是一对一的关系,导致我们难以处理这些问题。

自顶向下的消费顺序如何处理?

我们知道,根据滚动方向的不同,滚动量的消费顺序其实不同,一个CustomScrollView从收缩状态展开的时候,其实是祖先节点优先消费,最后才是触摸事件发生的控件节点消费。在手指触发的滚动中,我们可以通过ElementTree去做向上查询,然后找到对应的目标节点,然后进行消费,并且将多余的滚动量直接通过函数调用栈返回给下一层的节点

这件事情在惯性滚动量的传递中,是很难做到的,因为惯性滚动本质上是在启动动画,动画生成新的偏移量,偏移量去修改可滚动视图的偏移量。而动画变量生成的时候,产生偏移量更新视图的过程,其实已经在事件循环的下一个事件中了,无法直接通过函数调用栈进行返回。

这会对我们的传递造成一定的困难。

三、思考与实现

如果我们要跳脱出现有的Scroll体系,去设计一个支持多层级嵌套滚动的组件,经过总结上面的问题,我们知道它必须要满足两个条件:

  1. AnimationController可能会作为滚动的前端,同时控制多个**ScrollPositionDelegate**;(通俗点说就是动画会陆续操作多个层级的可滚动视图的偏移量)
  2. 实现AnimationController偏移量在不同的ScrollPositionDelegate之按正确的顺序,进行平滑传递;

解决这两个问题,我们回到惯性滚动的起点,也就是发生DragEnd事件的Scrollable组件本身,它通过delegate.goBallistic(velocity)来启动一次惯性滚动,这里的delegate,就是ScrollPositionDelegate,也就是我们所说的滚动的「后端」,用于描述滚动偏移量的类。我们要在之前实现的ScrollViewExPosition中,重写它的goBallistic方法,处理velocity的值,然后提交给我们之前定义的ScrollViewExCoordinator:

@override
void goBallistic(double velocity) {
    goIdle();
  if (velocity.abs() < precisionErrorTolerance) {
    return;
  }
  coordinator.onBallisticSubmit(velocity);
}

此前的goBallistic就不能再使用ScrollPhysics来获取父布局定义的Simulation了,因为后续我们需要通过Coordinator创建统一的、定制化的自定义的Simulation,因此我们这里按照ClampingScrollSimulation中的逻辑简单处理:

  1. 重置为空闲状态;
  2. 如果速度过小,那么直接不继续处理,保持空闲状态;
  3. 自己对应的Coordinator处理。

Coordinator中,我们在生成动画之前,先沿着Element Tree进行一次向上的递归搜索,这个和手指滚动的传递类似,只不过是在搜集所有可能发生滚动的节点,构成一个双端队列:

//// 搜集可滚动的视图
void assembleToQueue(ScrollViewExBallisticQueue queue) {
  ScrollViewExCoordinator? current = this;
  while (current != null) {
    queue.push(current);
    current = current.parentCoordinator;
  }
  T.i("(FlutterSourceCode)[coordinator.dart]->assemble pha:${queue.list.map((e) => e.key)}");
}

在这个assembleToQueue之后,对于这样的一个视图,你的queue队列中,应该存储了所有的绿色节点,这些绿色结点都是一个可能被惯性滚动动画所滚动的视图:

因此,你此刻会有如下的队列:[ListView1、CustomScrollView#3、CustomScrollView#2、CustomScrollView#1]。

由于我们动画控制器AnimationController会变成一对多的关系,一个动画控制器会对应着这队列中的所有组件,因此我们直接在队列中处理这个动画和Simulation,为Queue对应的类增加如下的方法和变量

///// Animation
late AnimationController _controller;
late TickerProvider vsync;
late ExClampingScrollSimulation _simulation;

void start() {
  if (size == 0) {
    return;
  }
  _simulation = ExClampingScrollSimulation(velocity: velocity);
  _controller = AnimationController.unbounded(
    debugLabel: kDebugMode
        ? objectRuntimeType(this, 'ExBallisticScrollActivity')
        : null,
    vsync: vsync,
  )
    ..addListener(_tick)
    ..animateWith(_simulation).whenComplete(_end);
  T.i("(FlutterSourceCode)[queue.dart]->velocity:$velocity");
}

其中的_tick是动画量生成的回调方法,而_end是动画量消耗完之后的回调方法:

void _tick() {
  if (consuming == null) {
    _controller.stop();
    return;
  }
  double controllerValue = _controller.value;
  if (!_applyToTarget(consuming!, controllerValue)) {
    pop();
  }
}

bool _applyToTarget(ScrollViewExCoordinator target, double value) {
  target. goBallisticFragment(velocity, true);
  return target.onBallisticDispatch(value: value, velocity: velocity) <
      precisionErrorTolerance;
}

void _end() {
  consuming?.goIdle();
}

_tick的逻辑很简单,AnimationController生成的动画量交给当前正在执行的consuming对象(它表示的是当前正在接受AnimationController控制的Coordinator),如果consuming已经滚动到顶(底)了之后,就调用pop(),更换节点。

由于传播本身是有方向性的,方向不同会影响是队头结点优先消费,还是队尾结点优先消费,因此,我们需要根据velocity这个矢量来判断从哪一个出口获取当前正在消费的结点,pop方法也是如此,需要决定哪个出口的节点出队。

ScrollViewExCoordinator ? get consuming => list. isEmpty  ? null  : velocity < 0  ? list. first  : list. last ;
       
       
void  pop () {
 ScrollViewExCoordinator ? coordinator;
 if (velocity < 0 ) {
coordinator = list. removeAt ( 0 );
} else {
coordinator = list. removeLast ();
}
coordinator. goIdle ();
} 

出队后的节点置为idle状态即可。

这样一来,所维护的队列的四个结点,就在同一个Simulation、AnimationController的驱动下完成了惯性滚动量的传递,前面我们提到Simulation我们也要去定制,这是因为普通的ClampScrollSimulation会根据它绑定ScrollPositionDelegate的偏移量去生成只属于这个ScrollPositionDelegate的数值:

@override
double x(double time) {
  final double t = clampDouble(time / _duration, 0.0, 1.0);
  return position + _distance * (1.0 - math.pow(1.0 - t, _kDecelerationRate));
}

position在多个可滚动视图中传递的过程中是会发生变更的。

我这里的思路其实非常简单粗暴,如果position会有影响,偏移量 + 后面的一串数值 = 新的偏移量,那么后面的这一串代码就是动画生成的偏移量数值,也就是当前X相较于初始状态0的偏移量,我们只要拿到每一个tick发生时对应的tick即可,我们记录lastX,然后新的偏移量 - 旧的偏移量即可得到当前tick和上一个tick中的deltaX:

double lastX = 0;

@override
double x(double time) {
  final double t = clampDouble(time / _duration, 0.0, 1.0);
  double newX = _distance * (1.0 - pow(1.0 - t, _kDecelerationRate));
  double deltaX = newX - lastX;
  lastX = newX;
  return deltaX;
}

这样每次返回的deltaX就是合理的。

target.goBallisticFragment的实现,让当前正在操作的consuming节点进入滚动状态而已,否则Scrollable会校验当前activity的isScrolling是否为true,然后大量地抛出断言异常。


void goBallisticFragment(double velocity, bool shouldIgnorePointer) {
  _currentPosition.beginActivity(ExBallisticAnimateScrollActivity(
      _currentPosition, velocity, shouldIgnorePointer));
}

将上面的代码组装起来,就能实现相关的功能:

whiteboard_exported_image-5.png

四、总结

总的流程如下:

whiteboard_exported_image-5.png