multitap.dart 18.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Hixie's avatar
Hixie committed
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
6
import 'package:vector_math/vector_math_64.dart';
Hixie's avatar
Hixie committed
7 8

import 'arena.dart';
9
import 'binding.dart';
Hixie's avatar
Hixie committed
10 11 12 13
import 'constants.dart';
import 'events.dart';
import 'pointer_router.dart';
import 'recognizer.dart';
14
import 'tap.dart';
Hixie's avatar
Hixie committed
15

16 17
/// Signature for callback when the user has tapped the screen at the same
/// location twice in quick succession.
18 19 20 21
///
/// See also:
///
///  * [GestureDetector.onDoubleTap], which matches this signature.
22
typedef GestureDoubleTapCallback = void Function();
Hixie's avatar
Hixie committed
23

24 25
/// Signature used by [MultiTapGestureRecognizer] for when a pointer that might
/// cause a tap has contacted the screen at a particular location.
26
typedef GestureMultiTapDownCallback = void Function(int pointer, TapDownDetails details);
27 28 29

/// Signature used by [MultiTapGestureRecognizer] for when a pointer that will
/// trigger a tap has stopped contacting the screen at a particular location.
30
typedef GestureMultiTapUpCallback = void Function(int pointer, TapUpDetails details);
31 32

/// Signature used by [MultiTapGestureRecognizer] for when a tap has occurred.
33
typedef GestureMultiTapCallback = void Function(int pointer);
34 35 36

/// Signature for when the pointer that previously triggered a
/// [GestureMultiTapDownCallback] will not end up causing a tap.
37
typedef GestureMultiTapCancelCallback = void Function(int pointer);
Hixie's avatar
Hixie committed
38

39 40 41
/// CountdownZoned tracks whether the specified duration has elapsed since
/// creation, honoring [Zone].
class _CountdownZoned {
42
  _CountdownZoned({ required Duration duration })
43
       : assert(duration != null) {
44
    Timer(duration, _onTimeout);
45 46 47 48 49 50 51 52 53 54 55
  }

  bool _timeout = false;

  bool get timeout => _timeout;

  void _onTimeout() {
    _timeout = true;
  }
}

Hixie's avatar
Hixie committed
56 57 58
/// TapTracker helps track individual tap sequences as part of a
/// larger gesture.
class _TapTracker {
59
  _TapTracker({
60
    required PointerDownEvent event,
61
    required this.entry,
62
    required Duration doubleTapMinTime,
63 64
  }) : assert(doubleTapMinTime != null),
       assert(event != null),
65
       assert(event.buttons != null),
66
       pointer = event.pointer,
67
       _initialGlobalPosition = event.position,
68
       initialButtons = event.buttons,
69
       _doubleTapMinTimeCountdown = _CountdownZoned(duration: doubleTapMinTime);
Hixie's avatar
Hixie committed
70 71

  final int pointer;
72
  final GestureArenaEntry entry;
73
  final Offset _initialGlobalPosition;
74
  final int initialButtons;
75
  final _CountdownZoned _doubleTapMinTimeCountdown;
Hixie's avatar
Hixie committed
76 77 78

  bool _isTrackingPointer = false;

79
  void startTrackingPointer(PointerRoute route, Matrix4? transform) {
Hixie's avatar
Hixie committed
80 81
    if (!_isTrackingPointer) {
      _isTrackingPointer = true;
82
      GestureBinding.instance!.pointerRouter.addRoute(pointer, route, transform);
Hixie's avatar
Hixie committed
83 84 85
    }
  }

86
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
87 88
    if (_isTrackingPointer) {
      _isTrackingPointer = false;
89
      GestureBinding.instance!.pointerRouter.removeRoute(pointer, route);
Hixie's avatar
Hixie committed
90 91 92
    }
  }

93 94
  bool isWithinGlobalTolerance(PointerEvent event, double tolerance) {
    final Offset offset = event.position - _initialGlobalPosition;
Hixie's avatar
Hixie committed
95 96
    return offset.distance <= tolerance;
  }
97 98 99 100

  bool hasElapsedMinTime() {
    return _doubleTapMinTimeCountdown.timeout;
  }
