在Flutter
中,给开发者提供了点击(TapGestureRecognizer
)、长按(LongPressGestureRecognizer
)、水平滑动(HorizontalDragGestureRecognizer
)、垂直滑动(VerticalDragGestureRecognizer
)等大量手势处理API,简化了各种手势的使用,但同时也引入了手势冲突问题。为了解决这个问题,Flutter
给开发者提供了一套解决方案。在该方案中,Flutter
引入了Arena
(竞技场)概念,然后把冲突的手势加入到Arena
中并竞争,谁胜利,谁就获得手势的后续处理权。下面来看Arena
的具体实现。
为了方便,后面用竞技场来表达Arena
。
1、竞技场原理解析
在Flutter
中,竞技场的代码实现很简单。包含注释在内也不到300行,所以其原理也很简单,就是先到先得,只有前面成员拒绝,后续成员才能够获得手势处理权。下面来看一下源码实现。
enum GestureDisposition {
/// 允许获得后续手势处理权
accepted,
/// 放弃后续手势处理权
rejected,
}
/// 代表竞技场中的成员,也只有继承该类才可使手势加入竞技场来竞争
abstract class GestureArenaMember {
/// 当手势处理权获取成功时的调用
void acceptGesture(int pointer);
/// 当手势处理权获取失败时的调用
void rejectGesture(int pointer);
}
/// 将信息传递给竞技场的接口
class GestureArenaEntry {
GestureArenaEntry._(this._arena, this._pointer, this._member);
final GestureArenaManager _arena;
final int _pointer;
final GestureArenaMember _member;
/// 通知当前成员是接受还是拒绝后续手势处理权
void resolve(GestureDisposition disposition) {
_arena._resolve(_pointer, _member, disposition);
}
}
/// 手势竞技场,
class _GestureArena {
final List<GestureArenaMember> members = <GestureArenaMember>[];
bool isOpen = true;
bool isHeld = false;
bool hasPendingSweep = false;
/// 一个“eager winner”,在竞技场未关闭时会寻找一个“eager winner”,如果存在则赋值给eagerWinner。
/// 关于“eager winner”可以参考EagerGestureRecognizer中的实现。
GestureArenaMember eagerWinner;
、/// 将成员加入到竞技场中
void add(GestureArenaMember member) {
members.add(member);
}
}
/// 竞技场的管理。第一个接受或者最后一个不拒绝的成员竞争成功。
class GestureArenaManager {
final Map<int, _GestureArena> _arenas = <int, _GestureArena>{};
/// 添加一个新的成员(继承GestureArenaMember的手势识别器)加入竞技场
GestureArenaEntry add(int pointer, GestureArenaMember member) {
final _GestureArena state = _arenas.putIfAbsent(pointer, () {
return _GestureArena();
});
state.add(member);
return GestureArenaEntry._(this, pointer, member);
}
/// 关闭竞技场(不允许新的成员进入竞技场),在framework中处理完down事件后调用
void close(int pointer) {
final _GestureArena state = _arenas[pointer];
if (state == null)
return; // This arena either never existed or has been resolved.
state.isOpen = false;
_tryToResolveArena(pointer, state);
}
/// 强制解决竞技场问题,将胜利授予第一位成员。
/// 通常是在对[PointerUpEvent]进行所有其他处理之后。 它可以确保多个被动手势不会造成僵局,从而使用户无法与应用程序进行交互。
/// 如果需要将竞技场中成员竞争的时间延长到[PointerUpEvent]之后,则应该调用[hold]方法,并在[release]中再次调用当前方法
void sweep(int pointer) {
final _GestureArena state = _arenas[pointer];
if (state == null)
return; // This arena either never existed or has been resolved.
if (state.isHeld) {
state.hasPendingSweep = true;
return; // This arena is being held for a long-lived member.
}
_arenas.remove(pointer);
if (state.members.isNotEmpty) {
// 第一个成员获胜
state.members.first.acceptGesture(pointer);
// 其他成员都被拒绝
for (int i = 1; i < state.members.length; i++)
state.members[i].rejectGesture(pointer);
}
}
/// 延长竞技场的存活时间
void hold(int pointer) {
final _GestureArena state = _arenas[pointer];
if (state == null)
return; // This arena either never existed or has been resolved.
state.isHeld = true;
}
/// 如果竞技场的存活时间被延长,可通过此方法来释放。
/// 此方法一般与hold方法配对使用
void release(int pointer) {
final _GestureArena state = _arenas[pointer];
if (state == null)
return; // This arena either never existed or has been resolved.
state.isHeld = false;
if (state.hasPendingSweep)
sweep(pointer);
}
/// 竞技场成员的竞争
void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) {
final _GestureArena state = _arenas[pointer];
if (state == null)
return; // This arena has already resolved.
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 {
_resolveInFavorOf(pointer, state, member);
}
}
}
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) {
/// 如果竞技场中存在eagerWinner,则eagerWinner获得获胜
_resolveInFavorOf(pointer, state, state.eagerWinner);
}
}
void _resolveByDefault(int pointer, _GestureArena state) {
if (!_arenas.containsKey(pointer))
return; // Already resolved earlier.
final List<GestureArenaMember> members = state.members;
_arenas.remove(pointer);
/// 竞技场中第一个成员获胜
state.members.first.acceptGesture(pointer);
}
void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {
_arenas.remove(pointer);
/// 拒绝非member的成员
for (final GestureArenaMember rejectedMember in state.members) {
if (rejectedMember != member)
rejectedMember.rejectGesture(pointer);
}
member.acceptGesture(pointer);
}
}
上面代码虽不多,但非常重要,所以先来梳理一下代码中各个类之间的关系。如下图。
从图中,可以看出竞技场中的各个成员是无法直接与竞技场进行沟通的,需要GestureArenaEntry
来进行转发。
**注意:**一个GestureArenaMember可以在不同arenas中有不同的GestureArenaEntry,这些不同GestureArenaEntry是根据pointer来区分的
下面再对上图中的四个类进行一一讲解。
GestureArenaMember
是个抽象类,实现仅有两个方法。但它代表的是竞技场中的成员,也只有继承该类的手势才有资格进入竞技场竞争。在Flutter
中主要有下面这些手势继承自GestureArenaMember
。
GestureArenaEntry
相当于一个桥梁,竞技场的成员只有通过该类才能把自己是放弃还是接受的决定告诉给竞技场。
_GestureArena
虽说是竞技场的实现,但实现很简单,主要是存储当前竞技场中所有成员及当前竞技场的状态。
最后再来看GestureArenaManager
。它是一个非常重要的类,主要实现了以下功能。
- 存储所有竞技场及竞技场成员的添加及移除。
- 竞技场状态的管理。如竞技场的创建、关闭、生命周期的延长等。
- 每个竞技场中成员竞争的具体实现。
在手指按下时,会遍历能够响应PointerDownEvent
事件的所有Widget
。在遍历过程中会根据pointer
将对应的成员添加进竞技场,如果竞技场不存在,则会创建一个新的竞技场。此时竞技场时打开状态,这时候基本上(有一种例外情况)是仅允许成员的添加,而不允许逻辑的处理。当遍历完成后,竞技场就会关闭,也就不允许往竞技场添加新的成员了。所有只有在处理PointerDownEvent
事件时才能添加成员到竞技场,具体实现代码在类GestureArenaManager
的add
与close
方法中。
当竞技场关闭后,就可以根据逻辑在GestureArenaManager
中判断竞技场中的某个成员是否获胜。其判断规则很简单,就是前面说的先到先得,只有前面成员拒绝,后续成员才能够获得手势处理权。具体实现代码在类GestureArenaManager
的_resolve
方法中。
**注意:**在整个手势操作中,事件竞争仅会发生一次。也就是某个成员竞争成功后,直到PointerUpEvent
事件结束都是该成员来处理后续手势事件。
当手势操作完毕后,会触发PointerUpEvent
事件。在该事件执行完毕后会执行最后的收尾操作,也就是调用GestureArenaManager
的sweep
方法。由于竞技场成员竞争失败后会把成员移除竞技场,所以当竞技场中没有任何成员时,sweep
不会做任何操作。但如果在调用sweep
方法时,竞技场中还存在成员,这时候需要把这些成员处理掉,从而避免影响后面的手势处理。这里的处理规则是直接让第一个成员获胜并拒绝其他成员。
根据以上内容,可以把一个完整手势冲突处理流程总结如下。
- 在处理
PointerDownEvent
事件时,竞技场是打开状态,可以向其中添加成员。当PointerDownEvent
事件处理完毕后,竞技场就变成关闭状态,那么从此时到PointerUpEvent
事件的处理完毕都无法向竞技场中添加成员。 - 当触发
PointerDownEvent
事件的下一个事件时(比如PointerMoveEvent
),竞技场中成员会进行竞争,其竞争规则是先到先得,只有前面成员拒绝,后续成员才能够获得手势处理权 - 当
PointerUpEvent
事件执行完毕后,会进行收尾操作。如果此时竞技场中还存在成员,那么将根据直接让第一个成员获胜并拒绝其他成员的规则来处理掉竞技场中的所有成员。
以上就是Flutter
中手势处理的正常处理流程。之所以是正常处理流程,是因为还有两种意外情况。
1.1、手势插队
在上面说过,在竞技场处于打开状态时,仅能向竞技场中添加成员,而无法做其他操作。但竞技场中的成员eagerWinner
却是在竞技场处于打开状态下设置的,所以来看eagerWinner
的使用。
eagerWinner
是在_tryToResolveArena
方法中且此时竞技场中成员大于1时才会被使用。而_tryToResolveArena
又在close
与_resolve
中被调用,所以就存在下面两种情况。
- 当竞技场关闭时,如果竞技场中存在
eagerWinner
且竞技场中成员大于1,那么成员eagerWinner
直接获胜,无论该成员是在什么时候添加进竞技场的。 - 再来看
_resolve
与方法_tryToResolveArena
方法,当满足竞技场被关闭、竞技场成员大于3且第一个成员拒绝时,eagerWinner
将直接获胜,无论eagerWinner
是在什么时候添加进竞技场的。
根据上面两种情况,就可以推断出eagerWinner
的作用就是插队,它可以使后加入进竞技场的成员不用等待前面成员的拒绝而直接获胜。
关于eagerWinner
的应用场景可以参考EagerGestureRecognizer
的使用。代码如下。
class EagerGestureRecognizer extends OneSequenceGestureRecognizer {
/// Create an eager gesture recognizer.
///
/// {@macro flutter.gestures.gestureRecognizer.kind}
EagerGestureRecognizer({ PointerDeviceKind kind }) : super(kind: kind);
@override
void addAllowedPointer(PointerDownEvent event) {
//将OneSequenceGestureRecognizer加入竞技场
startTrackingPointer(event.pointer, event.transform);
//将OneSequenceGestureRecognizer设置为eagerWinner
resolve(GestureDisposition.accepted);
//关闭竞技场
stopTrackingPointer(event.pointer);
}
@override
String get debugDescription => 'eager';
@override
void didStopTrackingLastPointer(int pointer) { }
@override
void handleEvent(PointerEvent event) { }
}
1.2、竞技场生命周期的延长
在Flutter
中,两次点击间隔的时间在40ms-300ms之间,则可说明当前触发了双击手势。而为了判断当前是单击还是双击,那么就需要把这两种手势加入到竞技场并竞争。由于在PointerUpEvent
事件执行完毕后会处理掉竞技场中还存在的成员,所以在正常情况下双击永远都无法获胜,也就无法响应双击事件。
那么如何在满足双击条件时触发双击事件尼?Flutter
给出了解决方案,就是依赖于GestureArenaManager
中的hold
方法与release
方法。
先来看竞技场中有两个属性
isHeld
与hasPendingSweep
。这两个属性是配对使用,当调用GestureArenaManager
的hold
方法时将会将isHeld
设置为true,这样在PointerUpEvent
事件执行完毕并调用sweep
方法时不会清除竞技场中的成员,此时在sweep
方法中也会将hasPendingSweep
设置为true。这也就说明当前竞技场还有用,再等等看看。当调用GestureArenaManager
的release
方法时则会将isHeld
重新设置为false并调用sweep
方法来根据规则清除竞技场的所有成员,此时当前竞技场已经无用了。这样就延长了当前竞技场的生命周期。
再来看单击与双击冲突的问题,就很好解决了。当第一次点击时,双击手势的处理中就调用hold
方法来延长竞技场的生命周期,如果满足双击条件则让竞技场中的双击事件获胜并调用release
方法来清除竞技场成员;如果不满足,则单击事件获胜并调用release
方法来清除竞技场成员。
下面来看Flutter
中双击手势的部分实现代码。
class DoubleTapGestureRecognizer extends GestureRecognizer {
//响应PointerEvent事件
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer];
if (event is PointerUpEvent) {//当响应PointerUpEvent事件时
if (_firstTap == null)//是否存在第一次点击
_registerFirstTap(tracker);
else//满足双击条件
_registerSecondTap(tracker);
} else if (event is PointerMoveEvent) {
//判断是否是滑动,如果是滑动,则调用_reject
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
_reject(tracker);//
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
void _reject(_TapTracker tracker) {
_trackers.remove(tracker.pointer);
//_TapTracker在竞技场中竞争失败
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
// If the first tap is in progress, and we've run out of taps to track,
// reset won't have any work to do. But if we're in the second tap, we need
// to clear intermediate state.
if (_firstTap != null &&
(_trackers.isEmpty || tracker == _firstTap))
_reset();
}
void _reset() {
//停止timer
_stopDoubleTapTimer();
if (_firstTap != null) {
// Note, order is important below in order for the resolve -> reject logic
// to work properly.
final _TapTracker tracker = _firstTap;
_firstTap = null;
_reject(tracker);
//释放竞技场
GestureBinding.instance.gestureArena.release(tracker.pointer);
}
_clearTrackers();
}
//记录双击中的第一次点击
void _registerFirstTap(_TapTracker tracker) {
_startDoubleTapTimer();
//延长竞技场的生命周期
GestureBinding.instance.gestureArena.hold(tracker.pointer);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_clearTrackers();
_firstTap = tracker;
}
//记录双击中的第二次点击,此时双击手势在竞技场竞争成功
void _registerSecondTap(_TapTracker tracker) {
_firstTap.entry.resolve(GestureDisposition.accepted);
tracker.entry.resolve(GestureDisposition.accepted);
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_checkUp(tracker.initialButtons);
_reset();
}
//使用Timer来判断第二次点击是否在间隔300ms内,如果超过300ms则调用_reset方法
void _startDoubleTapTimer() {
_doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
}
//停止timer
void _stopDoubleTapTimer() {
if (_doubleTapTimer != null) {
_doubleTapTimer.cancel();
_doubleTapTimer = null;
}
}
}
2、GestureArenaTeam原理解析
在阅读竞技场相关源码时,发现了一个有意思的类GestureArenaTeam
。它的实现代码很简单,如下。
class GestureArenaTeam {
final Map<int, _CombiningGestureArenaMember> _combiners = <int, _CombiningGestureArenaMember>{};
//队长。如果captain不为null,则当[GestureArenaTeam]成员中的任何一个赢得胜利时,captain都会接受该手势。 如果为null,则要求胜利的成员接受该手势。
GestureArenaMember captain;
//向团队中添加新成员
GestureArenaEntry add(int pointer, GestureArenaMember member) {
final _CombiningGestureArenaMember combiner = _combiners.putIfAbsent(
pointer, () => _CombiningGestureArenaMember(this, pointer));
return combiner._add(pointer, member);
}
}
顾名思义,该类就是把多个手势打包成一个team
,如果captain
不为null,则captain
就是该团队的领导。在team
中,相同pointer
的手势都会添加到_CombiningGestureArenaMember
中。由于_CombiningGestureArenaMember
继承自GestureArenaMember
,所以也就是_CombiningGestureArenaMember
来代表其中的所有成员去竞技场中竞争。
为了方便管理,_CombiningGestureArenaMember
对于其中成员也有一套属于自己的处理规则,来看代码实现。
class _CombiningGestureArenaMember extends GestureArenaMember {
_CombiningGestureArenaMember(this._owner, this._pointer);
final GestureArenaTeam _owner;
//相关手势
final List<GestureArenaMember> _members = <GestureArenaMember>[];
final int _pointer;
bool _resolved = false;
//胜利者
GestureArenaMember _winner;
GestureArenaEntry _entry;
//_CombiningGestureArenaMember对象竞争成功时调用
@override
void acceptGesture(int pointer) {
_close();
//如果_winner为null,则captain获胜,如果captain为null,则第一个成员获胜
_winner ??= _owner.captain ?? _members[0];
for (final GestureArenaMember member in _members) {
if (member != _winner)
member.rejectGesture(pointer);
}
//由_winner继续处理后续事件
_winner.acceptGesture(pointer);
}
//_CombiningGestureArenaMember对象竞争失败时调用
@override
void rejectGesture(int pointer) {
_close();
//所有成员都将竞争失败
for (final GestureArenaMember member in _members)
member.rejectGesture(pointer);
}
void _close() {
_resolved = true;
//从team中移除当前对象
final _CombiningGestureArenaMember combiner = _owner._combiners.remove(_pointer);
}
GestureArenaEntry _add(int pointer, GestureArenaMember member) {
_members.add(member);
//将_CombiningGestureArenaMember添加到竞技场
_entry ??= GestureBinding.instance.gestureArena.add(pointer, this);
return _CombiningGestureArenaEntry(this, member);
}
void _resolve(GestureArenaMember member, GestureDisposition disposition) {
if (_resolved)
return;
if (disposition == GestureDisposition.rejected) {
_members.remove(member);
member.rejectGesture(_pointer);
if (_members.isEmpty)
_entry.resolve(disposition);
} else {
//如果_winner为null则captain获胜,否则member获胜
_winner ??= _owner.captain ?? member;
_entry.resolve(disposition);
}
}
}
由于_CombiningGestureArenaMember
也是竞技场中一员,所以它也与其他成员一样在竞技场中竞争。如果其他成员竞争成功,_CombiningGestureArenaMember
也就被抛弃,其中的所有成员也就竞争失败并被抛弃,具体实现在上面代码的rejectGesture
方法中;反之则根据有队长(captain
)则队长竞争成功,否则第一个成员竞争成功的规则来处理_CombiningGestureArenaMember
中的成员。
如果_CombiningGestureArenaMember
还在竞技场中,但还未决出胜负。那么_CombiningGestureArenaMember
中的各个成员可以通过调用_resolve
方法来拒绝并移除某个成员或者使某个成员获胜(没有队长的情况下,如果存在队长还是队长获胜)。
例如当_CombiningGestureArenaMember
中存在成员EagerGestureRecognizer
时,竞技场关闭后,EagerGestureRecognizer
就会立即调用_resolve
方法来得到_winner
。也可以把EagerGestureRecognizer
中调用_resolve
方法时的第二个参数修改为GestureDisposition.rejected
,这样会在竞技场关闭时,调用_resolve
方法来把EagerGestureRecognizer
从_CombiningGestureArenaMember
的成员中移除。
3、iOS设备上WKWebView与侧滑冲突
上面阐述了竞技场的实现原理,那么现在就来看一个案例。
在Flutter
中,一些时候需要加载一些网页,这时候就可以使用谷歌提供的webview_flutter插件来实现,毕竟该插件是Google出品,还是有保障的。
在使用时,会发现在iOS设备中存在一个问题。就是当在WKWebView
内部跳转后,侧滑返回的不是上一个html页面,而是关闭了当前WKWebView
页面。如下动画所示。
那么该如何解决这一问题尼?熟悉WKWebView
的都知道,该控件是iOS的原生控件,在设置allowsBackForwardNavigationGestures
为true后,当WKWebView
内部跳转后,侧滑就会返回上一个html页面。但在Flutter
中却是触发了侧滑关闭当前页面,所以首先就想到滑动冲突问题。再通过禁止Flutter
的页面侧滑返回来验证一下该问题,当在WKWebView
内部跳转后,侧滑如预期一样返回了上一个html页面。所以该问题就是因为滑动冲突导致而产生的。
问题原因找到了,再来看解决方案。很简单,就是根据WKWebView
的canGoBack
来判断把手势交给谁处理,如果canGoBack
为true,就交给WKWebView
来处理,此时侧滑返回上一个html页面;否则就给Flutter
来处理,此时关闭当前Flutter
页面。
问题找到了,解决方案也有了,就来开始实现。由于WKWebView
内部跳转后侧滑返回上一html页面触发的是iOS中的事件,而Flutter
侧滑返回上一页触发的是Flutter
中的事件。所以首当其冲的就是如何把这两个事件放在同一环境下处理,由于一时半会没想到好的方案,所以就去翻了下源码。结果发现webview_flutter
已经处理好了该问题,就是当WKWebView
需要事件时,Flutter
会通过MethodHandler
告诉WKWebView
。所以此时就转化为Flutter
中事件冲突的处理。
根据上节中竞技场的原理,可以在WKWebView
的canGoBack
为true时,页面侧滑放弃手势的处理,从而把事件交给WKWebView
处理;反之则页面侧滑手势自己处理。具体实现代码如下。
class HorizontalDragGesture extends HorizontalDragGestureRecognizer {
///
HorizontalDragGesture({
Object debugOwner,
PointerDeviceKind kind,
}) : super(debugOwner: debugOwner, kind: kind);
///
bool webViewCanGoBack = false;
@override
void resolve(GestureDisposition disposition) {
//webViewCanGoBack为true时拒绝,否则走默认流程
super.resolve(webViewCanGoBack ? GestureDisposition.rejected : disposition);
}
///
void updateState(bool canGoBack) {
webViewCanGoBack = canGoBack;
}
}
再把HorizontalDragGesture
作为页面侧滑返回的手势处理,就解决了手势冲突问题。如下动画所示。
由于此方案更改的是Flutter
页面的侧滑手势,所以改动比较大,再来看一种改动比较小的方案。
由于webview_flutter
的手势处理中用到了GestureArenaTeam
且captain
不为null,那么如果让需要返回上一html页面时让captain
竞争成功,如果需要返回上一Flutter
页面时让页面侧滑手势竞争成功不就可以了嘛。
由于页面侧滑手势先加入竞技场,所以想要captain
竞争成功,就需要插队。这时候就是体现EagerGestureRecognizer
作用的时候,所以可以根据如下修改来解决上面的滑动冲突问题。
class WebEagerGestureRecognizer extends EagerGestureRecognizer {
bool canGoBack = false;
@override
void addAllowedPointer(PointerDownEvent event) {
// We call startTrackingPointer as this is where OneSequenceGestureRecognizer joins the arena.
startTrackingPointer(event.pointer, event.transform);
resolve(
canGoBack ? GestureDisposition.accepted : GestureDisposition.rejected);
stopTrackingPointer(event.pointer);
}
}
然后把WebEagerGestureRecognizer
添加到gestureRecognizers
属性中即可,如下。
WebView(
...
gestureNavigationEnabled: true,
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>[
Factory<OneSequenceGestureRecognizer>(
() => webEagerGestureRecognizer,
),
].toSet(),
onCanGoBack: (canGoBack) {
webEagerGestureRecognizer?.canGoBack = canGoBack;
},
)
修改后滑动动画如下所示。
4、总结
理解了上面的内容后,想必对于Flutter
中的手势冲突能够快速解决。再结合Flutter之事件处理一文,那么对于FLutter
中的事件机制就能有一个全面的了解,做到快速实现自定义手势及手势冲突问题的解决。