深入进阶-实战Flutter滑动原理
导语
一次需求中遇到了这样的场景,PageView中有三个页面,其中一个页面是TabBarView结构。结果出现了当滑动到TabBar的时候,外层PageView无法滑动(滑动冲突)。最终在stackoverflow上找到了这个问题的解法,过程中顺便将Flutter的手势与滑动机制总结了一番。这也是Flutter进阶必须掌握的一个知识点,相信我,这一定是全网最详细,易懂的总结!!
这个系列会分为四篇:
3、实战Flutter滑动原理
读完本文你将收获:从手指按下屏幕开始,Flutter是如何处理事件冲突以及让列表滑动起来的。
引言
前两篇文章中,我们解析了Flutter的事件分发机制以及Scrollable的嵌套结构,从整体上对于事件响应和滑动原理有了认识,这期文章,我会从以下三个常见的例子深入源码和大家一起学习Flutter的滑动原理以及提供几种解决滑动冲突的思路。
如图,整个屏幕被手势检测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
回调onTapUp
和OnTap方法
。
总结: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中的
DragGestureRecognizer
在handleEvent
中将自已选为这次手势的胜利者,响应后面的任何手势信息。调用_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阶段生成的_drag
的update
方法
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