Commit 739fda1a authored by krisgiesing's avatar krisgiesing

Merge pull request #1743 from krisgiesing/doubletap

Fix #1471 Add double tap gesture
parents 228469bb bf9e2187
......@@ -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';
......
// 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<int, TapTracker> _trackers = new Map<int, TapTracker>();
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<TapTracker> localTrackers = new List<TapTracker>.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;
}
}
}
......@@ -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);
}
......@@ -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);
}
......
......@@ -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<int, TapGesture> _gestureMap = new Map<int, TapGesture>();
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<TapGesture> localGestures = new List<TapGesture>.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;
}
}
......@@ -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<GestureDetector> {
final PointerRouter _router = FlutterBinding.instance.pointerRouter;
TapGestureRecognizer _tap;
DoubleTapGestureRecognizer _doubleTap;
ShowPressGestureRecognizer _showPress;
LongPressGestureRecognizer _longPress;
VerticalDragGestureRecognizer _verticalDrag;
......@@ -100,6 +103,7 @@ class _GestureDetectorState extends State<GestureDetector> {
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<GestureDetector> {
void _syncAll() {
_syncTap();
_syncDoubleTap();
_syncShowPress();
_syncLongPress();
_syncVerticalDrag();
......@@ -131,6 +136,15 @@ class _GestureDetectorState extends State<GestureDetector> {
}
}
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<GestureDetector> {
}
}
GestureRecognizer _ensureDisposed(GestureRecognizer recognizer) {
DisposableArenaMember _ensureDisposed(DisposableArenaMember recognizer) {
recognizer?.dispose();
return null;
}
......@@ -207,6 +221,8 @@ class _GestureDetectorState extends State<GestureDetector> {
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)
......
This diff is collapsed.
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<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
List<double> 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<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
List<double> 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<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
List<double> 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<double> x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
List<double> y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
List<double> 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);
});
}
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(down1);
GestureArena.instance.close(1);
expect(tapRecognized, isFalse);
router.route(down1);
expect(tapRecognized, isFalse);
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(down);
GestureArena.instance.close(5);
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(down);
router.route(down3);
expect(tapRecognized, isFalse);
PointerInputEvent up = new PointerInputEvent(
pointer: 5,
type: 'pointerup',
x: 11.0,
y: 9.0
);
router.route(move3);
expect(tapRecognized, isFalse);
router.route(up3);
expect(tapRecognized, isFalse);
GestureArena.instance.sweep(3);
expect(tapRecognized, isFalse);
router.route(up);
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();
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment