multitap.dart 17.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
// 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 'dart:ui' show Offset;
7
import 'package:vector_math/vector_math_64.dart';
Hixie's avatar
Hixie committed
8 9

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

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

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

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

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

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

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

  bool _timeout = false;

  bool get timeout => _timeout;

  void _onTimeout() {
    _timeout = true;
  }
}

Hixie's avatar
Hixie committed
57 58 59
/// TapTracker helps track individual tap sequences as part of a
/// larger gesture.
class _TapTracker {
60
  _TapTracker({
61
    required PointerDownEvent event,
62
    required this.entry,
63
    required Duration doubleTapMinTime,
64 65
  }) : assert(doubleTapMinTime != null),
       assert(event != null),
66
       assert(event.buttons != null),
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 int pointer;
73
  final GestureArenaEntry entry;
74
  final Offset _initialGlobalPosition;
75
  final int initialButtons;
76
  final _CountdownZoned _doubleTapMinTimeCountdown;
Hixie's avatar
Hixie committed
77 78 79

  bool _isTrackingPointer = false;

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

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

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

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

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

108 109
/// Recognizes when the user has tapped the screen at the same location twice in
/// quick succession.
110 111 112 113
///
/// [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.
///
114
class DoubleTapGestureRecognizer extends GestureRecognizer {
115
  /// Create a gesture recognizer for double taps.
116 117 118
  ///
  /// {@macro flutter.gestures.gestureRecognizer.kind}
  DoubleTapGestureRecognizer({
119 120
    Object? debugOwner,
    PointerDeviceKind? kind,
121
  }) : super(debugOwner: debugOwner, kind: kind);
122

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

147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
  /// 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;

163 164 165
  /// 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
166 167
  /// This triggers when the pointer stops contacting the device after the
  /// second tap.
168 169 170 171
  ///
  /// See also:
  ///
  ///  * [kPrimaryButton], the button this callback responds to.
172
  ///  * [GestureDetector.onDoubleTap], which exposes this callback.
173
  GestureDoubleTapCallback? onDoubleTap;
Hixie's avatar
Hixie committed
174

175 176 177 178 179 180 181 182 183 184 185 186 187 188
  /// 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;

189 190
  Timer? _doubleTapTimer;
  _TapTracker? _firstTap;
191
  final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
Hixie's avatar
Hixie committed
192

193
  @override
194
  bool isPointerAllowed(PointerDownEvent event) {
195 196 197
    if (_firstTap == null) {
      switch (event.buttons) {
        case kPrimaryButton:
198 199 200
          if (onDoubleTapDown == null &&
              onDoubleTap == null &&
              onDoubleTapCancel == null)
201 202 203 204 205 206
            return false;
          break;
        default:
          return false;
      }
    }
207
    return super.isPointerAllowed(event);
208 209
  }

210
  @override
211
  void addAllowedPointer(PointerDownEvent event) {
212
    if (_firstTap != null) {
213
      if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
214 215
        // Ignore out-of-bounds second taps.
        return;
216 217 218
      } 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.
219
        _reset();
220 221 222 223 224 225 226 227
        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));
228 229
      }
    }
230
    _trackTap(event);
231 232
  }

233
  void _trackTap(PointerDownEvent event) {
Hixie's avatar
Hixie committed
234
    _stopDoubleTapTimer();
235
    final _TapTracker tracker = _TapTracker(
236
      event: event,
237
      entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
238
      doubleTapMinTime: kDoubleTapMinTime,
Hixie's avatar
Hixie committed
239 240
    );
    _trackers[event.pointer] = tracker;
241
    tracker.startTrackingPointer(_handleEvent, event.transform);
Hixie's avatar
Hixie committed
242 243
  }

244
  void _handleEvent(PointerEvent event) {
245
    final _TapTracker tracker = _trackers[event.pointer]!;
Ian Hickson's avatar
Ian Hickson committed
246 247 248 249 250 251
    if (event is PointerUpEvent) {
      if (_firstTap == null)
        _registerFirstTap(tracker);
      else
        _registerSecondTap(tracker);
    } else if (event is PointerMoveEvent) {
252
      if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
Hixie's avatar
Hixie committed
253
        _reject(tracker);
Ian Hickson's avatar
Ian Hickson committed
254 255
    } else if (event is PointerCancelEvent) {
      _reject(tracker);
Hixie's avatar
Hixie committed
256 257 258
    }
  }

