1 Flutter 的滑动原理

1.1 PageView的核心结构

(一) PageView
PageView就是基于Scrollable进行了定制,通过封装Notification获取到ScrollNotification类的通知,根据通知信息里的偏移判断当前页面是否发生了切换,然后回调onPageChanged
(二)RawGestureDetector
手势收集类,在ScrollablesetCanDrag方法中,绑定了VerticalDragGestureRecognizer或者HorizontalDragGestureRecognizer用来收集垂直或水平方向的滑动信息。
(三) ScrollController与ScrollPosition
ScrollPositionScrollable中实际控制滑动的对象,在ScrollControllerattach方法中。ScrollPosition会将ScrollController作为它的观察者添加到Listeners中,我们往往使用ScrollController.addListener方法添加滚动监听,实际上的通知顺序是:ScrollPosition->ScrollController->添加的回调
(四) Viewport
接受来自ScrollPosition的偏移量,绘制不同的区域完成"滑动"。

1.2基本流程分析

当手指在屏幕上滑动的时候,首先RawGestureDetector收集了手势信息(里面处理了手势竞争的流程,下期展开叙述),之后将手势信息回调到Scrollable中。Scrollable接受到信息后,通过ScrollPosition进行滑动控制包含(1)修改偏移量,通知Viewport绘制不同的区域 (2)通知ScrollController,进行观察者的通知。

2 从一次点击探寻Flutter的事件分发原理

2.1 触摸事件传递

在这里插入图片描述

首先我们知道,用户的任何交互行为,一定是在原生设备上进行。所以我们事件分发肯定是从Native侧传递到Flutter,下面一张图描述了这个过程
在这里插入图片描述
一次点击响应可以分解为两个事件,一个手指按下的Down事件,一个手指抬起的Up事件。以安卓为例,首先这两个事件依次从Java层传递到了C++,最终传递至Dart。在Dart部分,我们注意到经过zone.runUnaryGuarded方法之后会调用到window.onPointDataPacket方法处(window是Flutter中一个非常核心的概念,是作为一个与Native交互的源对象,后面单独介绍),查看GestureBinding初始化的过程得知这个方法会执行_handlePointerDataPacket

mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
   //将_handlePointerDataPacket设置为 window.onPointerDataPacket回调
   window.onPointerDataPacket = _handlePointerDataPacket;
  }
}

注:mixin是面向对象程序设计语言中的类,提供了方法的实现。其他类可以访问mixin类的方法、变量而不必成为其子类。该方法中表示GestureBinding继承BindingBase(抽象类)。

GestureBinding# _handlePointerDataPacket(ui.PointerDataPacket packet)

//未处理的事件队列
final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
//这里的packet是一个点的信息
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
  // We convert pointer data to logical pixels so that e.g. the touch slop can be
  // defined in a device-independent manner.
  // 将data中的数据,映射到为逻辑像素 
  // window.devicePixelRatio 一个逻辑像素对应的设备像素,比如nex6:3.5
  _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
  if (!locked)
    _flushPointerEventQueue();
}

这个方法首先会根据设备的属性将传递来数据映射到为逻辑像素后添加至队列,下一步调用_flushPointerEventQueue()
GestureBinding# _flushPointerEventQueue()

void _flushPointerEventQueue() {
  assert(!locked);
  while (_pendingPointerEvents.isNotEmpty)
    //直接调用_handlePointerEvent
    _handlePointerEvent(_pendingPointerEvents.removeFirst());
}

GestureBinding# _handlePointerEvent(PointerEvent event)
/// 此处的key是event.pointer,pointer是不会重复的,每个down事件的时候会去+1

final Map<int, HitTestResult> _hitTests = <int, HitTestResult>{};

void _handlePointerEvent(PointerEvent event) {
  HitTestResult hitTestResult;
  if (event is PointerDownEvent || event is PointerSignalEvent) {
    //down事件进行hitTest
    hitTestResult = HitTestResult();
    hitTest(hitTestResult, event.position);
    if (event is PointerDownEvent) {
      // dowmn事件的话对这个hitTest集合赋值
      _hitTests[event.pointer] = hitTestResult;
    }
  } else if (event is PointerUpEvent || event is PointerCancelEvent) {
    // up事件标识这次操作已经结束,所以移除
    hitTestResult = _hitTests.remove(event.pointer);
  } else if (event.down) {
    // move事件也应该被分发在down事件初始点击的区域  比如点击了列表中的A item这个时候开始滑动,那处理这个事件的始终只是列表和A item, 只是如果滑动的话事件是由列表进行处理
    hitTestResult = _hitTests[event.pointer];
  }
  if (hitTestResult != null ||
      event is PointerHoverEvent ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    dispatchEvent(event, hitTestResult);
  }
}

调用最终走到_handlePointerEvent(PointerEvent event),因为点击事件肯定是从Down事件开始,在PointerDownEvent的流程中先声明了一个HitTestResult()对象,之后调用 hitTest(hitTestResult, event.position)

2.2 HitTest收集响应控件与分发

///renderview:负责绘制的root节点
RenderView get renderView => _pipelineOwner.rootNode;
///绘制树的owner,负责绘制,布局,合成
PipelineOwner get pipelineOwner => _pipelineOwner;
@override
void hitTest(HitTestResult result, Offset position) {
  assert(renderView != null);
  renderView.hitTest(result, position: position);
  super.hitTest(result, position);
  =>
  GestureBinding#hitTest(HitTestResult result, Offset position) {
    result.add(HitTestEntry(this));
  }
}

这个hitTest方法被RendererBinding重写,里面调用了renderView的hitTest(result, position: position)renderView是绘制树的根节点,是所有Widget的祖先

RenderView#hitTest(BoxHitTestResult result, { @required Offset position })

  bool hitTest(HitTestResult result, { Offset position }) {
    if (child != null)
      (RenderBox)child.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

RenderBox#hitTest(BoxHitTestResult result, { @required Offset position })

///作用:给出指定position的所有绘制控件
///返回true,当这个控件或者他的子控件位于给定的position的时候,添加这个绘制的对象到给定的hitResult中 这样标志当前的控件已经吸收了这个点击事件,其他控件不响应
///返回false,表示这个事件交给在当前对象之后的控件处理,
///例如一个row里面,多个区域可以响应点击,只要如果第一块能响应点击的话,那后续就不用判断是否能响应了
///调用方需要将全局的坐标转换为RenderBox关联的坐标,Renderbox负责判断这个坐标是否包含在当前的范围里
///这个方法依赖于最新的layout而不是paint,因为判断区域只要布局即可 
bool hitTest(BoxHitTestResult result, { @required Offset position }) {
  if (_size.contains(position)) {
    // 对于每一个child调用自己的 hitTest,所以布局最深的子wiget放在最开始
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

而RenderView的hitTest(BoxHitTestResult result, { @required Offset position })最终调用RenderBox的hitTest(BoxHitTestResult result, { @required Offset position })hitTestChildrenhitTestSelf是两个抽象方法(因为Widget可能有一个child或者多个child),查看具体实现发现其实逻辑和这儿差不多,也是先判断自己是否在这次点击的Postion范围内,然后递归调用子Widget的hitTest。观察这个方法结构,我们知道,如果一个Widget越深,则越先被添加进HitTestResult中。这样的流程执行下来,HitTestResult就得到了这次点击事件坐标上所有能响应的控件集合。需要注意,GestureBinding中最后把自己添加到Result的结尾

//GestureBinding中最后把自己添加到result中
@override // from HitTestable
void hitTest(HitTestResult result, Offset position) {
  result.add(HitTestEntry(this));
}

在这里插入图片描述
GestureBinding#void dispatchEvent(PointerEvent event, HitTestResult hitTestResult)

_handlePointerEvent() 
if (hitTestResult != null ||
      event is PointerHoverEvent ||
      event is PointerAddedEvent ||
      event is PointerRemovedEvent) {
    dispatchEvent(event, hitTestResult);
  }
/// Dispatch an event to a hit test result's path.
/// 分发hit事件到每一个可以响应的widget中
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
  ///循环调用每个空间的handleEvent
  for (HitTestEntry entry in hitTestResult.path) {
    try {
      entry.target.handleEvent(event.transformed(entry.transform), entry);
    } 
  }
}

回到_handlePointerEvent中,最后在hitTestResult不为空的情况下,进行事件分发 dispatchEvent(event, hitTestResult)。循环调用集合中每一个对象的handleEvent(event.transformed(entry.transform), entry)方法,但并不是所有的控件的都会处理 handleEvent ,大部分时候只有RenderPointerListener会处理。查询引用关系,他被嵌套在RawGestureDetector中。事实上Flutter中几乎所有的手势处理都是这个类的包装(如图,InkWell的结构,在最里层返回的是一个RenderPointerListener),handleEvent会根据不同的事件类型,回调到RawGestureDetector的相关手势处理中。
结论:在Down事件的时候,hitTest根据点击的position通过rendview获取一个可以响应事件的object集合,并且在集合最后 GestureBinding将自己添加到队尾。之后通过dispatchEvent 事件进行分发,但并不是所有的控件的 RenderObject 子类都会处理 handleEvent ,大部分时候,RenderPointerListener 处理 handleEvent 事件,这个控件被嵌套在RawGestureDetector中,handleEvent会根据不同的事件类型,回调到RawGestureDetector的相关手势处理。这里有个问题,可以看出每一个RawGestureDetector都能handleEvent,那如果点击区域里有多个RawGestureDetector控件,那这次的点击究竟应该由谁响应?

2.3 手势竞争

手势处理的基本概念:

  • GestureRecognizer :手势识别器基类,基本上 RenderPointerListener中需要处理的手势事件,都会分发到它对应的 GestureRecognizer,并经过它处理和竞技后再分发出去,常见有 :OneSequenceGestureRecognizerMultiTapGestureRecognizerVerticalDragGestureRecognizerTapGestureRecognizer 等等。
  • GestureArenaManagerr :手势竞技管理,它管理了整个“战争”的过程,原则上竞技胜出的条件是 :第一个竞技获胜的成员或最后一个不被拒绝的成员。
  • GestureArenaEntry :提供手势事件竞技信息的实体,内封装参与事件竞技的成员。
  • GestureArenaMember:参与竞技的成员抽象对象,内部有 acceptGesture 和 rejectGesture方法,它代表手势竞技的成员,默认 GestureRecognizer 都实现了它,所有竞技的成员可以理解为就是GestureRecognizer 之间的竞争。
  • _GestureArena:GestureArenaManager 内的竞技场,内部持参与竞技的 members 列表,官方对这个竞技场的解释是:如果一个手势试图在竞技场开放时(isOpen=true)获胜,它将成为一个带有“渴望获胜”的属性的对象。当竞技场关闭(isOpen=false)时,竞技场将寻找一个“渴望获胜”的对象成为新的参与者,如果这时候刚好只有一个,那这一个参与者将成为这次竞技场胜利的存在

通俗的理解,当有多个控件可响应事件时,这些GestureRecognizer会作为一个个GestureArenaMember被放置到_GestureArena中进行竞争。


上面我们提到了dispatchEvent(event, hitTestResult)中,会依次调用每一个参与者的handleEvent这个方法依据不同的事件类型触发RawGestureDetector中的不同处理。一次点击从Down事件开始,Up事件结束,依次HitResult中的对应回调。
RenderPointerListener#handleEvent(PointerEvent event, HitTestEntry entry)
在这里插入图片描述
根据图中调用时序可知,HitTestResult中的每一个RawGestureDetector会将自己添加到GestureArenaManager中。不要忘了GestureBinding被添加在HitTestResult的最后。
GestureBinding#handleEvent(PointerEvent event, HitTestEntry entry)

@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
 /// 导航事件去触发GestureRecognizer的handleEvent
 /// 一般 PointerDownEvent 在 route 执行中不怎么处理。
 ///gestureArena 就是 GestureArenaManager
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
  	/// 关闭竞技场
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
  	/// 清理竞技场选出一个胜利者
    gestureArena.sweep(event.pointer);
  } else if (event is PointerSignalEvent) {
    pointerSignalResolver.resolve(event);
  }
}

