multitap.dart 33.7 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 6 7
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

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

15 16 17 18 19
export 'dart:ui' show Offset, PointerDeviceKind;

export 'events.dart' show PointerDownEvent;
export 'tap.dart' show GestureTapCancelCallback, GestureTapDownCallback, TapDownDetails, TapUpDetails;

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

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

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

/// Signature used by [MultiTapGestureRecognizer] for when a tap has occurred.
37
typedef GestureMultiTapCallback = void Function(int pointer);
38 39 40

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

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

  bool _timeout = false;

  bool get timeout => _timeout;

  void _onTimeout() {
    _timeout = true;
  }
}

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

76
  final DeviceGestureSettings? gestureSettings;
Hixie's avatar
Hixie committed
77
  final int pointer;
78
  final GestureArenaEntry entry;
79
  final Offset _initialGlobalPosition;
80
  final int initialButtons;
81
  final _CountdownZoned _doubleTapMinTimeCountdown;
Hixie's avatar
Hixie committed
82 83 84

  bool _isTrackingPointer = false;

85
  void startTrackingPointer(PointerRoute route, Matrix4? transform) {
Hixie's avatar
Hixie committed
86 87
    if (!_isTrackingPointer) {
      _isTrackingPointer = true;
88
      GestureBinding.instance.pointerRouter.addRoute(pointer, route, transform);
Hixie's avatar
Hixie committed
89 90 91
    }
  }

92
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
93 94
    if (_isTrackingPointer) {
      _isTrackingPointer = false;
95
      GestureBinding.instance.pointerRouter.removeRoute(pointer, route);
Hixie's avatar
Hixie committed
96 97 98
    }
  }

99 100
  bool isWithinGlobalTolerance(PointerEvent event, double tolerance) {
    final Offset offset = event.position - _initialGlobalPosition;
Hixie's avatar
Hixie committed
101 102
    return offset.distance <= tolerance;
  }
103 104 105 106

  bool hasElapsedMinTime() {
    return _doubleTapMinTimeCountdown.timeout;
  }
107 108 109 110

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

113 114
/// Recognizes when the user has tapped the screen at the same location twice in
/// quick succession.
115 116 117 118
///
/// [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.
///
119
class DoubleTapGestureRecognizer extends GestureRecognizer {
120
  /// Create a gesture recognizer for double taps.
121
  ///
122
  /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
123
  DoubleTapGestureRecognizer({
124
    super.debugOwner,
125 126 127 128
    @Deprecated(
      'Migrate to supportedDevices. '
      'This feature was deprecated after v2.3.0-1.0.pre.',
    )
129 130 131
    super.kind,
    super.supportedDevices,
  });
132

Hixie's avatar
Hixie committed
133
  // Implementation notes:
134
  //
Hixie's avatar
Hixie committed
135 136
  // 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
137
  // the state of existing fields. Specifically:
138 139 140 141 142 143 144 145 146 147 148 149 150
  //
  // 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
151
  // There are various other scenarios that cause the state to reset:
152
  //
Hixie's avatar
Hixie committed
153 154 155 156
  // - 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

157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
  /// 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;

173 174 175
  /// 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
176 177
  /// This triggers when the pointer stops contacting the device after the
  /// second tap.
178 179 180 181
  ///
  /// See also:
  ///
  ///  * [kPrimaryButton], the button this callback responds to.
182
  ///  * [GestureDetector.onDoubleTap], which exposes this callback.
183
  GestureDoubleTapCallback? onDoubleTap;
Hixie's avatar
Hixie committed
184

185 186 187 188 189 190 191 192 193 194 195 196 197 198
  /// 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;

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

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

221
  @override
222
  void addAllowedPointer(PointerDownEvent event) {
223
    if (_firstTap != null) {
224
      if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
225 226
        // Ignore out-of-bounds second taps.
        return;
227 228 229
      } 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.
230
        _reset();
231 232 233 234 235 236 237 238
        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));
239 240
      }
    }
241
    _trackTap(event);
242 243
  }

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

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

273
  @override
274
  void acceptGesture(int pointer) { }
Hixie's avatar
Hixie committed
275

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

  void _reject(_TapTracker tracker) {
    _trackers.remove(tracker.pointer);
293
    tracker.entry.resolve(GestureDisposition.rejected);
Hixie's avatar
Hixie committed
294
    _freezeTracker(tracker);
295 296 297 298 299
    if (_firstTap != null) {
      if (tracker == _firstTap) {
        _reset();
      } else {
        _checkCancel();
300
        if (_trackers.isEmpty) {
301
          _reset();
302
        }
303 304
      }
    }
Hixie's avatar
Hixie committed
305 306
  }