101 102 103 104

  bool hasSameButton(PointerDownEvent event) {
    return event.buttons == initialButtons;
  }
Hixie's avatar
Hixie committed
105 106
}

107 108
/// Recognizes when the user has tapped the screen at the same location twice in
/// quick succession.
109 110 111 112
///
/// [DoubleTapGestureRecognizer] competes on pointer events of [kPrimaryButton]
/// only when it has a non-null callback. If it has no callbacks, it is a no-op.
///
113
class DoubleTapGestureRecognizer extends GestureRecognizer {
114
  /// Create a gesture recognizer for double taps.
115
  ///
116
  /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
117
  DoubleTapGestureRecognizer({
118
    Object? debugOwner,
119 120 121 122
    @Deprecated(
      'Migrate to supportedDevices. '
      'This feature was deprecated after v2.3.0-1.0.pre.',
    )
123
    PointerDeviceKind? kind,
124 125 126 127 128 129
    Set<PointerDeviceKind>? supportedDevices,
  }) : super(
         debugOwner: debugOwner,
         kind: kind,
         supportedDevices: supportedDevices,
       );
130

Hixie's avatar
Hixie committed
131
  // Implementation notes:
132
  //
Hixie's avatar
Hixie committed
133 134
  // The double tap recognizer can be in one of four states. There's no
  // explicit enum for the states, because they are already captured by
135
  // the state of existing fields. Specifically:
136 137 138 139 140 141 142 143 144 145 146 147 148
  //
  // 1. Waiting on first tap: In this state, the _trackers list is empty, and
  //    _firstTap is null.
  // 2. First tap in progress: In this state, the _trackers list contains all
  //    the states for taps that have begun but not completed. This list can
  //    have more than one entry if two pointers begin to tap.
  // 3. Waiting on second tap: In this state, one of the in-progress taps has
  //    completed successfully. The _trackers list is again empty, and
  //    _firstTap records the successful tap.
  // 4. Second tap in progress: Much like the "first tap in progress" state, but
  //    _firstTap is non-null. If a tap completes successfully while in this
  //    state, the callback is called and the state is reset.
  //
Hixie's avatar
Hixie committed
149
  // There are various other scenarios that cause the state to reset:
150
  //
Hixie's avatar
Hixie committed
151 152 153 154
  // - All in-progress taps are rejected (by time, distance, pointercancel, etc)
  // - The long timer between taps expires
  // - The gesture arena decides we have been rejected wholesale

155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170
  /// A pointer has contacted the screen with a primary button at the same
  /// location twice in quick succession, which might be the start of a double
  /// tap.
  ///
  /// This triggers immediately after the down event of the second tap.
  ///
  /// If this recognizer doesn't win the arena, [onDoubleTapCancel] is called
  /// next. Otherwise, [onDoubleTap] is called next.
  ///
  /// See also:
  ///
  ///  * [kPrimaryButton], the button this callback responds to.
  ///  * [TapDownDetails], which is passed as an argument to this callback.
  ///  * [GestureDetector.onDoubleTapDown], which exposes this callback.
  GestureTapDownCallback? onDoubleTapDown;

171 172 173
  /// Called when the user has tapped the screen with a primary button at the
  /// same location twice in quick succession.
  ///
Dan Field's avatar
Dan Field committed
174 175
  /// This triggers when the pointer stops contacting the device after the
  /// second tap.
176 177 178 179
  ///
  /// See also:
  ///
  ///  * [kPrimaryButton], the button this callback responds to.
180
  ///  * [GestureDetector.onDoubleTap], which exposes this callback.
181
  GestureDoubleTapCallback? onDoubleTap;
Hixie's avatar
Hixie committed
182

183 184 185 186 187 188 189 190 191 192 193 194 195 196
  /// A pointer that previously triggered [onDoubleTapDown] will not end up
  /// causing a double tap.
  ///
  /// This triggers once the gesture loses the arena if [onDoubleTapDown] has
  /// previously been triggered.
  ///
  /// If this recognizer wins the arena, [onDoubleTap] is called instead.
  ///
  /// See also:
  ///
  ///  * [kPrimaryButton], the button this callback responds to.
  ///  * [GestureDetector.onDoubleTapCancel], which exposes this callback.
  GestureTapCancelCallback? onDoubleTapCancel;

197 198
  Timer? _doubleTapTimer;
  _TapTracker? _firstTap;
199
  final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
Hixie's avatar
Hixie committed
200

201
  @override
202
  bool isPointerAllowed(PointerDownEvent event) {
203 204 205
    if (_firstTap == null) {
      switch (event.buttons) {
        case kPrimaryButton:
206 207 208
          if (onDoubleTapDown == null &&
              onDoubleTap == null &&
              onDoubleTapCancel == null)
209 210 211 212 213 214
            return false;
          break;
        default:
          return false;
      }
    }
215
    return super.isPointerAllowed(event);
216 217
  }

218
  @override
219
  void addAllowedPointer(PointerDownEvent event) {
220
    if (_firstTap != null) {
221
      if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
222 223
        // Ignore out-of-bounds second taps.
        return;
224 225 226
      } else if (!_firstTap!.hasElapsedMinTime() || !_firstTap!.hasSameButton(event)) {
        // Restart when the second tap is too close to the first (touch screens
        // often detect touches intermittently), or when buttons mismatch.
227
        _reset();
228 229 230 231 232 233 234 235
        return _trackTap(event);
      } else if (onDoubleTapDown != null) {
        final TapDownDetails details = TapDownDetails(
          globalPosition: event.position,
          localPosition: event.localPosition,
          kind: getKindForPointer(event.pointer),
        );
        invokeCallback<void>('onDoubleTapDown', () => onDoubleTapDown!(details));
236 237
      }
    }
238
    _trackTap(event);
239 240
  }

