Implement hover support for mouse pointers. (#24830)
This implements support for hovering mouse pointers, so that mice connected to Android devices, and ChromeOS devices running Android apps will work properly.
It teaches flutter_test about hover events, which required changing how they are created and used.
Also modifies AnnotatedRegion to allow a region that can be located someplace other than just the origin.
Along with tests for all of the above.
Fixes #5504
diff --git a/packages/flutter/lib/gestures.dart b/packages/flutter/lib/gestures.dart
index b0267aa..04f23e5 100644
--- a/packages/flutter/lib/gestures.dart
+++ b/packages/flutter/lib/gestures.dart
@@ -21,6 +21,7 @@
export 'src/gestures/long_press.dart';
export 'src/gestures/lsq_solver.dart';
export 'src/gestures/monodrag.dart';
+export 'src/gestures/mouse_tracking.dart';
export 'src/gestures/multidrag.dart';
export 'src/gestures/multitap.dart';
export 'src/gestures/pointer_router.dart';
diff --git a/packages/flutter/lib/src/gestures/binding.dart b/packages/flutter/lib/src/gestures/binding.dart
index ffd2469..63740b5 100644
--- a/packages/flutter/lib/src/gestures/binding.dart
+++ b/packages/flutter/lib/src/gestures/binding.dart
@@ -116,26 +116,38 @@
void _handlePointerEvent(PointerEvent event) {
assert(!locked);
- HitTestResult result;
+ HitTestResult hitTestResult;
if (event is PointerDownEvent) {
assert(!_hitTests.containsKey(event.pointer));
- result = HitTestResult();
- hitTest(result, event.position);
- _hitTests[event.pointer] = result;
+ hitTestResult = HitTestResult();
+ hitTest(hitTestResult, event.position);
+ _hitTests[event.pointer] = hitTestResult;
assert(() {
if (debugPrintHitTestResults)
- debugPrint('$event: $result');
+ debugPrint('$event: $hitTestResult');
return true;
}());
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
- result = _hitTests.remove(event.pointer);
+ hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
- result = _hitTests[event.pointer];
- } else {
- return; // We currently ignore add, remove, and hover move events.
+ // Because events that occur with the pointer down (like
+ // PointerMoveEvents) should be dispatched to the same place that their
+ // initial PointerDownEvent was, we want to re-use the path we found when
+ // the pointer went down, rather than do hit detection each time we get
+ // such an event.
+ hitTestResult = _hitTests[event.pointer];
}
- if (result != null)
- dispatchEvent(event, result);
+ assert(() {
+ if (debugPrintMouseHoverEvents && event is PointerHoverEvent)
+ debugPrint('$event');
+ return true;
+ }());
+ if (hitTestResult != null ||
+ event is PointerHoverEvent ||
+ event is PointerAddedEvent ||
+ event is PointerRemovedEvent) {
+ dispatchEvent(event, hitTestResult);
+ }
}
/// Determine which [HitTestTarget] objects are located at a given position.
@@ -146,14 +158,36 @@
/// Dispatch an event to a hit test result's path.
///
- /// This sends the given event to every [HitTestTarget] in the entries
- /// of the given [HitTestResult], and catches exceptions that any of
- /// the handlers might throw. The `result` argument must not be null.
+ /// This sends the given event to every [HitTestTarget] in the entries of the
+ /// given [HitTestResult], and catches exceptions that any of the handlers
+ /// might throw. The [hitTestResult] argument may only be null for
+ /// [PointerHoverEvent], [PointerAddedEvent], or [PointerRemovedEvent] events.
@override // from HitTestDispatcher
- void dispatchEvent(PointerEvent event, HitTestResult result) {
+ void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
assert(!locked);
- assert(result != null);
- for (HitTestEntry entry in result.path) {
+ // No hit test information implies that this is a hover or pointer
+ // add/remove event.
+ if (hitTestResult == null) {
+ assert(event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent);
+ try {
+ pointerRouter.route(event);
+ } catch (exception, stack) {
+ FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
+ exception: exception,
+ stack: stack,
+ library: 'gesture library',
+ context: 'while dispatching a non-hit-tested pointer event',
+ event: event,
+ hitTestEntry: null,
+ informationCollector: (StringBuffer information) {
+ information.writeln('Event:');
+ information.writeln(' $event');
+ },
+ ));
+ }
+ return;
+ }
+ for (HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event, entry);
} catch (exception, stack) {
@@ -169,7 +203,7 @@
information.writeln(' $event');
information.writeln('Target:');
information.write(' ${entry.target}');
- }
+ },
));
}
}
@@ -219,7 +253,8 @@
final PointerEvent event;
/// The hit test result entry for the object whose handleEvent method threw
- /// the exception.
+ /// the exception. May be null if no hit test entry is associated with the
+ /// event (e.g. hover and pointer add/remove events).
///
/// The target object itself is given by the [HitTestEntry.target] property of
/// the hitTestEntry object.
diff --git a/packages/flutter/lib/src/gestures/converter.dart b/packages/flutter/lib/src/gestures/converter.dart
index aea2b79..7b16650 100644
--- a/packages/flutter/lib/src/gestures/converter.dart
+++ b/packages/flutter/lib/src/gestures/converter.dart
@@ -4,6 +4,8 @@
import 'dart:ui' as ui show PointerData, PointerChange;
+import 'package:flutter/foundation.dart' show visibleForTesting;
+
import 'events.dart';
class _PointerState {
@@ -44,7 +46,16 @@
class PointerEventConverter {
PointerEventConverter._();
+ /// Clears internal state mapping platform pointer identifiers to
+ /// [PointerEvent] pointer identifiers.
+ ///
+ /// Visible only so that tests can reset the global state contained in
+ /// [PointerEventConverter].
+ @visibleForTesting
+ static void clearPointers() => _pointers.clear();
+
// Map from platform pointer identifiers to PointerEvent pointer identifiers.
+ // Static to guarantee that pointers are unique.
static final Map<int, _PointerState> _pointers = <int, _PointerState>{};
static _PointerState _ensureStateForPointer(ui.PointerData datum, Offset position) {
@@ -54,7 +65,8 @@
);
}
- /// Expand the given packet of pointer data into a sequence of framework pointer events.
+ /// Expand the given packet of pointer data into a sequence of framework
+ /// pointer events.
///
/// The `devicePixelRatio` argument (usually given the value from
/// [dart:ui.Window.devicePixelRatio]) is used to convert the incoming data
diff --git a/packages/flutter/lib/src/gestures/debug.dart b/packages/flutter/lib/src/gestures/debug.dart
index c718d06..797b81c 100644
--- a/packages/flutter/lib/src/gestures/debug.dart
+++ b/packages/flutter/lib/src/gestures/debug.dart
@@ -15,6 +15,15 @@
/// This has no effect in release builds.
bool debugPrintHitTestResults = false;
+/// Whether to print the details of each mouse hover event to the console.
+///
+/// When this is set, in debug mode, any time a mouse hover event is triggered
+/// by the [GestureBinding], the results are dumped to the console.
+///
+/// This has no effect in release builds, and only applies to mouse hover
+/// events.
+bool debugPrintMouseHoverEvents = false;
+
/// Prints information about gesture recognizers and gesture arenas.
///
/// This flag only has an effect in debug mode.
diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart
index 171fd1e..ea35a99 100644
--- a/packages/flutter/lib/src/gestures/events.dart
+++ b/packages/flutter/lib/src/gestures/events.dart
@@ -53,7 +53,7 @@
/// The bit of [PointerEvent.buttons] that corresponds to the nth mouse button.
///
-/// The number argument can be at most 62.
+/// The `number` argument can be at most 62.
///
/// See [kPrimaryMouseButton], [kSecondaryMouseButton], [kMiddleMouseButton],
/// [kBackMouseButton], and [kForwardMouseButton] for semantic names for some
@@ -62,7 +62,7 @@
/// The bit of [PointerEvent.buttons] that corresponds to the nth stylus button.
///
-/// The number argument can be at most 62.
+/// The `number` argument can be at most 62.
///
/// See [kPrimaryStylusButton] and [kSecondaryStylusButton] for semantic names
/// for some stylus buttons.
@@ -122,7 +122,8 @@
/// Time of event dispatch, relative to an arbitrary timeline.
final Duration timeStamp;
- /// Unique identifier for the pointer, not reused.
+ /// Unique identifier for the pointer, not reused. Changes for each new
+ /// pointer down event.
final int pointer;
/// The kind of input device for which the event was generated.
@@ -327,7 +328,7 @@
class PointerAddedEvent extends PointerEvent {
/// Creates a pointer added event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerAddedEvent({
Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.touch,
@@ -368,7 +369,7 @@
class PointerRemovedEvent extends PointerEvent {
/// Creates a pointer removed event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerRemovedEvent({
Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.touch,
@@ -400,12 +401,15 @@
///
/// See also:
///
+/// * [PointerEnterEvent], which reports when the pointer has entered an
+/// object.
+/// * [PointerExitEvent], which reports when the pointer has left an object.
/// * [PointerMoveEvent], which reports movement while the pointer is in
/// contact with the device.
class PointerHoverEvent extends PointerEvent {
/// Creates a pointer hover event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerHoverEvent({
Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.touch,
@@ -452,11 +456,187 @@
);
}
+/// The pointer has moved with respect to the device while the pointer is not
+/// in contact with the device, and it has entered a target object.
+///
+/// See also:
+///
+/// * [PointerHoverEvent], which reports when the pointer has moved while
+/// within an object.
+/// * [PointerExitEvent], which reports when the pointer has left an object.
+/// * [PointerMoveEvent], which reports movement while the pointer is in
+/// contact with the device.
+class PointerEnterEvent extends PointerEvent {
+ /// Creates a pointer enter event.
+ ///
+ /// All of the arguments must be non-null.
+ const PointerEnterEvent({
+ Duration timeStamp = Duration.zero,
+ PointerDeviceKind kind = PointerDeviceKind.touch,
+ int device = 0,
+ Offset position = Offset.zero,
+ Offset delta = Offset.zero,
+ int buttons = 0,
+ bool obscured = false,
+ double pressure = 0.0,
+ double pressureMin = 1.0,
+ double pressureMax = 1.0,
+ double distance = 0.0,
+ double distanceMax = 0.0,
+ double size = 0.0,
+ double radiusMajor = 0.0,
+ double radiusMinor = 0.0,
+ double radiusMin = 0.0,
+ double radiusMax = 0.0,
+ double orientation = 0.0,
+ double tilt = 0.0,
+ bool synthesized = false,
+ }) : super(
+ timeStamp: timeStamp,
+ kind: kind,
+ device: device,
+ position: position,
+ delta: delta,
+ buttons: buttons,
+ down: false,
+ obscured: obscured,
+ pressure: pressure,
+ pressureMin: pressureMin,
+ pressureMax: pressureMax,
+ distance: distance,
+ distanceMax: distanceMax,
+ size: size,
+ radiusMajor: radiusMajor,
+ radiusMinor: radiusMinor,
+ radiusMin: radiusMin,
+ radiusMax: radiusMax,
+ orientation: orientation,
+ tilt: tilt,
+ synthesized: synthesized,
+ );
+
+ /// Creates an enter event from a [PointerHoverEvent].
+ ///
+ /// This is used by the [MouseTracker] to synthesize enter events, since it
+ /// only actually receives hover events.
+ PointerEnterEvent.fromHoverEvent(PointerHoverEvent hover) : super(
+ timeStamp: hover?.timeStamp,
+ kind: hover?.kind,
+ device: hover?.device,
+ position: hover?.position,
+ delta: hover?.delta,
+ buttons: hover?.buttons,
+ down: hover?.down,
+ obscured: hover?.obscured,
+ pressure: hover?.pressure,
+ pressureMin: hover?.pressureMin,
+ pressureMax: hover?.pressureMax,
+ distance: hover?.distance,
+ distanceMax: hover?.distanceMax,
+ size: hover?.size,
+ radiusMajor: hover?.radiusMajor,
+ radiusMinor: hover?.radiusMinor,
+ radiusMin: hover?.radiusMin,
+ radiusMax: hover?.radiusMax,
+ orientation: hover?.orientation,
+ tilt: hover?.tilt,
+ synthesized: hover?.synthesized,
+ );
+}
+
+/// The pointer has moved with respect to the device while the pointer is not
+/// in contact with the device, and entered a target object.
+///
+/// See also:
+///
+/// * [PointerHoverEvent], which reports when the pointer has moved while
+/// within an object.
+/// * [PointerEnterEvent], which reports when the pointer has entered an object.
+/// * [PointerMoveEvent], which reports movement while the pointer is in
+/// contact with the device.
+class PointerExitEvent extends PointerEvent {
+ /// Creates a pointer exit event.
+ ///
+ /// All of the arguments must be non-null.
+ const PointerExitEvent({
+ Duration timeStamp = Duration.zero,
+ PointerDeviceKind kind = PointerDeviceKind.touch,
+ int device = 0,
+ Offset position = Offset.zero,
+ Offset delta = Offset.zero,
+ int buttons = 0,
+ bool obscured = false,
+ double pressure = 0.0,
+ double pressureMin = 1.0,
+ double pressureMax = 1.0,
+ double distance = 0.0,
+ double distanceMax = 0.0,
+ double size = 0.0,
+ double radiusMajor = 0.0,
+ double radiusMinor = 0.0,
+ double radiusMin = 0.0,
+ double radiusMax = 0.0,
+ double orientation = 0.0,
+ double tilt = 0.0,
+ bool synthesized = false,
+ }) : super(
+ timeStamp: timeStamp,
+ kind: kind,
+ device: device,
+ position: position,
+ delta: delta,
+ buttons: buttons,
+ down: false,
+ obscured: obscured,
+ pressure: pressure,
+ pressureMin: pressureMin,
+ pressureMax: pressureMax,
+ distance: distance,
+ distanceMax: distanceMax,
+ size: size,
+ radiusMajor: radiusMajor,
+ radiusMinor: radiusMinor,
+ radiusMin: radiusMin,
+ radiusMax: radiusMax,
+ orientation: orientation,
+ tilt: tilt,
+ synthesized: synthesized,
+ );
+
+ /// Creates an exit event from a [PointerHoverEvent].
+ ///
+ /// This is used by the [MouseTracker] to synthesize exit events, since it
+ /// only actually receives hover events.
+ PointerExitEvent.fromHoverEvent(PointerHoverEvent hover) : super(
+ timeStamp: hover?.timeStamp,
+ kind: hover?.kind,
+ device: hover?.device,
+ position: hover?.position,
+ delta: hover?.delta,
+ buttons: hover?.buttons,
+ down: hover?.down,
+ obscured: hover?.obscured,
+ pressure: hover?.pressure,
+ pressureMin: hover?.pressureMin,
+ pressureMax: hover?.pressureMax,
+ distance: hover?.distance,
+ distanceMax: hover?.distanceMax,
+ size: hover?.size,
+ radiusMajor: hover?.radiusMajor,
+ radiusMinor: hover?.radiusMinor,
+ radiusMin: hover?.radiusMin,
+ radiusMax: hover?.radiusMax,
+ orientation: hover?.orientation,
+ tilt: hover?.tilt,
+ synthesized: hover?.synthesized,
+ );
+}
+
/// The pointer has made contact with the device.
class PointerDownEvent extends PointerEvent {
/// Creates a pointer down event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerDownEvent({
Duration timeStamp = Duration.zero,
int pointer = 0,
@@ -510,7 +690,7 @@
class PointerMoveEvent extends PointerEvent {
/// Creates a pointer move event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerMoveEvent({
Duration timeStamp = Duration.zero,
int pointer = 0,
@@ -564,7 +744,7 @@
class PointerUpEvent extends PointerEvent {
/// Creates a pointer up event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerUpEvent({
Duration timeStamp = Duration.zero,
int pointer = 0,
@@ -613,7 +793,7 @@
class PointerCancelEvent extends PointerEvent {
/// Creates a pointer cancel event.
///
- /// All of the argument must be non-null.
+ /// All of the arguments must be non-null.
const PointerCancelEvent({
Duration timeStamp = Duration.zero,
int pointer = 0,
diff --git a/packages/flutter/lib/src/gestures/mouse_tracking.dart b/packages/flutter/lib/src/gestures/mouse_tracking.dart
new file mode 100644
index 0000000..da65c24
--- /dev/null
+++ b/packages/flutter/lib/src/gestures/mouse_tracking.dart
@@ -0,0 +1,260 @@
+// Copyright 2018 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:ui';
+
+import 'package:flutter/foundation.dart' show visibleForTesting;
+import 'package:flutter/scheduler.dart';
+
+import 'events.dart';
+import 'pointer_router.dart';
+
+/// Signature for listening to [PointerEnterEvent] events.
+///
+/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
+typedef PointerEnterEventListener = void Function(PointerEnterEvent event);
+
+/// Signature for listening to [PointerExitEvent] events.
+///
+/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
+typedef PointerExitEventListener = void Function(PointerExitEvent event);
+
+/// Signature for listening to [PointerHoverEvent] events.
+///
+/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
+typedef PointerHoverEventListener = void Function(PointerHoverEvent event);
+
+/// The annotation object used to annotate layers that are interested in mouse
+/// movements.
+///
+/// This is added to a layer and managed by the [Listener] widget.
+class MouseTrackerAnnotation {
+ /// Creates an annotation that can be used to find layers interested in mouse
+ /// movements.
+ const MouseTrackerAnnotation({this.onEnter, this.onHover, this.onExit});
+
+ /// Triggered when a pointer has entered the bounding box of the annotated
+ /// layer.
+ final PointerEnterEventListener onEnter;
+
+ /// Triggered when a pointer has moved within the bounding box of the
+ /// annotated layer.
+ final PointerHoverEventListener onHover;
+
+ /// Triggered when a pointer has exited the bounding box of the annotated
+ /// layer.
+ final PointerExitEventListener onExit;
+
+ @override
+ String toString() {
+ final String none = (onEnter == null && onExit == null && onHover == null) ? ' <none>' : '';
+ return '[$runtimeType${hashCode.toRadixString(16)}$none'
+ '${onEnter == null ? '' : ' onEnter'}'
+ '${onHover == null ? '' : ' onHover'}'
+ '${onExit == null ? '' : ' onExit'}]';
+ }
+}
+
+// Used internally by the MouseTracker for accounting for which annotation is
+// active on which devices inside of the MouseTracker.
+class _TrackedAnnotation {
+ _TrackedAnnotation(this.annotation);
+
+ final MouseTrackerAnnotation annotation;
+
+ /// Tracks devices that are currently active for this annotation.
+ ///
+ /// If the mouse pointer corresponding to the integer device ID is
+ /// present in the Set, then it is currently inside of the annotated layer.
+ ///
+ /// This is used to detect layers that used to have the mouse pointer inside
+ /// them, but now no longer do (to facilitate exit notification).
+ Set<int> activeDevices = Set<int>();
+}
+
+/// Describes a function that finds an annotation given an offset in logical
+/// coordinates.
+///
+/// It is used by the [MouseTracker] to fetch annotations for the mouse
+/// position.
+typedef MouseDetectorAnnotationFinder = MouseTrackerAnnotation Function(Offset offset);
+
+/// Keeps state about which objects are interested in tracking mouse positions
+/// and notifies them when a mouse pointer enters, moves, or leaves an annotated
+/// region that they are interested in.
+///
+/// Owned by the [RendererBinding] class.
+class MouseTracker {
+ /// Creates a mouse tracker to keep track of mouse locations.
+ ///
+ /// All of the parameters must not be null.
+ MouseTracker(PointerRouter router, this.annotationFinder)
+ : assert(router != null),
+ assert(annotationFinder != null) {
+ router.addGlobalRoute(_handleEvent);
+ }
+
+ /// Used to find annotations at a given logical coordinate.
+ final MouseDetectorAnnotationFinder annotationFinder;
+
+ // The collection of annotations that are currently being tracked. They may or
+ // may not be active, depending on the value of _TrackedAnnotation.active.
+ final Map<MouseTrackerAnnotation, _TrackedAnnotation> _trackedAnnotations = <MouseTrackerAnnotation, _TrackedAnnotation>{};
+
+ /// Track an annotation so that if the mouse enters it, we send it events.
+ ///
+ /// This is typically called when the [AnnotatedRegion] containing this
+ /// annotation has been added to the layer tree.
+ void attachAnnotation(MouseTrackerAnnotation annotation) {
+ _trackedAnnotations[annotation] = _TrackedAnnotation(annotation);
+ // Schedule a check so that we test this new annotation to see if the mouse
+ // is currently inside its region.
+ _scheduleMousePositionCheck();
+ }
+
+ /// Stops tracking an annotation, indicating that it has been removed from the
+ /// layer tree.
+ ///
+ /// If the associated layer is not removed, and receives a hit, then
+ /// [collectMousePositions] will assert the next time it is called.
+ void detachAnnotation(MouseTrackerAnnotation annotation) {
+ final _TrackedAnnotation trackedAnnotation = _findAnnotation(annotation);
+ assert(trackedAnnotation != null, "Tried to detach an annotation that wasn't attached: $annotation");
+ for (int deviceId in trackedAnnotation.activeDevices) {
+ annotation.onExit(PointerExitEvent.fromHoverEvent(_lastMouseEvent[deviceId]));
+ }
+ _trackedAnnotations.remove(trackedAnnotation);
+ }
+
+ void _scheduleMousePositionCheck() {
+ SchedulerBinding.instance.addPostFrameCallback((Duration _) => collectMousePositions());
+ SchedulerBinding.instance.scheduleFrame();
+ }
+
+ // Handler for events coming from the PointerRouter.
+ void _handleEvent(PointerEvent event) {
+ if (event.kind != PointerDeviceKind.mouse) {
+ return;
+ }
+ final int deviceId = event.device;
+ if (_trackedAnnotations.isEmpty) {
+ // If we're not tracking anything, then there is no point in registering a
+ // frame callback or scheduling a frame. By definition there are no active
+ // annotations that need exiting, either.
+ _lastMouseEvent.remove(deviceId);
+ return;
+ }
+ if (event is PointerRemovedEvent) {
+ _lastMouseEvent.remove(deviceId);
+ // If the mouse was removed, then we need to schedule one more check to
+ // exit any annotations that were active.
+ _scheduleMousePositionCheck();
+ } else {
+ if (event is PointerMoveEvent || event is PointerHoverEvent || event is PointerDownEvent) {
+ if (!_lastMouseEvent.containsKey(deviceId) || _lastMouseEvent[deviceId].position != event.position) {
+ // Only schedule a frame if we have our first event, or if the
+ // location of the mouse has changed.
+ _scheduleMousePositionCheck();
+ }
+ _lastMouseEvent[deviceId] = event;
+ }
+ }
+ }
+
+ _TrackedAnnotation _findAnnotation(MouseTrackerAnnotation annotation) {
+ final _TrackedAnnotation trackedAnnotation = _trackedAnnotations[annotation];
+ assert(
+ trackedAnnotation != null,
+ 'Unable to find annotation $annotation in tracked annotations. '
+ 'Check that attachAnnotation has been called for all annotated layers.');
+ return trackedAnnotation;
+ }
+
+ /// Tells interested objects that a mouse has entered, exited, or moved, given
+ /// a callback to fetch the [MouseTrackerAnnotation] associated with a global
+ /// offset.
+ ///
+ /// This is called from a post-frame callback when the layer tree has been
+ /// updated, right after rendering the frame.
+ ///
+ /// This function is only public to allow for proper testing of the
+ /// MouseTracker. Do not call in other contexts.
+ @visibleForTesting
+ void collectMousePositions() {
+ void exitAnnotation(_TrackedAnnotation trackedAnnotation, int deviceId) {
+ if (trackedAnnotation.annotation?.onExit != null && trackedAnnotation.activeDevices.contains(deviceId)) {
+ trackedAnnotation.annotation.onExit(PointerExitEvent.fromHoverEvent(_lastMouseEvent[deviceId]));
+ trackedAnnotation.activeDevices.remove(deviceId);
+ }
+ }
+
+ void exitAllDevices(_TrackedAnnotation trackedAnnotation) {
+ if (trackedAnnotation.activeDevices.isNotEmpty) {
+ final Set<int> deviceIds = trackedAnnotation.activeDevices.toSet();
+ for (int deviceId in deviceIds) {
+ exitAnnotation(trackedAnnotation, deviceId);
+ }
+ }
+ }
+
+ // This indicates that all mouse pointers were removed, or none have been
+ // connected yet. If no mouse is connected, then we want to make sure that
+ // all active annotations are exited.
+ if (!mouseIsConnected) {
+ _trackedAnnotations.values.forEach(exitAllDevices);
+ return;
+ }
+
+ for (int deviceId in _lastMouseEvent.keys) {
+ final PointerEvent lastEvent = _lastMouseEvent[deviceId];
+ final MouseTrackerAnnotation hit = annotationFinder(lastEvent.position);
+
+ // No annotation was found at this position for this deviceId, so send an
+ // exit to all active tracked annotations, since none of them were hit.
+ if (hit == null) {
+ // Send an exit to all tracked animations tracking this deviceId.
+ for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) {
+ exitAnnotation(trackedAnnotation, deviceId);
+ }
+ return;
+ }
+
+ final _TrackedAnnotation hitAnnotation = _findAnnotation(hit);
+ if (!hitAnnotation.activeDevices.contains(deviceId)) {
+ // A tracked annotation that just became active and needs to have an enter
+ // event sent to it.
+ hitAnnotation.activeDevices.add(deviceId);
+ if (hitAnnotation.annotation?.onEnter != null) {
+ hitAnnotation.annotation.onEnter(PointerEnterEvent.fromHoverEvent(lastEvent));
+ }
+ }
+ if (hitAnnotation.annotation?.onHover != null) {
+ hitAnnotation.annotation.onHover(lastEvent);
+ }
+
+ // Tell any tracked annotations that weren't hit that they are no longer
+ // active.
+ for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) {
+ if (hitAnnotation == trackedAnnotation) {
+ continue;
+ }
+ if (trackedAnnotation.activeDevices.contains(deviceId)) {
+ if (trackedAnnotation.annotation?.onExit != null) {
+ trackedAnnotation.annotation.onExit(PointerExitEvent.fromHoverEvent(lastEvent));
+ }
+ trackedAnnotation.activeDevices.remove(deviceId);
+ }
+ }
+ }
+ }
+
+ /// The most recent mouse event observed for each mouse device ID observed.
+ ///
+ /// May be null if no mouse is connected, or hasn't produced an event yet.
+ /// Will not be updated unless there is at least one tracked annotation.
+ final Map<int, PointerEvent> _lastMouseEvent = <int, PointerEvent>{};
+
+ /// Whether or not a mouse is connected and has produced events.
+ bool get mouseIsConnected => _lastMouseEvent.isNotEmpty;
+}
diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart
index 353c633..eca6113 100644
--- a/packages/flutter/lib/src/rendering/binding.dart
+++ b/packages/flutter/lib/src/rendering/binding.dart
@@ -21,7 +21,7 @@
export 'package:flutter/gestures.dart' show HitTestResult;
/// The glue between the render tree and the Flutter engine.
-mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, SemanticsBinding, HitTestable {
+mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, SemanticsBinding, HitTestable {
@override
void initInstances() {
super.initInstances();
@@ -40,6 +40,7 @@
_handleSemanticsEnabledChanged();
assert(renderView != null);
addPersistentFrameCallback(_handlePersistentFrameCallback);
+ _mouseTracker = _createMouseTracker();
}
/// The current [RendererBinding], if one has been created.
@@ -134,6 +135,11 @@
renderView.scheduleInitialFrame();
}
+ /// The object that manages state about currently connected mice, for hover
+ /// notification.
+ MouseTracker get mouseTracker => _mouseTracker;
+ MouseTracker _mouseTracker;
+
/// The render tree's owner, which maintains dirty state for layout,
/// composite, paint, and accessibility semantics
PipelineOwner get pipelineOwner => _pipelineOwner;
@@ -184,6 +190,18 @@
SemanticsHandle _semanticsHandle;
+ // Creates a [MouseTracker] which manages state about currently connected
+ // mice, for hover notification.
+ MouseTracker _createMouseTracker() {
+ return MouseTracker(pointerRouter, (Offset offset) {
+ // Layer hit testing is done using device pixels, so we have to convert
+ // the logical coordinates of the event location back to device pixels
+ // here.
+ return renderView.layer
+ .find<MouseTrackerAnnotation>(offset * ui.window.devicePixelRatio);
+ });
+ }
+
void _handleSemanticsEnabledChanged() {
setSemanticsEnabled(ui.window.semanticsEnabled);
}
diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart
index 6c45bc4..f3db290 100644
--- a/packages/flutter/lib/src/rendering/layer.dart
+++ b/packages/flutter/lib/src/rendering/layer.dart
@@ -1729,10 +1729,13 @@
/// a [Size] is provided to this layer, then find will check if the provided
/// offset is within the bounds of the layer.
class AnnotatedRegionLayer<T> extends ContainerLayer {
- /// Creates a new layer annotated with [value] that clips to [size] if provided.
+ /// Creates a new layer annotated with [value] that clips to rectangle defined
+ /// by the [size] and [offset] if provided.
///
- /// The value provided cannot be null.
- AnnotatedRegionLayer(this.value, {this.size}) : assert(value != null);
+ /// The [value] provided cannot be null.
+ AnnotatedRegionLayer(this.value, {this.size, Offset offset})
+ : offset = offset ?? Offset.zero,
+ assert(value != null);
/// The value returned by [find] if the offset is contained within this layer.
final T value;
@@ -1741,14 +1744,25 @@
///
/// If not provided, all offsets are considered to be contained within this
/// layer, unless an ancestor layer applies a clip.
+ ///
+ /// If [offset] is set, then the offset is applied to the size region before
+ /// hit testing in [find].
final Size size;
+ /// The [offset] is optionally used to translate the clip region for the
+ /// hit-testing of [find] by [offset].
+ ///
+ /// If not provided, offset defaults to [Offset.zero].
+ ///
+ /// Ignored if [size] is not set.
+ final Offset offset;
+
@override
S find<S>(Offset regionOffset) {
final S result = super.find<S>(regionOffset);
if (result != null)
return result;
- if (size != null && !size.contains(regionOffset))
+ if (size != null && !(offset & size).contains(regionOffset))
return null;
if (T == S) {
final Object untypedResult = value;
@@ -1763,5 +1777,6 @@
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<T>('value', value));
properties.add(DiagnosticsProperty<Size>('size', size, defaultValue: null));
+ properties.add(DiagnosticsProperty<Offset>('offset', offset, defaultValue: null));
}
}
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart
index a6b3edd..d081d42 100644
--- a/packages/flutter/lib/src/rendering/proxy_box.dart
+++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -14,6 +14,7 @@
import 'package:vector_math/vector_math_64.dart';
+import 'binding.dart';
import 'box.dart';
import 'layer.dart';
import 'object.dart';
@@ -2486,25 +2487,84 @@
/// If it has a child, defers to the child for sizing behavior.
///
/// If it does not have a child, grows to fit the parent-provided constraints.
+///
+/// The [onPointerEnter], [onPointerHover], and [onPointerExit] events are only
+/// relevant to and fired by pointers that can hover (e.g. mouse pointers, but
+/// not most touch pointers).
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
- /// Creates a render object that forwards point events to callbacks.
+ /// Creates a render object that forwards pointer events to callbacks.
///
/// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
RenderPointerListener({
this.onPointerDown,
this.onPointerMove,
+ PointerEnterEventListener onPointerEnter,
+ PointerHoverEventListener onPointerHover,
+ PointerExitEventListener onPointerExit,
this.onPointerUp,
this.onPointerCancel,
HitTestBehavior behavior = HitTestBehavior.deferToChild,
- RenderBox child
- }) : super(behavior: behavior, child: child);
+ RenderBox child,
+ }) : _onPointerEnter = onPointerEnter,
+ _onPointerHover = onPointerHover,
+ _onPointerExit = onPointerExit,
+ super(behavior: behavior, child: child) {
+ if (_onPointerEnter != null || _onPointerHover != null || _onPointerExit != null) {
+ _hoverAnnotation = MouseTrackerAnnotation(
+ onEnter: _onPointerEnter,
+ onHover: _onPointerHover,
+ onExit: _onPointerExit,
+ );
+ }
+ }
- /// Called when a pointer comes into contact with the screen at this object.
+ /// Called when a pointer comes into contact with the screen (for touch
+ /// pointers), or has its button pressed (for mouse pointers) at this widget's
+ /// location.
PointerDownEventListener onPointerDown;
/// Called when a pointer that triggered an [onPointerDown] changes position.
PointerMoveEventListener onPointerMove;
+ /// Called when a hovering pointer enters the region for this widget.
+ ///
+ /// If this is a mouse pointer, this will fire when the mouse pointer enters
+ /// the region defined by this widget.
+ PointerEnterEventListener get onPointerEnter => _onPointerEnter;
+ set onPointerEnter(PointerEnterEventListener value) {
+ if (_onPointerEnter != value) {
+ _onPointerEnter = value;
+ _updateAnnotations();
+ }
+ }
+ PointerEnterEventListener _onPointerEnter;
+
+ /// Called when a pointer that has not triggered an [onPointerDown] changes
+ /// position.
+ ///
+ /// Typically only triggered for mouse pointers.
+ PointerHoverEventListener get onPointerHover => _onPointerHover;
+ set onPointerHover(PointerHoverEventListener value) {
+ if (_onPointerHover != value) {
+ _onPointerHover = value;
+ _updateAnnotations();
+ }
+ }
+ PointerHoverEventListener _onPointerHover;
+
+ /// Called when a hovering pointer leaves the region for this widget.
+ ///
+ /// If this is a mouse pointer, this will fire when the mouse pointer leaves
+ /// the region defined by this widget.
+ PointerExitEventListener get onPointerExit => _onPointerExit;
+ set onPointerExit(PointerExitEventListener value) {
+ if (_onPointerExit != value) {
+ _onPointerExit = value;
+ _updateAnnotations();
+ }
+ }
+ PointerExitEventListener _onPointerExit;
+
/// Called when a pointer that triggered an [onPointerDown] is no longer in
/// contact with the screen.
PointerUpEventListener onPointerUp;
@@ -2513,6 +2573,56 @@
/// no longer directed towards this receiver.
PointerCancelEventListener onPointerCancel;
+ // Object used for annotation of the layer used for hover hit detection.
+ MouseTrackerAnnotation _hoverAnnotation;
+
+ void _updateAnnotations() {
+ if (_hoverAnnotation != null && attached) {
+ RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
+ }
+ if (_onPointerEnter != null || _onPointerHover != null || _onPointerExit != null) {
+ _hoverAnnotation = MouseTrackerAnnotation(
+ onEnter: _onPointerEnter,
+ onHover: _onPointerHover,
+ onExit: _onPointerExit,
+ );
+ if (attached) {
+ RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
+ }
+ } else {
+ _hoverAnnotation = null;
+ }
+ }
+
+ @override
+ void attach(PipelineOwner owner) {
+ super.attach(owner);
+ if (_hoverAnnotation != null) {
+ RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
+ }
+ }
+
+ @override
+ void detach() {
+ if (_hoverAnnotation != null) {
+ RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
+ }
+ super.detach();
+ }
+
+ @override
+ void paint(PaintingContext context, Offset offset) {
+ if (_hoverAnnotation != null) {
+ final AnnotatedRegionLayer<MouseTrackerAnnotation> layer = AnnotatedRegionLayer<MouseTrackerAnnotation>(
+ _hoverAnnotation,
+ size: size,
+ offset: offset,
+ );
+ context.pushLayer(layer, super.paint, offset);
+ }
+ super.paint(context, offset);
+ }
+
@override
void performResize() {
size = constraints.biggest;
@@ -2521,6 +2631,8 @@
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
+ // The onPointerEnter, onPointerHover, and onPointerExit events are are
+ // triggered from within the MouseTracker, not here.
if (onPointerDown != null && event is PointerDownEvent)
return onPointerDown(event);
if (onPointerMove != null && event is PointerMoveEvent)
@@ -2539,6 +2651,12 @@
listeners.add('down');
if (onPointerMove != null)
listeners.add('move');
+ if (onPointerEnter != null)
+ listeners.add('enter');
+ if (onPointerHover != null)
+ listeners.add('hover');
+ if (onPointerExit != null)
+ listeners.add('exit');
if (onPointerUp != null)
listeners.add('up');
if (onPointerCancel != null)
@@ -4647,7 +4765,11 @@
@override
void paint(PaintingContext context, Offset offset) {
- final AnnotatedRegionLayer<T> layer = AnnotatedRegionLayer<T>(value, size: sized ? size : null);
+ final AnnotatedRegionLayer<T> layer = AnnotatedRegionLayer<T>(
+ value,
+ size: sized ? size : null,
+ offset: sized ? offset : null,
+ );
context.pushLayer(layer, super.paint, offset);
}
}
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index 36a26e0..0fbe2fb 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -5,6 +5,7 @@
import 'dart:ui' as ui show Image, ImageFilter;
import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@@ -4996,6 +4997,80 @@
///
/// If it has a child, this widget defers to the child for sizing behavior. If
/// it does not have a child, it grows to fit the parent instead.
+///
+/// {@tool snippet --template=stateful_widget}
+/// This example makes a [Container] react to being entered by a mouse
+/// pointer, showing a count of the number of entries and exits.
+///
+/// ```dart imports
+/// import 'package:flutter/gestures.dart';
+/// ```
+///
+/// ```dart
+/// int _enterCounter = 0;
+/// int _exitCounter = 0;
+/// double x = 0.0;
+/// double y = 0.0;
+///
+/// void _incrementCounter(PointerEnterEvent details) {
+/// setState(() {
+/// _enterCounter++;
+/// });
+/// }
+///
+/// void _decrementCounter(PointerExitEvent details) {
+/// setState(() {
+/// _exitCounter++;
+/// });
+/// }
+///
+/// void _updateLocation(PointerHoverEvent details) {
+/// setState(() {
+/// x = details.position.dx;
+/// y = details.position.dy;
+/// });
+/// }
+///
+/// @override
+/// Widget build(BuildContext context) {
+/// return Scaffold(
+/// appBar: AppBar(
+/// title: Text('Hover Example'),
+/// ),
+/// body: Center(
+/// child: ConstrainedBox(
+/// constraints: new BoxConstraints.tight(Size(300.0, 200.0)),
+/// child: Listener(
+/// onPointerEnter: _incrementCounter,
+/// onPointerHover: _updateLocation,
+/// onPointerExit: _decrementCounter,
+/// child: Container(
+/// color: Colors.lightBlueAccent,
+/// child: Column(
+/// mainAxisAlignment: MainAxisAlignment.center,
+/// children: <Widget>[
+/// Text('You have pointed at this box this many times:'),
+/// Text(
+/// '$_enterCounter Entries\n$_exitCounter Exits',
+/// style: Theme.of(context).textTheme.display1,
+/// ),
+/// Text(
+/// 'The cursor is here: (${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)})',
+/// ),
+/// ],
+/// ),
+/// ),
+/// ),
+/// ),
+/// ),
+/// );
+/// }
+/// ```
+/// {@end-tool}
+///
+/// See also:
+///
+/// * [MouseTracker] an object that tracks mouse locations in the [GestureBinding].
class Listener extends SingleChildRenderObjectWidget {
/// Creates a widget that forwards point events to callbacks.
///
@@ -5004,6 +5079,9 @@
Key key,
this.onPointerDown,
this.onPointerMove,
+ this.onPointerEnter,
+ this.onPointerExit,
+ this.onPointerHover,
this.onPointerUp,
this.onPointerCancel,
this.behavior = HitTestBehavior.deferToChild,
@@ -5011,16 +5089,47 @@
}) : assert(behavior != null),
super(key: key, child: child);
- /// Called when a pointer comes into contact with the screen at this object.
+ /// Called when a pointer comes into contact with the screen (for touch
+ /// pointers), or has its button pressed (for mouse pointers) at this widget's
+ /// location.
final PointerDownEventListener onPointerDown;
/// Called when a pointer that triggered an [onPointerDown] changes position.
final PointerMoveEventListener onPointerMove;
- /// Called when a pointer that triggered an [onPointerDown] is no longer in contact with the screen.
+ /// Called when a pointer enters the region for this widget.
+ ///
+ /// This is only fired for pointers which report their location when not down
+ /// (e.g. mouse pointers, but not most touch pointers).
+ ///
+ /// If this is a mouse pointer, this will fire when the mouse pointer enters
+ /// the region defined by this widget, or when the widget appears under the
+ /// pointer.
+ final PointerEnterEventListener onPointerEnter;
+
+ /// Called when a pointer that has not triggered an [onPointerDown] changes
+ /// position.
+ ///
+ /// This is only fired for pointers which report their location when not down
+ /// (e.g. mouse pointers, but not most touch pointers).
+ final PointerHoverEventListener onPointerHover;
+
+ /// Called when a pointer leaves the region for this widget.
+ ///
+ /// This is only fired for pointers which report their location when not down
+ /// (e.g. mouse pointers, but not most touch pointers).
+ ///
+ /// If this is a mouse pointer, this will fire when the mouse pointer leaves
+ /// the region defined by this widget, or when the widget disappears from
+ /// under the pointer.
+ final PointerExitEventListener onPointerExit;
+
+ /// Called when a pointer that triggered an [onPointerDown] is no longer in
+ /// contact with the screen.
final PointerUpEventListener onPointerUp;
- /// Called when the input from a pointer that triggered an [onPointerDown] is no longer directed towards this receiver.
+ /// Called when the input from a pointer that triggered an [onPointerDown] is
+ /// no longer directed towards this receiver.
final PointerCancelEventListener onPointerCancel;
/// How to behave during hit testing.
@@ -5031,6 +5140,9 @@
return RenderPointerListener(
onPointerDown: onPointerDown,
onPointerMove: onPointerMove,
+ onPointerEnter: onPointerEnter,
+ onPointerHover: onPointerHover,
+ onPointerExit: onPointerExit,
onPointerUp: onPointerUp,
onPointerCancel: onPointerCancel,
behavior: behavior
@@ -5042,6 +5154,9 @@
renderObject
..onPointerDown = onPointerDown
..onPointerMove = onPointerMove
+ ..onPointerEnter = onPointerEnter
+ ..onPointerHover = onPointerHover
+ ..onPointerExit = onPointerExit
..onPointerUp = onPointerUp
..onPointerCancel = onPointerCancel
..behavior = behavior;
@@ -5055,6 +5170,12 @@
listeners.add('down');
if (onPointerMove != null)
listeners.add('move');
+ if (onPointerEnter != null)
+ listeners.add('enter');
+ if (onPointerExit != null)
+ listeners.add('exit');
+ if (onPointerHover != null)
+ listeners.add('hover');
if (onPointerUp != null)
listeners.add('up');
if (onPointerCancel != null)
diff --git a/packages/flutter/test/gestures/gesture_binding_test.dart b/packages/flutter/test/gestures/gesture_binding_test.dart
index 8db85da..d9424ad 100644
--- a/packages/flutter/test/gestures/gesture_binding_test.dart
+++ b/packages/flutter/test/gestures/gesture_binding_test.dart
@@ -16,9 +16,9 @@
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
+ super.handleEvent(event, entry);
if (callback != null)
callback(event);
- super.handleEvent(event, entry);
}
}
@@ -26,6 +26,7 @@
void ensureTestGestureBinding() {
_binding ??= TestGestureFlutterBinding();
+ PointerEventConverter.clearPointers();
assert(GestureBinding.instance != null);
}
@@ -68,6 +69,34 @@
expect(events[2].runtimeType, equals(PointerUpEvent));
});
+ test('Pointer hover events', () {
+ const ui.PointerDataPacket packet = ui.PointerDataPacket(
+ data: <ui.PointerData>[
+ ui.PointerData(change: ui.PointerChange.hover),
+ ui.PointerData(change: ui.PointerChange.hover),
+ ui.PointerData(change: ui.PointerChange.remove),
+ ui.PointerData(change: ui.PointerChange.hover),
+ ]
+ );
+
+ final List<PointerEvent> pointerRouterEvents = <PointerEvent>[];
+ GestureBinding.instance.pointerRouter.addGlobalRoute(pointerRouterEvents.add);
+
+ final List<PointerEvent> events = <PointerEvent>[];
+ _binding.callback = events.add;
+
+ ui.window.onPointerDataPacket(packet);
+ expect(events.length, 0);
+ expect(pointerRouterEvents.length, 6,
+ reason: 'pointerRouterEvents contains: $pointerRouterEvents');
+ expect(pointerRouterEvents[0].runtimeType, equals(PointerAddedEvent));
+ expect(pointerRouterEvents[1].runtimeType, equals(PointerHoverEvent));
+ expect(pointerRouterEvents[2].runtimeType, equals(PointerHoverEvent));
+ expect(pointerRouterEvents[3].runtimeType, equals(PointerRemovedEvent));
+ expect(pointerRouterEvents[4].runtimeType, equals(PointerAddedEvent));
+ expect(pointerRouterEvents[5].runtimeType, equals(PointerHoverEvent));
+ });
+
test('Synthetic move events', () {
final ui.PointerDataPacket packet = ui.PointerDataPacket(
data: <ui.PointerData>[
diff --git a/packages/flutter/test/gestures/mouse_tracking_test.dart b/packages/flutter/test/gestures/mouse_tracking_test.dart
new file mode 100644
index 0000000..3afe335
--- /dev/null
+++ b/packages/flutter/test/gestures/mouse_tracking_test.dart
@@ -0,0 +1,260 @@
+// Copyright 2018 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:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
+
+import '../flutter_test_alternative.dart';
+
+typedef HandleEventCallback = void Function(PointerEvent event);
+
+class TestGestureFlutterBinding extends BindingBase with ServicesBinding, SchedulerBinding, GestureBinding {
+ HandleEventCallback callback;
+
+ @override
+ void handleEvent(PointerEvent event, HitTestEntry entry) {
+ super.handleEvent(event, entry);
+ if (callback != null) {
+ callback(event);
+ }
+ }
+}
+
+TestGestureFlutterBinding _binding = TestGestureFlutterBinding();
+
+void ensureTestGestureBinding() {
+ _binding ??= TestGestureFlutterBinding();
+ assert(GestureBinding.instance != null);
+}
+
+void main() {
+ setUp(ensureTestGestureBinding);
+
+ group(MouseTracker, () {
+ final List<PointerEnterEvent> enter = <PointerEnterEvent>[];
+ final List<PointerHoverEvent> move = <PointerHoverEvent>[];
+ final List<PointerExitEvent> exit = <PointerExitEvent>[];
+ final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
+ onEnter: (PointerEnterEvent event) => enter.add(event),
+ onHover: (PointerHoverEvent event) => move.add(event),
+ onExit: (PointerExitEvent event) => exit.add(event),
+ );
+ bool isInHitRegion;
+ MouseTracker tracker;
+
+ void clear() {
+ enter.clear();
+ exit.clear();
+ move.clear();
+ }
+
+ setUp(() {
+ clear();
+ isInHitRegion = true;
+ tracker = MouseTracker(
+ GestureBinding.instance.pointerRouter,
+ (Offset _) => isInHitRegion ? annotation : null,
+ );
+ });
+
+ test('receives and processes mouse hover events', () {
+ final ui.PointerDataPacket packet1 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 0.0 * ui.window.devicePixelRatio,
+ physicalY: 0.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ final ui.PointerDataPacket packet2 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 1.0 * ui.window.devicePixelRatio,
+ physicalY: 101.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ const ui.PointerDataPacket packet3 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.remove,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ final ui.PointerDataPacket packet4 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 1.0 * ui.window.devicePixelRatio,
+ physicalY: 201.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ final ui.PointerDataPacket packet5 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 1.0 * ui.window.devicePixelRatio,
+ physicalY: 301.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ device: 1,
+ ),
+ ]);
+ tracker.attachAnnotation(annotation);
+ isInHitRegion = true;
+ ui.window.onPointerDataPacket(packet1);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(1), reason: 'enter contains $enter');
+ expect(enter.first.position, equals(const Offset(0.0, 0.0)));
+ expect(enter.first.device, equals(0));
+ expect(enter.first.runtimeType, equals(PointerEnterEvent));
+ expect(exit.length, equals(0), reason: 'exit contains $exit');
+ expect(move.length, equals(1), reason: 'move contains $move');
+ expect(move.first.position, equals(const Offset(0.0, 0.0)));
+ expect(move.first.device, equals(0));
+ expect(move.first.runtimeType, equals(PointerHoverEvent));
+ clear();
+
+ ui.window.onPointerDataPacket(packet2);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(0), reason: 'enter contains $enter');
+ expect(exit.length, equals(0), reason: 'exit contains $exit');
+ expect(move.length, equals(1), reason: 'move contains $move');
+ expect(move.first.position, equals(const Offset(1.0, 101.0)));
+ expect(move.first.device, equals(0));
+ expect(move.first.runtimeType, equals(PointerHoverEvent));
+ clear();
+
+ ui.window.onPointerDataPacket(packet3);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(0), reason: 'enter contains $enter');
+ expect(move.length, equals(0), reason: 'move contains $move');
+ expect(exit.length, equals(1), reason: 'exit contains $exit');
+ expect(exit.first.position, isNull);
+ expect(exit.first.device, isNull);
+ expect(exit.first.runtimeType, equals(PointerExitEvent));
+
+ clear();
+ ui.window.onPointerDataPacket(packet4);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(1), reason: 'enter contains $enter');
+ expect(enter.first.position, equals(const Offset(1.0, 201.0)));
+ expect(enter.first.device, equals(0));
+ expect(enter.first.runtimeType, equals(PointerEnterEvent));
+ expect(exit.length, equals(0), reason: 'exit contains $exit');
+ expect(move.length, equals(1), reason: 'move contains $move');
+ expect(move.first.position, equals(const Offset(1.0, 201.0)));
+ expect(move.first.device, equals(0));
+ expect(move.first.runtimeType, equals(PointerHoverEvent));
+
+ // add in a second mouse simultaneously.
+ clear();
+ ui.window.onPointerDataPacket(packet5);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(1), reason: 'enter contains $enter');
+ expect(enter.first.position, equals(const Offset(1.0, 301.0)));
+ expect(enter.first.device, equals(1));
+ expect(enter.first.runtimeType, equals(PointerEnterEvent));
+ expect(exit.length, equals(0), reason: 'exit contains $exit');
+ expect(move.length, equals(2), reason: 'move contains $move');
+ expect(move.first.position, equals(const Offset(1.0, 201.0)));
+ expect(move.first.device, equals(0));
+ expect(move.first.runtimeType, equals(PointerHoverEvent));
+ expect(move.last.position, equals(const Offset(1.0, 301.0)));
+ expect(move.last.device, equals(1));
+ expect(move.last.runtimeType, equals(PointerHoverEvent));
+ });
+ test('detects exit when annotated layer no longer hit', () {
+ final ui.PointerDataPacket packet1 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 0.0 * ui.window.devicePixelRatio,
+ physicalY: 0.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 1.0 * ui.window.devicePixelRatio,
+ physicalY: 101.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ final ui.PointerDataPacket packet2 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 1.0 * ui.window.devicePixelRatio,
+ physicalY: 201.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ isInHitRegion = true;
+ tracker.attachAnnotation(annotation);
+
+ ui.window.onPointerDataPacket(packet1);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(1), reason: 'enter contains $enter');
+ expect(enter.first.position, equals(const Offset(1.0, 101.0)));
+ expect(enter.first.device, equals(0));
+ expect(enter.first.runtimeType, equals(PointerEnterEvent));
+ expect(move.length, equals(1), reason: 'move contains $move');
+ expect(move.first.position, equals(const Offset(1.0, 101.0)));
+ expect(move.first.device, equals(0));
+ expect(move.first.runtimeType, equals(PointerHoverEvent));
+ expect(exit.length, equals(0), reason: 'exit contains $exit');
+ // Simulate layer going away by detaching it.
+ clear();
+ isInHitRegion = false;
+
+ ui.window.onPointerDataPacket(packet2);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(0), reason: 'enter contains $enter');
+ expect(move.length, equals(0), reason: 'enter contains $move');
+ expect(exit.length, equals(1), reason: 'enter contains $exit');
+ expect(exit.first.position, const Offset(1.0, 201.0));
+ expect(exit.first.device, equals(0));
+ expect(exit.first.runtimeType, equals(PointerExitEvent));
+ });
+ test('detects exit when mouse goes away', () {
+ final ui.PointerDataPacket packet1 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 0.0 * ui.window.devicePixelRatio,
+ physicalY: 0.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ui.PointerData(
+ change: ui.PointerChange.hover,
+ physicalX: 1.0 * ui.window.devicePixelRatio,
+ physicalY: 101.0 * ui.window.devicePixelRatio,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ const ui.PointerDataPacket packet2 = ui.PointerDataPacket(data: <ui.PointerData>[
+ ui.PointerData(
+ change: ui.PointerChange.remove,
+ kind: PointerDeviceKind.mouse,
+ ),
+ ]);
+ isInHitRegion = true;
+ tracker.attachAnnotation(annotation);
+ ui.window.onPointerDataPacket(packet1);
+ tracker.collectMousePositions();
+ ui.window.onPointerDataPacket(packet2);
+ tracker.collectMousePositions();
+ expect(enter.length, equals(1), reason: 'enter contains $enter');
+ expect(enter.first.position, equals(const Offset(1.0, 101.0)));
+ expect(enter.first.device, equals(0));
+ expect(enter.first.runtimeType, equals(PointerEnterEvent));
+ expect(move.length, equals(1), reason: 'move contains $move');
+ expect(move.first.position, equals(const Offset(1.0, 101.0)));
+ expect(move.first.device, equals(0));
+ expect(move.first.runtimeType, equals(PointerHoverEvent));
+ expect(exit.length, equals(1), reason: 'exit contains $exit');
+ expect(exit.first.position, isNull);
+ expect(exit.first.device, isNull);
+ expect(exit.first.runtimeType, equals(PointerExitEvent));
+ });
+ });
+}
diff --git a/packages/flutter/test/widgets/annotated_region_test.dart b/packages/flutter/test/widgets/annotated_region_test.dart
index f61711d..066e1ca 100644
--- a/packages/flutter/test/widgets/annotated_region_test.dart
+++ b/packages/flutter/test/widgets/annotated_region_test.dart
@@ -2,12 +2,15 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'dart:ui' show window;
+
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
- testWidgets('provides a value to the layer tree', (WidgetTester tester) async {
+ testWidgets('provides a value to the layer tree',
+ (WidgetTester tester) async {
await tester.pumpWidget(
const AnnotatedRegion<int>(
child: SizedBox(width: 100.0, height: 100.0),
@@ -15,7 +18,30 @@
),
);
final List<Layer> layers = tester.layers;
- final AnnotatedRegionLayer<int> layer = layers.firstWhere((Layer layer) => layer is AnnotatedRegionLayer<int>);
+ final AnnotatedRegionLayer<int> layer =
+ layers.firstWhere((Layer layer) => layer is AnnotatedRegionLayer<int>);
expect(layer.value, 1);
});
+ testWidgets('provides a value to the layer tree in a particular region',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Transform.translate(
+ offset: const Offset(25.0, 25.0),
+ child: const AnnotatedRegion<int>(
+ child: SizedBox(width: 100.0, height: 100.0),
+ value: 1,
+ ),
+ ),
+ );
+ int result = RendererBinding.instance.renderView.layer.find<int>(Offset(
+ 10.0 * window.devicePixelRatio,
+ 10.0 * window.devicePixelRatio,
+ ));
+ expect(result, null);
+ result = RendererBinding.instance.renderView.layer.find<int>(Offset(
+ 50.0 * window.devicePixelRatio,
+ 50.0 * window.devicePixelRatio,
+ ));
+ expect(result, 1);
+ });
}
diff --git a/packages/flutter/test/widgets/listener_test.dart b/packages/flutter/test/widgets/listener_test.dart
index 49a2aff..5331598 100644
--- a/packages/flutter/test/widgets/listener_test.dart
+++ b/packages/flutter/test/widgets/listener_test.dart
@@ -4,6 +4,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
+import 'package:flutter/gestures.dart';
void main() {
testWidgets('Events bubble up the tree', (WidgetTester tester) async {
@@ -39,4 +40,91 @@
'top',
]));
});
+
+ group('Listener hover detection', () {
+ testWidgets('detects pointer enter', (WidgetTester tester) async {
+ PointerEnterEvent enter;
+ PointerHoverEvent move;
+ PointerExitEvent exit;
+ await tester.pumpWidget(Center(
+ child: Listener(
+ child: Container(
+ color: const Color.fromARGB(0xff, 0xff, 0x00, 0x00),
+ width: 100.0,
+ height: 100.0,
+ ),
+ onPointerEnter: (PointerEnterEvent details) => enter = details,
+ onPointerHover: (PointerHoverEvent details) => move = details,
+ onPointerExit: (PointerExitEvent details) => exit = details,
+ ),
+ ));
+ final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.moveTo(const Offset(400.0, 300.0));
+ await tester.pump();
+ expect(move, isNotNull);
+ expect(move.position, equals(const Offset(400.0, 300.0)));
+ expect(enter, isNotNull);
+ expect(enter.position, equals(const Offset(400.0, 300.0)));
+ expect(exit, isNull);
+ });
+ testWidgets('detects pointer exit', (WidgetTester tester) async {
+ PointerEnterEvent enter;
+ PointerHoverEvent move;
+ PointerExitEvent exit;
+ await tester.pumpWidget(Center(
+ child: Listener(
+ child: Container(
+ width: 100.0,
+ height: 100.0,
+ ),
+ onPointerEnter: (PointerEnterEvent details) => enter = details,
+ onPointerHover: (PointerHoverEvent details) => move = details,
+ onPointerExit: (PointerExitEvent details) => exit = details,
+ ),
+ ));
+ final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.moveTo(const Offset(400.0, 300.0));
+ await tester.pump();
+ move = null;
+ enter = null;
+ await gesture.moveTo(const Offset(1.0, 1.0));
+ await tester.pump();
+ expect(move, isNull);
+ expect(enter, isNull);
+ expect(exit, isNotNull);
+ expect(exit.position, equals(const Offset(1.0, 1.0)));
+ });
+ testWidgets('detects pointer exit when widget disappears', (WidgetTester tester) async {
+ PointerEnterEvent enter;
+ PointerHoverEvent move;
+ PointerExitEvent exit;
+ await tester.pumpWidget(Center(
+ child: Listener(
+ child: Container(
+ width: 100.0,
+ height: 100.0,
+ ),
+ onPointerEnter: (PointerEnterEvent details) => enter = details,
+ onPointerHover: (PointerHoverEvent details) => move = details,
+ onPointerExit: (PointerExitEvent details) => exit = details,
+ ),
+ ));
+ final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.moveTo(const Offset(400.0, 300.0));
+ await tester.pump();
+ expect(move, isNotNull);
+ expect(move.position, equals(const Offset(400.0, 300.0)));
+ expect(enter, isNotNull);
+ expect(enter.position, equals(const Offset(400.0, 300.0)));
+ expect(exit, isNull);
+ await tester.pumpWidget(Center(
+ child: Container(
+ width: 100.0,
+ height: 100.0,
+ ),
+ ));
+ expect(exit, isNotNull);
+ expect(exit.position, equals(const Offset(400.0, 300.0)));
+ });
+ });
}
diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart
index 151adca..06bbb5a 100644
--- a/packages/flutter_test/lib/src/binding.dart
+++ b/packages/flutter_test/lib/src/binding.dart
@@ -303,11 +303,11 @@
Offset localToGlobal(Offset point) => point;
@override
- void dispatchEvent(PointerEvent event, HitTestResult result, {
+ void dispatchEvent(PointerEvent event, HitTestResult hitTestResult, {
TestBindingEventSource source = TestBindingEventSource.device
}) {
assert(source == TestBindingEventSource.test);
- super.dispatchEvent(event, result);
+ super.dispatchEvent(event, hitTestResult);
}
/// A stub for the system's onscreen keyboard. Callers must set the
@@ -1177,7 +1177,7 @@
HitTestDispatcher deviceEventDispatcher;
@override
- void dispatchEvent(PointerEvent event, HitTestResult result, {
+ void dispatchEvent(PointerEvent event, HitTestResult hitTestResult, {
TestBindingEventSource source = TestBindingEventSource.device
}) {
switch (source) {
@@ -1191,11 +1191,11 @@
renderView._pointers[event.pointer].decay = _kPointerDecay;
}
_handleViewNeedsPaint();
- super.dispatchEvent(event, result, source: source);
+ super.dispatchEvent(event, hitTestResult, source: source);
break;
case TestBindingEventSource.device:
if (deviceEventDispatcher != null)
- deviceEventDispatcher.dispatchEvent(event, result);
+ deviceEventDispatcher.dispatchEvent(event, hitTestResult);
break;
}
}
diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart
index 7f7defc..6e1141a 100644
--- a/packages/flutter_test/lib/src/controller.dart
+++ b/packages/flutter_test/lib/src/controller.dart
@@ -38,15 +38,13 @@
return finder.evaluate().isNotEmpty;
}
-
/// All widgets currently in the widget tree (lazy pre-order traversal).
///
/// Can contain duplicates, since widgets can be used in multiple
/// places in the widget tree.
Iterable<Widget> get allWidgets {
TestAsyncUtils.guardSync();
- return allElements
- .map<Widget>((Element element) => element.widget);
+ return allElements.map<Widget>((Element element) => element.widget);
}
/// The matching widget in the widget tree.
@@ -84,7 +82,6 @@
});
}
-
/// All elements currently in the widget tree (lazy pre-order traversal).
///
/// The returned iterable is lazy. It does not walk the entire widget tree
@@ -127,7 +124,6 @@
return finder.evaluate();
}
-
/// All states currently in the widget tree (lazy pre-order traversal).
///
/// The returned iterable is lazy. It does not walk the entire widget tree
@@ -135,9 +131,7 @@
/// using [Iterator.moveNext].
Iterable<State> get allStates {
TestAsyncUtils.guardSync();
- return allElements
- .whereType<StatefulElement>()
- .map<State>((StatefulElement element) => element.state);
+ return allElements.whereType<StatefulElement>().map<State>((StatefulElement element) => element.state);
}
/// The matching state in the widget tree.
@@ -183,7 +177,6 @@
throw StateError('Widget of type ${element.widget.runtimeType}, with ${finder.description}, is not a StatefulWidget.');
}
-
/// Render objects of all the widgets currently in the widget tree
/// (lazy pre-order traversal).
///
@@ -193,8 +186,7 @@
/// their own render object.
Iterable<RenderObject> get allRenderObjects {
TestAsyncUtils.guardSync();
- return allElements
- .map<RenderObject>((Element element) => element.renderObject);
+ return allElements.map<RenderObject>((Element element) => element.renderObject);
}
/// The render object of the matching widget in the widget tree.
@@ -232,7 +224,6 @@
});
}
-
/// Returns a list of all the [Layer] objects in the rendering.
List<Layer> get layers => _walkLayers(binding.renderView.layer).toList();
Iterable<Layer> _walkLayers(Layer layer) sync* {
@@ -248,7 +239,6 @@
}
}
-
// INTERACTION
/// Dispatch a pointer down / pointer up sequence at the center of
@@ -256,12 +246,12 @@
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
- Future<void> tap(Finder finder, { int pointer }) {
+ Future<void> tap(Finder finder, {int pointer}) {
return tapAt(getCenter(finder), pointer: pointer);
}
/// Dispatch a pointer down / pointer up sequence at the given location.
- Future<void> tapAt(Offset location, { int pointer }) {
+ Future<void> tapAt(Offset location, {int pointer}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer);
await gesture.up();
@@ -273,7 +263,7 @@
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
- Future<TestGesture> press(Finder finder, { int pointer }) {
+ Future<TestGesture> press(Finder finder, {int pointer}) {
return TestAsyncUtils.guard<TestGesture>(() {
return startGesture(getCenter(finder), pointer: pointer);
});
@@ -285,13 +275,13 @@
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
- Future<void> longPress(Finder finder, { int pointer }) {
+ Future<void> longPress(Finder finder, {int pointer}) {
return longPressAt(getCenter(finder), pointer: pointer);
}
/// Dispatch a pointer down / pointer up sequence at the given location with
/// a delay of [kLongPressTimeout] + [kPressTimeout] between the two events.
- Future<void> longPressAt(Offset location, { int pointer }) {
+ Future<void> longPressAt(Offset location, {int pointer}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer);
await pump(kLongPressTimeout + kPressTimeout);
@@ -319,7 +309,10 @@
/// opposite direction of the fling (e.g. dragging 200 pixels to the right,
/// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started).
- Future<void> fling(Finder finder, Offset offset, double speed, {
+ Future<void> fling(
+ Finder finder,
+ Offset offset,
+ double speed, {
int pointer,
Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero,
@@ -361,7 +354,10 @@
/// opposite direction of the fling (e.g. dragging 200 pixels to the right,
/// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started).
- Future<void> flingFrom(Offset startLocation, Offset offset, double speed, {
+ Future<void> flingFrom(
+ Offset startLocation,
+ Offset offset,
+ double speed, {
int pointer,
Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero,
@@ -414,7 +410,7 @@
///
/// If you want the drag to end with a speed so that the gesture recognition
/// system identifies the gesture as a fling, consider using [fling] instead.
- Future<void> drag(Finder finder, Offset offset, { int pointer }) {
+ Future<void> drag(Finder finder, Offset offset, {int pointer}) {
return dragFrom(getCenter(finder), offset, pointer: pointer);
}
@@ -424,7 +420,7 @@
/// If you want the drag to end with a speed so that the gesture recognition
/// system identifies the gesture as a fling, consider using [flingFrom]
/// instead.
- Future<void> dragFrom(Offset startLocation, Offset offset, { int pointer }) {
+ Future<void> dragFrom(Offset startLocation, Offset offset, {int pointer}) {
return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(startLocation, pointer: pointer);
assert(gesture != null);
@@ -445,17 +441,32 @@
return result;
}
- /// Begins a gesture at a particular point, and returns the
- /// [TestGesture] object which you can use to continue the gesture.
- Future<TestGesture> startGesture(Offset downLocation, { int pointer }) {
- return TestGesture.down(
- downLocation,
- pointer: pointer ?? _getNextPointer(),
+ /// Creates gesture and returns the [TestGesture] object which you can use
+ /// to continue the gesture using calls on the [TestGesture] object.
+ ///
+ /// You can use [startGesture] instead if your gesture begins with a down
+ /// event.
+ Future<TestGesture> createGesture({int pointer, PointerDeviceKind kind = PointerDeviceKind.touch}) async {
+ return TestGesture(
hitTester: hitTestOnBinding,
dispatcher: sendEventToBinding,
+ kind: kind,
+ pointer: pointer ?? _getNextPointer(),
);
}
+ /// Creates a gesture with an initial down gesture at a particular point, and
+ /// returns the [TestGesture] object which you can use to continue the
+ /// gesture.
+ ///
+ /// You can use [createGesture] if your gesture doesn't begin with an initial
+ /// down gesture.
+ Future<TestGesture> startGesture(Offset downLocation, {int pointer}) async {
+ final TestGesture result = await createGesture(pointer: pointer);
+ await result.down(downLocation);
+ return result;
+ }
+
/// Forwards the given location to the binding's hitTest logic.
HitTestResult hitTestOnBinding(Offset location) {
final HitTestResult result = HitTestResult();
@@ -470,7 +481,6 @@
});
}
-
// GEOMETRY
/// Returns the point at the center of the given widget.
diff --git a/packages/flutter_test/lib/src/test_pointer.dart b/packages/flutter_test/lib/src/test_pointer.dart
index c37c06b..10f45f1 100644
--- a/packages/flutter_test/lib/src/test_pointer.dart
+++ b/packages/flutter_test/lib/src/test_pointer.dart
@@ -13,8 +13,8 @@
/// A class for generating coherent artificial pointer events.
///
-/// You can use this to manually simulate individual events, but the
-/// simplest way to generate coherent gestures is to use [TestGesture].
+/// You can use this to manually simulate individual events, but the simplest
+/// way to generate coherent gestures is to use [TestGesture].
class TestPointer {
/// Creates a [TestPointer]. By default, the pointer identifier used is 1,
/// however this can be overridden by providing an argument to the
@@ -22,13 +22,19 @@
///
/// Multiple [TestPointer]s created with the same pointer identifier will
/// interfere with each other if they are used in parallel.
- TestPointer([ this.pointer = 1 ]);
+ TestPointer([this.pointer = 1, this.kind = PointerDeviceKind.touch])
+ : assert(kind != null),
+ assert(pointer != null);
/// The pointer identifier used for events generated by this object.
///
/// Set when the object is constructed. Defaults to 1.
final int pointer;
+ /// The kind of pointer device to simulate. Defaults to
+ /// [PointerDeviceKind.touch].
+ final PointerDeviceKind kind;
+
/// Whether the pointer simulated by this object is currently down.
///
/// A pointer is released (goes up) by calling [up] or [cancel].
@@ -64,68 +70,103 @@
/// Create a [PointerDownEvent] at the given location.
///
- /// By default, the time stamp on the event is [Duration.zero]. You
- /// can give a specific time stamp by passing the `timeStamp`
- /// argument.
- PointerDownEvent down(Offset newLocation, { Duration timeStamp = Duration.zero }) {
+ /// By default, the time stamp on the event is [Duration.zero]. You can give a
+ /// specific time stamp by passing the `timeStamp` argument.
+ PointerDownEvent down(Offset newLocation, {Duration timeStamp = Duration.zero}) {
assert(!isDown);
_isDown = true;
_location = newLocation;
return PointerDownEvent(
timeStamp: timeStamp,
+ kind: kind,
pointer: pointer,
- position: location
+ position: location,
);
}
/// Create a [PointerMoveEvent] to the given location.
///
- /// By default, the time stamp on the event is [Duration.zero]. You
- /// can give a specific time stamp by passing the `timeStamp`
- /// argument.
- PointerMoveEvent move(Offset newLocation, { Duration timeStamp = Duration.zero }) {
- assert(isDown);
+ /// By default, the time stamp on the event is [Duration.zero]. You can give a
+ /// specific time stamp by passing the `timeStamp` argument.
+ ///
+ /// [isDown] must be true when this is called, since move events can only
+ /// be generated when the pointer is down.
+ PointerMoveEvent move(Offset newLocation, {Duration timeStamp = Duration.zero}) {
+ assert(
+ isDown,
+ 'Move events can only be generated when the pointer is down. To '
+ 'create a movement event simulating a pointer move when the pointer is '
+ 'up, use hover() instead.');
final Offset delta = newLocation - location;
_location = newLocation;
return PointerMoveEvent(
timeStamp: timeStamp,
+ kind: kind,
pointer: pointer,
position: newLocation,
- delta: delta
+ delta: delta,
);
}
/// Create a [PointerUpEvent].
///
- /// By default, the time stamp on the event is [Duration.zero]. You
- /// can give a specific time stamp by passing the `timeStamp`
- /// argument.
+ /// By default, the time stamp on the event is [Duration.zero]. You can give a
+ /// specific time stamp by passing the `timeStamp` argument.
///
/// The object is no longer usable after this method has been called.
- PointerUpEvent up({ Duration timeStamp = Duration.zero }) {
+ PointerUpEvent up({Duration timeStamp = Duration.zero}) {
assert(isDown);
_isDown = false;
return PointerUpEvent(
timeStamp: timeStamp,
+ kind: kind,
pointer: pointer,
- position: location
+ position: location,
);
}
/// Create a [PointerCancelEvent].
///
- /// By default, the time stamp on the event is [Duration.zero]. You
- /// can give a specific time stamp by passing the `timeStamp`
- /// argument.
+ /// By default, the time stamp on the event is [Duration.zero]. You can give a
+ /// specific time stamp by passing the `timeStamp` argument.
///
/// The object is no longer usable after this method has been called.
- PointerCancelEvent cancel({ Duration timeStamp = Duration.zero }) {
+ PointerCancelEvent cancel({Duration timeStamp = Duration.zero}) {
assert(isDown);
_isDown = false;
return PointerCancelEvent(
timeStamp: timeStamp,
+ kind: kind,
pointer: pointer,
- position: location
+ position: location,
+ );
+ }
+
+ /// Create a [PointerHoverEvent] to the given location.
+ ///
+ /// By default, the time stamp on the event is [Duration.zero]. You can give a
+ /// specific time stamp by passing the `timeStamp` argument.
+ ///
+ /// [isDown] must be false, since hover events can't be sent when the pointer
+ /// is up.
+ PointerHoverEvent hover(
+ Offset newLocation, {
+ Duration timeStamp = Duration.zero,
+ }) {
+ assert(newLocation != null);
+ assert(timeStamp != null);
+ assert(
+ !isDown,
+ 'Hover events can only be generated when the pointer is up. To '
+ 'simulate movement when the pointer is down, use move() instead.');
+ assert(kind != PointerDeviceKind.touch, "Touch pointers can't generate hover events");
+ final Offset delta = location != null ? newLocation - location : Offset.zero;
+ _location = newLocation;
+ return PointerHoverEvent(
+ timeStamp: timeStamp,
+ kind: kind,
+ position: newLocation,
+ delta: delta,
);
}
}
@@ -142,77 +183,53 @@
/// The simplest way to create a [TestGesture] is to call
/// [WidgetTester.startGesture].
class TestGesture {
- TestGesture._(this._dispatcher, this._result, this._pointer);
-
- /// Create a [TestGesture] by starting with a pointerDown at the
- /// given point.
+ /// Create a [TestGesture] without dispatching any events from it.
+ /// The [TestGesture] can then be manipulated to perform future actions.
///
/// By default, the pointer identifier used is 1. This can be overridden by
/// providing the `pointer` argument.
///
- /// A function to use for hit testing should be provided via the `hitTester`
- /// argument, and a function to use for dispatching events should be provided
+ /// A function to use for hit testing must be provided via the `hitTester`
+ /// argument, and a function to use for dispatching events must be provided
/// via the `dispatcher` argument.
- static Future<TestGesture> down(Offset downLocation, {
- int pointer = 1,
- @required HitTester hitTester,
- @required EventDispatcher dispatcher,
- }) async {
- assert(hitTester != null);
- assert(dispatcher != null);
- TestGesture result;
- return TestAsyncUtils.guard<void>(() async {
- // dispatch down event
- final HitTestResult hitTestResult = hitTester(downLocation);
- final TestPointer testPointer = TestPointer(pointer);
- await dispatcher(testPointer.down(downLocation), hitTestResult);
-
- // create a TestGesture
- result = TestGesture._(dispatcher, hitTestResult, testPointer);
- }).then<TestGesture>((void value) {
- return result;
- }, onError: (dynamic error, StackTrace stack) {
- return Future<TestGesture>.error(error, stack);
- });
- }
-
- /// Create a [TestGesture] by starting with a custom [PointerDownEvent] at the
- /// given point.
///
- /// By default, the pointer identifier used is 1. This can be overridden by
- /// providing the `pointer` argument.
+ /// The device `kind` defaults to [PointerDeviceKind.touch], but move events
+ /// when the pointer is "up" require a kind other than
+ /// [PointerDeviceKind.touch], like [PointerDeviceKind.mouse], for example,
+ /// because touch devices can't produce movement events when they are "up".
///
- /// A function to use for hit testing should be provided via the `hitTester`
- /// argument, and a function to use for dispatching events should be provided
- /// via the `dispatcher` argument.
- static Future<TestGesture> downWithCustomEvent(Offset downLocation, PointerDownEvent downEvent, {
- int pointer = 1,
- @required HitTester hitTester,
+ /// None of the arguments may be null. The `dispatcher` and `hitTester`
+ /// arguments are required.
+ TestGesture({
@required EventDispatcher dispatcher,
- }) async {
- assert(hitTester != null);
- assert(dispatcher != null);
- TestGesture result;
+ @required HitTester hitTester,
+ int pointer = 1,
+ PointerDeviceKind kind = PointerDeviceKind.touch,
+ }) : assert(dispatcher != null),
+ assert(hitTester != null),
+ assert(pointer != null),
+ assert(kind != null),
+ _dispatcher = dispatcher,
+ _hitTester = hitTester,
+ _pointer = TestPointer(pointer, kind),
+ _result = null;
+
+ /// Dispatch a pointer down event at the given `downLocation`, caching the
+ /// hit test result.
+ Future<void> down(Offset downLocation) async {
return TestAsyncUtils.guard<void>(() async {
- // dispatch down event
- final HitTestResult hitTestResult = hitTester(downLocation);
- final TestPointer testPointer = TestPointer(pointer);
- testPointer.setDownInfo(downEvent, downLocation);
- await dispatcher(downEvent, hitTestResult);
- // create a TestGesture
- result = TestGesture._(dispatcher, hitTestResult, testPointer);
- }).then<TestGesture>((void value) {
- return result;
- }, onError: (dynamic error, StackTrace stack) {
- return Future<TestGesture>.error(error, stack);
+ _result = _hitTester(downLocation);
+ return _dispatcher(_pointer.down(downLocation), _result);
});
}
final EventDispatcher _dispatcher;
- final HitTestResult _result;
+ final HitTester _hitTester;
final TestPointer _pointer;
+ HitTestResult _result;
- /// Send a move event moving the pointer by the given offset.
+ /// In a test, send a move event that moves the pointer by the given offset.
+ @visibleForTesting
Future<void> updateWithCustomEvent(PointerEvent event, { Duration timeStamp = Duration.zero }) {
_pointer.setDownInfo(event, event.position);
return TestAsyncUtils.guard<void>(() {
@@ -221,40 +238,53 @@
}
/// Send a move event moving the pointer by the given offset.
- Future<void> moveBy(Offset offset, { Duration timeStamp = Duration.zero }) {
- assert(_pointer._isDown);
+ ///
+ /// If the pointer is down, then a move event is dispatched. If the pointer is
+ /// up, then a hover event is dispatched. Touch devices are not able to send
+ /// hover events.
+ Future<void> moveBy(Offset offset, {Duration timeStamp = Duration.zero}) {
return moveTo(_pointer.location + offset, timeStamp: timeStamp);
}
/// Send a move event moving the pointer to the given location.
- Future<void> moveTo(Offset location, { Duration timeStamp = Duration.zero }) {
+ ///
+ /// If the pointer is down, then a move event is dispatched. If the pointer is
+ /// up, then a hover event is dispatched. Touch devices are not able to send
+ /// hover events.
+ Future<void> moveTo(Offset location, {Duration timeStamp = Duration.zero}) {
return TestAsyncUtils.guard<void>(() {
- assert(_pointer._isDown);
- return _dispatcher(_pointer.move(location, timeStamp: timeStamp), _result);
+ if (_pointer._isDown) {
+ assert(_result != null,
+ 'Move events with the pointer down must be preceeded by a down '
+ 'event that captures a hit test result.');
+ return _dispatcher(_pointer.move(location, timeStamp: timeStamp), _result);
+ } else {
+ assert(_pointer.kind != PointerDeviceKind.touch,
+ 'Touch device move events can only be sent if the pointer is down.');
+ return _dispatcher(_pointer.hover(location, timeStamp: timeStamp), null);
+ }
});
}
/// End the gesture by releasing the pointer.
- ///
- /// The object is no longer usable after this method has been called.
Future<void> up() {
return TestAsyncUtils.guard<void>(() async {
assert(_pointer._isDown);
await _dispatcher(_pointer.up(), _result);
assert(!_pointer._isDown);
+ _result = null;
});
}
/// End the gesture by canceling the pointer (as would happen if the
/// system showed a modal dialog on top of the Flutter application,
/// for instance).
- ///
- /// The object is no longer usable after this method has been called.
Future<void> cancel() {
return TestAsyncUtils.guard<void>(() async {
assert(_pointer._isDown);
await _dispatcher(_pointer.cancel(), _result);
assert(!_pointer._isDown);
+ _result = null;
});
}
}