307
  @override
Hixie's avatar
Hixie committed
308 309
  void dispose() {
    _reset();
310
    super.dispose();
Hixie's avatar
Hixie committed
311 312 313 314 315
  }

  void _reset() {
    _stopDoubleTapTimer();
    if (_firstTap != null) {
316
      if (_trackers.isNotEmpty) {
317
        _checkCancel();
318
      }
Hixie's avatar
Hixie committed
319
      // Note, order is important below in order for the resolve -> reject logic
Florian Loitsch's avatar
Florian Loitsch committed
320
      // to work properly.
321
      final _TapTracker tracker = _firstTap!;
Hixie's avatar
Hixie committed
322 323
      _firstTap = null;
      _reject(tracker);
324
      GestureBinding.instance.gestureArena.release(tracker.pointer);
Hixie's avatar
Hixie committed
325 326 327 328 329 330
    }
    _clearTrackers();
  }

  void _registerFirstTap(_TapTracker tracker) {
    _startDoubleTapTimer();
331
    GestureBinding.instance.gestureArena.hold(tracker.pointer);
Hixie's avatar
Hixie committed
332 333 334 335 336 337 338 339 340
    // 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) {
341 342
    _firstTap!.entry.resolve(GestureDisposition.accepted);
    tracker.entry.resolve(GestureDisposition.accepted);
Hixie's avatar
Hixie committed
343 344
    _freezeTracker(tracker);
    _trackers.remove(tracker.pointer);
345
    _checkUp(tracker.initialButtons);
Hixie's avatar
Hixie committed
346 347 348 349
    _reset();
  }

  void _clearTrackers() {
350
    _trackers.values.toList().forEach(_reject);
Hixie's avatar
Hixie committed
351 352 353 354
    assert(_trackers.isEmpty);
  }

  void _freezeTracker(_TapTracker tracker) {
355
    tracker.stopTrackingPointer(_handleEvent);
Hixie's avatar
Hixie committed
356 357 358
  }

  void _startDoubleTapTimer() {
359
    _doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
Hixie's avatar
Hixie committed
360 361 362 363
  }

  void _stopDoubleTapTimer() {
    if (_doubleTapTimer != null) {
364
      _doubleTapTimer!.cancel();
Hixie's avatar
Hixie committed
365 366 367 368
      _doubleTapTimer = null;
    }
  }

369 370
  void _checkUp(int buttons) {
    assert(buttons == kPrimaryButton);
371
    if (onDoubleTap != null) {
372
      invokeCallback<void>('onDoubleTap', onDoubleTap!);
373
    }
374 375
  }

376
  void _checkCancel() {
377
    if (onDoubleTapCancel != null) {
378
      invokeCallback<void>('onDoubleTapCancel', onDoubleTapCancel!);
379
    }
380 381
  }

382
  @override
383
  String get debugDescription => 'double tap';
Hixie's avatar
Hixie committed
384 385 386 387 388 389 390 391
}

/// 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({
392 393 394
    required this.gestureRecognizer,
    required PointerEvent event,
    required Duration longTapDelay,
395
    required super.gestureSettings,
396
  }) : _lastPosition = OffsetPair.fromEventPosition(event),
Ian Hickson's avatar
Ian Hickson committed
397
       super(
398
    event: event as PointerDownEvent,
399
    entry: GestureBinding.instance.gestureArena.add(event.pointer, gestureRecognizer),
400
    doubleTapMinTime: kDoubleTapMinTime,
Ian Hickson's avatar
Ian Hickson committed
401
  ) {
402
    startTrackingPointer(handleEvent, event.transform);
403
    if (longTapDelay > Duration.zero) {
404
      _timer = Timer(longTapDelay, () {
Hixie's avatar
Hixie committed
405
        _timer = null;
406
        gestureRecognizer._dispatchLongTap(event.pointer, _lastPosition);
Hixie's avatar
Hixie committed
407 408
      });
    }
Hixie's avatar
Hixie committed
409 410 411 412 413
  }

  final MultiTapGestureRecognizer gestureRecognizer;

  bool _wonArena = false;
414
  Timer? _timer;
Hixie's avatar
Hixie committed
415

