multitap.dart 33.6 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
    Timer(duration, _onTimeout);
48 49 50 51 52 53 54 55 56 57 58
  }

  bool _timeout = false;

  bool get timeout => _timeout;

  void _onTimeout() {
    _timeout = true;
  }
}

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

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

  bool _isTrackingPointer = false;

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

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

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

  bool hasElapsedMinTime() {
    return _doubleTapMinTimeCountdown.timeout;
  }
103 104 105 106

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

109 110
/// Recognizes when the user has tapped the screen at the same location twice in
/// quick succession.
111
///
112 113
/// [DoubleTapGestureRecognizer] competes on pointer events when it
/// has a non-null callback. If it has no callbacks, it is a no-op.
114
///
115
class DoubleTapGestureRecognizer extends GestureRecognizer {
116
  /// Create a gesture recognizer for double taps.
117
  ///
118
  /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
119
  DoubleTapGestureRecognizer({
120 121
    super.debugOwner,
    super.supportedDevices,
122 123
    AllowedButtonsFilter? allowedButtonsFilter,
  }) : super(allowedButtonsFilter: allowedButtonsFilter ?? _defaultButtonAcceptBehavior);
124

125 126 127 128
  // The default value for [allowedButtonsFilter].
  // Accept the input if, and only if, [kPrimaryButton] is pressed.
  static bool _defaultButtonAcceptBehavior(int buttons) => buttons == kPrimaryButton;

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

153 154 155 156 157 158 159 160 161 162 163
  /// 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:
  ///
164
  ///  * [allowedButtonsFilter], which decides which button will be allowed.
165 166 167 168
  ///  * [TapDownDetails], which is passed as an argument to this callback.
  ///  * [GestureDetector.onDoubleTapDown], which exposes this callback.
  GestureTapDownCallback? onDoubleTapDown;

169 170 171
  /// 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
172 173
  /// This triggers when the pointer stops contacting the device after the
  /// second tap.
174 175 176
  ///
  /// See also:
  ///
177
  ///  * [allowedButtonsFilter], which decides which button will be allowed.
178
  ///  * [GestureDetector.onDoubleTap], which exposes this callback.
179
  GestureDoubleTapCallback? onDoubleTap;
Hixie's avatar
Hixie committed
180

181 182 183 184 185 186 187 188 189 190
  /// 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:
  ///
191
  ///  * [allowedButtonsFilter], which decides which button will be allowed.
192 193 194
  ///  * [GestureDetector.onDoubleTapCancel], which exposes this callback.
  GestureTapCancelCallback? onDoubleTapCancel;

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

199
  @override
200
  bool isPointerAllowed(PointerDownEvent event) {
201
    if (_firstTap == null) {
202 203 204 205
      if (onDoubleTapDown == null &&
          onDoubleTap == null &&
          onDoubleTapCancel == null) {
        return false;
206 207
      }
    }
208 209 210

    // If second tap is not allowed, reset the state.
    final bool isPointerAllowed = super.isPointerAllowed(event);
211
    if (!isPointerAllowed) {
212 213 214
      _reset();
    }
    return isPointerAllowed;
215 216
  }

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

240
  void _trackTap(PointerDownEvent event) {
Hixie's avatar
Hixie committed
241
    _stopDoubleTapTimer();
242
    final _TapTracker tracker = _TapTracker(
243
      event: event,
244
      entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
245
      doubleTapMinTime: kDoubleTapMinTime,
246
      gestureSettings: gestureSettings,
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
    if (event is PointerUpEvent) {
255
      if (_firstTap == null) {
Ian Hickson's avatar
Ian Hickson committed
256
        _registerFirstTap(tracker);
257
      } else {
Ian Hickson's avatar
Ian Hickson committed
258
        _registerSecondTap(tracker);
259
      }
Ian Hickson's avatar
Ian Hickson committed
260
    } else if (event is PointerMoveEvent) {
261
      if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
Hixie's avatar
Hixie committed
262
        _reject(tracker);
263
      }
Ian Hickson's avatar
Ian Hickson committed
264 265
    } else if (event is PointerCancelEvent) {
      _reject(tracker);
Hixie's avatar
Hixie committed
266 267 268
    }
  }

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

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

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

303
  @override
Hixie's avatar
Hixie committed
304 305
  void dispose() {
    _reset();
306
    super.dispose();
Hixie's avatar
Hixie committed
307 308 309 310 311
  }

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

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

