From c9986651f498c8bbbf81f10943bec6ced234e59b Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Fri, 2 Oct 2015 18:49:38 -0700 Subject: [PATCH 1/8] Fix #1471 Add double tap gesture --- packages/flutter/lib/gestures.dart | 1 + .../flutter/lib/src/gestures/double_tap.dart | 51 +++++++++++++++++++ .../flutter/lib/src/gestures/recognizer.dart | 6 ++- packages/flutter/lib/src/gestures/tap.dart | 8 ++- .../lib/src/widgets/gesture_detector.dart | 20 ++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/flutter/lib/src/gestures/double_tap.dart diff --git a/packages/flutter/lib/gestures.dart b/packages/flutter/lib/gestures.dart index 138cf554ca..22d387ab51 100644 --- a/packages/flutter/lib/gestures.dart +++ b/packages/flutter/lib/gestures.dart @@ -8,6 +8,7 @@ library gestures; export 'src/gestures/arena.dart'; export 'src/gestures/constants.dart'; export 'src/gestures/drag.dart'; +export 'src/gestures/double_tap.dart'; export 'src/gestures/long_press.dart'; export 'src/gestures/pointer_router.dart'; export 'src/gestures/recognizer.dart'; diff --git a/packages/flutter/lib/src/gestures/double_tap.dart b/packages/flutter/lib/src/gestures/double_tap.dart new file mode 100644 index 0000000000..f441239184 --- /dev/null +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -0,0 +1,51 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:sky' as sky; + +import 'package:sky/src/gestures/arena.dart'; +import 'package:sky/src/gestures/constants.dart'; +import 'package:sky/src/gestures/recognizer.dart'; +import 'package:sky/src/gestures/tap.dart'; + +class DoubleTapGestureRecognizer extends PrimaryPointerGestureRecognizer { + DoubleTapGestureRecognizer({ PointerRouter router, this.onDoubleTap }) + : super(router: router, deadline: kTapTimeout); + + GestureTapListener onDoubleTap; + int _numTaps = 0; + Timer _longTimer; + + void resolve(GestureDisposition disposition) { + super.resolve(disposition); + if (disposition == GestureDisposition.rejected) { + _numTaps = 0; + } + } + + void didExceedDeadline() { + stopTrackingPointer(primaryPointer); + resolve(GestureDisposition.rejected); + } + + void didExceedLongDeadline() { + _numTaps = 0; + _longTimer = null; + } + + void handlePrimaryPointer(sky.PointerEvent event) { + if (event.type == 'pointerup') { + _numTaps++; + if (_numTaps == 1) { + _longTimer = new Timer(kDoubleTapTimeout, didExceedLongDeadline); + } else if (_numTaps == 2) { + resolve(GestureDisposition.accepted); + onDoubleTap(); + } + } + } + + +} diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index 4705e4c611..488b8e9c1a 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -99,10 +99,12 @@ abstract class PrimaryPointerGestureRecognizer extends GestureRecognizer { assert(state != GestureRecognizerState.ready); if (state == GestureRecognizerState.possible && event.pointer == primaryPointer) { // TODO(abarth): Maybe factor the slop handling out into a separate class? - if (event.type == 'pointermove' && _getDistance(event) > kTouchSlop) + if (event.type == 'pointermove' && _getDistance(event) > kTouchSlop) { resolve(GestureDisposition.rejected); - else + stopTrackingPointer(event.pointer); + } else { handlePrimaryPointer(event); + } } stopTrackingIfPointerNoLongerDown(event); } diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index fe107b149d..69cb7bbd4a 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -5,16 +5,22 @@ import 'dart:sky' as sky; import 'package:sky/src/gestures/arena.dart'; +import 'package:sky/src/gestures/constants.dart'; import 'package:sky/src/gestures/recognizer.dart'; typedef void GestureTapListener(); class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { TapGestureRecognizer({ PointerRouter router, this.onTap }) - : super(router: router); + : super(router: router, deadline: kTapTimeout); GestureTapListener onTap; + void didExceedDeadline() { + stopTrackingPointer(primaryPointer); + resolve(GestureDisposition.rejected); + } + void handlePrimaryPointer(sky.PointerEvent event) { if (event.type == 'pointerup') { resolve(GestureDisposition.accepted); diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 88cacddbbf..991d612fb0 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -14,6 +14,7 @@ class GestureDetector extends StatefulComponent { Key key, this.child, this.onTap, + this.onDoubleTap, this.onShowPress, this.onLongPress, this.onVerticalDragStart, @@ -32,6 +33,7 @@ class GestureDetector extends StatefulComponent { final Widget child; final GestureTapListener onTap; + final GestureTapListener onDoubleTap; final GestureShowPressListener onShowPress; final GestureLongPressListener onLongPress; @@ -69,6 +71,13 @@ class GestureDetectorState extends State { return _tap; } + DoubleTapGestureRecognizer _doubleTap; + DoubleTapGestureRecognizer _ensureDoubleTap() { + if (_doubleTap == null) + _doubleTap = new DoubleTapGestureRecognizer(router: _router); + return _doubleTap; + } + ShowPressGestureRecognizer _showPress; ShowPressGestureRecognizer _ensureShowPress() { if (_showPress == null) @@ -115,6 +124,7 @@ class GestureDetectorState extends State { void dispose() { _tap = _ensureDisposed(_tap); + _doubleTap = _ensureDisposed(_doubleTap); _showPress = _ensureDisposed(_showPress); _longPress = _ensureDisposed(_longPress); _verticalDrag = _ensureDisposed(_verticalDrag); @@ -126,6 +136,7 @@ class GestureDetectorState extends State { void didUpdateConfig(GestureDetector oldConfig) { _syncTap(); + _syncDoubleTap(); _syncShowPress(); _syncLongPress(); _syncVerticalDrag(); @@ -141,6 +152,13 @@ class GestureDetectorState extends State { _ensureTap().onTap = config.onTap; } + void _syncDoubleTap() { + if (config.onDoubleTap == null) + _doubleTap = _ensureDisposed(_doubleTap); + else + _ensureDoubleTap().onDoubleTap = config.onDoubleTap; + } + void _syncShowPress() { if (config.onShowPress == null) _showPress = _ensureDisposed(_showPress); @@ -207,6 +225,8 @@ class GestureDetectorState extends State { void _handlePointerDown(sky.PointerEvent event) { if (_tap != null) _tap.addPointer(event); + if (_doubleTap != null) + _doubleTap.addPointer(event); if (_showPress != null) _showPress.addPointer(event); if (_longPress != null) From cc201f32939316d98d57ccd689c58d552beec951 Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Fri, 9 Oct 2015 11:13:11 -0700 Subject: [PATCH 2/8] Temporary holding commit --- .../flutter/lib/src/gestures/double_tap.dart | 150 +++++++++++++--- packages/flutter/lib/src/gestures/tap.dart | 161 +++++++++++++++--- .../lib/src/widgets/gesture_detector.dart | 8 +- 3 files changed, 273 insertions(+), 46 deletions(-) diff --git a/packages/flutter/lib/src/gestures/double_tap.dart b/packages/flutter/lib/src/gestures/double_tap.dart index f441239184..510f024479 100644 --- a/packages/flutter/lib/src/gestures/double_tap.dart +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -10,42 +10,150 @@ import 'package:sky/src/gestures/constants.dart'; import 'package:sky/src/gestures/recognizer.dart'; import 'package:sky/src/gestures/tap.dart'; -class DoubleTapGestureRecognizer extends PrimaryPointerGestureRecognizer { - DoubleTapGestureRecognizer({ PointerRouter router, this.onDoubleTap }) - : super(router: router, deadline: kTapTimeout); +class DoubleTapGestureRecognizer extends GestureArenaMember { + static int sInstances = 0; + DoubleTapGestureRecognizer({ this.router, this.onDoubleTap }) { + _instance = sInstances++; + } + + PointerRouter router; GestureTapListener onDoubleTap; - int _numTaps = 0; - Timer _longTimer; - void resolve(GestureDisposition disposition) { - super.resolve(disposition); - if (disposition == GestureDisposition.rejected) { - _numTaps = 0; + int _numTaps = 0; + int _instance = 0; + bool _isTrackingPointer = false; + int _pointer; + sky.Point _initialPosition; + Timer _tapTimer; + Timer _doubleTapTimer; + GestureArenaEntry _entry = null; + + void addPointer(sky.PointerEvent event) { + message("add pointer"); + if (_initialPosition != null && !_isWithinTolerance(event)) { + message("reset"); + _reset(); + } + _pointer = event.pointer; + _initialPosition = _getPoint(event); + _isTrackingPointer = false; + _startTapTimer(); + _stopDoubleTapTimer(); + _startTrackingPointer(); + if (_entry == null) { + message("register entry"); + _entry = GestureArena.instance.add(event.pointer, this); } } - void didExceedDeadline() { - stopTrackingPointer(primaryPointer); - resolve(GestureDisposition.rejected); + void message(String s) { + print("Double tap " + _instance.toString() + ": " + s); } - void didExceedLongDeadline() { - _numTaps = 0; - _longTimer = null; - } - - void handlePrimaryPointer(sky.PointerEvent event) { + void handleEvent(sky.PointerEvent event) { + message("handle event"); if (event.type == 'pointerup') { _numTaps++; + _stopTapTimer(); + _stopTrackingPointer(); if (_numTaps == 1) { - _longTimer = new Timer(kDoubleTapTimeout, didExceedLongDeadline); + message("start long timer"); + _startDoubleTapTimer(); } else if (_numTaps == 2) { - resolve(GestureDisposition.accepted); - onDoubleTap(); + message("start found second tap"); + _entry.resolve(GestureDisposition.accepted); } + } else if (event.type == 'pointermove' && !_isWithinTolerance(event)) { + message("outside tap tolerance"); + _entry.resolve(GestureDisposition.rejected); + } else if (event.type == 'pointercancel') { + message("cancel"); + _entry.resolve(GestureDisposition.rejected); } } + void acceptGesture(int pointer) { + message("accepted"); + _reset(); + _entry = null; + print ("Entry is assigned null"); + onDoubleTap?.call(); + } + + void rejectGesture(int pointer) { + message("rejected"); + _reset(); + _entry = null; + print ("Entry is assigned null"); + } + + void dispose() { + _entry?.resolve(GestureDisposition.rejected); + router = null; + } + + void _reset() { + _numTaps = 0; + _initialPosition = null; + _stopTapTimer(); + _stopDoubleTapTimer(); + _stopTrackingPointer(); + } + + void _startTapTimer() { + if (_tapTimer == null) { + _tapTimer = new Timer( + kTapTimeout, + () => _entry.resolve(GestureDisposition.rejected) + ); + } + } + + void _stopTapTimer() { + if (_tapTimer != null) { + _tapTimer.cancel(); + _tapTimer = null; + } + } + + void _startDoubleTapTimer() { + if (_doubleTapTimer == null) { + _doubleTapTimer = new Timer( + kDoubleTapTimeout, + () => _entry.resolve(GestureDisposition.rejected) + ); + } + } + + void _stopDoubleTapTimer() { + if (_doubleTapTimer != null) { + _doubleTapTimer.cancel(); + _doubleTapTimer = null; + } + } + + void _startTrackingPointer() { + if (!_isTrackingPointer) { + _isTrackingPointer = true; + router.addRoute(_pointer, handleEvent); + } + } + + void _stopTrackingPointer() { + if (_isTrackingPointer) { + _isTrackingPointer = false; + router.removeRoute(_pointer, handleEvent); + } + } + + sky.Point _getPoint(sky.PointerEvent event) { + return new sky.Point(event.x, event.y); + } + + bool _isWithinTolerance(sky.PointerEvent event) { + sky.Offset offset = _getPoint(event) - _initialPosition; + return offset.distance <= kDoubleTapTouchSlop; + } } diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index fdb1db6e88..521d1f047a 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -2,44 +2,161 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:sky' as sky; import 'package:sky/src/gestures/arena.dart'; import 'package:sky/src/gestures/constants.dart'; -import 'package:sky/src/gestures/recognizer.dart'; typedef void GestureTapCallback(); -class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { - TapGestureRecognizer({ PointerRouter router, this.onTap }) - : super(router: router, deadline: kTapTimeout); +enum TapResolution { + tap, + cancel +} +class _TapGesture { + _TapGesture({ this.gestureRecognizer, sky.PointerEvent event }) { + assert(event.type == 'pointerdown'); + _pointer = event.pointer; + _isTrackingPointer = false; + _initialPosition = _getPoint(event); + _entry = GestureArena.instance.add(_pointer, gestureRecognizer); + _wonArena = false; + _didTap = false; + _startTimer(); + _startTrackingPointer(); + } + + TapGestureRecognizer gestureRecognizer; + + int _pointer; + bool _isTrackingPointer; + sky.Point _initialPosition; + GestureArenaEntry _entry; + Timer _deadline; + bool _wonArena; + bool _didTap; + + void handleEvent(sky.PointerEvent event) { + print("Tap gesture handleEvent"); + assert(event.pointer == _pointer); + if (event.type == 'pointermove' && !_isWithinTolerance(event)) { + _entry.resolve(GestureDisposition.rejected); + } else if (event.type == 'pointercancel') { + _entry.resolve(GestureDisposition.rejected); + } else if (event.type == 'pointerup') { + _stopTimer(); + _stopTrackingPointer(); + _didTap = true; + _check(); + } + } + + void accept() { + print("Tap gesture accept"); + _wonArena = true; + _check(); + } + + void reject() { + print("Tap gesture reject"); + _stopTimer(); + _stopTrackingPointer(); + gestureRecognizer._resolveTap(_pointer, TapResolution.cancel); + } + + void abort() { + _entry.resolve(GestureDisposition.rejected); + } + + void _check() { + if (_wonArena && _didTap) + gestureRecognizer._resolveTap(_pointer, TapResolution.tap); + } + + void _startTimer() { + if (_deadline == null) { + _deadline = new Timer( + kTapTimeout, + () => _entry.resolve(GestureDisposition.rejected) + ); + } + } + + void _stopTimer() { + if (_deadline != null) { + _deadline.cancel(); + _deadline = null; + } + } + + void _startTrackingPointer() { + if (!_isTrackingPointer) { + _isTrackingPointer = true; + gestureRecognizer.router.addRoute(_pointer, handleEvent); + } + } + + void _stopTrackingPointer() { + if (_isTrackingPointer) { + _isTrackingPointer = false; + gestureRecognizer.router.removeRoute(_pointer, handleEvent); + } + } + + sky.Point _getPoint(sky.PointerEvent event) { + return new sky.Point(event.x, event.y); + } + + bool _isWithinTolerance(sky.PointerEvent event) { + sky.Offset offset = _getPoint(event) - _initialPosition; + return offset.distance <= kTouchSlop; + } + +} + +class TapGestureRecognizer extends GestureArenaMember { + TapGestureRecognizer({ this.router, this.onTap, this.onTapDown, this.onTapCancel }); + + PointerRouter router; GestureTapCallback onTap; GestureTapCallback onTapDown; GestureTapCallback onTapCancel; - void didExceedDeadline() { - stopTrackingPointer(primaryPointer); - resolve(GestureDisposition.rejected); + Map _gestureMap = new Map(); + + void addPointer(sky.PointerEvent event) { + _gestureMap[event.pointer] = new _TapGesture( + gestureRecognizer: this, + event: event + ); + onTapDown?.call(); } - void handlePrimaryPointer(sky.PointerEvent event) { - if (event.type == 'pointerdown') { - if (onTapDown != null) - onTapDown(); - } else if (event.type == 'pointerup') { - resolve(GestureDisposition.accepted); - if (onTap != null) - onTap(); - } + void acceptGesture(int pointer) { + _gestureMap[pointer]?.accept(); } void rejectGesture(int pointer) { - super.rejectGesture(pointer); - if (pointer == primaryPointer) { - assert(state == GestureRecognizerState.defunct); - if (onTapCancel != null) - onTapCancel(); - } + _gestureMap[pointer]?.reject(); } + + void _resolveTap(int pointer, TapResolution resolution) { + _gestureMap.remove(pointer); + if (resolution == TapResolution.tap) + onTap?.call(); + else + onTapCancel?.call(); + } + + void dispose() { + List<_TapGesture> localGestures = new List.from(_gestureMap.values); + for (_TapGesture gesture in localGestures) + entry.abort(); + // Rejection of each gesture should cause it to be removed from our map + assert(_gestureMap.isEmpty); + router = null; + } + } diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 13e74e6cc0..d47ba05815 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -119,10 +119,12 @@ class _GestureDetectorState extends State { } void _syncDoubleTap() { - if (config.onDoubleTap == null) + if (config.onDoubleTap == null) { _doubleTap = _ensureDisposed(_doubleTap); - else - _ensureDoubleTap().onDoubleTap = config.onDoubleTap; + } else { + _doubleTap = new DoubleTapGestureRecognizer(router: _router); + _doubleTap.onDoubleTap = config.onDoubleTap; + } } void _syncShowPress() { From 18e154d40b540fc52cd43b728f1cd9b8e9cc8a5d Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Tue, 20 Oct 2015 15:30:19 -0700 Subject: [PATCH 3/8] Improve tap; add double tap; add tests --- .../flutter/lib/src/gestures/double_tap.dart | 208 ++++---- packages/flutter/lib/src/gestures/events.dart | 5 + packages/flutter/lib/src/gestures/tap.dart | 161 ++++--- .../unit/test/gestures/double_tap_test.dart | 451 ++++++++++++++++++ packages/unit/test/gestures/tap_test.dart | 229 ++++++++- 5 files changed, 859 insertions(+), 195 deletions(-) create mode 100644 packages/unit/test/gestures/double_tap_test.dart diff --git a/packages/flutter/lib/src/gestures/double_tap.dart b/packages/flutter/lib/src/gestures/double_tap.dart index 5693a06708..94f89d8669 100644 --- a/packages/flutter/lib/src/gestures/double_tap.dart +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:ui' as ui; import 'arena.dart'; import 'constants.dart'; @@ -14,117 +13,145 @@ import 'tap.dart'; class DoubleTapGestureRecognizer extends DisposableArenaMember { static int sInstances = 0; - DoubleTapGestureRecognizer({ this.router, this.onDoubleTap }) { - _instance = sInstances++; - } + DoubleTapGestureRecognizer({ this.router, this.onDoubleTap }); + + // 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 + // the state of existing fields. Specifically: + // 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 + // _firstTap is non-null. If a tap completes successfully while in this + // state, the callback is invoked and the state is reset. + // 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 PointerRouter router; GestureTapCallback onDoubleTap; - int _numTaps = 0; - int _instance = 0; - bool _isTrackingPointer = false; - int _pointer; - ui.Point _initialPosition; - Timer _tapTimer; Timer _doubleTapTimer; - GestureArenaEntry _entry = null; + TapTracker _firstTap; + Map _trackers = new Map(); void addPointer(PointerInputEvent event) { - message("add pointer"); - if (_initialPosition != null && !_isWithinTolerance(event)) { - message("reset"); - _reset(); - } - _pointer = event.pointer; - _initialPosition = _getPoint(event); - _isTrackingPointer = false; - _startTapTimer(); + // Ignore out-of-bounds second taps + if (_firstTap != null && + !_firstTap.isWithinTolerance(event, kDoubleTapTouchSlop)) + return; _stopDoubleTapTimer(); - _startTrackingPointer(); - if (_entry == null) { - message("register entry"); - _entry = GestureArena.instance.add(event.pointer, this); - } - } - - void message(String s) { - print("Double tap " + _instance.toString() + ": " + s); + TapTracker tracker = new TapTracker( + event: event, + entry: GestureArena.instance.add(event.pointer, this) + ); + _trackers[event.pointer] = tracker; + tracker.startTimer(() => _reject(tracker)); + tracker.startTrackingPointer(router, handleEvent); } void handleEvent(PointerInputEvent event) { - message("handle event"); + TapTracker tracker = _trackers[event.pointer]; + assert(tracker != null); if (event.type == 'pointerup') { - _numTaps++; - _stopTapTimer(); - _stopTrackingPointer(); - if (_numTaps == 1) { - message("start long timer"); - _startDoubleTapTimer(); - } else if (_numTaps == 2) { - message("start found second tap"); - _entry.resolve(GestureDisposition.accepted); - } - } else if (event.type == 'pointermove' && !_isWithinTolerance(event)) { - message("outside tap tolerance"); - _entry.resolve(GestureDisposition.rejected); + if (_firstTap == null) + _registerFirstTap(tracker); + else + _registerSecondTap(tracker); + } else if (event.type == 'pointermove' && + !tracker.isWithinTolerance(event, kTouchSlop)) { + _reject(tracker); } else if (event.type == 'pointercancel') { - message("cancel"); - _entry.resolve(GestureDisposition.rejected); + _reject(tracker); } } - void acceptGesture(int pointer) { - message("accepted"); - _reset(); - _entry = null; - print ("Entry is assigned null"); - onDoubleTap?.call(); - } + void acceptGesture(int pointer) {} void rejectGesture(int pointer) { - message("rejected"); - _reset(); - _entry = null; - print ("Entry is assigned null"); + 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, + // reset won't have any work to do. But if we're in the second tap, we need + // to clear intermediate state. + if (_firstTap != null && + (_trackers.isEmpty || tracker == _firstTap)) + _reset(); } void dispose() { - _entry?.resolve(GestureDisposition.rejected); + _reset(); router = null; } void _reset() { - _numTaps = 0; - _initialPosition = null; - _stopTapTimer(); _stopDoubleTapTimer(); - _stopTrackingPointer(); + if (_firstTap != null) { + // Note, order is important below in order for the resolve -> reject logic + // to work properly + TapTracker tracker = _firstTap; + _firstTap = null; + _reject(tracker); + GestureArena.instance.release(tracker.pointer); + } + _clearTrackers(); } - void _startTapTimer() { - if (_tapTimer == null) { - _tapTimer = new Timer( - kTapTimeout, - () => _entry.resolve(GestureDisposition.rejected) - ); - } + void _registerFirstTap(TapTracker tracker) { + _startDoubleTapTimer(); + GestureArena.instance.hold(tracker.pointer); + // 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 _stopTapTimer() { - if (_tapTimer != null) { - _tapTimer.cancel(); - _tapTimer = null; - } + void _registerSecondTap(TapTracker tracker) { + _firstTap.entry.resolve(GestureDisposition.accepted); + tracker.entry.resolve(GestureDisposition.accepted); + _freezeTracker(tracker); + _trackers.remove(tracker.pointer); + onDoubleTap?.call(); + _reset(); + } + + void _clearTrackers() { + List localTrackers = new List.from(_trackers.values); + for (TapTracker tracker in localTrackers) + _reject(tracker); + assert(_trackers.isEmpty); + } + + void _freezeTracker(TapTracker tracker) { + tracker.stopTimer(); + tracker.stopTrackingPointer(router, handleEvent); } void _startDoubleTapTimer() { - if (_doubleTapTimer == null) { - _doubleTapTimer = new Timer( - kDoubleTapTimeout, - () => _entry.resolve(GestureDisposition.rejected) - ); - } + if (_doubleTapTimer == null) + _doubleTapTimer = new Timer(kDoubleTapTimeout, () => _reset()); } void _stopDoubleTapTimer() { @@ -134,27 +161,4 @@ class DoubleTapGestureRecognizer extends DisposableArenaMember { } } - void _startTrackingPointer() { - if (!_isTrackingPointer) { - _isTrackingPointer = true; - router.addRoute(_pointer, handleEvent); - } - } - - void _stopTrackingPointer() { - if (_isTrackingPointer) { - _isTrackingPointer = false; - router.removeRoute(_pointer, handleEvent); - } - } - - ui.Point _getPoint(PointerInputEvent event) { - return new ui.Point(event.x, event.y); - } - - bool _isWithinTolerance(PointerInputEvent event) { - ui.Offset offset = _getPoint(event) - _initialPosition; - return offset.distance <= kDoubleTapTouchSlop; - } - } diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart index 722b9984a9..7519440fc1 100644 --- a/packages/flutter/lib/src/gestures/events.dart +++ b/packages/flutter/lib/src/gestures/events.dart @@ -2,6 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' as ui; + +export 'dart:ui' show Point; + /// Base class for input events. class InputEvent { @@ -67,4 +71,5 @@ class PointerInputEvent extends InputEvent { final double orientation; final double tilt; + ui.Point get position => new ui.Point(x, y); } diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index fd0466a0e3..f42a91ce1d 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -8,6 +8,7 @@ import 'dart:ui' as ui; import 'arena.dart'; import 'constants.dart'; import 'events.dart'; +import 'pointer_router.dart'; import 'recognizer.dart'; typedef void GestureTapCallback(); @@ -17,103 +18,113 @@ enum TapResolution { cancel } -class _TapGesture { - _TapGesture({ this.gestureRecognizer, PointerInputEvent event }) { - assert(event.type == 'pointerdown'); - _pointer = event.pointer; - _isTrackingPointer = false; - _initialPosition = _getPoint(event); - _entry = GestureArena.instance.add(_pointer, gestureRecognizer); - _wonArena = false; - _didTap = false; - _startTimer(); - _startTrackingPointer(); +/// TapTracker helps track individual tap sequences as part of a +/// larger gesture. +class TapTracker { + + TapTracker({ PointerInputEvent event, this.entry }) + : pointer = event.pointer, + initialPosition = event.position, + isTrackingPointer = false { + assert(event.type == 'pointerdown'); + } + + int pointer; + ui.Point initialPosition; + bool isTrackingPointer; + Timer timer; + GestureArenaEntry entry; + + void startTimer(void callback()) { + if (timer == null) { + timer = new Timer(kTapTimeout, callback); + } } + void stopTimer() { + if (timer != null) { + timer.cancel(); + timer = null; + } + } + + void startTrackingPointer(PointerRouter router, PointerRoute route) { + if (!isTrackingPointer) { + isTrackingPointer = true; + router.addRoute(pointer, route); + } + } + + void stopTrackingPointer(PointerRouter router, PointerRoute route) { + if (isTrackingPointer) { + isTrackingPointer = false; + router.removeRoute(pointer, route); + } + } + + bool isWithinTolerance(PointerInputEvent event, double tolerance) { + ui.Offset offset = event.position - initialPosition; + return offset.distance <= tolerance; + } + +} + +/// TapGesture represents a full gesture resulting from a single tap +/// sequence. Tap gestures are passive, meaning that they will not +/// pre-empt any other arena member in play. +class TapGesture extends TapTracker { + + TapGesture({ this.gestureRecognizer, PointerInputEvent event }) + : super(event: event) { + entry = GestureArena.instance.add(event.pointer, gestureRecognizer); + _wonArena = false; + _didTap = false; + startTimer(() => cancel()); + startTrackingPointer(gestureRecognizer.router, handleEvent); + } + TapGestureRecognizer gestureRecognizer; - int _pointer; - bool _isTrackingPointer; - ui.Point _initialPosition; - GestureArenaEntry _entry; - Timer _deadline; bool _wonArena; bool _didTap; void handleEvent(PointerInputEvent event) { - print("Tap gesture handleEvent"); - assert(event.pointer == _pointer); - if (event.type == 'pointermove' && !_isWithinTolerance(event)) { - _entry.resolve(GestureDisposition.rejected); + assert(event.pointer == pointer); + if (event.type == 'pointermove' && !isWithinTolerance(event, kTouchSlop)) { + cancel(); } else if (event.type == 'pointercancel') { - _entry.resolve(GestureDisposition.rejected); + cancel(); } else if (event.type == 'pointerup') { - _stopTimer(); - _stopTrackingPointer(); + stopTimer(); + stopTrackingPointer(gestureRecognizer.router, handleEvent); _didTap = true; _check(); } } void accept() { - print("Tap gesture accept"); _wonArena = true; _check(); } void reject() { - print("Tap gesture reject"); - _stopTimer(); - _stopTrackingPointer(); - gestureRecognizer._resolveTap(_pointer, TapResolution.cancel); + stopTimer(); + stopTrackingPointer(gestureRecognizer.router, handleEvent); + gestureRecognizer._resolveTap(pointer, TapResolution.cancel); } - void abort() { - _entry.resolve(GestureDisposition.rejected); + 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 + entry.resolve(GestureDisposition.rejected); } void _check() { if (_wonArena && _didTap) - gestureRecognizer._resolveTap(_pointer, TapResolution.tap); - } - - void _startTimer() { - if (_deadline == null) { - _deadline = new Timer( - kTapTimeout, - () => _entry.resolve(GestureDisposition.rejected) - ); - } - } - - void _stopTimer() { - if (_deadline != null) { - _deadline.cancel(); - _deadline = null; - } - } - - void _startTrackingPointer() { - if (!_isTrackingPointer) { - _isTrackingPointer = true; - gestureRecognizer.router.addRoute(_pointer, handleEvent); - } - } - - void _stopTrackingPointer() { - if (_isTrackingPointer) { - _isTrackingPointer = false; - gestureRecognizer.router.removeRoute(_pointer, handleEvent); - } - } - - ui.Point _getPoint(PointerInputEvent event) { - return new ui.Point(event.x, event.y); - } - - bool _isWithinTolerance(PointerInputEvent event) { - ui.Offset offset = _getPoint(event) - _initialPosition; - return offset.distance <= kTouchSlop; + gestureRecognizer._resolveTap(pointer, TapResolution.tap); } } @@ -126,10 +137,10 @@ class TapGestureRecognizer extends DisposableArenaMember { GestureTapCallback onTapDown; GestureTapCallback onTapCancel; - Map _gestureMap = new Map(); + Map _gestureMap = new Map(); void addPointer(PointerInputEvent event) { - _gestureMap[event.pointer] = new _TapGesture( + _gestureMap[event.pointer] = new TapGesture( gestureRecognizer: this, event: event ); @@ -153,9 +164,9 @@ class TapGestureRecognizer extends DisposableArenaMember { } void dispose() { - List<_TapGesture> localGestures = new List.from(_gestureMap.values); - for (_TapGesture gesture in localGestures) - gesture.abort(); + List localGestures = new List.from(_gestureMap.values); + for (TapGesture gesture in localGestures) + gesture.cancel(); // Rejection of each gesture should cause it to be removed from our map assert(_gestureMap.isEmpty); router = null; diff --git a/packages/unit/test/gestures/double_tap_test.dart b/packages/unit/test/gestures/double_tap_test.dart new file mode 100644 index 0000000000..61d0e401ee --- /dev/null +++ b/packages/unit/test/gestures/double_tap_test.dart @@ -0,0 +1,451 @@ +import 'package:flutter/gestures.dart'; +import 'package:quiver/testing/async.dart'; +import 'package:test/test.dart'; + +class TestGestureArenaMember extends GestureArenaMember { + void acceptGesture(Object key) {} + void rejectGesture(Object key) {} +} + +void main() { + + // Down/up pair 1: normal tap sequence + final PointerInputEvent down1 = new PointerInputEvent( + pointer: 1, + type: 'pointerdown', + x: 10.0, + y: 10.0 + ); + + final PointerInputEvent up1 = new PointerInputEvent( + pointer: 1, + type: 'pointerup', + x: 11.0, + y: 9.0 + ); + + // Down/up pair 2: normal tap sequence close to pair 1 + final PointerInputEvent down2 = new PointerInputEvent( + pointer: 2, + type: 'pointerdown', + x: 12.0, + y: 12.0 + ); + + final PointerInputEvent up2 = new PointerInputEvent( + pointer: 2, + type: 'pointerup', + x: 13.0, + y: 11.0 + ); + + // Down/up pair 3: normal tap sequence far away from pair 1 + final PointerInputEvent down3 = new PointerInputEvent( + pointer: 3, + type: 'pointerdown', + x: 30.0, + y: 30.0 + ); + + final PointerInputEvent up3 = new PointerInputEvent( + pointer: 3, + type: 'pointerup', + x: 31.0, + y: 29.0 + ); + + // Down/move/up sequence 4: intervening motion + final PointerInputEvent down4 = new PointerInputEvent( + pointer: 4, + type: 'pointerdown', + x: 10.0, + y: 10.0 + ); + + final PointerInputEvent move4 = new PointerInputEvent( + pointer: 4, + type: 'pointermove', + x: 25.0, + y: 25.0 + ); + + final PointerInputEvent up4 = new PointerInputEvent( + pointer: 4, + type: 'pointerup', + x: 25.0, + y: 25.0 + ); + + test('Should recognize double tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isTrue); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isTrue); + + tap.dispose(); + }); + + test('Inter-tap distance cancels double tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down3); + GestureArena.instance.close(3); + expect(doubleTapRecognized, isFalse); + router.route(down3); + expect(doubleTapRecognized, isFalse); + + router.route(up3); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(3); + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + + test('Intra-tap distance cancels double tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down4); + GestureArena.instance.close(4); + expect(doubleTapRecognized, isFalse); + router.route(down4); + expect(doubleTapRecognized, isFalse); + + router.route(move4); + expect(doubleTapRecognized, isFalse); + router.route(up4); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(4); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + + test('Inter-tap delay cancels double tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + new FakeAsync().run((FakeAsync async) { + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + async.elapse(new Duration(milliseconds: 5000)); + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + }); + + tap.dispose(); + }); + + test('Intra-tap delay cancels double tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + new FakeAsync().run((FakeAsync async) { + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + async.elapse(new Duration(milliseconds: 1000)); + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + }); + + tap.dispose(); + }); + + test('Should not recognize two overlapping taps', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + + test('Should recognize one tap of group followed by second tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isTrue); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isTrue); + + tap.dispose(); + + }); + + test('Should cancel on arena reject during first tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + TestGestureArenaMember member = new TestGestureArenaMember(); + GestureArenaEntry entry = GestureArena.instance.add(1, member); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + entry.resolve(GestureDisposition.accepted); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + + test('Should cancel on arena reject between taps', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + TestGestureArenaMember member = new TestGestureArenaMember(); + GestureArenaEntry entry = GestureArena.instance.add(1, member); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + entry.resolve(GestureDisposition.accepted); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + + test('Should cancel on arena reject during last tap', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + tap.addPointer(down1); + TestGestureArenaMember member = new TestGestureArenaMember(); + GestureArenaEntry entry = GestureArena.instance.add(1, member); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(doubleTapRecognized, isFalse); + router.route(down2); + expect(doubleTapRecognized, isFalse); + + entry.resolve(GestureDisposition.accepted); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + + tap.dispose(); + }); + +} diff --git a/packages/unit/test/gestures/tap_test.dart b/packages/unit/test/gestures/tap_test.dart index a8cf91c9f9..95062db93d 100644 --- a/packages/unit/test/gestures/tap_test.dart +++ b/packages/unit/test/gestures/tap_test.dart @@ -1,7 +1,66 @@ import 'package:flutter/gestures.dart'; +import 'package:quiver/testing/async.dart'; import 'package:test/test.dart'; +class TestGestureArenaMember extends GestureArenaMember { + void acceptGesture(Object key) {} + void rejectGesture(Object key) {} +} + void main() { + + // Down/up pair 1: normal tap sequence + final PointerInputEvent down1 = new PointerInputEvent( + pointer: 1, + type: 'pointerdown', + x: 10.0, + y: 10.0 + ); + + final PointerInputEvent up1 = new PointerInputEvent( + pointer: 1, + type: 'pointerup', + x: 11.0, + y: 9.0 + ); + + // Down/up pair 2: normal tap sequence far away from pair 1 + final PointerInputEvent down2 = new PointerInputEvent( + pointer: 2, + type: 'pointerdown', + x: 30.0, + y: 30.0 + ); + + final PointerInputEvent up2 = new PointerInputEvent( + pointer: 2, + type: 'pointerup', + x: 31.0, + y: 29.0 + ); + + // Down/move/up sequence 3: intervening motion + final PointerInputEvent down3 = new PointerInputEvent( + pointer: 3, + type: 'pointerdown', + x: 10.0, + y: 10.0 + ); + + final PointerInputEvent move3 = new PointerInputEvent( + pointer: 3, + type: 'pointermove', + x: 25.0, + y: 25.0 + ); + + final PointerInputEvent up3 = new PointerInputEvent( + pointer: 3, + type: 'pointerup', + x: 25.0, + y: 25.0 + ); + test('Should recognize tap', () { PointerRouter router = new PointerRouter(); TapGestureRecognizer tap = new TapGestureRecognizer(router: router); @@ -11,29 +70,163 @@ void main() { tapRecognized = true; }; - PointerInputEvent down = new PointerInputEvent( - pointer: 5, - type: 'pointerdown', - x: 10.0, - y: 10.0 - ); - - tap.addPointer(down); - GestureArena.instance.close(5); + tap.addPointer(down1); + GestureArena.instance.close(1); expect(tapRecognized, isFalse); - router.route(down); + router.route(down1); expect(tapRecognized, isFalse); - PointerInputEvent up = new PointerInputEvent( - pointer: 5, - type: 'pointerup', - x: 11.0, - y: 9.0 - ); - - router.route(up); + router.route(up1); + expect(tapRecognized, isTrue); + GestureArena.instance.sweep(1); expect(tapRecognized, isTrue); tap.dispose(); }); + + test('Should recognize two overlapping taps', () { + PointerRouter router = new PointerRouter(); + TapGestureRecognizer tap = new TapGestureRecognizer(router: router); + + int tapsRecognized = 0; + tap.onTap = () { + tapsRecognized++; + }; + + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(tapsRecognized, 0); + router.route(down1); + expect(tapsRecognized, 0); + + tap.addPointer(down2); + GestureArena.instance.close(2); + expect(tapsRecognized, 0); + router.route(down1); + expect(tapsRecognized, 0); + + + router.route(up1); + expect(tapsRecognized, 1); + GestureArena.instance.sweep(1); + expect(tapsRecognized, 1); + + router.route(up2); + expect(tapsRecognized, 2); + GestureArena.instance.sweep(2); + expect(tapsRecognized, 2); + + tap.dispose(); + }); + + test('Distance cancels tap', () { + PointerRouter router = new PointerRouter(); + TapGestureRecognizer tap = new TapGestureRecognizer(router: router); + + bool tapRecognized = false; + tap.onTap = () { + tapRecognized = true; + }; + + tap.addPointer(down3); + GestureArena.instance.close(3); + expect(tapRecognized, isFalse); + router.route(down3); + expect(tapRecognized, isFalse); + + router.route(move3); + expect(tapRecognized, isFalse); + router.route(up3); + expect(tapRecognized, isFalse); + GestureArena.instance.sweep(3); + expect(tapRecognized, isFalse); + + tap.dispose(); + }); + + test('Timeout cancels tap', () { + PointerRouter router = new PointerRouter(); + TapGestureRecognizer tap = new TapGestureRecognizer(router: router); + + bool tapRecognized = false; + tap.onTap = () { + tapRecognized = true; + }; + + new FakeAsync().run((FakeAsync async) { + tap.addPointer(down1); + GestureArena.instance.close(1); + expect(tapRecognized, isFalse); + router.route(down1); + expect(tapRecognized, isFalse); + + async.elapse(new Duration(milliseconds: 500)); + expect(tapRecognized, isFalse); + router.route(up1); + expect(tapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(tapRecognized, isFalse); + }); + + tap.dispose(); + }); + + test('Should yield to other arena members', () { + PointerRouter router = new PointerRouter(); + TapGestureRecognizer tap = new TapGestureRecognizer(router: router); + + bool tapRecognized = false; + tap.onTap = () { + tapRecognized = true; + }; + + tap.addPointer(down1); + TestGestureArenaMember member = new TestGestureArenaMember(); + GestureArenaEntry entry = GestureArena.instance.add(1, member); + GestureArena.instance.hold(1); + GestureArena.instance.close(1); + expect(tapRecognized, isFalse); + router.route(down1); + expect(tapRecognized, isFalse); + + router.route(up1); + expect(tapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(tapRecognized, isFalse); + + entry.resolve(GestureDisposition.accepted); + expect(tapRecognized, isFalse); + + tap.dispose(); + }); + + test('Should trigger on release of held arena', () { + PointerRouter router = new PointerRouter(); + TapGestureRecognizer tap = new TapGestureRecognizer(router: router); + + bool tapRecognized = false; + tap.onTap = () { + tapRecognized = true; + }; + + tap.addPointer(down1); + TestGestureArenaMember member = new TestGestureArenaMember(); + GestureArenaEntry entry = GestureArena.instance.add(1, member); + GestureArena.instance.hold(1); + GestureArena.instance.close(1); + expect(tapRecognized, isFalse); + router.route(down1); + expect(tapRecognized, isFalse); + + router.route(up1); + expect(tapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(tapRecognized, isFalse); + + entry.resolve(GestureDisposition.rejected); + expect(tapRecognized, isTrue); + + tap.dispose(); + }); + } From 608e971f5b2ec96eee99faf3cebea0e32a2e42fb Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Thu, 22 Oct 2015 15:29:39 -0700 Subject: [PATCH 4/8] Add another double-tap test --- .../unit/test/gestures/double_tap_test.dart | 46 +++++++++++- .../gestures/lsq_solver_test_disabled.dart | 75 +++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 packages/unit/test/gestures/lsq_solver_test_disabled.dart diff --git a/packages/unit/test/gestures/double_tap_test.dart b/packages/unit/test/gestures/double_tap_test.dart index 61d0e401ee..934a4703e2 100644 --- a/packages/unit/test/gestures/double_tap_test.dart +++ b/packages/unit/test/gestures/double_tap_test.dart @@ -3,8 +3,14 @@ import 'package:quiver/testing/async.dart'; import 'package:test/test.dart'; class TestGestureArenaMember extends GestureArenaMember { - void acceptGesture(Object key) {} - void rejectGesture(Object key) {} + void acceptGesture(Object key) { + accepted = true; + } + void rejectGesture(Object key) { + rejected = true; + } + bool accepted = false; + bool rejected = false; } void main() { @@ -354,6 +360,7 @@ void main() { router.route(up1); expect(doubleTapRecognized, isFalse); entry.resolve(GestureDisposition.accepted); + expect(member.accepted, isTrue); expect(doubleTapRecognized, isFalse); GestureArena.instance.sweep(1); expect(doubleTapRecognized, isFalse); @@ -395,6 +402,7 @@ void main() { expect(doubleTapRecognized, isFalse); entry.resolve(GestureDisposition.accepted); + expect(member.accepted, isTrue); tap.addPointer(down2); GestureArena.instance.close(2); @@ -439,6 +447,7 @@ void main() { expect(doubleTapRecognized, isFalse); entry.resolve(GestureDisposition.accepted); + expect(member.accepted, isTrue); router.route(up2); expect(doubleTapRecognized, isFalse); @@ -448,4 +457,37 @@ void main() { tap.dispose(); }); + test('Passive gesture should trigger on double tap cancel', () { + PointerRouter router = new PointerRouter(); + DoubleTapGestureRecognizer tap = new DoubleTapGestureRecognizer(router: router); + + bool doubleTapRecognized = false; + tap.onDoubleTap = () { + doubleTapRecognized = true; + }; + + new FakeAsync().run((FakeAsync async) { + tap.addPointer(down1); + TestGestureArenaMember member = new TestGestureArenaMember(); + GestureArena.instance.add(1, member); + GestureArena.instance.close(1); + expect(doubleTapRecognized, isFalse); + router.route(down1); + expect(doubleTapRecognized, isFalse); + + router.route(up1); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(1); + expect(doubleTapRecognized, isFalse); + + expect(member.accepted, isFalse); + + async.elapse(new Duration(milliseconds: 5000)); + + expect(member.accepted, isTrue); + }); + + tap.dispose(); + }); + } diff --git a/packages/unit/test/gestures/lsq_solver_test_disabled.dart b/packages/unit/test/gestures/lsq_solver_test_disabled.dart new file mode 100644 index 0000000000..a868c96c12 --- /dev/null +++ b/packages/unit/test/gestures/lsq_solver_test_disabled.dart @@ -0,0 +1,75 @@ +import 'package:flutter/gestures.dart'; +import 'package:test/test.dart'; + + +void main() { + void printFit(PolynomialFit fit) { + print("Confidence: " + fit.confidence.toString()); + for (int i = 0; i < fit.coefficients.length; i++) + print(i.toString() + ": " + fit.coefficients[i].toString()); + } + + approx(double value, double expectation) { + const double eps = 1e-6; + return (value - expectation).abs() < eps; + } + + test('Least-squares fit: linear polynomial to line', () { + List x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + List y = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + List w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + + LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w); + PolynomialFit fit = solver.solve(1); + + expect(fit.coefficients.length, 2); + expect(approx(fit.coefficients[0], 1.0), isTrue); + expect(approx(fit.coefficients[1], 0.0), isTrue); + expect(approx(fit.confidence, 1.0), isTrue); + }); + + test('Least-squares fit: linear polynomial to sloped line', () { + List x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + List y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + List w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + + LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w); + PolynomialFit fit = solver.solve(1); + + expect(fit.coefficients.length, 2); + expect(approx(fit.coefficients[0], 1.0), isTrue); + expect(approx(fit.coefficients[1], 1.0), isTrue); + expect(approx(fit.confidence, 1.0), isTrue); + }); + + test('Least-squares fit: quadratic polynomial to line', () { + List x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + List y = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + List w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + + LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w); + PolynomialFit fit = solver.solve(2); + + expect(fit.coefficients.length, 3); + expect(approx(fit.coefficients[0], 1.0), isTrue); + expect(approx(fit.coefficients[1], 0.0), isTrue); + expect(approx(fit.coefficients[2], 0.0), isTrue); + expect(approx(fit.confidence, 1.0), isTrue); + }); + + test('Least-squares fit: quadratic polynomial to sloped line', () { + List x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + List y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]; + List w = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + + LeastSquaresSolver solver = new LeastSquaresSolver(x, y, w); + PolynomialFit fit = solver.solve(2); + + expect(fit.coefficients.length, 3); + expect(approx(fit.coefficients[0], 1.0), isTrue); + expect(approx(fit.coefficients[1], 1.0), isTrue); + expect(approx(fit.coefficients[2], 0.0), isTrue); + expect(approx(fit.confidence, 1.0), isTrue); + }); + +} From 709b3550a6ba14d25b68178458356a35a34e4319 Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Thu, 22 Oct 2015 17:21:47 -0700 Subject: [PATCH 5/8] Address comments --- .../flutter/lib/src/gestures/double_tap.dart | 6 +- packages/flutter/lib/src/gestures/tap.dart | 66 ++++++++++--------- .../lib/src/widgets/gesture_detector.dart | 2 +- 3 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/flutter/lib/src/gestures/double_tap.dart b/packages/flutter/lib/src/gestures/double_tap.dart index 94f89d8669..32e938382b 100644 --- a/packages/flutter/lib/src/gestures/double_tap.dart +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -11,7 +11,6 @@ import 'recognizer.dart'; import 'tap.dart'; class DoubleTapGestureRecognizer extends DisposableArenaMember { - static int sInstances = 0; DoubleTapGestureRecognizer({ this.router, this.onDoubleTap }); @@ -133,12 +132,13 @@ class DoubleTapGestureRecognizer extends DisposableArenaMember { tracker.entry.resolve(GestureDisposition.accepted); _freezeTracker(tracker); _trackers.remove(tracker.pointer); - onDoubleTap?.call(); + if (onDoubleTap != null) + onDoubleTap(); _reset(); } void _clearTrackers() { - List localTrackers = new List.from(_trackers.values); + List localTrackers = new List.from(_trackers.values); for (TapTracker tracker in localTrackers) _reject(tracker); assert(_trackers.isEmpty); diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index f42a91ce1d..d4da4c0418 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -23,47 +23,45 @@ enum TapResolution { class TapTracker { TapTracker({ PointerInputEvent event, this.entry }) - : pointer = event.pointer, - initialPosition = event.position, - isTrackingPointer = false { - assert(event.type == 'pointerdown'); - } + : pointer = event.pointer, + _initialPosition = event.position, + _isTrackingPointer = false { + assert(event.type == 'pointerdown'); + } int pointer; - ui.Point initialPosition; - bool isTrackingPointer; - Timer timer; GestureArenaEntry entry; + ui.Point _initialPosition; + bool _isTrackingPointer; + Timer _timer; void startTimer(void callback()) { - if (timer == null) { - timer = new Timer(kTapTimeout, callback); - } + _timer ??= new Timer(kTapTimeout, callback); } void stopTimer() { - if (timer != null) { - timer.cancel(); - timer = null; + if (_timer != null) { + _timer.cancel(); + _timer = null; } } void startTrackingPointer(PointerRouter router, PointerRoute route) { - if (!isTrackingPointer) { - isTrackingPointer = true; + if (!_isTrackingPointer) { + _isTrackingPointer = true; router.addRoute(pointer, route); } } void stopTrackingPointer(PointerRouter router, PointerRoute route) { - if (isTrackingPointer) { - isTrackingPointer = false; + if (_isTrackingPointer) { + _isTrackingPointer = false; router.removeRoute(pointer, route); } } bool isWithinTolerance(PointerInputEvent event, double tolerance) { - ui.Offset offset = event.position - initialPosition; + ui.Offset offset = event.position - _initialPosition; return offset.distance <= tolerance; } @@ -75,13 +73,13 @@ class TapTracker { class TapGesture extends TapTracker { TapGesture({ this.gestureRecognizer, PointerInputEvent event }) - : super(event: event) { - entry = GestureArena.instance.add(event.pointer, gestureRecognizer); - _wonArena = false; - _didTap = false; - startTimer(() => cancel()); - startTrackingPointer(gestureRecognizer.router, handleEvent); - } + : super(event: event) { + entry = GestureArena.instance.add(event.pointer, gestureRecognizer); + _wonArena = false; + _didTap = false; + startTimer(() => cancel()); + startTrackingPointer(gestureRecognizer.router, handleEvent); + } TapGestureRecognizer gestureRecognizer; @@ -144,7 +142,8 @@ class TapGestureRecognizer extends DisposableArenaMember { gestureRecognizer: this, event: event ); - onTapDown?.call(); + if (onTapDown != null) + onTapDown(); } void acceptGesture(int pointer) { @@ -157,14 +156,17 @@ class TapGestureRecognizer extends DisposableArenaMember { void _resolveTap(int pointer, TapResolution resolution) { _gestureMap.remove(pointer); - if (resolution == TapResolution.tap) - onTap?.call(); - else - onTapCancel?.call(); + if (resolution == TapResolution.tap) { + if (onTap != null) + onTap(); + } else { + if (onTapCancel != null) + onTapCancel(); + } } void dispose() { - List localGestures = new List.from(_gestureMap.values); + List localGestures = new List.from(_gestureMap.values); for (TapGesture gesture in localGestures) gesture.cancel(); // Rejection of each gesture should cause it to be removed from our map diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 36e818c3b5..2bab4d55d5 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -140,7 +140,7 @@ class _GestureDetectorState extends State { if (config.onDoubleTap == null) { _doubleTap = _ensureDisposed(_doubleTap); } else { - _doubleTap = new DoubleTapGestureRecognizer(router: _router); + _doubleTap ??= new DoubleTapGestureRecognizer(router: _router); _doubleTap.onDoubleTap = config.onDoubleTap; } } From c2916f47f90d0234a12182fe95547fc7daa167d4 Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Thu, 22 Oct 2015 17:30:16 -0700 Subject: [PATCH 6/8] Add assertions to tap recognizer --- packages/flutter/lib/src/gestures/tap.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/gestures/tap.dart b/packages/flutter/lib/src/gestures/tap.dart index d4da4c0418..0482d3a5b5 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -112,7 +112,7 @@ class TapGesture extends TapTracker { } void cancel() { - // If we won the arena already, then _entry is resolved, so resolving + // 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(); @@ -138,6 +138,7 @@ class TapGestureRecognizer extends DisposableArenaMember { Map _gestureMap = new Map(); void addPointer(PointerInputEvent event) { + assert(!_gestureMap.containsKey(event.pointer)); _gestureMap[event.pointer] = new TapGesture( gestureRecognizer: this, event: event @@ -147,10 +148,12 @@ class TapGestureRecognizer extends DisposableArenaMember { } void acceptGesture(int pointer) { + assert(_gestureMap.containsKey(pointer)); _gestureMap[pointer]?.accept(); } void rejectGesture(int pointer) { + assert(_gestureMap.containsKey(pointer)); _gestureMap[pointer]?.reject(); } From c7ccf256b1520cfa86092bb3ff098238ec8cadde Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Fri, 23 Oct 2015 11:25:01 -0700 Subject: [PATCH 7/8] Style tweak in double tap recognizer --- packages/flutter/lib/src/gestures/double_tap.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/gestures/double_tap.dart b/packages/flutter/lib/src/gestures/double_tap.dart index 32e938382b..3abcfd8c3a 100644 --- a/packages/flutter/lib/src/gestures/double_tap.dart +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -150,8 +150,7 @@ class DoubleTapGestureRecognizer extends DisposableArenaMember { } void _startDoubleTapTimer() { - if (_doubleTapTimer == null) - _doubleTapTimer = new Timer(kDoubleTapTimeout, () => _reset()); + _doubleTapTimer ??= new Timer(kDoubleTapTimeout, () => _reset()); } void _stopDoubleTapTimer() { From bf9e21876eaba9f1b579712fad7785ec82c45335 Mon Sep 17 00:00:00 2001 From: Kris Giesing Date: Fri, 23 Oct 2015 11:26:40 -0700 Subject: [PATCH 8/8] Mark field final in double tap recognizer --- packages/flutter/lib/src/gestures/double_tap.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/gestures/double_tap.dart b/packages/flutter/lib/src/gestures/double_tap.dart index 3abcfd8c3a..6b5d4c8fe8 100644 --- a/packages/flutter/lib/src/gestures/double_tap.dart +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -39,7 +39,7 @@ class DoubleTapGestureRecognizer extends DisposableArenaMember { Timer _doubleTapTimer; TapTracker _firstTap; - Map _trackers = new Map(); + final Map _trackers = new Map(); void addPointer(PointerInputEvent event) { // Ignore out-of-bounds second taps