416
  OffsetPair _lastPosition;
417
  OffsetPair? _finalPosition;
Hixie's avatar
Hixie committed
418

Ian Hickson's avatar
Ian Hickson committed
419
  void handleEvent(PointerEvent event) {
Hixie's avatar
Hixie committed
420
    assert(event.pointer == pointer);
Ian Hickson's avatar
Ian Hickson committed
421
    if (event is PointerMoveEvent) {
422
      if (!isWithinGlobalTolerance(event, computeHitSlop(event.kind, gestureSettings))) {
Hixie's avatar
Hixie committed
423
        cancel();
424
      } else {
425
        _lastPosition = OffsetPair.fromEventPosition(event);
426
      }
Ian Hickson's avatar
Ian Hickson committed
427
    } else if (event is PointerCancelEvent) {
Hixie's avatar
Hixie committed
428
      cancel();
Ian Hickson's avatar
Ian Hickson committed
429
    } else if (event is PointerUpEvent) {
430
      stopTrackingPointer(handleEvent);
431
      _finalPosition = OffsetPair.fromEventPosition(event);
Hixie's avatar
Hixie committed
432 433 434 435
      _check();
    }
  }

436
  @override
437
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
438 439
    _timer?.cancel();
    _timer = null;
440
    super.stopTrackingPointer(route);
Hixie's avatar
Hixie committed
441 442
  }

Hixie's avatar
Hixie committed
443 444 445 446 447 448
  void accept() {
    _wonArena = true;
    _check();
  }

  void reject() {
449
    stopTrackingPointer(handleEvent);
450
    gestureRecognizer._dispatchCancel(pointer);
Hixie's avatar
Hixie committed
451 452 453 454 455
  }

  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.
456
    if (_wonArena) {
Hixie's avatar
Hixie committed
457
      reject();
458
    } else {
459
      entry.resolve(GestureDisposition.rejected); // eventually calls reject()
460
    }
Hixie's avatar
Hixie committed
461 462 463
  }

  void _check() {
464
    if (_wonArena && _finalPosition != null) {
465
      gestureRecognizer._dispatchTap(pointer, _finalPosition!);
466
    }
Hixie's avatar
Hixie committed
467 468 469
  }
}

470 471 472 473 474 475 476 477 478
/// 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]
479
class MultiTapGestureRecognizer extends GestureRecognizer {
480 481
  /// Creates a multi-tap gesture recognizer.
  ///
482
  /// The [longTapDelay] defaults to [Duration.zero], which means
483
  /// [onLongTapDown] is called immediately after [onTapDown].
484 485
  ///
  /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
Hixie's avatar
Hixie committed
486
  MultiTapGestureRecognizer({
487
    this.longTapDelay = Duration.zero,
488
    super.debugOwner,
489 490 491 492
    @Deprecated(
      'Migrate to supportedDevices. '
      'This feature was deprecated after v2.3.0-1.0.pre.',
    )
493 494 495
    super.kind,
    super.supportedDevices,
  });
Hixie's avatar
Hixie committed
496

497 498
  /// A pointer that might cause a tap has contacted the screen at a particular
  /// location.
499
  GestureMultiTapDownCallback? onTapDown;
500 501 502

  /// A pointer that will trigger a tap has stopped contacting the screen at a
  /// particular location.
503
  GestureMultiTapUpCallback? onTapUp;
504 505

  /// A tap has occurred.
506
  GestureMultiTapCallback? onTap;
507 508 509

  /// The pointer that previously triggered [onTapDown] will not end up causing
  /// a tap.
510
  GestureMultiTapCancelCallback? onTapCancel;
511 512

  /// The amount of time between [onTapDown] and [onLongTapDown].
Hixie's avatar
Hixie committed
513
  Duration longTapDelay;
514 515 516

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

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

521
  @override
522
  void addAllowedPointer(PointerDownEvent event) {
Hixie's avatar
Hixie committed
523
    assert(!_gestureMap.containsKey(event.pointer));
524
    _gestureMap[event.pointer] = _TapGesture(
Hixie's avatar
Hixie committed
525
      gestureRecognizer: this,
Hixie's avatar
Hixie committed
526
      event: event,
527
      longTapDelay: longTapDelay,
528
      gestureSettings: gestureSettings,
Hixie's avatar
Hixie committed
529
    );
530
    if (onTapDown != null) {
531
      invokeCallback<void>('onTapDown', () {
532
        onTapDown!(event.pointer, TapDownDetails(
533
          globalPosition: event.position,
534
          localPosition: event.localPosition,
535 536 537
          kind: event.kind,
        ));
      });
538
    }
Hixie's avatar
Hixie committed
539 540
  }

541
  @override
Hixie's avatar
Hixie committed
542 543
  void acceptGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
544
    _gestureMap[pointer]!.accept();
Hixie's avatar
Hixie committed
545 546
  }

547
  @override
Hixie's avatar
Hixie committed
548 549
  void rejectGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
550
    _gestureMap[pointer]!.reject();
Hixie's avatar
Hixie committed
551
    assert(!_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
552 553
  }

554 555
  void _dispatchCancel(int pointer) {
    assert(_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
556
    _gestureMap.remove(pointer);
557
    if (onTapCancel != null) {
558
      invokeCallback<void>('onTapCancel', () => onTapCancel!(pointer));
559
    }
560 561
  }

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

579
  void _dispatchLongTap(int pointer, OffsetPair lastPosition) {
Hixie's avatar
Hixie committed
580
    assert(_gestureMap.containsKey(pointer));
581
    if (onLongTapDown != null) {
582
      invokeCallback<void>('onLongTapDown', () {
583
        onLongTapDown!(
584 585
          pointer,
          TapDownDetails(
586 587
            globalPosition: lastPosition.global,
            localPosition: lastPosition.local,
588 589 590 591
            kind: getKindForPointer(pointer),
          ),
        );
      });
592
    }
Hixie's avatar
Hixie committed
593 594
  }

595
  @override
Hixie's avatar
Hixie committed
596
  void dispose() {
597
    final List<_TapGesture> localGestures = List<_TapGesture>.of(_gestureMap.values);
598
    for (final _TapGesture gesture in localGestures) {
Hixie's avatar
Hixie committed
599
      gesture.cancel();
600
    }
Hixie's avatar
Hixie committed
601 602
    // Rejection of each gesture should cause it to be removed from our map
    assert(_gestureMap.isEmpty);
603
    super.dispose();
Hixie's avatar
Hixie committed
604 605
  }

606
  @override
607
  String get debugDescription => 'multitap';
Hixie's avatar
Hixie committed
608
}
609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813

/// Signature used by [SerialTapGestureRecognizer.onSerialTapDown] for when a
/// pointer that might cause a serial tap has contacted the screen at a
/// particular location.
typedef GestureSerialTapDownCallback = void Function(SerialTapDownDetails details);

/// Details for [GestureSerialTapDownCallback], such as the tap count within
/// the series.
///
/// See also:
///
///  * [SerialTapGestureRecognizer], which passes this information to its
///    [SerialTapGestureRecognizer.onSerialTapDown] callback.
class SerialTapDownDetails {
  /// Creates details for a [GestureSerialTapDownCallback].
  ///
  /// The `count` argument must be greater than zero.
  SerialTapDownDetails({
    this.globalPosition = Offset.zero,
    Offset? localPosition,
    required this.kind,
    this.buttons = 0,
    this.count = 1,
  }) : assert(count > 0),
       localPosition = localPosition ?? globalPosition;

  /// The global position at which the pointer contacted the screen.
  final Offset globalPosition;

  /// The local position at which the pointer contacted the screen.
  final Offset localPosition;

  /// The kind of the device that initiated the event.
  final PointerDeviceKind kind;

  /// Which buttons were pressed when the pointer contacted the screen.
  ///
  /// See also:
  ///
  ///  * [PointerEvent.buttons], which this field reflects.
  final int buttons;

  /// The number of consecutive taps that this "tap down" represents.
  ///
  /// This value will always be greater than zero. When the first pointer in a
  /// possible series contacts the screen, this value will be `1`, the second
  /// tap in a double-tap will be `2`, and so on.
  ///
  /// If a tap is determined to not be in the same series as the tap that
  /// preceded it (e.g. because too much time elapsed between the two taps or
  /// the two taps had too much distance between them), then this count will
  /// reset back to `1`, and a new series will have begun.
  final int count;
}

/// Signature used by [SerialTapGestureRecognizer.onSerialTapCancel] for when a
/// pointer that previously triggered a [GestureSerialTapDownCallback] will not
/// end up completing the serial tap.
typedef GestureSerialTapCancelCallback = void Function(SerialTapCancelDetails details);

