multitap.dart 15.9 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:flutter/foundation.dart' show required;
8
import 'package:vector_math/vector_math_64.dart';
Hixie's avatar
Hixie committed
9 10

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

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

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

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

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

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

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

  bool _timeout = false;

  bool get timeout => _timeout;

  void _onTimeout() {
    _timeout = true;
  }
}

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

  final int pointer;
  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 114
///
/// [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.
///
115
class DoubleTapGestureRecognizer extends GestureRecognizer {
116
  /// Create a gesture recognizer for double taps.
117 118 119 120 121 122
  ///
  /// {@macro flutter.gestures.gestureRecognizer.kind}
  DoubleTapGestureRecognizer({
    Object debugOwner,
    PointerDeviceKind kind,
  }) : super(debugOwner: debugOwner, kind: kind);
123

Hixie's avatar
Hixie committed
124 125 126
  // Implementation notes:
  // 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:
Hixie's avatar
Hixie committed
128 129 130 131 132 133 134 135 136
  // Waiting on first tap: In this state, the _trackers list is empty, and
  // _firstTap is null.
  // 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.
  // 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.
  // Second tap in progress: Much like the "first tap in progress" state, but
137
  // _firstTap is non-null. If a tap completes successfully while in this
138
  // state, the callback is called and the state is reset.
Hixie's avatar
Hixie committed
139 140 141 142 143
  // There are various other scenarios that cause the state to reset:
  // - 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

144 145 146
  /// 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
147 148
  /// This triggers when the pointer stops contacting the device after the
  /// second tap.
149 150 151 152
  ///
  /// See also:
  ///
  ///  * [kPrimaryButton], the button this callback responds to.
Hixie's avatar
Hixie committed
153
  GestureDoubleTapCallback onDoubleTap;
Hixie's avatar
Hixie committed
154 155 156

  Timer _doubleTapTimer;
  _TapTracker _firstTap;
157
  final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
Hixie's avatar
Hixie committed
158

159 160 161 162 163 164 165 166 167 168 169 170
  @override
  bool isPointerAllowed(PointerEvent event) {
    if (_firstTap == null) {
      switch (event.buttons) {
        case kPrimaryButton:
          if (onDoubleTap == null)
            return false;
          break;
        default:
          return false;
      }
    }
171
    return super.isPointerAllowed(event as PointerDownEvent);
172 173
  }

174
  @override
175
  void addAllowedPointer(PointerEvent event) {
176
    if (_firstTap != null) {
177
      if (!_firstTap.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
178 179
        // Ignore out-of-bounds second taps.
        return;
180
      } else if (!_firstTap.hasElapsedMinTime() || !_firstTap.hasSameButton(event as PointerDownEvent)) {
181 182
        // Restart when the second tap is too close to the first, or when buttons
        // mismatch.
183
        _reset();
184
        return _trackFirstTap(event);
185 186
      }
    }
187 188 189 190
    _trackFirstTap(event);
  }

  void _trackFirstTap(PointerEvent event) {
Hixie's avatar
Hixie committed
191
    _stopDoubleTapTimer();
192
    final _TapTracker tracker = _TapTracker(
193
      event: event as PointerDownEvent,
194
      entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
195
      doubleTapMinTime: kDoubleTapMinTime,
Hixie's avatar
Hixie committed
196 197
    );
    _trackers[event.pointer] = tracker;
198
    tracker.startTrackingPointer(_handleEvent, event.transform);
Hixie's avatar
Hixie committed
199 200
  }

201
  void _handleEvent(PointerEvent event) {
202
    final _TapTracker tracker = _trackers[event.pointer];
Hixie's avatar
Hixie committed
203
    assert(tracker != null);
Ian Hickson's avatar
Ian Hickson committed
204 205 206 207 208 209
    if (event is PointerUpEvent) {
      if (_firstTap == null)
        _registerFirstTap(tracker);
      else
        _registerSecondTap(tracker);
    } else if (event is PointerMoveEvent) {
210
      if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
Hixie's avatar
Hixie committed
211
        _reject(tracker);
Ian Hickson's avatar
Ian Hickson committed
212 213
    } else if (event is PointerCancelEvent) {
      _reject(tracker);
Hixie's avatar
Hixie committed
214 215 216
    }
  }

217
  @override
218
  void acceptGesture(int pointer) { }
Hixie's avatar
Hixie committed
219

220
  @override
Hixie's avatar
Hixie committed
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
  void rejectGesture(int pointer) {
    _TapTracker tracker = _trackers[pointer];
    // If tracker isn't in the list, check if this is the first tap tracker
    if (tracker == null &&
        _firstTap != null &&
        _firstTap.pointer == pointer)
      tracker = _firstTap;
    // If tracker is still null, we rejected ourselves already
    if (tracker != null)
      _reject(tracker);
  }