259
  @override
260
  void acceptGesture(int pointer) { }
Hixie's avatar
Hixie committed
261

262
  @override
Hixie's avatar
Hixie committed
263
  void rejectGesture(int pointer) {
264
    _TapTracker? tracker = _trackers[pointer];
Hixie's avatar
Hixie committed
265 266 267
    // If tracker isn't in the list, check if this is the first tap tracker
    if (tracker == null &&
        _firstTap != null &&
268
        _firstTap!.pointer == pointer)
Hixie's avatar
Hixie committed
269 270 271 272 273 274 275 276
      tracker = _firstTap;
    // If tracker is still null, we rejected ourselves already
    if (tracker != null)
      _reject(tracker);
  }

  void _reject(_TapTracker tracker) {
    _trackers.remove(tracker.pointer);
277
    tracker.entry.resolve(GestureDisposition.rejected);
Hixie's avatar
Hixie committed
278
    _freezeTracker(tracker);
279 280 281 282 283 284 285 286 287
    if (_firstTap != null) {
      if (tracker == _firstTap) {
        _reset();
      } else {
        _checkCancel();
        if (_trackers.isEmpty)
          _reset();
      }
    }
Hixie's avatar
Hixie committed
288 289
  }

290
  @override
Hixie's avatar
Hixie committed
291 292
  void dispose() {
    _reset();
293
    super.dispose();
Hixie's avatar
Hixie committed
294 295 296 297 298
  }

  void _reset() {
    _stopDoubleTapTimer();
    if (_firstTap != null) {
299 300
      if (_trackers.isNotEmpty)
        _checkCancel();
Hixie's avatar
Hixie committed
301
      // Note, order is important below in order for the resolve -> reject logic
Florian Loitsch's avatar
Florian Loitsch committed
302
      // to work properly.
303
      final _TapTracker tracker = _firstTap!;
Hixie's avatar
Hixie committed
304 305
      _firstTap = null;
      _reject(tracker);
306
      GestureBinding.instance!.gestureArena.release(tracker.pointer);
Hixie's avatar
Hixie committed
307 308 309 310 311 312
    }
    _clearTrackers();
  }

  void _registerFirstTap(_TapTracker tracker) {
    _startDoubleTapTimer();
313
    GestureBinding.instance!.gestureArena.hold(tracker.pointer);
Hixie's avatar
Hixie committed
314 315 316 317 318 319 320 321 322
    // 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) {
323 324
    _firstTap!.entry.resolve(GestureDisposition.accepted);
    tracker.entry.resolve(GestureDisposition.accepted);
Hixie's avatar
Hixie committed
325 326
    _freezeTracker(tracker);
    _trackers.remove(tracker.pointer);
327
    _checkUp(tracker.initialButtons);
Hixie's avatar
Hixie committed
328 329 330 331
    _reset();
  }

  void _clearTrackers() {
332
    _trackers.values.toList().forEach(_reject);
Hixie's avatar
Hixie committed
333 334 335 336
    assert(_trackers.isEmpty);
  }

  void _freezeTracker(_TapTracker tracker) {
337
    tracker.stopTrackingPointer(_handleEvent);
Hixie's avatar
Hixie committed
338 339 340
  }

  void _startDoubleTapTimer() {
341
    _doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
Hixie's avatar
Hixie committed
342 343 344 345
  }

  void _stopDoubleTapTimer() {
    if (_doubleTapTimer != null) {
346
      _doubleTapTimer!.cancel();
Hixie's avatar
Hixie committed
347 348 349 350
      _doubleTapTimer = null;
    }
  }

351 352 353
  void _checkUp(int buttons) {
    assert(buttons == kPrimaryButton);
    if (onDoubleTap != null)
354
      invokeCallback<void>('onDoubleTap', onDoubleTap!);
355 356
  }