/// Details for [GestureSerialTapCancelCallback], such as the tap count within
/// the series.
///
/// See also:
///
///  * [SerialTapGestureRecognizer], which passes this information to its
///    [SerialTapGestureRecognizer.onSerialTapCancel] callback.
class SerialTapCancelDetails {
  /// Creates details for a [GestureSerialTapCancelCallback].
  ///
  /// The `count` argument must be greater than zero.
  SerialTapCancelDetails({
    this.count = 1,
  }) : assert(count != null),
       assert(count > 0);

  /// The number of consecutive taps that were in progress when the gesture was
  /// interrupted.
  ///
  /// This number will match the corresponding count that was specified in
  /// [SerialTapDownDetails.count] for the tap that is being canceled. See
  /// that field for more information on how this count is reported.
  final int count;
}

/// Signature used by [SerialTapGestureRecognizer.onSerialTapUp] for when a
/// pointer that will trigger a serial tap has stopped contacting the screen.
typedef GestureSerialTapUpCallback = void Function(SerialTapUpDetails details);

/// Details for [GestureSerialTapUpCallback], such as the tap count within
/// the series.
///
/// See also:
///
///  * [SerialTapGestureRecognizer], which passes this information to its
///    [SerialTapGestureRecognizer.onSerialTapUp] callback.
class SerialTapUpDetails {
  /// Creates details for a [GestureSerialTapUpCallback].
  ///
  /// The `count` argument must be greater than zero.
  SerialTapUpDetails({
    this.globalPosition = Offset.zero,
    Offset? localPosition,
    this.kind,
    this.count = 1,
  }) : assert(count > 0),
       localPosition = localPosition ?? globalPosition;

  /// The global position at which the pointer contacted the screen.
  final Offset globalPosition;

  /// The local position at which the pointer contacted the screen.
  final Offset localPosition;

  /// The kind of the device that initiated the event.
  final PointerDeviceKind? kind;

  /// The number of consecutive taps that this tap represents.
  ///
  /// This value will always be greater than zero. When the first pointer in a
  /// possible series completes its tap, this value will be `1`, the second
  /// tap in a double-tap will be `2`, and so on.
  ///
  /// If a tap is determined to not be in the same series as the tap that
  /// preceded it (e.g. because too much time elapsed between the two taps or
  /// the two taps had too much distance between them), then this count will
  /// reset back to `1`, and a new series will have begun.
  final int count;
}

/// Recognizes serial taps (taps in a series).
///
/// A collection of taps are considered to be _in a series_ if they occur in
/// rapid succession in the same location (within a tolerance). The number of
/// taps in the series is its count. A double-tap, for instance, is a special
/// case of a tap series with a count of two.
///
/// ### Gesture arena behavior
///
/// [SerialTapGestureRecognizer] competes on all pointer events (regardless of
/// button). It will declare defeat if it determines that a gesture is not a
/// tap (e.g. if the pointer is dragged too far while it's contacting the
/// screen). It will immediately declare victory for every tap that it
/// recognizes.
///
/// Each time a pointer contacts the screen, this recognizer will enter that
/// gesture into the arena. This means that this recognizer will yield multiple
/// winning entries in the arena for a single tap series as the series
/// progresses.
///
/// If this recognizer loses the arena (either by declaring defeat or by
/// another recognizer declaring victory) while the pointer is contacting the
/// screen, it will fire [onSerialTapCancel], and [onSerialTapUp] will not
/// be fired.
///
/// ### Button behavior
///
/// A tap series is defined to have the same buttons across all taps. If a tap
/// with a different combination of buttons is delivered in the middle of a
/// series, it will "steal" the series and begin a new series, starting the
/// count over.
///
/// ### Interleaving tap behavior
///
/// A tap must be _completed_ in order for a subsequent tap to be considered
/// "in the same series" as that tap. Thus, if tap A is in-progress (the down
/// event has been received, but the corresponding up event has not yet been
/// received), and tap B begins (another pointer contacts the screen), tap A
/// will fire [onSerialTapCancel], and tap B will begin a new series (tap B's
/// [SerialTapDownDetails.count] will be 1).
///
/// ### Relation to `TapGestureRecognizer` and `DoubleTapGestureRecognizer`
///
/// [SerialTapGestureRecognizer] fires [onSerialTapDown] and [onSerialTapUp]
/// for every tap that it recognizes (passing the count in the details),
/// regardless of whether that tap is a single-tap, double-tap, etc. This
/// makes it especially useful when you want to respond to every tap in a
/// series. Contrast this with [DoubleTapGestureRecognizer], which only fires
/// if the user completes a double-tap, and [TapGestureRecognizer], which
/// _doesn't_ fire if the recognizer is competing with a
/// `DoubleTapGestureRecognizer`, and the user double-taps.
///
/// For example, consider a list item that should be _selected_ on the first
/// tap and _cause an edit dialog to open_ on a double-tap. If you use both
/// [TapGestureRecognizer] and [DoubleTapGestureRecognizer], there are a few
/// problems:
///
///   1. If the user single-taps the list item, it will not select
///      the list item until after enough time has passed to rule out a
///      double-tap.
///   2. If the user double-taps the list item, it will not select the list
///      item at all.
///
/// The solution is to use [SerialTapGestureRecognizer] and use the tap count
/// to either select the list item or open the edit dialog.
///
/// ### When competing with `TapGestureRecognizer` and `DoubleTapGestureRecognizer`
///
/// Unlike [TapGestureRecognizer] and [DoubleTapGestureRecognizer],
/// [SerialTapGestureRecognizer] aggressively declares victory when it detects
/// a tap, so when it is competing with those gesture recognizers, it will beat
/// them in the arena, regardless of which recognizer entered the arena first.
class SerialTapGestureRecognizer extends GestureRecognizer {
  /// Creates a serial tap gesture recognizer.
  SerialTapGestureRecognizer({
814 815 816
    super.debugOwner,
    super.supportedDevices,
  });
817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900