GestureBinding对于PointerDownEvent事件去 gestureArena.close(event.pointer),PointerUpEvent事件gestureArena.sweep(event.pointer)
GestureArenaManager#void close(int pointer)

/// Prevents new members from entering the arena.
/// 阻止新成员进入竞技
/// Called after the framework has finished dispatching the pointer down event.
/// 在完成事件分发后调用
void close(int pointer) {
	/// 拿到上面 addPointer 时添加的成员封装
  final _GestureArena state = _arenas[pointer];
  ///关闭竞技场
  state.isOpen = false;
	///尝试打一架
  _tryToResolveArena(pointer, state);
}

GestureArenaManager# _tryToResolveArena(int pointer, _GestureArena state)

void _tryToResolveArena(int pointer, _GestureArena state) {
  if (state.members.length == 1) {
 		///只有一个竞技成员的话,直接交给他处理
    scheduleMicrotask(() => _resolveByDefault(pointer, state));
  } else if (state.members.isEmpty) {
    _arenas.remove(pointer);
  } else if (state.eagerWinner != null) {
    _resolveInFavorOf(pointer, state, state.eagerWinner);
  }
}

查跟踪上面的流程可以得知,down事件最后会驱动竞技场的关闭(因为此时参加手势竞争的控件已经确定了)。如果只有一个控件响应手势的情况下这个控件直接获得胜利,触发它的acceptGesture流程。如果控件区域内存在多个 TapGestureRecognizer ,那么在 PointerDownEvent 流程是不会产生胜利者的。以单单击为例,整个过程不会产生Move。到了 UP 事件时,就会执行 gestureArena.sweep(event.pointer) 强行选取一个。


GestureArenaManager#sweep(int pointer)

/// Forces resolution of the arena, giving the win to the first member.
/// 迫使竞技场得出一个决胜者
/// Sweep is typically after all the other processing for a [PointerUpEvent]
/// have taken place. It ensures that multiple passive gestures do not cause a
/// stalemate that prevents the user from interacting with the app.
/// sweep通常是在[PointerUpEvent]发生之后。它确保了竞争不会造成卡顿,从而阻止用户与应用程序交互。
/// See also:
void sweep(int pointer) {
  ///获取竞争的对象
  final _GestureArena state = _arenas[pointer];
  if (state.isHeld) {
    state.hasPendingSweep = true;
    return; // This arena is being held for a long-lived member.
  }
  _arenas.remove(pointer);
  if (state.members.isNotEmpty) {
    // First member wins.
    ///第一个竞争者获取胜利
    state.members.first.acceptGesture(pointer);
    // Give all the other members the bad news.
    for (int i = 1; i < state.members.length; i++)
      ///后续所有的竞争者拒绝接受手势
      state.members[i].rejectGesture(pointer);
  }
}

sweep中流程很简单,就是让竞争者中的第一位直接获取胜利,其他的拒绝响应。而竞争者中的第一个,就是就是Widget树中,最深的手势响应者。

2.4 响应点击

BaseTapGestureRecognizer#acceptGesture(int pointer)

@override
void acceptGesture(int pointer) {
  ///标志自己已经获得了手势的竞争
  super.acceptGesture(pointer);
  if (pointer == primaryPointer) {
    _checkDown();
    _wonArenaForPrimaryPointer = true;
    _checkUp();
  }
}
void _checkDown() {
   ///如果已经处理过了,就不会再次处理!!
   if (_sentTapDown) {
     return;
   }
   ///交给子控件处理down事件
   handleTapDown(down: _down);
   _sentTapDown = true;
}

BaseTapGestureRecognizer#_checkUp()

void _checkUp() {
  ///_up为空或者不是手势竞争的胜利者,则直接返回
  if (!_wonArenaForPrimaryPointer || _up == null) {
    return;
  }
  handleTapUp(down: _down, up: _up);
  _reset();
}

这里如果_up事件为空的话也不会执行 handleTapUp(),这个_up的会在PointUpEvent的时候调用handlePrimaryPointer赋值,所以即使当竞技场,只有一个竞技者的时候在Down事件也不会被识别为一个完整的点击动作。
TapGestureRecognizer#handleTapUp({PointerDownEvent down, PointerUpEvent up})

void handleTapUp({PointerDownEvent down, PointerUpEvent up}) {
  final TapUpDetails details = TapUpDetails(
    globalPosition: up.position,
    localPosition: up.localPosition,
  );
  switch (down.buttons) {
    case kPrimaryButton:
      if (onTapUp != null)
        invokeCallback<void>('onTapUp', () => onTapUp(details));
      if (onTap != null)
        invokeCallback<void>('onTap', onTap);
      break;
    case kSecondaryButton:
      if (onSecondaryTapUp != null)
        invokeCallback<void>('onSecondaryTapUp',
          () => onSecondaryTapUp(details));
      break;
    default:
  }
}

最后在handleTapUp中先执行onTapUp,后执行onTap,完成一次点击事件的识别。
以一次点击事件来看,Flutter的事件分发流程为:
1、事件从Native层通过C++传递到Dart层,通过映射为逻辑像素后在GestureBinding中进行处理

2、无论什么手势一定是从Down事件开始,在Down阶段,HitTest从负责绘制树的根节点开始,递归将可以响应事件的控件添加至HitTestResult中,GesureBinding将自己添加到列表最后,对result中的每一个对象进行事件分发。

3、并非所有控件都会handleEvent ,主要是RawGestureDetector会进行处理。在Down事件中,所有的竞争者被添加到_GestureArena进行竞争,最后回到GestureBinding关闭竞技场。这时如果区域中只有一个RawGestureDetector,则在Down事件阶段这个控件直接获得胜利,进行acceptGesture,但这时并不会触发onTap,等到Up事件之后,触发onTapUp,后执行onTap

4、如果区域内有多个RawGestureDetector,在Down 事件时竞技场 close 不会竞出胜利者。Up 事件的时候,竞技场 sweep 选取排在第一个位置的为胜利者进行acceptGesture

这个过程只是以点击为例,例如滑动,肯定在Move阶段就产生了胜利者,不会等到Up事件。文章只是提供一个全流程和大家一起分析Flutter的事件分发机制~这是一切手势交互的基础流程。手势总结下来无非几种单击,双击,长按,滑动。拆解下来,可能是在down,up,或者move事件中做了特殊判断。熟悉了整体流程,在解决问题的时候就不至于无从下手了。

3 Flutter滑动原理

在这里插入图片描述
         如图,整个屏幕被手势检测out所包裹,其中有一部分是一个ListView,对于其中的每一个Item都有点击事件。

3.1 Down事件阶段

GestureBinding 会从调用 hitTest 方法,从负责绘制的树根节点出发,递归收集所有区域范围内包含这个点击坐标的 view 得到一个 hitresult 集合,在树层级越深的view会被位置越靠前。在有了这个集合之后,遍历调用每一个对象的handleEvent,一般回调到 RawGestureDetector 中,会将自己添加到竞技声中。遍历完后如果竞技场只有一个参与者,则这个参与者直接获得胜利;如果有多个参与者,则暂时不会决出胜负。

所以Case1中的out控件在down事件时竞争成功,并且回调onTapDown方法,但这是还不能认为onTap;而Case2中,参与竞争的有三个对象:out、listView、ges2,所以在down事件时无法选出胜者

如果有多个手势识别类,在down事件阶段是无法判断这是一次什么样的行为,可能是点击(后续Up事件),可能是滑动(后续Move事件),所以Down事件阶段主要作用是收集手势竞争对象。