  void _clearTrackers() {
346
    _trackers.values.toList().forEach(_reject);
Hixie's avatar
Hixie committed
347 348 349 350
    assert(_trackers.isEmpty);
  }

  void _freezeTracker(_TapTracker tracker) {
351
    tracker.stopTrackingPointer(_handleEvent);
Hixie's avatar
Hixie committed
352 353 354
  }

  void _startDoubleTapTimer() {
355
    _doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
Hixie's avatar
Hixie committed
356 357 358 359
  }

  void _stopDoubleTapTimer() {
    if (_doubleTapTimer != null) {
360
      _doubleTapTimer!.cancel();
Hixie's avatar
Hixie committed
361 362 363 364
      _doubleTapTimer = null;
    }
  }

365
  void _checkUp(int buttons) {
366
    if (onDoubleTap != null) {
367
      invokeCallback<void>('onDoubleTap', onDoubleTap!);
368
    }
369 370
  }

371
  void _checkCancel() {
372
    if (onDoubleTapCancel != null) {
373
      invokeCallback<void>('onDoubleTapCancel', onDoubleTapCancel!);
374
    }
375 376
  }

377
  @override
378
  String get debugDescription => 'double tap';
Hixie's avatar
Hixie committed
379 380 381 382 383 384 385 386
}

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

  final MultiTapGestureRecognizer gestureRecognizer;

  bool _wonArena = false;
409
  Timer? _timer;
Hixie's avatar
Hixie committed
410

411
  OffsetPair _lastPosition;
412
  OffsetPair? _finalPosition;
Hixie's avatar
Hixie committed
413

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

431
  @override
432
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
433 434
    _timer?.cancel();
    _timer = null;
435
    super.stopTrackingPointer(route);
Hixie's avatar
Hixie committed
436 437
  }

Hixie's avatar
Hixie committed
438 439 440 441 442 443
  void accept() {
    _wonArena = true;
    _check();
  }

  void reject() {
444
    stopTrackingPointer(handleEvent);
445
    gestureRecognizer._dispatchCancel(pointer);
Hixie's avatar
Hixie committed
446 447 448 449 450
  }

  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.
451
    if (_wonArena) {
Hixie's avatar
Hixie committed
452
      reject();
453
    } else {
454
      entry.resolve(GestureDisposition.rejected); // eventually calls reject()
455
    }
Hixie's avatar
Hixie committed
456 457 458
  }

  void _check() {
459
    if (_wonArena && _finalPosition != null) {
460
      gestureRecognizer._dispatchTap(pointer, _finalPosition!);
461
    }
Hixie's avatar
Hixie committed
462 463 464
  }
}

465 466 467 468 469 470 471 472 473
/// 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]
474
class MultiTapGestureRecognizer extends GestureRecognizer {
475 476
  /// Creates a multi-tap gesture recognizer.
  ///
477
  /// The [longTapDelay] defaults to [Duration.zero], which means
478
  /// [onLongTapDown] is called immediately after [onTapDown].
479 480
  ///
  /// {@macro flutter.gestures.GestureRecognizer.supportedDevices}
Hixie's avatar
Hixie committed
481
  MultiTapGestureRecognizer({
482
    this.longTapDelay = Duration.zero,
483 484
    super.debugOwner,
    super.supportedDevices,
485
    super.allowedButtonsFilter,
486
  });
Hixie's avatar
Hixie committed
487

488 489
  /// A pointer that might cause a tap has contacted the screen at a particular
  /// location.
490
  GestureMultiTapDownCallback? onTapDown;
491 492 493