  /// A pointer has contacted the screen at a particular location, which might
  /// be the start of a serial tap.
  ///
  /// If this recognizer loses the arena before the serial tap is completed
  /// (either because the gesture does not end up being a tap or because another
  /// recognizer wins the arena), [onSerialTapCancel] is called next. Otherwise,
  /// [onSerialTapUp] is called next.
  ///
  /// The [SerialTapDownDetails.count] that is passed to this callback
  /// specifies the series tap count.
  GestureSerialTapDownCallback? onSerialTapDown;

  /// A pointer that previously triggered [onSerialTapDown] will not end up
  /// triggering the corresponding [onSerialTapUp].
  ///
  /// If the user completes the serial tap, [onSerialTapUp] is called instead.
  ///
  /// The [SerialTapCancelDetails.count] that is passed to this callback will
  /// match the [SerialTapDownDetails.count] that was passed to the
  /// [onSerialTapDown] callback.
  GestureSerialTapCancelCallback? onSerialTapCancel;

  /// A pointer has stopped contacting the screen at a particular location,
  /// representing a serial tap.
  ///
  /// If the user didn't complete the tap, or if another recognizer won the
  /// arena, then [onSerialTapCancel] is called instead.
  ///
  /// The [SerialTapUpDetails.count] that is passed to this callback specifies
  /// the series tap count and will match the [SerialTapDownDetails.count] that
  /// was passed to the [onSerialTapDown] callback.
  GestureSerialTapUpCallback? onSerialTapUp;

  Timer? _serialTapTimer;
  final List<_TapTracker> _completedTaps = <_TapTracker>[];
  final Map<int, GestureDisposition> _gestureResolutions = <int, GestureDisposition>{};
  _TapTracker? _pendingTap;

  /// Indicates whether this recognizer is currently tracking a pointer that's
  /// in contact with the screen.
  ///
  /// If this is true, it implies that [onSerialTapDown] has fired, but neither
  /// [onSerialTapCancel] nor [onSerialTapUp] have yet fired.
  bool get isTrackingPointer => _pendingTap != null;

  @override
  bool isPointerAllowed(PointerDownEvent event) {
    if (onSerialTapDown == null &&
        onSerialTapCancel == null &&
        onSerialTapUp == null) {
      return false;
    }
    return super.isPointerAllowed(event);
  }

  @override
  void addAllowedPointer(PointerDownEvent event) {
    if ((_completedTaps.isNotEmpty && !_representsSameSeries(_completedTaps.last, event))
        || _pendingTap != null) {
      _reset();
    }
    _trackTap(event);
  }

  bool _representsSameSeries(_TapTracker tap, PointerDownEvent event) {
    return tap.hasElapsedMinTime() // touch screens often detect touches intermittently
        && tap.hasSameButton(event)
        && tap.isWithinGlobalTolerance(event, kDoubleTapSlop);
  }