注意:handleEvent只是将自己添加到路由,并不会回调GestureDetector中的onTapDown事件。除非这个GestureDetector在最后获得竞争胜利,则可以回调。
在这里插入图片描述

在这里插入图片描述

3.2 Up事件阶段

Up事件会调用GestureArenaManager.sweep ,如果没有已经胜利的竞争者,则在这个阶段会强行选择hitresult中的第一位的竞争者为胜利,并且回调acceptGesture回调onTapUp和OnTap方法。

总结:Case 1和Case 2都是点击响应,但区别在于Case 1中只有一个竞争者,所以在Down事件阶段已经竞争成功,但不会马上回调onTap还需等待Up事件。而Case 2中,由于有三个竞争者(out、listView、ges2)所以在Down事件阶段没有决出胜者,在Up事件阶段ges2作为hitTestResult的第一位元素响应了点击。

3.3 ListView上的滑动事件

一次滑动可以看做由Down事件+第一个Move事件+后续Move事件驱动。
Down事件阶段

和case2类似,因为手指在ges4即列表的item上滑动,所以ges4对象也会在hitResult的第一位,但这时由于有多个竞争者,所以无法决定胜利者。在这里插入图片描述
第一个Move事件阶段
Move事件从Native侧传递到Flutter,先通过GestureBinding处理。在GestureBinding._handlePointerEvent方法中,hitTestResult不为空,所以也会将Move事件分发给每一个对象处理
GestureBinding.class

if (hitTestResult != null ||
    event is PointerHoverEvent ||
    event is PointerAddedEvent ||
    event is PointerRemovedEvent) {
  //分发事件给每一个RawGestureDetector对象
  dispatchEvent(event, hitTestResult);
}

Scrollable中嵌套了RawGestureDetector,绑定了VerticalDragGestureRecognizer或者HorizontalDragGestureRecognizer用来收集垂直或水平方向的滑动信息。所以Move事件也会分发到这里面进行处理,查看这部分源码
DragGestureRecognizer.class

@override
void handleEvent(PointerEvent event) {
///············///
  if (event is PointerMoveEvent) {
    if (event.buttons != _initialButtons) {
      _giveUpPointer(event.pointer);
      return;
    }
    if (_state == _DragState.accepted) {
      //move事件第一次还没有到accepted状态
      _checkUpdate(
        sourceTimeStamp: event.timeStamp,
        delta: _getDeltaForDetails(event.localDelta),
        primaryDelta: _getPrimaryValueFromOffset(event.localDelta),
        globalPosition: event.position,
        localPosition: event.localPosition,
      );
    } else {
      //第一次进入走这个分支
      _pendingDragOffset += OffsetPair(local: event.localDelta, global: event.delta);
      _lastPendingEventTimestamp = event.timeStamp;
      _lastTransform = event.transform;
      final Offset movedLocally = _getDeltaForDetails(event.localDelta);
      final Matrix4 localToGlobalTransform = event.transform == null ? null : Matrix4.tryInvert(event.transform);
      _globalDistanceMoved += PointerEvent.transformDeltaViaPositions(
        transform: localToGlobalTransform,
        untransformedDelta: movedLocally,
        untransformedEndPosition: event.localPosition,
      ).distance * (_getPrimaryValueFromOffset(movedLocally) ?? 1).sign;
      //重点在这
      if (_hasSufficientGlobalDistanceToAccept)
        resolve(GestureDisposition.accepted);
    }
  }
///·········////
}

在第一次Move事件阶段,_state == _DragState.accepted肯定为false,走下面的分支发现了一个判断

/// Reject or accept a gesture recognizer.
/// This is called by calling [GestureArenaEntry.resolve] on the object returned from [add].
void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {
  final _GestureArena state = _arenas[pointer];
  if (disposition == GestureDisposition.rejected) {
    state.members.remove(member);
    member.rejectGesture(pointer);
    if (!state.isOpen)
      _tryToResolveArena(pointer, state);
  } else {
    if (state.isOpen) {
      state.eagerWinner ??= member;
    } else {
      //在move阶段竞技场已经关闭
      _resolveInFavorOf(pointer, state, member);
    }
  }
}

由于Down事件最后竞技场已关闭,走到_resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member)

void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {
  _arenas.remove(pointer);
  for (GestureArenaMember rejectedMember in state.members) {
    if (rejectedMember != member)
      rejectedMember.rejectGesture(pointer);
  }
  member.acceptGesture(pointer);
}

源码很直观,即将当前滑动手势竞争者调用acceptGesture,其他的竞争者rejectGesture回到滑动竞争者DragGestureRecognizer中看acceptGesture
DragGestureRecognizer.class