241
  void _trackTap(PointerDownEvent event) {
Hixie's avatar
Hixie committed
242
    _stopDoubleTapTimer();
243
    final _TapTracker tracker = _TapTracker(
244
      event: event,
245
      entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
246
      doubleTapMinTime: kDoubleTapMinTime,
Hixie's avatar
Hixie committed
247 248
    );
    _trackers[event.pointer] = tracker;
249
    tracker.startTrackingPointer(_handleEvent, event.transform);
Hixie's avatar
Hixie committed
250 251
  }

252
  void _handleEvent(PointerEvent event) {
253
    final _TapTracker tracker = _trackers[event.pointer]!;
Ian Hickson's avatar
Ian Hickson committed
254 255 256 257 258 259
    if (event is PointerUpEvent) {
      if (_firstTap == null)
        _registerFirstTap(tracker);
      else
        _registerSecondTap(tracker);
    } else if (event is PointerMoveEvent) {
260
      if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
Hixie's avatar
Hixie committed
261
        _reject(tracker);
Ian Hickson's avatar
Ian Hickson committed
262 263
    } else if (event is PointerCancelEvent) {
      _reject(tracker);
Hixie's avatar
Hixie committed
264 265 266
    }
  }

267
  @override
268
  void acceptGesture(int pointer) { }
Hixie's avatar
Hixie committed
269

270
  @override
Hixie's avatar
Hixie committed
271
  void rejectGesture(int pointer) {
272
    _TapTracker? tracker = _trackers[pointer];
Hixie's avatar
Hixie committed
273 274 275
    // If tracker isn't in the list, check if this is the first tap tracker
    if (tracker == null &&
        _firstTap != null &&
276
        _firstTap!.pointer == pointer)
Hixie's avatar
Hixie committed
277 278 279 280 281 282 283 284
      tracker = _firstTap;
    // If tracker is still null, we rejected ourselves already
    if (tracker != null)
      _reject(tracker);
  }

  void _reject(_TapTracker tracker) {
    _trackers.remove(tracker.pointer);
285
    tracker.entry.resolve(GestureDisposition.rejected);
Hixie's avatar
Hixie committed
286
    _freezeTracker(tracker);
287 288 289 290 291 292 293 294 295
    if (_firstTap != null) {
      if (tracker == _firstTap) {
        _reset();
      } else {
        _checkCancel();
        if (_trackers.isEmpty)
          _reset();
      }
    }
Hixie's avatar
Hixie committed
296 297
  }

298
  @override
Hixie's avatar
Hixie committed
299 300
  void dispose() {
    _reset();
301
    super.dispose();
Hixie's avatar
Hixie committed
302 303 304 305 306
  }

  void _reset() {
    _stopDoubleTapTimer();
    if (_firstTap != null) {
307 308
      if (_trackers.isNotEmpty)
        _checkCancel();
Hixie's avatar
Hixie committed
309
      // Note, order is important below in order for the resolve -> reject logic
Florian Loitsch's avatar
Florian Loitsch committed
310
      // to work properly.
311
      final _TapTracker tracker = _firstTap!;
Hixie's avatar
Hixie committed
312 313
      _firstTap = null;
      _reject(tracker);
314
      GestureBinding.instance!.gestureArena.release(tracker.pointer);
Hixie's avatar
Hixie committed
315 316 317 318 319 320
    }
    _clearTrackers();
  }

  void _registerFirstTap(_TapTracker tracker) {
    _startDoubleTapTimer();
321
    GestureBinding.instance!.gestureArena.hold(tracker.pointer);
Hixie's avatar
Hixie committed
322 323 324 325 326 327 328 329 330
    // 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) {
331 332
    _firstTap!.entry.resolve(GestureDisposition.accepted);
    tracker.entry.resolve(GestureDisposition.accepted);
Hixie's avatar
Hixie committed
333 334
    _freezeTracker(tracker);
    _trackers.remove(tracker.pointer);
335
    _checkUp(tracker.initialButtons);
Hixie's avatar
Hixie committed
336 337 338 339
    _reset();
  }

  void _clearTrackers() {
340
    _trackers.values.toList().forEach(_reject);
Hixie's avatar
Hixie committed
341 342 343 344
    assert(_trackers.isEmpty);
  }

  void _freezeTracker(_TapTracker tracker) {
345
    tracker.stopTrackingPointer(_handleEvent);
Hixie's avatar
Hixie committed
346 347 348
  }

  void _startDoubleTapTimer() {
349
    _doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
Hixie's avatar
Hixie committed
350 351 352 353
  }

  void _stopDoubleTapTimer() {
    if (_doubleTapTimer != null) {
354
      _doubleTapTimer!.cancel();
Hixie's avatar
Hixie committed
355 356 357 358
      _doubleTapTimer = null;
    }
  }

359 360 361
  void _checkUp(int buttons) {
    assert(buttons == kPrimaryButton);
    if (onDoubleTap != null)
362
      invokeCallback<void>('onDoubleTap', onDoubleTap!);
363 364
  }

365 366 367 368 369
  void _checkCancel() {
    if (onDoubleTapCancel != null)
      invokeCallback<void>('onDoubleTapCancel', onDoubleTapCancel!);
  }

370
  @override
371
  String get debugDescription => 'double tap';
Hixie's avatar
Hixie committed
372 373 374 375 376 377 378 379
}

/// TapGesture represents a full gesture resulting from a single tap sequence,
/// as part of a [MultiTapGestureRecognizer]. Tap gestures are passive, meaning
/// that they will not preempt any other arena member in play.
class _TapGesture extends _TapTracker {

  _TapGesture({
380 381 382
    required this.gestureRecognizer,
    required PointerEvent event,
    required Duration longTapDelay,
383
  }) : _lastPosition = OffsetPair.fromEventPosition(event),
Ian Hickson's avatar
Ian Hickson committed
384
       super(
385
    event: event as PointerDownEvent,
386
    entry: GestureBinding.instance!.gestureArena.add(event.pointer, gestureRecognizer),
387
    doubleTapMinTime: kDoubleTapMinTime,
Ian Hickson's avatar
Ian Hickson committed
388
  ) {
389
    startTrackingPointer(handleEvent, event.transform);
390
    if (longTapDelay > Duration.zero) {
391
      _timer = Timer(longTapDelay, () {
Hixie's avatar
Hixie committed
392
        _timer = null;
393
        gestureRecognizer._dispatchLongTap(event.pointer, _lastPosition);
Hixie's avatar
Hixie committed
394 395
      });
    }
Hixie's avatar
Hixie committed
396 397 398 399 400
  }