  /// A pointer that will trigger a tap has stopped contacting the screen at a
  /// particular location.
494
  GestureMultiTapUpCallback? onTapUp;
495 496

  /// A tap has occurred.
497
  GestureMultiTapCallback? onTap;
498 499 500

  /// The pointer that previously triggered [onTapDown] will not end up causing
  /// a tap.
501
  GestureMultiTapCancelCallback? onTapCancel;
502 503

  /// The amount of time between [onTapDown] and [onLongTapDown].
Hixie's avatar
Hixie committed
504
  Duration longTapDelay;
505 506 507

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

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

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

532
  @override
Hixie's avatar
Hixie committed
533 534
  void acceptGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
535
    _gestureMap[pointer]!.accept();
Hixie's avatar
Hixie committed
536 537
  }

538
  @override
Hixie's avatar
Hixie committed
539 540
  void rejectGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
541
    _gestureMap[pointer]!.reject();
Hixie's avatar
Hixie committed
542
    assert(!_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
543 544
  }

545 546
  void _dispatchCancel(int pointer) {
    assert(_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
547
    _gestureMap.remove(pointer);
548
    if (onTapCancel != null) {
549
      invokeCallback<void>('onTapCancel', () => onTapCancel!(pointer));
550
    }
551 552
  }

553
  void _dispatchTap(int pointer, OffsetPair position) {
554 555
    assert(_gestureMap.containsKey(pointer));
    _gestureMap.remove(pointer);
556
    if (onTapUp != null) {
557
      invokeCallback<void>('onTapUp', () {
558
        onTapUp!(pointer, TapUpDetails(
559
          kind: getKindForPointer(pointer),
560 561 562 563
          localPosition: position.local,
          globalPosition: position.global,
        ));
      });
564 565
    }
    if (onTap != null) {
566
      invokeCallback<void>('onTap', () => onTap!(pointer));
567
    }
Hixie's avatar
Hixie committed
568 569
  }

570
  void _dispatchLongTap(int pointer, OffsetPair lastPosition) {
Hixie's avatar
Hixie committed
571
    assert(_gestureMap.containsKey(pointer));
572
    if (onLongTapDown != null) {
573
      invokeCallback<void>('onLongTapDown', () {
574
        onLongTapDown!(
575 576
          pointer,
          TapDownDetails(
577 578
            globalPosition: lastPosition.global,
            localPosition: lastPosition.local,
579 580 581 582
            kind: getKindForPointer(pointer),
          ),
        );
      });
583
    }
Hixie's avatar
Hixie committed
584 585
  }

586
  @override
Hixie's avatar
Hixie committed
587
  void dispose() {
588
    final List<_TapGesture> localGestures = List<_TapGesture>.of(_gestureMap.values);
589
    for (final _TapGesture gesture in localGestures) {
Hixie's avatar
Hixie committed
590
      gesture.cancel();
591
    }
Hixie's avatar
Hixie committed
592 593
    // Rejection of each gesture should cause it to be removed from our map
    assert(_gestureMap.isEmpty);
594
    super.dispose();
Hixie's avatar
Hixie committed
595 596
  }

597
  @override
598
  String get debugDescription => 'multitap';
Hixie's avatar
Hixie committed
599
}
600 601 602 603 604 605 606 607 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

/// 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,
673
  }) : assert(count > 0);
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

  /// 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({
804 805
    super.debugOwner,
    super.supportedDevices,
806
    super.allowedButtonsFilter,
807
  });
808 809 810 811 812 813 814 815 816 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

  /// 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(
892
      gestureSettings: gestureSettings,
893
      event: event,
894
      entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
895 896 897 898 899 900 901 902 903 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
      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';
}