深入进阶- Flutter是如何实现列表滑动的

5,190 阅读9分钟

深入进阶-实战Flutter滑动原理

导语

一次需求中遇到了这样的场景,PageView中有三个页面,其中一个页面是TabBarView结构。结果出现了当滑动到TabBar的时候,外层PageView无法滑动(滑动冲突)。最终在stackoverflow上找到了这个问题的解法,过程中顺便将Flutter的手势与滑动机制总结了一番。这也是Flutter进阶必须掌握的一个知识点,相信我,这一定是全网最详细,易懂的总结!!

这个系列会分为四篇:

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

2、一张图理清Flutter的滑动原理

3、实战Flutter滑动原理

4、解决Flutter滑动冲突的两种思路

读完本文你将收获:从手指按下屏幕开始,Flutter是如何处理事件冲突以及让列表滑动起来的。


引言

前两篇文章中,我们解析了Flutter的事件分发机制以及Scrollable的嵌套结构,从整体上对于事件响应和滑动原理有了认识,这期文章,我会从以下三个常见的例子深入源码和大家一起学习Flutter的滑动原理以及提供几种解决滑动冲突的思路。

滑动原理3

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


Case 1&2:手指的点击事件

Case1和我们Case2与第一篇文章的场景一样,就是一次简单的点击事件,可以回看一期的分析从一次点击探寻Flutter的事件分发原理。我们简单的总结一下:一次点击分为两个事件Down事件和Up事件。

Down事件阶段

GestureBindg会从调用hitTest方法,从负责绘制的树根节点出发,递归收集所有区域范围内包含这个点击坐标的view得到一个hitresult集合,在树层级层级越深的view会被位置越靠前。在有了这个集合之后,遍历调用每一个对象的handleEvent,一般回调到RawGestureDetector中,会将自己添加到竞技场中。遍历完后如果竞技场只有一个参与者,则这个参与者直接获得胜利;如果有多个参与者,则暂时不会决出胜负。所以Case1中的out控件在down事件时竞争成功,并且回调onTapDown方法,但这是还不能认为onTap;而Case2中,参与竞争的有三个对象:out、listView、ges2,所以在down事件时无法选出胜者

这样的设计也好理解,只有一个手势识别类,那肯定是交给这个对象处理手势。但如果有多个手势识别类,在down事件阶段是无法判断这是一次什么样的行为,可能是点击(后续Up事件),可能是滑动(后续Move事件),所以Down事件阶段主要作用是手机手势竞争对象。

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

Up事件阶段

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

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


Case 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);
}

在第二篇文章一张图理清Flutter的滑动原理中提到过,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,走下面的分支发现了一个判断

const double kTouchSlop = 18.0; // Logical pixels
bool get _hasSufficientGlobalDistanceToAccept => _globalDistanceMoved.abs() > kTouchSlop;

这个判断里对比了手指在屏幕上的滑动距离,如果大于18逻辑像素则认为是一次滑动,调用 resolve(GestureDisposition.accepted),这个方法即决出胜者

/// 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中的DragGestureRecognizerhandleEvent中将自已选为这次手势的胜利者,响应后面的任何手势信息。调用_checkStat回调到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滑动位置的保存

整个流程差不多分析完了!细节可以深入在看看,例如Scrollable还有对于OverScroll的处理等。这里插个题外话,在开发过程中我们发现对于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即可保存当前位置了!

最后

本想这篇一起写完滑动原理以及滑动冲突的解决思路,哪知具体写下来内容那么长,最实用的滑动冲突留到下一篇了,不过在分析原理的过程中也提到了解决滑动冲突的一些关键点。下篇根据原理实践解决两个工作中的滑动冲突!!! 最后 求个赞QAQ