  void _trackTap(PointerDownEvent event) {
    _stopSerialTapTimer();
    if (onSerialTapDown != null) {
      final SerialTapDownDetails details = SerialTapDownDetails(
        globalPosition: event.position,
        localPosition: event.localPosition,
        kind: getKindForPointer(event.pointer),
        buttons: event.buttons,
        count: _completedTaps.length + 1,
      );
      invokeCallback<void>('onSerialTapDown', () => onSerialTapDown!(details));
    }
    final _TapTracker tracker = _TapTracker(
901
      gestureSettings: gestureSettings,
902
      event: event,
903
      entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024
      doubleTapMinTime: kDoubleTapMinTime,
    );
    assert(_pendingTap == null);
    _pendingTap = tracker;
    tracker.startTrackingPointer(_handleEvent, event.transform);
  }

  void _handleEvent(PointerEvent event) {
    assert(_pendingTap != null);
    assert(_pendingTap!.pointer == event.pointer);
    final _TapTracker tracker = _pendingTap!;
    if (event is PointerUpEvent) {
      _registerTap(event, tracker);
    } else if (event is PointerMoveEvent) {
      if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
        _reset();
      }
    } else if (event is PointerCancelEvent) {
      _reset();
    }
  }

  @override
  void acceptGesture(int pointer) {
    assert(_pendingTap != null);
    assert(_pendingTap!.pointer == pointer);
    _gestureResolutions[pointer] = GestureDisposition.accepted;
  }

  @override
  void rejectGesture(int pointer) {
    _gestureResolutions[pointer] = GestureDisposition.rejected;
    _reset();
  }

  void _rejectPendingTap() {
    assert(_pendingTap != null);
    final _TapTracker tracker = _pendingTap!;
    _pendingTap = null;
    // Order is important here; the `resolve` call can yield a re-entrant
    // `reset()`, so we need to check cancel here while we can trust the
    // length of our _completedTaps list.
    _checkCancel(_completedTaps.length + 1);
    if (!_gestureResolutions.containsKey(tracker.pointer)) {
      tracker.entry.resolve(GestureDisposition.rejected);
    }
    _stopTrackingPointer(tracker);
  }

  @override
  void dispose() {
    _reset();
    super.dispose();
  }

  void _reset() {
    if (_pendingTap != null) {
      _rejectPendingTap();
    }
    _pendingTap = null;
    _completedTaps.clear();
    _gestureResolutions.clear();
    _stopSerialTapTimer();
  }

  void _registerTap(PointerUpEvent event, _TapTracker tracker) {
    assert(tracker == _pendingTap);
    assert(tracker.pointer == event.pointer);
    _startSerialTapTimer();
    assert(_gestureResolutions[event.pointer] != GestureDisposition.rejected);
    if (!_gestureResolutions.containsKey(event.pointer)) {
      tracker.entry.resolve(GestureDisposition.accepted);
    }
    assert(_gestureResolutions[event.pointer] == GestureDisposition.accepted);
    _stopTrackingPointer(tracker);
    // Note, order is important below in order for the clear -> reject logic to
    // work properly.
    _pendingTap = null;
    _checkUp(event, tracker);
    _completedTaps.add(tracker);
  }

  void _stopTrackingPointer(_TapTracker tracker) {
    tracker.stopTrackingPointer(_handleEvent);
  }

  void _startSerialTapTimer() {
    _serialTapTimer ??= Timer(kDoubleTapTimeout, _reset);
  }

  void _stopSerialTapTimer() {
    if (_serialTapTimer != null) {
      _serialTapTimer!.cancel();
      _serialTapTimer = null;
    }
  }

  void _checkUp(PointerUpEvent event, _TapTracker tracker) {
    if (onSerialTapUp != null) {
      final SerialTapUpDetails details = SerialTapUpDetails(
        globalPosition: event.position,
        localPosition: event.localPosition,
        kind: getKindForPointer(tracker.pointer),
        count: _completedTaps.length + 1,
      );
      invokeCallback<void>('onSerialTapUp', () => onSerialTapUp!(details));
    }
  }

  void _checkCancel(int count) {
    if (onSerialTapCancel != null) {
      final SerialTapCancelDetails details = SerialTapCancelDetails(
        count: count,
      );
      invokeCallback<void>('onSerialTapCancel', () => onSerialTapCancel!(details));
    }
  }

  @override
  String get debugDescription => 'serial tap';
}