diff --git a/packages/flutter/lib/gestures.dart b/packages/flutter/lib/gestures.dart index f5432bc455..8a3f7dfde3 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/events.dart'; export 'src/gestures/long_press.dart'; export 'src/gestures/pointer_router.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..6b5d4c8fe8 --- /dev/null +++ b/packages/flutter/lib/src/gestures/double_tap.dart @@ -0,0 +1,163 @@ +// 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 'arena.dart'; +import 'constants.dart'; +import 'events.dart'; +import 'recognizer.dart'; +import 'tap.dart'; + +class DoubleTapGestureRecognizer extends DisposableArenaMember { + + 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; + + Timer _doubleTapTimer; + TapTracker _firstTap; + final Map _trackers = new Map(); + + void addPointer(PointerInputEvent event) { + // Ignore out-of-bounds second taps + if (_firstTap != null && + !_firstTap.isWithinTolerance(event, kDoubleTapTouchSlop)) + return; + _stopDoubleTapTimer(); + 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) { + TapTracker tracker = _trackers[event.pointer]; + assert(tracker != null); + if (event.type == 'pointerup') { + if (_firstTap == null) + _registerFirstTap(tracker); + else + _registerSecondTap(tracker); + } else if (event.type == 'pointermove' && + !tracker.isWithinTolerance(event, kTouchSlop)) { + _reject(tracker); + } else if (event.type == 'pointercancel') { + _reject(tracker); + } + } + + void acceptGesture(int pointer) {} + + 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, + // 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() { + _reset(); + router = null; + } + + void _reset() { + _stopDoubleTapTimer(); + 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 _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 _registerSecondTap(TapTracker tracker) { + _firstTap.entry.resolve(GestureDisposition.accepted); + tracker.entry.resolve(GestureDisposition.accepted); + _freezeTracker(tracker); + _trackers.remove(tracker.pointer); + if (onDoubleTap != null) + onDoubleTap(); + _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() { + _doubleTapTimer ??= new Timer(kDoubleTapTimeout, () => _reset()); + } + + void _stopDoubleTapTimer() { + if (_doubleTapTimer != null) { + _doubleTapTimer.cancel(); + _doubleTapTimer = null; + } + } + +} diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart index 9fd8e3ef6c..93afde1fa1 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/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index 93bc2d85be..f2a84985d3 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -12,7 +12,11 @@ import 'pointer_router.dart'; export 'pointer_router.dart' show PointerRouter; -abstract class GestureRecognizer extends GestureArenaMember { +abstract class DisposableArenaMember extends GestureArenaMember { + void dispose(); +} + +abstract class GestureRecognizer extends DisposableArenaMember { GestureRecognizer({ PointerRouter router }) : _router = router { assert(_router != null); } @@ -102,10 +106,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 16ee90f081..0482d3a5b5 100644 --- a/packages/flutter/lib/src/gestures/tap.dart +++ b/packages/flutter/lib/src/gestures/tap.dart @@ -2,37 +2,179 @@ // 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:ui' as ui; + import 'arena.dart'; +import 'constants.dart'; import 'events.dart'; +import 'pointer_router.dart'; import 'recognizer.dart'; typedef void GestureTapCallback(); -class TapGestureRecognizer extends PrimaryPointerGestureRecognizer { - TapGestureRecognizer({ PointerRouter router, this.onTap }) - : super(router: router); +enum TapResolution { + tap, + cancel +} +/// 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; + GestureArenaEntry entry; + ui.Point _initialPosition; + bool _isTrackingPointer; + Timer _timer; + + void startTimer(void callback()) { + _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; + + bool _wonArena; + bool _didTap; + + void handleEvent(PointerInputEvent event) { + assert(event.pointer == pointer); + if (event.type == 'pointermove' && !isWithinTolerance(event, kTouchSlop)) { + cancel(); + } else if (event.type == 'pointercancel') { + cancel(); + } else if (event.type == 'pointerup') { + stopTimer(); + stopTrackingPointer(gestureRecognizer.router, handleEvent); + _didTap = true; + _check(); + } + } + + void accept() { + _wonArena = true; + _check(); + } + + void reject() { + stopTimer(); + stopTrackingPointer(gestureRecognizer.router, handleEvent); + gestureRecognizer._resolveTap(pointer, TapResolution.cancel); + } + + 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); + } + +} + +class TapGestureRecognizer extends DisposableArenaMember { + TapGestureRecognizer({ this.router, this.onTap, this.onTapDown, this.onTapCancel }); + + PointerRouter router; GestureTapCallback onTap; GestureTapCallback onTapDown; GestureTapCallback onTapCancel; - void handlePrimaryPointer(PointerInputEvent event) { - if (event.type == 'pointerdown') { - if (onTapDown != null) - onTapDown(); - } else if (event.type == 'pointerup') { - resolve(GestureDisposition.accepted); - if (onTap != null) - onTap(); - } + Map _gestureMap = new Map(); + + void addPointer(PointerInputEvent event) { + assert(!_gestureMap.containsKey(event.pointer)); + _gestureMap[event.pointer] = new TapGesture( + gestureRecognizer: this, + event: event + ); + if (onTapDown != null) + onTapDown(); + } + + void acceptGesture(int pointer) { + assert(_gestureMap.containsKey(pointer)); + _gestureMap[pointer]?.accept(); } void rejectGesture(int pointer) { - super.rejectGesture(pointer); - if (pointer == primaryPointer) { - assert(state == GestureRecognizerState.defunct); + assert(_gestureMap.containsKey(pointer)); + _gestureMap[pointer]?.reject(); + } + + void _resolveTap(int pointer, TapResolution resolution) { + _gestureMap.remove(pointer); + if (resolution == TapResolution.tap) { + if (onTap != null) + onTap(); + } else { if (onTapCancel != null) onTapCancel(); } } + + void dispose() { + 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/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 0b8841c84d..2bab4d55d5 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -32,6 +32,7 @@ class GestureDetector extends StatefulComponent { Key key, this.child, this.onTap, + this.onDoubleTap, this.onTapDown, this.onTapCancel, this.onShowPress, @@ -55,6 +56,7 @@ class GestureDetector extends StatefulComponent { final GestureTapCallback onTap; final GestureTapCallback onTapDown; final GestureTapCallback onTapCancel; + final GestureTapCallback onDoubleTap; final GestureShowPressCallback onShowPress; final GestureLongPressCallback onLongPress; @@ -82,6 +84,7 @@ class _GestureDetectorState extends State { final PointerRouter _router = FlutterBinding.instance.pointerRouter; TapGestureRecognizer _tap; + DoubleTapGestureRecognizer _doubleTap; ShowPressGestureRecognizer _showPress; LongPressGestureRecognizer _longPress; VerticalDragGestureRecognizer _verticalDrag; @@ -100,6 +103,7 @@ class _GestureDetectorState extends State { void dispose() { _tap = _ensureDisposed(_tap); + _doubleTap = _ensureDisposed(_doubleTap); _showPress = _ensureDisposed(_showPress); _longPress = _ensureDisposed(_longPress); _verticalDrag = _ensureDisposed(_verticalDrag); @@ -111,6 +115,7 @@ class _GestureDetectorState extends State { void _syncAll() { _syncTap(); + _syncDoubleTap(); _syncShowPress(); _syncLongPress(); _syncVerticalDrag(); @@ -131,6 +136,15 @@ class _GestureDetectorState extends State { } } + void _syncDoubleTap() { + if (config.onDoubleTap == null) { + _doubleTap = _ensureDisposed(_doubleTap); + } else { + _doubleTap ??= new DoubleTapGestureRecognizer(router: _router); + _doubleTap.onDoubleTap = config.onDoubleTap; + } + } + void _syncShowPress() { if (config.onShowPress == null) { _showPress = _ensureDisposed(_showPress); @@ -199,7 +213,7 @@ class _GestureDetectorState extends State { } } - GestureRecognizer _ensureDisposed(GestureRecognizer recognizer) { + DisposableArenaMember _ensureDisposed(DisposableArenaMember recognizer) { recognizer?.dispose(); return null; } @@ -207,6 +221,8 @@ class _GestureDetectorState extends State { void _handlePointerDown(PointerInputEvent event) { if (_tap != null) _tap.addPointer(event); + if (_doubleTap != null) + _doubleTap.addPointer(event); if (_showPress != null) _showPress.addPointer(event); if (_longPress != 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..934a4703e2 --- /dev/null +++ b/packages/unit/test/gestures/double_tap_test.dart @@ -0,0 +1,493 @@ +import 'package:flutter/gestures.dart'; +import 'package:quiver/testing/async.dart'; +import 'package:test/test.dart'; + +class TestGestureArenaMember extends GestureArenaMember { + void acceptGesture(Object key) { + accepted = true; + } + void rejectGesture(Object key) { + rejected = true; + } + bool accepted = false; + bool rejected = false; +} + +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(member.accepted, isTrue); + 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); + expect(member.accepted, isTrue); + + 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); + expect(member.accepted, isTrue); + + router.route(up2); + expect(doubleTapRecognized, isFalse); + GestureArena.instance.sweep(2); + expect(doubleTapRecognized, isFalse); + + 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); + }); + +} 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(); + }); + }