@override
void acceptGesture(int pointer) {
  if (_state != _DragState.accepted) {
    _state = _DragState.accepted;
    final OffsetPair delta = _pendingDragOffset;
    final Duration timestamp = _lastPendingEventTimestamp;
    final Matrix4 transform = _lastTransform;
    Offset localUpdateDelta;
    switch (dragStartBehavior) {
      case DragStartBehavior.start:
        _initialPosition = _initialPosition + delta;
        localUpdateDelta = Offset.zero;
        break;
      case DragStartBehavior.down:
        localUpdateDelta = _getDeltaForDetails(delta.local);
        break;
    }
    _pendingDragOffset = OffsetPair.zero;
    _lastPendingEventTimestamp = null;
    _lastTransform = null;
    //主流程
    _checkStart(timestamp);
    ///····················///
  }
}

主流程上调用 _checkStart(timestamp)
DragGestureRecognizer.class

void _checkStart(Duration timestamp) {
  //封装了一个DragStartDetails对象
    final DragStartDetails details = DragStartDetails(
      sourceTimeStamp: timestamp,
      globalPosition: _initialPosition.global,
      localPosition: _initialPosition.local,
    );
    if (onStart != null)
      invokeCallback<void>('onStart', () => onStart(details));
  }

这里先封装了一个DragStartDetails对象,之后调用的onStart(details)是外界传入的一个变量。我们返回看Scrollable的 setCanDrag(bool canDrag)方法
Scrollable.class

@protected
void setCanDrag(bool canDrag) {
  if (!canDrag) {
    _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
  } else {
    switch (widget.axis) {
      case Axis.vertical:
        _gestureRecognizers = <Type, GestureRecognizerFactory>{
          VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
            () => VerticalDragGestureRecognizer(),
            (VerticalDragGestureRecognizer instance) {
              instance
                ..onDown = _handleDragDown
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd
                ..onCancel = _handleDragCancel
                ..minFlingDistance = _physics?.minFlingDistance
                ..minFlingVelocity = _physics?.minFlingVelocity
                ..maxFlingVelocity = _physics?.maxFlingVelocity
                ..dragStartBehavior = widget.dragStartBehavior;
            },
          ),
        };
        break;
        ///省略水平方向
    }
  }
  _lastCanDrag = canDrag;
  _lastAxisDirection = widget.axis;
  if (_gestureDetectorKey.currentState != null)
    _gestureDetectorKey.currentState.replaceGestureRecognizers(_gestureRecognizers);
}

所以这里上面回调的onStart(details)方法即Scrollable._handleDragStart
Scrollable.class

Drag _drag;
void _handleDragStart(DragStartDetails details) {
  _drag = position.drag(details, _disposeDrag);
}

这里会通过ScrollPosition(一般是ScrollPositionWithSingleContext)生成一个Drag对象。Darg对象是一个接口,widgets 库中的滚动基础结构使用它在用户滑动Scrollable的时候进行一次DragScrollActivity(Flutter的每次滑动看做一个滑动活动)。

@override
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));
  _currentDrag = drag;
  return drag;
}

底下的 beginActivity(DragScrollActivity(this, drag))就不深入看了,里面主要进行一些滑动的准备以及向上发起ScrollStartNotification通知。
Flutter在滑动中每次都会发起ScrollNotification相关通知,这是我们处理滑动冲突的一个思路
在这里插入图片描述
总结:第一个Move事件阶段,ListView中的DragGestureRecognizer在handleEvent中将自已选为这次手势的胜利者,响应后面的任何手势信息。调用_checkStart回调到Scrollable中,通过ScrollPosition生成一个Drag对象,这个对象发起了ScrollStartNotification,开发者可以通过监听这个这个通知,进行一些自定义的手势处理,下一篇会详细说明。

后续Move事件
因为在第一次Move事件的时候,ListView已经取得了手势竞争的胜利,响应所有的手势处理,查看DragGestureRecognizer.handleEvent中Move事件的处理
DragGestureRecognizer.class

@override
void handleEvent(PointerEvent event) {
  //忽略其他
  if (event is PointerMoveEvent) {
    if (event.buttons != _initialButtons) {
      _giveUpPointer(event.pointer);
      return;
    }
    if (_state == _DragState.accepted) {
      //第一次Move事件竞争成功,状态为accepted
      _checkUpdate(
        sourceTimeStamp: event.timeStamp,
        delta: _getDeltaForDetails(event.localDelta),
        primaryDelta: _getPrimaryValueFromOffset(event.localDelta),
        globalPosition: event.position,
        localPosition: event.localPosition,
      );
    } else {
      //忽略
    }
  }
  //忽略其他
}

由于第一次Move时间已经竞争成功,所以_state == _DragState.accepted为ture,执行_checkUpdate。这个_checkUpdate方法和之前的_checkStart类似回调到Scrollable_handleDragUpdate
Scrollable.class

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

调用在Start阶段生成的_dragupdate方法
Drag.class

@override
void update(DragUpdateDetails details) {
  _lastDetails = details;
  double offset = details.primaryDelta;
  if (offset != 0.0) {
    _lastNonStationaryTimestamp = 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);
}

这里会调用 delegate.applyUserOffset(offset),这是滑动的关键,这个delegate是个ScrollActivityDelegate接口,注释说明[ScrollActivity]的子类用来操作它们正在操作的滚动视图,主要实现类是ScrollPositionWithSingleContext
ScrollPositionWithSingleContext.class

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

