阅读 1330

从研究 Flutter 双击源码到实现 N 击事件

一、效果展示

最近在研究 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 。通过它,我们能自己决定需要使用的手势检测器 及回调事件。这里使用了自定义的 NTapGestureRecognizerTapGestureRecognizer 分别用于检测 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 Tapcolor 状态量改为 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 时长的计时器。这里 durationdoubleTapMinTime 常量,为 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;
    }
  }
}
复制代码

那本文就到这里,谢谢观看~


@张风捷特烈 2021.04.18 未允禁转
我的公众号:编程之王
联系我--邮箱:1981462002@qq.com -- 微信:zdl1994328
~ END ~

文章分类
Android
文章标签