357 358 359 360 361
  void _checkCancel() {
    if (onDoubleTapCancel != null)
      invokeCallback<void>('onDoubleTapCancel', onDoubleTapCancel!);
  }

362
  @override
363
  String get debugDescription => 'double tap';
Hixie's avatar
Hixie committed
364 365 366 367 368 369 370 371
}

/// 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({
372 373 374
    required this.gestureRecognizer,
    required PointerEvent event,
    required Duration longTapDelay,
375
  }) : _lastPosition = OffsetPair.fromEventPosition(event),
Ian Hickson's avatar
Ian Hickson committed
376
       super(
377
    event: event as PointerDownEvent,
378
    entry: GestureBinding.instance!.gestureArena.add(event.pointer, gestureRecognizer),
379
    doubleTapMinTime: kDoubleTapMinTime,
Ian Hickson's avatar
Ian Hickson committed
380
  ) {
381
    startTrackingPointer(handleEvent, event.transform);
382
    if (longTapDelay > Duration.zero) {
383
      _timer = Timer(longTapDelay, () {
Hixie's avatar
Hixie committed
384
        _timer = null;
385
        gestureRecognizer._dispatchLongTap(event.pointer, _lastPosition);
Hixie's avatar
Hixie committed
386 387
      });
    }
Hixie's avatar
Hixie committed
388 389 390 391 392
  }

  final MultiTapGestureRecognizer gestureRecognizer;

  bool _wonArena = false;
393
  Timer? _timer;
Hixie's avatar
Hixie committed
394

395
  OffsetPair _lastPosition;
396
  OffsetPair? _finalPosition;
Hixie's avatar
Hixie committed
397

Ian Hickson's avatar
Ian Hickson committed
398
  void handleEvent(PointerEvent event) {
Hixie's avatar
Hixie committed
399
    assert(event.pointer == pointer);
Ian Hickson's avatar
Ian Hickson committed
400
    if (event is PointerMoveEvent) {
401
      if (!isWithinGlobalTolerance(event, computeHitSlop(event.kind)))
Hixie's avatar
Hixie committed
402 403
        cancel();
      else
404
        _lastPosition = OffsetPair.fromEventPosition(event);
Ian Hickson's avatar
Ian Hickson committed
405
    } else if (event is PointerCancelEvent) {
Hixie's avatar
Hixie committed
406
      cancel();
Ian Hickson's avatar
Ian Hickson committed
407
    } else if (event is PointerUpEvent) {
408
      stopTrackingPointer(handleEvent);
409
      _finalPosition = OffsetPair.fromEventPosition(event);
Hixie's avatar
Hixie committed
410 411 412 413
      _check();
    }
  }

414
  @override
415
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
416 417
    _timer?.cancel();
    _timer = null;
418
    super.stopTrackingPointer(route);
Hixie's avatar
Hixie committed
419 420
  }

Hixie's avatar
Hixie committed
421 422 423 424 425 426
  void accept() {
    _wonArena = true;
    _check();
  }

  void reject() {
427
    stopTrackingPointer(handleEvent);
428
    gestureRecognizer._dispatchCancel(pointer);
Hixie's avatar
Hixie committed
429 430 431 432 433 434 435 436
  }

  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
437
      entry.resolve(GestureDisposition.rejected); // eventually calls reject()
Hixie's avatar
Hixie committed
438 439 440 441
  }

  void _check() {
    if (_wonArena && _finalPosition != null)
442
      gestureRecognizer._dispatchTap(pointer, _finalPosition!);
Hixie's avatar
Hixie committed
443 444 445
  }
}

446 447 448 449 450 451 452 453 454
/// 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]
455
class MultiTapGestureRecognizer extends GestureRecognizer {
456 457
  /// Creates a multi-tap gesture recognizer.
  ///
458
  /// The [longTapDelay] defaults to [Duration.zero], which means
459
  /// [onLongTapDown] is called immediately after [onTapDown].
Hixie's avatar
Hixie committed
460
  MultiTapGestureRecognizer({
461
    this.longTapDelay = Duration.zero,
462 463
    Object? debugOwner,
    PointerDeviceKind? kind,
464
  }) : super(debugOwner: debugOwner, kind: kind);
Hixie's avatar
Hixie committed
465

466 467
  /// A pointer that might cause a tap has contacted the screen at a particular
  /// location.
468
  GestureMultiTapDownCallback? onTapDown;
469 470 471