最终调用这两个方法(解决滑动冲突的另一个思路,修改applyUserOffset方法),第一个方法updateUserScrollDirection会发起一个滑动方向的通知,第二个方法setPixels会发起一个ScrollUpdateNotification通知并且调用notifyListeners()。那谁是ScrollPositionWithSingleContext的监听者呢?
对!就是Viewport,Viewport收到这个被通知之后,根据这个偏移量渲染不同的位置完成了滚动!
同时,不光是Viewport监听ScrollPostion,查看ScrollController.attach

void attach(ScrollPosition position) {
  _positions.add(position);
  position.addListener(notifyListeners);
}

所以我们一般通过ScrollController.addListener可以收到滚动位置的回调,这个链路是ScrollPosition->ScrollController->我们添加的回调。而且根据applyUserOffset我们可知,可以通过添加NotificationListener来获取滑动位置的通知。
在这里插入图片描述
总结:后续的move事件会调用Darg.update,会向上层节点发起滚动方向、滚动偏移等通知。最后调用notifyListeners()通知Viewport更新偏移量已经我们自己添加到ScrollController中的方法
后续阶段
后续就是抬起后屏幕会有个惯性滚动的效果,可以按照上面的思路继续看看最终会回调到void goBallistic(double velocity)进行惯性滚动。
ListView滑动位置的保存
在开发过程中我们发现对于ListView控件,每次我们在滑动完成后调用setState()发现ListView还能保留在当前的位置,这是如何实现的呢。
在ScrollPostion中发现了一个bool keepScrollOffset属性,查询它的使用地方看到了didEndScroll

//滑动结束后调用
void didEndScroll() {
  activity.dispatchScrollEndNotification(copyWith(), context.notificationContext);
  if (keepScrollOffset)
    saveScrollOffset();
}

void saveScrollOffset() {
    PageStorage.of(context.storageContext)?.writeState(context.storageContext, pixels);
  }

这是滑动结束后调用的一个方法,将自己当前的offset存储起来。所以以后重新创建的时候从PageStorage取出offset即可保存当前位置了!

4 Flutter上的滑动冲突

在这里插入图片描述

4.1 Case 1:CustomScrollView嵌套ListView

在这里插入图片描述
如图,页面的第二个部分是一个滑动到顶部之后固定的TabBar,首先大家会想到CustomScrollView吧(其实NestedScrollView已经解决了嵌套冲突,不过有的时候会失效,只是我当时不知道有它),将TabBar配置到SliverPersistentHeaderdelegate属性中。

