Unverified Commit 1811d574 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

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
parent c9d5b129
...@@ -21,6 +21,7 @@ export 'src/gestures/hit_test.dart'; ...@@ -21,6 +21,7 @@ export 'src/gestures/hit_test.dart';
export 'src/gestures/long_press.dart'; export 'src/gestures/long_press.dart';
export 'src/gestures/lsq_solver.dart'; export 'src/gestures/lsq_solver.dart';
export 'src/gestures/monodrag.dart'; export 'src/gestures/monodrag.dart';
export 'src/gestures/mouse_tracking.dart';
export 'src/gestures/multidrag.dart'; export 'src/gestures/multidrag.dart';
export 'src/gestures/multitap.dart'; export 'src/gestures/multitap.dart';
export 'src/gestures/pointer_router.dart'; export 'src/gestures/pointer_router.dart';
......
...@@ -116,26 +116,38 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H ...@@ -116,26 +116,38 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
void _handlePointerEvent(PointerEvent event) { void _handlePointerEvent(PointerEvent event) {
assert(!locked); assert(!locked);
HitTestResult result; HitTestResult hitTestResult;
if (event is PointerDownEvent) { if (event is PointerDownEvent) {
assert(!_hitTests.containsKey(event.pointer)); assert(!_hitTests.containsKey(event.pointer));
result = HitTestResult(); hitTestResult = HitTestResult();
hitTest(result, event.position); hitTest(hitTestResult, event.position);
_hitTests[event.pointer] = result; _hitTests[event.pointer] = hitTestResult;
assert(() { assert(() {
if (debugPrintHitTestResults) if (debugPrintHitTestResults)
debugPrint('$event: $result'); debugPrint('$event: $hitTestResult');
return true; return true;
}()); }());
} else if (event is PointerUpEvent || event is PointerCancelEvent) { } else if (event is PointerUpEvent || event is PointerCancelEvent) {
result = _hitTests.remove(event.pointer); hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) { } else if (event.down) {
result = _hitTests[event.pointer]; // Because events that occur with the pointer down (like
} else { // PointerMoveEvents) should be dispatched to the same place that their
return; // We currently ignore add, remove, and hover move events. // 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];
}
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);
} }
if (result != null)
dispatchEvent(event, result);
} }
/// Determine which [HitTestTarget] objects are located at a given position. /// Determine which [HitTestTarget] objects are located at a given position.
...@@ -146,14 +158,36 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H ...@@ -146,14 +158,36 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
/// Dispatch an event to a hit test result's path. /// Dispatch an event to a hit test result's path.
/// ///
/// This sends the given event to every [HitTestTarget] in the entries /// This sends the given event to every [HitTestTarget] in the entries of the
/// of the given [HitTestResult], and catches exceptions that any of /// given [HitTestResult], and catches exceptions that any of the handlers
/// the handlers might throw. The `result` argument must not be null. /// might throw. The [hitTestResult] argument may only be null for
/// [PointerHoverEvent], [PointerAddedEvent], or [PointerRemovedEvent] events.
@override // from HitTestDispatcher @override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult result) { void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
assert(!locked); assert(!locked);
assert(result != null); // No hit test information implies that this is a hover or pointer
for (HitTestEntry entry in result.path) { // 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 { try {
entry.target.handleEvent(event, entry); entry.target.handleEvent(event, entry);
} catch (exception, stack) { } catch (exception, stack) {
...@@ -169,7 +203,7 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H ...@@ -169,7 +203,7 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
information.writeln(' $event'); information.writeln(' $event');
information.writeln('Target:'); information.writeln('Target:');
information.write(' ${entry.target}'); information.write(' ${entry.target}');
} },
)); ));
} }
} }
...@@ -219,7 +253,8 @@ class FlutterErrorDetailsForPointerEventDispatcher extends FlutterErrorDetails { ...@@ -219,7 +253,8 @@ class FlutterErrorDetailsForPointerEventDispatcher extends FlutterErrorDetails {
final PointerEvent event; final PointerEvent event;
/// The hit test result entry for the object whose handleEvent method threw /// 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 target object itself is given by the [HitTestEntry.target] property of
/// the hitTestEntry object. /// the hitTestEntry object.
......
...@@ -4,6 +4,8 @@ ...@@ -4,6 +4,8 @@
import 'dart:ui' as ui show PointerData, PointerChange; import 'dart:ui' as ui show PointerData, PointerChange;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'events.dart'; import 'events.dart';
class _PointerState { class _PointerState {
...@@ -44,7 +46,16 @@ class _PointerState { ...@@ -44,7 +46,16 @@ class _PointerState {
class PointerEventConverter { class PointerEventConverter {
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. // 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 final Map<int, _PointerState> _pointers = <int, _PointerState>{};
static _PointerState _ensureStateForPointer(ui.PointerData datum, Offset position) { static _PointerState _ensureStateForPointer(ui.PointerData datum, Offset position) {
...@@ -54,7 +65,8 @@ class PointerEventConverter { ...@@ -54,7 +65,8 @@ class PointerEventConverter {
); );
} }
/// 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 /// The `devicePixelRatio` argument (usually given the value from
/// [dart:ui.Window.devicePixelRatio]) is used to convert the incoming data /// [dart:ui.Window.devicePixelRatio]) is used to convert the incoming data
......
...@@ -15,6 +15,15 @@ import 'package:flutter/foundation.dart'; ...@@ -15,6 +15,15 @@ import 'package:flutter/foundation.dart';
/// This has no effect in release builds. /// This has no effect in release builds.
bool debugPrintHitTestResults = false; 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. /// Prints information about gesture recognizers and gesture arenas.
/// ///
/// This flag only has an effect in debug mode. /// This flag only has an effect in debug mode.
......
...@@ -53,7 +53,7 @@ const int kForwardMouseButton = 0x10; ...@@ -53,7 +53,7 @@ const int kForwardMouseButton = 0x10;
/// The bit of [PointerEvent.buttons] that corresponds to the nth mouse button. /// 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], /// See [kPrimaryMouseButton], [kSecondaryMouseButton], [kMiddleMouseButton],
/// [kBackMouseButton], and [kForwardMouseButton] for semantic names for some /// [kBackMouseButton], and [kForwardMouseButton] for semantic names for some
...@@ -62,7 +62,7 @@ int nthMouseButton(int number) => (kPrimaryMouseButton << (number - 1)) & kMaxUn ...@@ -62,7 +62,7 @@ int nthMouseButton(int number) => (kPrimaryMouseButton << (number - 1)) & kMaxUn
/// The bit of [PointerEvent.buttons] that corresponds to the nth stylus button. /// 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 /// See [kPrimaryStylusButton] and [kSecondaryStylusButton] for semantic names
/// for some stylus buttons. /// for some stylus buttons.
...@@ -122,7 +122,8 @@ abstract class PointerEvent { ...@@ -122,7 +122,8 @@ abstract class PointerEvent {
/// Time of event dispatch, relative to an arbitrary timeline. /// Time of event dispatch, relative to an arbitrary timeline.
final Duration timeStamp; 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; final int pointer;
/// The kind of input device for which the event was generated. /// The kind of input device for which the event was generated.
...@@ -327,7 +328,7 @@ abstract class PointerEvent { ...@@ -327,7 +328,7 @@ abstract class PointerEvent {
class PointerAddedEvent extends PointerEvent { class PointerAddedEvent extends PointerEvent {
/// Creates a pointer added event. /// Creates a pointer added event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerAddedEvent({ const PointerAddedEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.touch, PointerDeviceKind kind = PointerDeviceKind.touch,
...@@ -368,7 +369,7 @@ class PointerAddedEvent extends PointerEvent { ...@@ -368,7 +369,7 @@ class PointerAddedEvent extends PointerEvent {
class PointerRemovedEvent extends PointerEvent { class PointerRemovedEvent extends PointerEvent {
/// Creates a pointer removed event. /// Creates a pointer removed event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerRemovedEvent({ const PointerRemovedEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.touch, PointerDeviceKind kind = PointerDeviceKind.touch,
...@@ -400,12 +401,15 @@ class PointerRemovedEvent extends PointerEvent { ...@@ -400,12 +401,15 @@ class PointerRemovedEvent extends PointerEvent {
/// ///
/// See also: /// 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 /// * [PointerMoveEvent], which reports movement while the pointer is in
/// contact with the device. /// contact with the device.
class PointerHoverEvent extends PointerEvent { class PointerHoverEvent extends PointerEvent {
/// Creates a pointer hover event. /// Creates a pointer hover event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerHoverEvent({ const PointerHoverEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.touch, PointerDeviceKind kind = PointerDeviceKind.touch,
...@@ -452,11 +456,187 @@ class PointerHoverEvent extends PointerEvent { ...@@ -452,11 +456,187 @@ class PointerHoverEvent extends PointerEvent {
); );
} }
/// 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. /// The pointer has made contact with the device.
class PointerDownEvent extends PointerEvent { class PointerDownEvent extends PointerEvent {
/// Creates a pointer down event. /// Creates a pointer down event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerDownEvent({ const PointerDownEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
int pointer = 0, int pointer = 0,
...@@ -510,7 +690,7 @@ class PointerDownEvent extends PointerEvent { ...@@ -510,7 +690,7 @@ class PointerDownEvent extends PointerEvent {
class PointerMoveEvent extends PointerEvent { class PointerMoveEvent extends PointerEvent {
/// Creates a pointer move event. /// Creates a pointer move event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerMoveEvent({ const PointerMoveEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
int pointer = 0, int pointer = 0,
...@@ -564,7 +744,7 @@ class PointerMoveEvent extends PointerEvent { ...@@ -564,7 +744,7 @@ class PointerMoveEvent extends PointerEvent {
class PointerUpEvent extends PointerEvent { class PointerUpEvent extends PointerEvent {
/// Creates a pointer up event. /// Creates a pointer up event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerUpEvent({ const PointerUpEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
int pointer = 0, int pointer = 0,
...@@ -613,7 +793,7 @@ class PointerUpEvent extends PointerEvent { ...@@ -613,7 +793,7 @@ class PointerUpEvent extends PointerEvent {
class PointerCancelEvent extends PointerEvent { class PointerCancelEvent extends PointerEvent {
/// Creates a pointer cancel event. /// Creates a pointer cancel event.
/// ///
/// All of the argument must be non-null. /// All of the arguments must be non-null.
const PointerCancelEvent({ const PointerCancelEvent({
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
int pointer = 0, int pointer = 0,
......
This diff is collapsed.
...@@ -21,7 +21,7 @@ import 'view.dart'; ...@@ -21,7 +21,7 @@ import 'view.dart';
export 'package:flutter/gestures.dart' show HitTestResult; export 'package:flutter/gestures.dart' show HitTestResult;
/// The glue between the render tree and the Flutter engine. /// 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 @override
void initInstances() { void initInstances() {
super.initInstances(); super.initInstances();
...@@ -40,6 +40,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Semanti ...@@ -40,6 +40,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Semanti
_handleSemanticsEnabledChanged(); _handleSemanticsEnabledChanged();
assert(renderView != null); assert(renderView != null);
addPersistentFrameCallback(_handlePersistentFrameCallback); addPersistentFrameCallback(_handlePersistentFrameCallback);
_mouseTracker = _createMouseTracker();
} }
/// The current [RendererBinding], if one has been created. /// The current [RendererBinding], if one has been created.
...@@ -134,6 +135,11 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Semanti ...@@ -134,6 +135,11 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Semanti
renderView.scheduleInitialFrame(); 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, /// The render tree's owner, which maintains dirty state for layout,
/// composite, paint, and accessibility semantics /// composite, paint, and accessibility semantics
PipelineOwner get pipelineOwner => _pipelineOwner; PipelineOwner get pipelineOwner => _pipelineOwner;
...@@ -184,6 +190,18 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Semanti ...@@ -184,6 +190,18 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Semanti
SemanticsHandle _semanticsHandle; 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() { void _handleSemanticsEnabledChanged() {
setSemanticsEnabled(ui.window.semanticsEnabled); setSemanticsEnabled(ui.window.semanticsEnabled);
} }
......
...@@ -1729,10 +1729,13 @@ class FollowerLayer extends ContainerLayer { ...@@ -1729,10 +1729,13 @@ class FollowerLayer extends ContainerLayer {
/// a [Size] is provided to this layer, then find will check if the provided /// a [Size] is provided to this layer, then find will check if the provided
/// offset is within the bounds of the layer. /// offset is within the bounds of the layer.
class AnnotatedRegionLayer<T> extends ContainerLayer { 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. /// The [value] provided cannot be null.
AnnotatedRegionLayer(this.value, {this.size}) : assert(value != 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. /// The value returned by [find] if the offset is contained within this layer.
final T value; final T value;
...@@ -1741,14 +1744,25 @@ class AnnotatedRegionLayer<T> extends ContainerLayer { ...@@ -1741,14 +1744,25 @@ class AnnotatedRegionLayer<T> extends ContainerLayer {
/// ///
/// If not provided, all offsets are considered to be contained within this /// If not provided, all offsets are considered to be contained within this
/// layer, unless an ancestor layer applies a clip. /// 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; 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 @override
S find<S>(Offset regionOffset) { S find<S>(Offset regionOffset) {
final S result = super.find<S>(regionOffset); final S result = super.find<S>(regionOffset);
if (result != null) if (result != null)
return result; return result;
if (size != null && !size.contains(regionOffset)) if (size != null && !(offset & size).contains(regionOffset))
return null; return null;
if (T == S) { if (T == S) {
final Object untypedResult = value; final Object untypedResult = value;
...@@ -1763,5 +1777,6 @@ class AnnotatedRegionLayer<T> extends ContainerLayer { ...@@ -1763,5 +1777,6 @@ class AnnotatedRegionLayer<T> extends ContainerLayer {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<T>('value', value)); properties.add(DiagnosticsProperty<T>('value', value));
properties.add(DiagnosticsProperty<Size>('size', size, defaultValue: null)); properties.add(DiagnosticsProperty<Size>('size', size, defaultValue: null));
properties.add(DiagnosticsProperty<Offset>('offset', offset, defaultValue: null));
} }
} }
...@@ -14,6 +14,7 @@ import 'package:flutter/semantics.dart'; ...@@ -14,6 +14,7 @@ import 'package:flutter/semantics.dart';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
import 'binding.dart';
import 'box.dart'; import 'box.dart';
import 'layer.dart'; import 'layer.dart';
import 'object.dart'; import 'object.dart';
...@@ -2486,25 +2487,84 @@ typedef PointerCancelEventListener = void Function(PointerCancelEvent event); ...@@ -2486,25 +2487,84 @@ typedef PointerCancelEventListener = void Function(PointerCancelEvent event);
/// If it has a child, defers to the child for sizing behavior. /// 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. /// 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 { 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]. /// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
RenderPointerListener({ RenderPointerListener({
this.onPointerDown, this.onPointerDown,
this.onPointerMove, this.onPointerMove,
PointerEnterEventListener onPointerEnter,
PointerHoverEventListener onPointerHover,
PointerExitEventListener onPointerExit,
this.onPointerUp, this.onPointerUp,
this.onPointerCancel, this.onPointerCancel,
HitTestBehavior behavior = HitTestBehavior.deferToChild, HitTestBehavior behavior = HitTestBehavior.deferToChild,
RenderBox child RenderBox child,
}) : super(behavior: behavior, child: 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; PointerDownEventListener onPointerDown;
/// Called when a pointer that triggered an [onPointerDown] changes position. /// Called when a pointer that triggered an [onPointerDown] changes position.
PointerMoveEventListener onPointerMove; 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 /// Called when a pointer that triggered an [onPointerDown] is no longer in
/// contact with the screen. /// contact with the screen.
PointerUpEventListener onPointerUp; PointerUpEventListener onPointerUp;
...@@ -2513,6 +2573,56 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2513,6 +2573,56 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
/// no longer directed towards this receiver. /// no longer directed towards this receiver.
PointerCancelEventListener onPointerCancel; 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 @override
void performResize() { void performResize() {
size = constraints.biggest; size = constraints.biggest;
...@@ -2521,6 +2631,8 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2521,6 +2631,8 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
@override @override
void handleEvent(PointerEvent event, HitTestEntry entry) { void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, 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) if (onPointerDown != null && event is PointerDownEvent)
return onPointerDown(event); return onPointerDown(event);
if (onPointerMove != null && event is PointerMoveEvent) if (onPointerMove != null && event is PointerMoveEvent)
...@@ -2539,6 +2651,12 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2539,6 +2651,12 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
listeners.add('down'); listeners.add('down');
if (onPointerMove != null) if (onPointerMove != null)
listeners.add('move'); listeners.add('move');
if (onPointerEnter != null)
listeners.add('enter');
if (onPointerHover != null)
listeners.add('hover');
if (onPointerExit != null)
listeners.add('exit');
if (onPointerUp != null) if (onPointerUp != null)
listeners.add('up'); listeners.add('up');
if (onPointerCancel != null) if (onPointerCancel != null)
...@@ -4647,7 +4765,11 @@ class RenderAnnotatedRegion<T> extends RenderProxyBox { ...@@ -4647,7 +4765,11 @@ class RenderAnnotatedRegion<T> extends RenderProxyBox {
@override @override
void paint(PaintingContext context, Offset offset) { 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); context.pushLayer(layer, super.paint, offset);
} }
} }
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'dart:ui' as ui show Image, ImageFilter; import 'dart:ui' as ui show Image, ImageFilter;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -4996,6 +4997,80 @@ class WidgetToRenderBoxAdapter extends LeafRenderObjectWidget { ...@@ -4996,6 +4997,80 @@ class WidgetToRenderBoxAdapter extends LeafRenderObjectWidget {
/// ///
/// If it has a child, this widget defers to the child for sizing behavior. If /// 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. /// 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 { class Listener extends SingleChildRenderObjectWidget {
/// Creates a widget that forwards point events to callbacks. /// Creates a widget that forwards point events to callbacks.
/// ///
...@@ -5004,6 +5079,9 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5004,6 +5079,9 @@ class Listener extends SingleChildRenderObjectWidget {
Key key, Key key,
this.onPointerDown, this.onPointerDown,
this.onPointerMove, this.onPointerMove,
this.onPointerEnter,
this.onPointerExit,
this.onPointerHover,
this.onPointerUp, this.onPointerUp,
this.onPointerCancel, this.onPointerCancel,
this.behavior = HitTestBehavior.deferToChild, this.behavior = HitTestBehavior.deferToChild,
...@@ -5011,16 +5089,47 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5011,16 +5089,47 @@ class Listener extends SingleChildRenderObjectWidget {
}) : assert(behavior != null), }) : assert(behavior != null),
super(key: key, child: child); 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; final PointerDownEventListener onPointerDown;
/// Called when a pointer that triggered an [onPointerDown] changes position. /// Called when a pointer that triggered an [onPointerDown] changes position.
final PointerMoveEventListener onPointerMove; 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; 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; final PointerCancelEventListener onPointerCancel;
/// How to behave during hit testing. /// How to behave during hit testing.
...@@ -5031,6 +5140,9 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5031,6 +5140,9 @@ class Listener extends SingleChildRenderObjectWidget {
return RenderPointerListener( return RenderPointerListener(
onPointerDown: onPointerDown, onPointerDown: onPointerDown,
onPointerMove: onPointerMove, onPointerMove: onPointerMove,
onPointerEnter: onPointerEnter,
onPointerHover: onPointerHover,
onPointerExit: onPointerExit,
onPointerUp: onPointerUp, onPointerUp: onPointerUp,
onPointerCancel: onPointerCancel, onPointerCancel: onPointerCancel,
behavior: behavior behavior: behavior
...@@ -5042,6 +5154,9 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5042,6 +5154,9 @@ class Listener extends SingleChildRenderObjectWidget {
renderObject renderObject
..onPointerDown = onPointerDown ..onPointerDown = onPointerDown
..onPointerMove = onPointerMove ..onPointerMove = onPointerMove
..onPointerEnter = onPointerEnter
..onPointerHover = onPointerHover
..onPointerExit = onPointerExit
..onPointerUp = onPointerUp ..onPointerUp = onPointerUp
..onPointerCancel = onPointerCancel ..onPointerCancel = onPointerCancel
..behavior = behavior; ..behavior = behavior;
...@@ -5055,6 +5170,12 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5055,6 +5170,12 @@ class Listener extends SingleChildRenderObjectWidget {
listeners.add('down'); listeners.add('down');
if (onPointerMove != null) if (onPointerMove != null)
listeners.add('move'); listeners.add('move');
if (onPointerEnter != null)
listeners.add('enter');
if (onPointerExit != null)
listeners.add('exit');
if (onPointerHover != null)
listeners.add('hover');
if (onPointerUp != null) if (onPointerUp != null)
listeners.add('up'); listeners.add('up');
if (onPointerCancel != null) if (onPointerCancel != null)
......
...@@ -16,9 +16,9 @@ class TestGestureFlutterBinding extends BindingBase with GestureBinding { ...@@ -16,9 +16,9 @@ class TestGestureFlutterBinding extends BindingBase with GestureBinding {
@override @override
void handleEvent(PointerEvent event, HitTestEntry entry) { void handleEvent(PointerEvent event, HitTestEntry entry) {
super.handleEvent(event, entry);
if (callback != null) if (callback != null)
callback(event); callback(event);
super.handleEvent(event, entry);
} }
} }
...@@ -26,6 +26,7 @@ TestGestureFlutterBinding _binding = TestGestureFlutterBinding(); ...@@ -26,6 +26,7 @@ TestGestureFlutterBinding _binding = TestGestureFlutterBinding();
void ensureTestGestureBinding() { void ensureTestGestureBinding() {
_binding ??= TestGestureFlutterBinding(); _binding ??= TestGestureFlutterBinding();
PointerEventConverter.clearPointers();
assert(GestureBinding.instance != null); assert(GestureBinding.instance != null);
} }
...@@ -68,6 +69,34 @@ void main() { ...@@ -68,6 +69,34 @@ void main() {
expect(events[2].runtimeType, equals(PointerUpEvent)); 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', () { test('Synthetic move events', () {
final ui.PointerDataPacket packet = ui.PointerDataPacket( final ui.PointerDataPacket packet = ui.PointerDataPacket(
data: <ui.PointerData>[ data: <ui.PointerData>[
......
This diff is collapsed.
...@@ -2,12 +2,15 @@ ...@@ -2,12 +2,15 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' show window;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { 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( await tester.pumpWidget(
const AnnotatedRegion<int>( const AnnotatedRegion<int>(
child: SizedBox(width: 100.0, height: 100.0), child: SizedBox(width: 100.0, height: 100.0),
...@@ -15,7 +18,30 @@ void main() { ...@@ -15,7 +18,30 @@ void main() {
), ),
); );
final List<Layer> layers = tester.layers; 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); 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);
});
} }
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/gestures.dart';
void main() { void main() {
testWidgets('Events bubble up the tree', (WidgetTester tester) async { testWidgets('Events bubble up the tree', (WidgetTester tester) async {
...@@ -39,4 +40,91 @@ void main() { ...@@ -39,4 +40,91 @@ void main() {
'top', '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)));
});
});
} }
...@@ -303,11 +303,11 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -303,11 +303,11 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
Offset localToGlobal(Offset point) => point; Offset localToGlobal(Offset point) => point;
@override @override
void dispatchEvent(PointerEvent event, HitTestResult result, { void dispatchEvent(PointerEvent event, HitTestResult hitTestResult, {
TestBindingEventSource source = TestBindingEventSource.device TestBindingEventSource source = TestBindingEventSource.device
}) { }) {
assert(source == TestBindingEventSource.test); assert(source == TestBindingEventSource.test);
super.dispatchEvent(event, result); super.dispatchEvent(event, hitTestResult);
} }
/// A stub for the system's onscreen keyboard. Callers must set the /// A stub for the system's onscreen keyboard. Callers must set the
...@@ -1177,7 +1177,7 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { ...@@ -1177,7 +1177,7 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding {
HitTestDispatcher deviceEventDispatcher; HitTestDispatcher deviceEventDispatcher;
@override @override
void dispatchEvent(PointerEvent event, HitTestResult result, { void dispatchEvent(PointerEvent event, HitTestResult hitTestResult, {
TestBindingEventSource source = TestBindingEventSource.device TestBindingEventSource source = TestBindingEventSource.device
}) { }) {
switch (source) { switch (source) {
...@@ -1191,11 +1191,11 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { ...@@ -1191,11 +1191,11 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding {
renderView._pointers[event.pointer].decay = _kPointerDecay; renderView._pointers[event.pointer].decay = _kPointerDecay;
} }
_handleViewNeedsPaint(); _handleViewNeedsPaint();
super.dispatchEvent(event, result, source: source); super.dispatchEvent(event, hitTestResult, source: source);
break; break;
case TestBindingEventSource.device: case TestBindingEventSource.device:
if (deviceEventDispatcher != null) if (deviceEventDispatcher != null)
deviceEventDispatcher.dispatchEvent(event, result); deviceEventDispatcher.dispatchEvent(event, hitTestResult);
break; break;
} }
} }
......
...@@ -38,15 +38,13 @@ abstract class WidgetController { ...@@ -38,15 +38,13 @@ abstract class WidgetController {
return finder.evaluate().isNotEmpty; return finder.evaluate().isNotEmpty;
} }
/// All widgets currently in the widget tree (lazy pre-order traversal). /// All widgets currently in the widget tree (lazy pre-order traversal).
/// ///
/// Can contain duplicates, since widgets can be used in multiple /// Can contain duplicates, since widgets can be used in multiple
/// places in the widget tree. /// places in the widget tree.
Iterable<Widget> get allWidgets { Iterable<Widget> get allWidgets {
TestAsyncUtils.guardSync(); TestAsyncUtils.guardSync();
return allElements return allElements.map<Widget>((Element element) => element.widget);
.map<Widget>((Element element) => element.widget);
} }
/// The matching widget in the widget tree. /// The matching widget in the widget tree.
...@@ -84,7 +82,6 @@ abstract class WidgetController { ...@@ -84,7 +82,6 @@ abstract class WidgetController {
}); });
} }
/// All elements currently in the widget tree (lazy pre-order traversal). /// All elements currently in the widget tree (lazy pre-order traversal).
/// ///
/// The returned iterable is lazy. It does not walk the entire widget tree /// The returned iterable is lazy. It does not walk the entire widget tree
...@@ -127,7 +124,6 @@ abstract class WidgetController { ...@@ -127,7 +124,6 @@ abstract class WidgetController {
return finder.evaluate(); return finder.evaluate();
} }
/// All states currently in the widget tree (lazy pre-order traversal). /// All states currently in the widget tree (lazy pre-order traversal).
/// ///
/// The returned iterable is lazy. It does not walk the entire widget tree /// The returned iterable is lazy. It does not walk the entire widget tree
...@@ -135,9 +131,7 @@ abstract class WidgetController { ...@@ -135,9 +131,7 @@ abstract class WidgetController {
/// using [Iterator.moveNext]. /// using [Iterator.moveNext].
Iterable<State> get allStates { Iterable<State> get allStates {
TestAsyncUtils.guardSync(); TestAsyncUtils.guardSync();
return allElements return allElements.whereType<StatefulElement>().map<State>((StatefulElement element) => element.state);
.whereType<StatefulElement>()
.map<State>((StatefulElement element) => element.state);
} }
/// The matching state in the widget tree. /// The matching state in the widget tree.
...@@ -183,7 +177,6 @@ abstract class WidgetController { ...@@ -183,7 +177,6 @@ abstract class WidgetController {
throw StateError('Widget of type ${element.widget.runtimeType}, with ${finder.description}, is not a StatefulWidget.'); 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 /// Render objects of all the widgets currently in the widget tree
/// (lazy pre-order traversal). /// (lazy pre-order traversal).
/// ///
...@@ -193,8 +186,7 @@ abstract class WidgetController { ...@@ -193,8 +186,7 @@ abstract class WidgetController {
/// their own render object. /// their own render object.
Iterable<RenderObject> get allRenderObjects { Iterable<RenderObject> get allRenderObjects {
TestAsyncUtils.guardSync(); TestAsyncUtils.guardSync();
return allElements return allElements.map<RenderObject>((Element element) => element.renderObject);
.map<RenderObject>((Element element) => element.renderObject);
} }
/// The render object of the matching widget in the widget tree. /// The render object of the matching widget in the widget tree.
...@@ -232,7 +224,6 @@ abstract class WidgetController { ...@@ -232,7 +224,6 @@ abstract class WidgetController {
}); });
} }
/// Returns a list of all the [Layer] objects in the rendering. /// Returns a list of all the [Layer] objects in the rendering.
List<Layer> get layers => _walkLayers(binding.renderView.layer).toList(); List<Layer> get layers => _walkLayers(binding.renderView.layer).toList();
Iterable<Layer> _walkLayers(Layer layer) sync* { Iterable<Layer> _walkLayers(Layer layer) sync* {
...@@ -248,7 +239,6 @@ abstract class WidgetController { ...@@ -248,7 +239,6 @@ abstract class WidgetController {
} }
} }
// INTERACTION // INTERACTION
/// Dispatch a pointer down / pointer up sequence at the center of /// Dispatch a pointer down / pointer up sequence at the center of
...@@ -256,12 +246,12 @@ abstract class WidgetController { ...@@ -256,12 +246,12 @@ abstract class WidgetController {
/// ///
/// If the center of the widget is not exposed, this might send events to /// If the center of the widget is not exposed, this might send events to
/// another object. /// another object.
Future<void> tap(Finder finder, { int pointer }) { Future<void> tap(Finder finder, {int pointer}) {
return tapAt(getCenter(finder), pointer: pointer); return tapAt(getCenter(finder), pointer: pointer);
} }
/// Dispatch a pointer down / pointer up sequence at the given location. /// 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 { return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer); final TestGesture gesture = await startGesture(location, pointer: pointer);
await gesture.up(); await gesture.up();
...@@ -273,7 +263,7 @@ abstract class WidgetController { ...@@ -273,7 +263,7 @@ abstract class WidgetController {
/// ///
/// If the center of the widget is not exposed, this might send events to /// If the center of the widget is not exposed, this might send events to
/// another object. /// another object.
Future<TestGesture> press(Finder finder, { int pointer }) { Future<TestGesture> press(Finder finder, {int pointer}) {
return TestAsyncUtils.guard<TestGesture>(() { return TestAsyncUtils.guard<TestGesture>(() {
return startGesture(getCenter(finder), pointer: pointer); return startGesture(getCenter(finder), pointer: pointer);
}); });
...@@ -285,13 +275,13 @@ abstract class WidgetController { ...@@ -285,13 +275,13 @@ abstract class WidgetController {
/// ///
/// If the center of the widget is not exposed, this might send events to /// If the center of the widget is not exposed, this might send events to
/// another object. /// another object.
Future<void> longPress(Finder finder, { int pointer }) { Future<void> longPress(Finder finder, {int pointer}) {
return longPressAt(getCenter(finder), pointer: pointer); return longPressAt(getCenter(finder), pointer: pointer);
} }
/// Dispatch a pointer down / pointer up sequence at the given location with /// Dispatch a pointer down / pointer up sequence at the given location with
/// a delay of [kLongPressTimeout] + [kPressTimeout] between the two events. /// 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 { return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(location, pointer: pointer); final TestGesture gesture = await startGesture(location, pointer: pointer);
await pump(kLongPressTimeout + kPressTimeout); await pump(kLongPressTimeout + kPressTimeout);
...@@ -319,7 +309,10 @@ abstract class WidgetController { ...@@ -319,7 +309,10 @@ abstract class WidgetController {
/// opposite direction of the fling (e.g. dragging 200 pixels to the right, /// 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 /// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started). /// drag started).
Future<void> fling(Finder finder, Offset offset, double speed, { Future<void> fling(
Finder finder,
Offset offset,
double speed, {
int pointer, int pointer,
Duration frameInterval = const Duration(milliseconds: 16), Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero, Offset initialOffset = Offset.zero,
...@@ -361,7 +354,10 @@ abstract class WidgetController { ...@@ -361,7 +354,10 @@ abstract class WidgetController {
/// opposite direction of the fling (e.g. dragging 200 pixels to the right, /// 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 /// then fling to the left over 200 pixels, ending at the exact point that the
/// drag started). /// drag started).
Future<void> flingFrom(Offset startLocation, Offset offset, double speed, { Future<void> flingFrom(
Offset startLocation,
Offset offset,
double speed, {
int pointer, int pointer,
Duration frameInterval = const Duration(milliseconds: 16), Duration frameInterval = const Duration(milliseconds: 16),
Offset initialOffset = Offset.zero, Offset initialOffset = Offset.zero,
...@@ -414,7 +410,7 @@ abstract class WidgetController { ...@@ -414,7 +410,7 @@ abstract class WidgetController {
/// ///
/// If you want the drag to end with a speed so that the gesture recognition /// 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. /// 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); return dragFrom(getCenter(finder), offset, pointer: pointer);
} }
...@@ -424,7 +420,7 @@ abstract class WidgetController { ...@@ -424,7 +420,7 @@ abstract class WidgetController {
/// If you want the drag to end with a speed so that the gesture recognition /// 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] /// system identifies the gesture as a fling, consider using [flingFrom]
/// instead. /// instead.
Future<void> dragFrom(Offset startLocation, Offset offset, { int pointer }) { Future<void> dragFrom(Offset startLocation, Offset offset, {int pointer}) {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
final TestGesture gesture = await startGesture(startLocation, pointer: pointer); final TestGesture gesture = await startGesture(startLocation, pointer: pointer);
assert(gesture != null); assert(gesture != null);
...@@ -445,17 +441,32 @@ abstract class WidgetController { ...@@ -445,17 +441,32 @@ abstract class WidgetController {
return result; return result;
} }
/// Begins a gesture at a particular point, and returns the /// Creates gesture and returns the [TestGesture] object which you can use
/// [TestGesture] object which you can use to continue the gesture. /// to continue the gesture using calls on the [TestGesture] object.
Future<TestGesture> startGesture(Offset downLocation, { int pointer }) { ///
return TestGesture.down( /// You can use [startGesture] instead if your gesture begins with a down
downLocation, /// event.
pointer: pointer ?? _getNextPointer(), Future<TestGesture> createGesture({int pointer, PointerDeviceKind kind = PointerDeviceKind.touch}) async {
return TestGesture(
hitTester: hitTestOnBinding, hitTester: hitTestOnBinding,
dispatcher: sendEventToBinding, 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. /// Forwards the given location to the binding's hitTest logic.
HitTestResult hitTestOnBinding(Offset location) { HitTestResult hitTestOnBinding(Offset location) {
final HitTestResult result = HitTestResult(); final HitTestResult result = HitTestResult();
...@@ -470,7 +481,6 @@ abstract class WidgetController { ...@@ -470,7 +481,6 @@ abstract class WidgetController {
}); });
} }
// GEOMETRY // GEOMETRY
/// Returns the point at the center of the given widget. /// Returns the point at the center of the given widget.
......
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