  final MultiTapGestureRecognizer gestureRecognizer;

  bool _wonArena = false;
401
  Timer? _timer;
Hixie's avatar
Hixie committed
402

403
  OffsetPair _lastPosition;
404
  OffsetPair? _finalPosition;
Hixie's avatar
Hixie committed
405

Ian Hickson's avatar
Ian Hickson committed
406
  void handleEvent(PointerEvent event) {
Hixie's avatar
Hixie committed
407
    assert(event.pointer == pointer);
Ian Hickson's avatar
Ian Hickson committed
408
    if (event is PointerMoveEvent) {
409
      if (!isWithinGlobalTolerance(event, computeHitSlop(event.kind)))
Hixie's avatar
Hixie committed
410 411
        cancel();
      else
412
        _lastPosition = OffsetPair.fromEventPosition(event);
Ian Hickson's avatar
Ian Hickson committed
413
    } else if (event is PointerCancelEvent) {
Hixie's avatar
Hixie committed
414
      cancel();
Ian Hickson's avatar
Ian Hickson committed
415
    } else if (event is PointerUpEvent) {
416
      stopTrackingPointer(handleEvent);
417
      _finalPosition = OffsetPair.fromEventPosition(event);
Hixie's avatar
Hixie committed
418 419 420 421
      _check();
    }
  }

422
  @override
423
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
424 425
    _timer?.cancel();
    _timer = null;
426
    super.stopTrackingPointer(route);
Hixie's avatar
Hixie committed
427 428
  }

Hixie's avatar
Hixie committed
429 430 431 432 433 434
  void accept() {
    _wonArena = true;
    _check();
  }

  void reject() {
435
    stopTrackingPointer(handleEvent);
436
    gestureRecognizer._dispatchCancel(pointer);
Hixie's avatar
Hixie committed
437 438 439 440 441 442 443 444
  }

  void cancel() {
    // If we won the arena already, then entry is resolved, so resolving
    // again is a no-op. But we still need to clean up our own state.
    if (_wonArena)
      reject();
    else
445
      entry.resolve(GestureDisposition.rejected); // eventually calls reject()
Hixie's avatar
Hixie committed
446 447 448 449
  }

  void _check() {
    if (_wonArena && _finalPosition != null)
450
      gestureRecognizer._dispatchTap(pointer, _finalPosition!);
Hixie's avatar
Hixie committed
451 452 453
  }
}

454 455 456 457 458 459 460 461 462
/// Recognizes taps on a per-pointer basis.
///
/// [MultiTapGestureRecognizer] considers each sequence of pointer events that
/// could constitute a tap independently of other pointers: For example, down-1,
/// down-2, up-1, up-2 produces two taps, on up-1 and up-2.
///
/// See also:
///
///  * [TapGestureRecognizer]
463
class MultiTapGestureRecognizer extends GestureRecognizer {
464 465
  /// Creates a multi-tap gesture recognizer.
  ///
466
  /// The [longTapDelay] defaults to [Duration.zero], which means
467
  /// [onLongTapDown] is called immediately after [onTapDown].
468 469
  ///
  /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
Hixie's avatar
Hixie committed
470
  MultiTapGestureRecognizer({
471
    this.longTapDelay = Duration.zero,
472
    Object? debugOwner,
473 474 475 476
    @Deprecated(
      'Migrate to supportedDevices. '
      'This feature was deprecated after v2.3.0-1.0.pre.',
    )
477
    PointerDeviceKind? kind,
478 479 480 481 482 483
    Set<PointerDeviceKind>? supportedDevices,
  }) : super(
         debugOwner: debugOwner,
         kind: kind,
         supportedDevices: supportedDevices,
       );
Hixie's avatar
Hixie committed
484

485 486
  /// A pointer that might cause a tap has contacted the screen at a particular
  /// location.
487
  GestureMultiTapDownCallback? onTapDown;
488 489 490

  /// A pointer that will trigger a tap has stopped contacting the screen at a
  /// particular location.
491
  GestureMultiTapUpCallback? onTapUp;
492 493