return SliverPersistentHeader(
    pinned: true,
    delegate: StickyTabBarDelegate(
        child: TabBar(
            controller:  tabController,
            tabs: tabs,

这样当滑动到顶部的时候TabBar即可吸顶,底部TabBarView的内容是一个ListView。这时如果滑动ListView以外的CustomScrollView是没问题。但如果滑动ListView,根据实战Flutter滑动原理可知,在手势竞争的时候,位于最里层的ListView先调用handleEvent处理手势事件,所以滑动事件被ListView消耗,表现出来就是只有ListView在滑动,外部的Head和Tab位置固定。

4.2 Case 2: 网易云结构 PageView嵌套TabBarView 网易云结构

在这里插入图片描述
case2类似网易云,整个页面是一个PageView,有三个Child,第二个Child是一个TabBarView。当滑动到第二个Child的时候手势被TabBarView获取处理。这时如果滑动到Tab的最后一页时,会发现整个页面无法继续滑动到第三个Page页面。原因也很简单,其实TabBarView就是对PageVIew进行了封装使其能和Tab交互,所以当滑动到最后一个页面后,TabBarView认为已经滑动到最后一页了,而所有的手势事件都已经被TabBarView处理,外面的PageView无法收到手势事件,所以外面的Page无法滑动。


Case 1:自定义ScrollPosition
Case 1的解决办法来自实战Flutter滑动原理中我们提到了对于每一个Scrollable组件真正控制滑动的是ScrollPosition,文章的核心思路是:对于Case1的滑动冲突,其实当手指滑动外面的ScrollView的时候现象是不存在冲突问题的,所以只要解决ListView的上的滑动事件即可。对于ListView我们考虑两种场景,当上滑ListView的时候,如果Head还能继续滑动则应该由外面的ScrollView处理。下滑的时候先让ListView处理,如果ListView已经滑动到顶部了,则将剩余的事件交给外面的ScrollView处理。
ScrollController上官方为我们提供了两种自定义滑动行为的建议:
To further customize scrolling behavior with a Scrollable:

You can provide a viewportBuilder to customize the child model. For example, SingleChildScrollView uses a viewport that displays a single box child whereas CustomScrollView uses a Viewport or a ShrinkWrappingViewport, both of which display a list of slivers.
You can provide a custom ScrollController that creates a custom ScrollPosition subclass. For example, PageView uses a PageController, which creates a page-oriented scroll position subclass that keeps the same page visible when the Scrollable resizes.

这里选择了第二种方式,自定义ScrollPosition

可以提供一个自定义的[ScrollController]来创建一个自定义的 [ScrollPosition]子类。例如,[PageView]使用了 [PageController],它创建了一个面向页面的滚动位置子类,该子类在[Scrollable]调整大小时保持同一页面可见。
具体做法:
在这里插入图片描述
整体解法如图,自定义三个类ConflictScrollController(滑动控制器),ConflictScrollPosition(处理滑动的实际对象),ConflictScrollCoordinator(滑动协调器)。关系:ConflictScrollController生成ConflictScrollPosition
ConflictScrollPosition->将滑动事件交给ConflictScrollCoordinator进行协调。
一些核心的处理逻辑

ConflictScrollPosition#applyUserOffset

/// 当手指滑动时,该方法会获取到滑动距离
/// [delta]滑动距离,正增量表示下滑,负增量向上滑
/// 我们需要把子部件的 滑动数据 交给协调器处理,主部件无干扰
@override
void applyUserOffset(double delta) {
  ScrollDirection userScrollDirection =
      delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse;
  if (debugLabel != coordinator.pageLabel)
    //如果是嵌套内部滑动控件,则通过协调器处理
    return coordinator.applyUserOffset(delta, userScrollDirection, this);
  //否则使用自身的行为处理
  updateUserScrollDirection(userScrollDirection);
  setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
}

在实战Flutter滑动原理中提到,在确定一个滑动控件响应滑动之后,最终会调到ScrollPosition#applyUserOffset,这里先判断是不是嵌套内部的ListView,如果是交给协调器处理,否则自身响应。
ConflictScrollCoordinator#applyUserOffset

/// 子部件滑动数据协调
/// [userScrollDirection]用户滑动方向
/// [position]被滑动的子部件的位置信息
void applyUserOffset(double delta,
    [ScrollDirection userScrollDirection, ConflictScrollPosition position]) {
  if (userScrollDirection == ScrollDirection.reverse) {
    //如果是上滑先交给外部控制器消费,有剩余再自己消费
    updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
    final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta);
    if (innerDelta != 0.0) {
      updateUserScrollDirection(position, userScrollDirection);
      position.applyFullDragUpdate(innerDelta);
    }
  } else {
    updateUserScrollDirection(position, userScrollDirection);
    //否则先自己处理,有剩余在交给外部
    final outerDelta = position.applyClampedDragUpdate(delta);
    if (outerDelta != 0.0) {
      updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
      _pageScrollPosition.applyFullDragUpdate(outerDelta);
    }
  }
}

协调器中先判断滑动的方向如果是上滑先交给外部控制器消费,有剩余再自己消费,否则先自己处理,有剩余在交给外部。

当然不光要处理applyUserOffset,还有goBallistic


4.3 Case 2:监听ScrollNotification

case2的解决的思路是:当滑动到TabBarView的最后一页(无论是左还是右的时候)系统会发出OverscrollNotification的通知。那么我们在外部监听,当收到这个通知的时候将滑动事件交给外部处理即可。

NotificationListener(
            onNotification: (notification) {
              if (notification is ScrollStartNotification) {
              	//滑动起始的通知先存起来
                dragStartDetails = notification.dragDetails;
              }
              if (notification is OverscrollNotification) {
              	//当发生OverScroll的时候,生成外部滑动的drag对象
                drag = _pageController.position.drag(dragStartDetails, () {});
                //使用外部滑动的drag对象进行滑动
                drag.update(notification.dragDetails);
              }
              if (notification is ScrollEndNotification) {
              	//滑动结束后取消
                drag?.cancel();
              }
              return true;
            },
            child: TabBarView(
              controller: _tabController,
              children: <Widget>[
                Container(color: Colors.green[800]),
                Container(color: Colors.green),
                Container(color: Colors.green[200]),
              ],
            ),
          )

不过这个按照下面的做法可以使滑动过程更加的流畅

if (notification is UserScrollNotification &&
        notification.direction == ScrollDirection.forward &&
        !_tabController.indexIsChanging &&
        dragStartDetails != null &&
        _tabController.index == 0) {
      _pageController.position.drag(dragStartDetails, () {});
    }

    // Simialrly Handle the last tab.
    if (notification is UserScrollNotification &&
        notification.direction == ScrollDirection.reverse &&
        !_tabController.indexIsChanging &&
        dragStartDetails != null &&
        _tabController.index == _tabController.length - 1) {
      _pageController.position.drag(dragStartDetails, () {});
    }

结语
阅读源码是一个逐渐明朗的过程。对于一个装置如果不了解他的原理,就会把他想象的很复杂,但一层层解开后会发现其实整个过程没那么复杂,遇到问题也能从里面找到解决的思路。但源码的阅读切忌以小失大,先有一条主线索,看整个流程,之后再去把握细节,就不会陷入源码无法得其道了。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