  void _reject(_TapTracker tracker) {
    _trackers.remove(tracker.pointer);
    tracker.entry.resolve(GestureDisposition.rejected);
    _freezeTracker(tracker);
    // If the first tap is in progress, and we've run out of taps to track,
238
    // reset won't have any work to do. But if we're in the second tap, we need
Hixie's avatar
Hixie committed
239 240 241 242 243 244
    // to clear intermediate state.
    if (_firstTap != null &&
        (_trackers.isEmpty || tracker == _firstTap))
      _reset();
  }

245
  @override
Hixie's avatar
Hixie committed
246 247
  void dispose() {
    _reset();
248
    super.dispose();
Hixie's avatar
Hixie committed
249 250 251 252 253 254
  }

  void _reset() {
    _stopDoubleTapTimer();
    if (_firstTap != null) {
      // Note, order is important below in order for the resolve -> reject logic
Florian Loitsch's avatar
Florian Loitsch committed
255
      // to work properly.
256
      final _TapTracker tracker = _firstTap;
Hixie's avatar
Hixie committed
257 258
      _firstTap = null;
      _reject(tracker);
259
      GestureBinding.instance.gestureArena.release(tracker.pointer);
Hixie's avatar
Hixie committed
260 261 262 263 264 265
    }
    _clearTrackers();
  }

  void _registerFirstTap(_TapTracker tracker) {
    _startDoubleTapTimer();
266
    GestureBinding.instance.gestureArena.hold(tracker.pointer);
Hixie's avatar
Hixie committed
267 268 269 270 271 272 273 274 275 276 277 278 279
    // 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) {
    _firstTap.entry.resolve(GestureDisposition.accepted);
    tracker.entry.resolve(GestureDisposition.accepted);
    _freezeTracker(tracker);
    _trackers.remove(tracker.pointer);
280
    _checkUp(tracker.initialButtons);
Hixie's avatar
Hixie committed
281 282 283 284
    _reset();
  }

  void _clearTrackers() {
285
    _trackers.values.toList().forEach(_reject);
Hixie's avatar
Hixie committed
286 287 288 289
    assert(_trackers.isEmpty);
  }

  void _freezeTracker(_TapTracker tracker) {
290
    tracker.stopTrackingPointer(_handleEvent);
Hixie's avatar
Hixie committed
291 292 293
  }

  void _startDoubleTapTimer() {
294
    _doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset);
Hixie's avatar
Hixie committed
295 296 297 298 299 300 301 302 303
  }

  void _stopDoubleTapTimer() {
    if (_doubleTapTimer != null) {
      _doubleTapTimer.cancel();
      _doubleTapTimer = null;
    }
  }

304 305 306 307 308 309
  void _checkUp(int buttons) {
    assert(buttons == kPrimaryButton);
    if (onDoubleTap != null)
      invokeCallback<void>('onDoubleTap', onDoubleTap);
  }

310
  @override
311
  String get debugDescription => 'double tap';
Hixie's avatar
Hixie committed
312 313 314 315 316 317 318 319
}

/// 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({
320
    this.gestureRecognizer,
Ian Hickson's avatar
Ian Hickson committed
321
    PointerEvent event,
322
    Duration longTapDelay,
323
  }) : _lastPosition = OffsetPair.fromEventPosition(event),
