一、效果展示
最近在研究 Flutter 手势体系,对手势竞技有了深入的了解。在此之前,一直疑惑如何实现多连击手势事件,比如三连击、八连击,在网上并没有找到解决方案。虽然没有相关的需求,但如果一旦有了,就会很麻烦,未雨绸缪,就决定研究一下。在读完 DoubleTapGestureRecognizer
的源码之后,让我有了很大的收获,也为实现 N 次连击提供了思路。相关源码在本问第三节,将代码考入文件中即可使用。
1. N 次连击手势
可以指定最大连击数
,当连续点击达到指定次数时,会回调成功事件。在连击期间,每次点击会对调对应次数的 TapDown
事件。如下 8 连击测试,在连击过程中,会触发各次的按下事件
,使界面呈橙色; 8 连击完成后,会回调连击成功事件
,使界面呈绿色。
2. N 次连击手势失败监听
连击失败的回调,比如下面 8 连击测试中,当点击四次就不再点击。检测器的计时器 300ms
后重置,执行拒绝手势,从而触发失败的取消监听。检测器的其他取消逻辑同 双击检测器
一致,主要是追踪手势过程中 18 逻辑像素
的偏移。
3. N 次连击手势的注意点
N
连击手势不会与源码内置的单击手势冲突,其中的竞技规则是根据双击事件进行的拓展。如下,在八连击成功中,单击手势依然可以正常响应。另外,由于源码中的双击手势是 N
击手势是子集。而 源码中的双击手势
在校验成功时,会直接宣布胜利,使得其他手势参赛者皆失败,所以 N
连击手势不能与 双击手势
一起使用。(我觉得这是双击手势源码的问题,第二点抬起,它会直接宣布胜利,这让多次连击在和双击竞争时没有获胜的可能)。
二、 测试案例
1. 程序入口和组件
由于方便调试,这里没用 MaterialApp
,有文字展示,使用在外层套了 Directionality
。主要的展示在 RawGestureDetectorDemo
中完成,由于需要根据手势回调进行界面变化,所以使用 StatefulWidget
。
void main() {
runApp(Directionality(
textDirection: TextDirection.ltr,
child: RawGestureDetectorDemo()));
}
class RawGestureDetectorDemo extends StatefulWidget {
@override
_RawGestureDetectorDemoState createState() => _RawGestureDetectorDemoState();
}
2. 组件状态与构建
状态量主要有行为名称 action
和 界面颜色 color
两个,他们会在不同的事件回调中进行变化和刷新。 由于是使用自定义的手势检测器,所以 GestureDetector
是无法胜任的,可以使用幕后大佬: RawGestureDetector
。通过它,我们能自己决定需要使用的手势检测器
及回调事件。这里使用了自定义的 NTapGestureRecognizer
和 TapGestureRecognizer
分别用于检测 N 击和 单击。N 击手势使用很简单,只要指定 maxN
即可。
class _RawGestureDetectorDemoState extends State<RawGestureDetectorDemo> {
String action = '';
Color color = Colors.blue;
@override
Widget build(BuildContext context) {
var gestures = <Type, GestureRecognizerFactory>{
NTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<NTapGestureRecognizer>(() {
return NTapGestureRecognizer(maxN: 8);
},
(NTapGestureRecognizer instance) {
instance
..onNTap = _onNTap
..onNTapDown = _onNTapDown
..onNTapCancel = _onNTapCancel;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(() {
return TapGestureRecognizer();
}, (TapGestureRecognizer instance) {
instance
..onTapDown = _tapDown
..onTapCancel = _tapCancel
..onTapUp=_tapUp
..onTap = _tap;
}),
};
return RawGestureDetector(
gestures: gestures,
child: Container(
color: color,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"8 连击测试",
style: TextStyle(color: Colors.white,fontSize: 24),
),
Text(
"Action:$action",
style: TextStyle(color: Colors.white,fontSize: 24),
),
],
)),
);
}
3.回调事件与状态变化
主要就是在回调事件中打印一下信息和处理状态的变化。比如八连击完成,会回调 _onNTap
方法,将 action
状态量变为 _on 8 Tap
,color
状态量改为 Colors.green
,并执行 setState
重构组件。
void _tapDown(TapDownDetails details) {
print('_tapDown');
}
void _tapUp(TapUpDetails details) {
print('_tapUp');
}
void _tap() {
print('_tap');
setState(() {
action = 'tap';
color = Colors.blue;
});
}
void _tapCancel() {
print('_tapCancel');
}
void _onNTap() {
print('_onNTap-----[8]---');
setState(() {
action = '_on 8 Tap';
color = Colors.green;
});
}
void _onNTapDown(TapDownDetails details,int n) {
print('_onNTapDown----$n---');
setState(() {
action = '_onNTapDown 第 $n 次';
color = Colors.orange;
});
}
void _onNTapCancel(int n) {
print('_onNTapCancel');
setState(() {
action = '_onNTapCancel 第 $n 次';
color = Colors.red;
});
}
三、 N 击手势检测器源码
将本节所有代码考入一个文件里,结构如下,下面分别简单地介绍。
1. _TapTracker
触点追踪器
当一个触点按下时,且允许注册入检测器中,检测器则会创建 _TapTracker
对象,并维护一个与触点 id 的映射表。 触点追踪器主要用于:通过 entry
属性来通知竞技场自己要获胜,或者想要退出。
class _TapTracker {
_TapTracker({
@required PointerDownEvent event,
@required this.entry,
@required Duration doubleTapMinTime,
}) : assert(doubleTapMinTime != null),
assert(event != null),
assert(event.buttons != null),
pointer = event.pointer,
_initialGlobalPosition = event.position,
initialButtons = event.buttons,
_doubleTapMinTimeCountdown =
_CountdownZoned(duration: doubleTapMinTime);
final int pointer;
final GestureArenaEntry entry;
final Offset _initialGlobalPosition;
final int initialButtons;
final _CountdownZoned _doubleTapMinTimeCountdown;
bool _isTrackingPointer = false;
void startTrackingPointer(PointerRoute route, Matrix4 transform) {
if (!_isTrackingPointer) {
_isTrackingPointer = true;
GestureBinding.instance.pointerRouter.addRoute(pointer, route, transform);
}
}
void stopTrackingPointer(PointerRoute route) {
if (_isTrackingPointer) {
_isTrackingPointer = false;
GestureBinding.instance.pointerRouter.removeRoute(pointer, route);
}
}
bool isWithinGlobalTolerance(PointerEvent event, double tolerance) {
final Offset offset = event.position - _initialGlobalPosition;
return offset.distance <= tolerance;
}
bool hasElapsedMinTime() {
return _doubleTapMinTimeCountdown.timeout;
}
bool hasSameButton(PointerDownEvent event) {
return event.buttons == initialButtons;
}
}
2.倒计时区 _CountdownZoned
触点追踪器持有该类型成员,在构造时会创建 duration
时长的计时器。这里 duration
是 doubleTapMinTime
常量,为 40 ms
,超时后,会将 _timeout
置为 true。这可以校验两次触点最短的时间差,如果小于 40 ms
,会重置检测器,重新追踪该触点。
class _CountdownZoned {
_CountdownZoned({@required Duration duration}) : assert(duration != null) {
Timer(duration, _onTimeout);
}
bool _timeout = false;
bool get timeout => _timeout;
void _onTimeout() {
_timeout = true;
}
}
3.N 连击手势检测器 NTapGestureRecognizer
isPointerAllowed
用于校验触点是否可以注册如该检测器,如果可以会通过 addAllowedPointer
进行指针追中。在 GestureBinding
进行事件分发时,会回调 _handleEvent
用于手势校验。竞技获胜时,会回调 acceptGesture
方法;竞技失败,会触发 rejectGesture
方法。其中有一个 300ms
的计时器,用于校验最大时长。过时后,会执行重置检测器及发送竞技失败通知。
typedef GestureNTapCallback = void Function();
typedef GestureNTapDownCallback = void Function(TapDownDetails details, int n);
typedef GestureNTapCancelCallback = void Function(int n);
//1 相邻触点大于 200 ms --- 取消 N 击
//2 触点自己触发取消事件 --- 取消 N 击
//3 落点在追踪中偏移量 > 18 逻辑像素 --- 取消 N 击
//4 落点与第一触点距离 > 200逻辑像素 --- 无效 N 击
//5 相邻触点间距 小于 40 ms --- 无效 N 击,重新追踪
class NTapGestureRecognizer extends GestureRecognizer {
NTapGestureRecognizer(
{Object debugOwner, PointerDeviceKind kind, this.maxN = 3})
: super(debugOwner: debugOwner, kind: kind);
@override
void acceptGesture(int pointer) {
if(tapCount!=maxN){
_checkCancel();
}
}
GestureNTapCallback onNTap;
GestureNTapCancelCallback onNTapCancel;
GestureNTapDownCallback onNTapDown;
final int maxN;
_TapTracker _prevTap;
int tapCount = 0;
final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
Timer _tapTimer;
@override
String get debugDescription => 'N tap';
@override
void rejectGesture(int pointer) {
_TapTracker tracker = _trackers[pointer];
if (tracker == null && _prevTap != null && _prevTap.pointer == pointer)
tracker = _prevTap;
if (tracker != null) _reject(tracker);
}
@override
bool isPointerAllowed(PointerDownEvent event) {
if (_prevTap == null) {
switch (event.buttons) {
case kPrimaryButton:
if (onNTap == null || onNTapCancel == null || onNTapDown == null)
return false;
break;
default:
return false;
}
}
return super.isPointerAllowed(event);
}
@override
void addAllowedPointer(PointerDownEvent event) {
tapCount ;
if (_prevTap != null) {
if (!_prevTap.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
return;
} else if (!_prevTap.hasElapsedMinTime() ||
!_prevTap.hasSameButton(event)) {
_reset();
return _trackTap(event);
} else if (onNTapDown != null) {
final TapDownDetails details = TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(event.pointer),
);
invokeCallback<void>('onNTapDown', () => onNTapDown(details, tapCount));
}
}
_trackTap(event);
}
void _trackTap(PointerDownEvent event) {
_stopDoubleTapTimer();
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
);
_trackers[event.pointer] = tracker;
tracker.startTrackingPointer(_handleEvent, event.transform);
}
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer];
if (event is PointerUpEvent) {
if (_prevTap == null || tapCount != maxN) {
_registerTap(tracker);
} else {
_registerLastTap(tracker);
}
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
_reject(tracker);
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
assert(_trackers.isEmpty);
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _reject(_TapTracker tracker) {
_trackers.remove(tracker.pointer);
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
if (_prevTap != null) {
if (tracker == _prevTap) {
_reset();
} else {
_checkCancel();
if (_trackers.isEmpty) _reset();
}
}
}
void _checkCancel() {
if (onNTapCancel != null)
invokeCallback<void>('onNTapCancel', ()=>onNTapCancel(tapCount));
}
void _startDoubleTapTimer() {
_tapTimer ??= Timer(kDoubleTapTimeout, _reset);
}
void _registerTap(_TapTracker tracker) {
_startDoubleTapTimer();
GestureBinding.instance.gestureArena.hold(tracker.pointer);
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_clearTrackers();
_prevTap = tracker;
}
void _registerLastTap(_TapTracker tracker) {
tracker.entry.resolve(GestureDisposition.accepted);
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_checkUp(tracker.initialButtons);
_reset();
}
void _checkUp(int buttons) {
assert(buttons == kPrimaryButton);
if (onNTap != null) invokeCallback<void>('onNTap', () => onNTap);
}
void _reset() {
_stopDoubleTapTimer();
if (_prevTap != null) {
if (_trackers.isNotEmpty) _checkCancel();
final _TapTracker tracker = _prevTap;
_prevTap = null;
if (tapCount == 1) {
tracker.entry.resolve(GestureDisposition.rejected);
} else {
tracker.entry.resolve(GestureDisposition.accepted);
}
_freezeTracker(tracker);
GestureBinding.instance.gestureArena.release(tracker.pointer);
}
_clearTrackers();
tapCount = 0;
}
void _stopDoubleTapTimer() {
if (_tapTimer != null) {
_tapTimer.cancel();
_tapTimer = null;
}
}
}
那本文就到这里,谢谢观看~