  /// A tap has occurred.
494
  GestureMultiTapCallback? onTap;
495 496 497

  /// The pointer that previously triggered [onTapDown] will not end up causing
  /// a tap.
498
  GestureMultiTapCancelCallback? onTapCancel;
499 500

  /// The amount of time between [onTapDown] and [onLongTapDown].
Hixie's avatar
Hixie committed
501
  Duration longTapDelay;
502 503 504

  /// A pointer that might cause a tap is still in contact with the screen at a
  /// particular location after [longTapDelay].
505
  GestureMultiTapDownCallback? onLongTapDown;
Hixie's avatar
Hixie committed
506

507
  final Map<int, _TapGesture> _gestureMap = <int, _TapGesture>{};
Hixie's avatar
Hixie committed
508

509
  @override
510
  void addAllowedPointer(PointerDownEvent event) {
Hixie's avatar
Hixie committed
511
    assert(!_gestureMap.containsKey(event.pointer));
512
    _gestureMap[event.pointer] = _TapGesture(
Hixie's avatar
Hixie committed
513
      gestureRecognizer: this,
Hixie's avatar
Hixie committed
514
      event: event,
515
      longTapDelay: longTapDelay,
Hixie's avatar
Hixie committed
516 517
    );
    if (onTapDown != null)
518
      invokeCallback<void>('onTapDown', () {
519
        onTapDown!(event.pointer, TapDownDetails(
520
          globalPosition: event.position,
521
          localPosition: event.localPosition,
522 523 524
          kind: event.kind,
        ));
      });
Hixie's avatar
Hixie committed
525 526
  }

527
  @override
Hixie's avatar
Hixie committed
528 529
  void acceptGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
530
    _gestureMap[pointer]!.accept();
Hixie's avatar
Hixie committed
531 532
  }

533
  @override
Hixie's avatar
Hixie committed
534 535
  void rejectGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
536
    _gestureMap[pointer]!.reject();
Hixie's avatar
Hixie committed
537
    assert(!_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
538 539
  }

540 541
  void _dispatchCancel(int pointer) {
    assert(_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
542
    _gestureMap.remove(pointer);
543
    if (onTapCancel != null)
544
      invokeCallback<void>('onTapCancel', () => onTapCancel!(pointer));
545 546
  }

547
  void _dispatchTap(int pointer, OffsetPair position) {
548 549 550
    assert(_gestureMap.containsKey(pointer));
    _gestureMap.remove(pointer);
    if (onTapUp != null)
551
      invokeCallback<void>('onTapUp', () {
552
        onTapUp!(pointer, TapUpDetails(
553
          kind: getKindForPointer(pointer),
554 555 556 557
          localPosition: position.local,
          globalPosition: position.global,
        ));
      });
558
    if (onTap != null)
559
      invokeCallback<void>('onTap', () => onTap!(pointer));
Hixie's avatar
Hixie committed
560 561
  }

562
  void _dispatchLongTap(int pointer, OffsetPair lastPosition) {
Hixie's avatar
Hixie committed
563 564
    assert(_gestureMap.containsKey(pointer));
    if (onLongTapDown != null)
565
      invokeCallback<void>('onLongTapDown', () {
566
        onLongTapDown!(
567 568
          pointer,
          TapDownDetails(
569 570
            globalPosition: lastPosition.global,
            localPosition: lastPosition.local,
571 572 573 574
            kind: getKindForPointer(pointer),
          ),
        );
      });
Hixie's avatar
Hixie committed
575 576
  }

577
  @override
Hixie's avatar
Hixie committed
578
  void dispose() {
579
    final List<_TapGesture> localGestures = List<_TapGesture>.from(_gestureMap.values);
580
    for (final _TapGesture gesture in localGestures)
Hixie's avatar
Hixie committed
581 582 583
      gesture.cancel();
    // Rejection of each gesture should cause it to be removed from our map
    assert(_gestureMap.isEmpty);
584
    super.dispose();
Hixie's avatar
Hixie committed
585 586
  }

587
  @override
588
  String get debugDescription => 'multitap';
Hixie's avatar
Hixie committed
589
}