Ian Hickson's avatar
Ian Hickson committed
324
       super(
325
    event: event as PointerDownEvent,
326 327
    entry: GestureBinding.instance.gestureArena.add(event.pointer, gestureRecognizer),
    doubleTapMinTime: kDoubleTapMinTime,
Ian Hickson's avatar
Ian Hickson committed
328
  ) {
329
    startTrackingPointer(handleEvent, event.transform);
330
    if (longTapDelay > Duration.zero) {
331
      _timer = Timer(longTapDelay, () {
Hixie's avatar
Hixie committed
332
        _timer = null;
333
        gestureRecognizer._dispatchLongTap(event.pointer, _lastPosition);
Hixie's avatar
Hixie committed
334 335
      });
    }
Hixie's avatar
Hixie committed
336 337 338 339 340
  }

  final MultiTapGestureRecognizer gestureRecognizer;

  bool _wonArena = false;
Hixie's avatar
Hixie committed
341 342
  Timer _timer;

343 344
  OffsetPair _lastPosition;
  OffsetPair _finalPosition;
Hixie's avatar
Hixie committed
345

Ian Hickson's avatar
Ian Hickson committed
346
  void handleEvent(PointerEvent event) {
Hixie's avatar
Hixie committed
347
    assert(event.pointer == pointer);
Ian Hickson's avatar
Ian Hickson committed
348
    if (event is PointerMoveEvent) {
349
      if (!isWithinGlobalTolerance(event, kTouchSlop))
Hixie's avatar
Hixie committed
350 351
        cancel();
      else
352
        _lastPosition = OffsetPair.fromEventPosition(event);
Ian Hickson's avatar
Ian Hickson committed
353
    } else if (event is PointerCancelEvent) {
Hixie's avatar
Hixie committed
354
      cancel();
Ian Hickson's avatar
Ian Hickson committed
355
    } else if (event is PointerUpEvent) {
356
      stopTrackingPointer(handleEvent);
357
      _finalPosition = OffsetPair.fromEventPosition(event);
Hixie's avatar
Hixie committed
358 359 360 361
      _check();
    }
  }

362
  @override
363
  void stopTrackingPointer(PointerRoute route) {
Hixie's avatar
Hixie committed
364 365
    _timer?.cancel();
    _timer = null;
366
    super.stopTrackingPointer(route);
Hixie's avatar
Hixie committed
367 368
  }

Hixie's avatar
Hixie committed
369 370 371 372 373 374
  void accept() {
    _wonArena = true;
    _check();
  }

  void reject() {
375
    stopTrackingPointer(handleEvent);
376
    gestureRecognizer._dispatchCancel(pointer);
Hixie's avatar
Hixie committed
377 378 379 380 381 382 383 384
  }

  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
385
      entry.resolve(GestureDisposition.rejected); // eventually calls reject()
Hixie's avatar
Hixie committed
386 387 388 389
  }

  void _check() {
    if (_wonArena && _finalPosition != null)
390
      gestureRecognizer._dispatchTap(pointer, _finalPosition);
Hixie's avatar
Hixie committed
391 392 393
  }
}

394 395 396 397 398 399 400 401 402
/// 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]
403
class MultiTapGestureRecognizer extends GestureRecognizer {
404 405
  /// Creates a multi-tap gesture recognizer.
  ///
406
  /// The [longTapDelay] defaults to [Duration.zero], which means
407
  /// [onLongTapDown] is called immediately after [onTapDown].
Hixie's avatar
Hixie committed
408
  MultiTapGestureRecognizer({
409
    this.longTapDelay = Duration.zero,
410
    Object debugOwner,
411 412
    PointerDeviceKind kind,
  }) : super(debugOwner: debugOwner, kind: kind);
Hixie's avatar
Hixie committed
413

414 415
  /// A pointer that might cause a tap has contacted the screen at a particular
  /// location.
Hixie's avatar
Hixie committed
416
  GestureMultiTapDownCallback onTapDown;
417 418 419

  /// A pointer that will trigger a tap has stopped contacting the screen at a
  /// particular location.
Hixie's avatar
Hixie committed
420
  GestureMultiTapUpCallback onTapUp;
421 422

  /// A tap has occurred.
Hixie's avatar
Hixie committed
423
  GestureMultiTapCallback onTap;
424 425 426

  /// The pointer that previously triggered [onTapDown] will not end up causing
  /// a tap.
Hixie's avatar
Hixie committed
427
  GestureMultiTapCancelCallback onTapCancel;
428 429

  /// The amount of time between [onTapDown] and [onLongTapDown].
Hixie's avatar
Hixie committed
430
  Duration longTapDelay;
431 432 433

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

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

438
  @override
439
  void addAllowedPointer(PointerEvent event) {
Hixie's avatar
Hixie committed
440
    assert(!_gestureMap.containsKey(event.pointer));
441
    _gestureMap[event.pointer] = _TapGesture(
Hixie's avatar
Hixie committed
442
      gestureRecognizer: this,
Hixie's avatar
Hixie committed
443
      event: event,
444
      longTapDelay: longTapDelay,
Hixie's avatar
Hixie committed
445 446
    );
    if (onTapDown != null)
447 448 449
      invokeCallback<void>('onTapDown', () {
        onTapDown(event.pointer, TapDownDetails(
          globalPosition: event.position,
450
          localPosition: event.localPosition,
451 452 453
          kind: event.kind,
        ));
      });
Hixie's avatar
Hixie committed
454 455
  }

456
  @override
Hixie's avatar
Hixie committed
457 458
  void acceptGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
459
    _gestureMap[pointer].accept();
Hixie's avatar
Hixie committed
460 461
  }

462
  @override
Hixie's avatar
Hixie committed
463 464
  void rejectGesture(int pointer) {
    assert(_gestureMap.containsKey(pointer));
465
    _gestureMap[pointer].reject();
Hixie's avatar
Hixie committed
466
    assert(!_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
467 468
  }

469 470
  void _dispatchCancel(int pointer) {
    assert(_gestureMap.containsKey(pointer));
Hixie's avatar
Hixie committed
471
    _gestureMap.remove(pointer);
472
    if (onTapCancel != null)
473
      invokeCallback<void>('onTapCancel', () => onTapCancel(pointer));
474 475
  }

476
  void _dispatchTap(int pointer, OffsetPair position) {
477 478 479
    assert(_gestureMap.containsKey(pointer));
    _gestureMap.remove(pointer);
    if (onTapUp != null)
480 481 482 483 484 485
      invokeCallback<void>('onTapUp', () {
        onTapUp(pointer, TapUpDetails(
          localPosition: position.local,
          globalPosition: position.global,
        ));
      });
486
    if (onTap != null)
487
      invokeCallback<void>('onTap', () => onTap(pointer));
Hixie's avatar
Hixie committed
488 489
  }

490
  void _dispatchLongTap(int pointer, OffsetPair lastPosition) {
Hixie's avatar
Hixie committed
491 492
    assert(_gestureMap.containsKey(pointer));
    if (onLongTapDown != null)
493 494 495 496
      invokeCallback<void>('onLongTapDown', () {
        onLongTapDown(
          pointer,
          TapDownDetails(
497 498
            globalPosition: lastPosition.global,
            localPosition: lastPosition.local,
499 500 501 502
            kind: getKindForPointer(pointer),
          ),
        );
      });
Hixie's avatar
Hixie committed
503 504
  }

505
  @override
Hixie's avatar
Hixie committed
506
  void dispose() {
507
    final List<_TapGesture> localGestures = List<_TapGesture>.from(_gestureMap.values);
508
    for (final _TapGesture gesture in localGestures)
Hixie's avatar
Hixie committed
509 510 511
      gesture.cancel();
    // Rejection of each gesture should cause it to be removed from our map
    assert(_gestureMap.isEmpty);
512
    super.dispose();
Hixie's avatar
Hixie committed
513 514
  }

515
  @override
516
  String get debugDescription => 'multitap';
Hixie's avatar
Hixie committed
517
}