  /// A pointer that will trigger a tap has stopped contacting the screen at a
  /// particular location.
472
  GestureMultiTapUpCallback? onTapUp;
473 474

  /// A tap has occurred.
475
  GestureMultiTapCallback? onTap;
476 477 478

  /// The pointer that previously triggered [onTapDown] will not end up causing
  /// a tap.
479
  GestureMultiTapCancelCallback? onTapCancel;
480 481

  /// The amount of time between [onTapDown] and [onLongTapDown].
Hixie's avatar
Hixie committed
482
  Duration longTapDelay;
483 484 485

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

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

490
  @override
491
  void addAllowedPointer(PointerEvent event) {
Hixie's avatar
Hixie committed
492
    assert(!_gestureMap.containsKey(event.pointer));
493
    _gestureMap[event.pointer] = _TapGesture(
Hixie's avatar
Hixie committed
494
      gestureRecognizer: this,
Hixie's avatar
Hixie committed
495
      event: event,
496
      longTapDelay: longTapDelay,
Hixie's avatar
Hixie committed
497 498
    );
    if (onTapDown != null)
499
      invokeCallback<void>('onTapDown', () {
500
        onTapDown!(event.pointer, TapDownDetails(
501
          globalPosition: event.position,
502
          localPosition: event.localPosition,
503 504 505
          kind: event.kind,
        ));
      });
Hixie's avatar
Hixie committed
506 507
  }

508
  @override
Hixie's avatar
Hixie committed
509 510
  void acceptGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
511
    _gestureMap[pointer]!.accept();
Hixie's avatar
Hixie committed
512 513
  }

514
  @override
Hixie's avatar
Hixie committed
515 516
  void rejectGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
517
    _gestureMap[pointer]!.reject();
Hixie's avatar
Hixie committed
518
    assert(!_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
519 520
  }

521 522
  void _dispatchCancel(int pointer) {
    assert(_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
523
    _gestureMap.remove(pointer);
524
    if (onTapCancel != null)
525
      invokeCallback<void>('onTapCancel', () => onTapCancel!(pointer));
526 527
  }

528
  void _dispatchTap(int pointer, OffsetPair position) {
529 530 531
    assert(_gestureMap.containsKey(pointer));
    _gestureMap.remove(pointer);
    if (onTapUp != null)
532
      invokeCallback<void>('onTapUp', () {
533
        onTapUp!(pointer, TapUpDetails(
534
          kind: getKindForPointer(pointer),
535 536 537 538
          localPosition: position.local,
          globalPosition: position.global,
        ));
      });
539
    if (onTap != null)
540
      invokeCallback<void>('onTap', () => onTap!(pointer));
Hixie's avatar
Hixie committed
541 542
  }

543
  void _dispatchLongTap(int pointer, OffsetPair lastPosition) {
Hixie's avatar
Hixie committed
544 545
    assert(_gestureMap.containsKey(pointer));
    if (onLongTapDown != null)
546
      invokeCallback<void>('onLongTapDown', () {
547
        onLongTapDown!(
548 549
          pointer,
          TapDownDetails(
550 551
            globalPosition: lastPosition.global,
            localPosition: lastPosition.local,
552 553 554 555
            kind: getKindForPointer(pointer),
          ),
        );
      });
Hixie's avatar
Hixie committed
556 557
  }

558
  @override
Hixie's avatar
Hixie committed
559
  void dispose() {
560
    final List<_TapGesture> localGestures = List<_TapGesture>.from(_gestureMap.values);
561
    for (final _TapGesture gesture in localGestures)
Hixie's avatar
Hixie committed
562 563 564
      gesture.cancel();
    // Rejection of each gesture should cause it to be removed from our map
    assert(_gestureMap.isEmpty);
565
    super.dispose();
Hixie's avatar
Hixie committed
566 567
  }

568
  @override
569
  String get debugDescription => 'multitap';
Hixie's avatar
Hixie committed
570
}