Unverified Commit afb8f382 authored by Tong Mu's avatar Tong Mu Committed by GitHub

Improve MouseTracker lifecycle: Move checks to post-frame (#44631)

This PR rewrites MouseTracker's lifecycle, so that mouse callbacks are all triggered in post frame, instead of the current one where some are triggered during the build phase. This PR also changes the onExit callback to MouseRegion, RenderMouseRegion, and MouseTrackerAnnotation, so that it is no longer triggered on dispose.
parent 8954ee85
...@@ -13,38 +13,109 @@ import 'pointer_router.dart'; ...@@ -13,38 +13,109 @@ import 'pointer_router.dart';
/// Signature for listening to [PointerEnterEvent] events. /// Signature for listening to [PointerEnterEvent] events.
/// ///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener]. /// Used by [MouseTrackerAnnotation], [MouseRegion] and [RenderMouseRegion].
typedef PointerEnterEventListener = void Function(PointerEnterEvent event); typedef PointerEnterEventListener = void Function(PointerEnterEvent event);
/// Signature for listening to [PointerExitEvent] events. /// Signature for listening to [PointerExitEvent] events.
/// ///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener]. /// Used by [MouseTrackerAnnotation], [MouseRegion] and [RenderMouseRegion].
typedef PointerExitEventListener = void Function(PointerExitEvent event); typedef PointerExitEventListener = void Function(PointerExitEvent event);
/// Signature for listening to [PointerHoverEvent] events. /// Signature for listening to [PointerHoverEvent] events.
/// ///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener]. /// Used by [MouseTrackerAnnotation], [MouseRegion] and [RenderMouseRegion].
typedef PointerHoverEventListener = void Function(PointerHoverEvent event); typedef PointerHoverEventListener = void Function(PointerHoverEvent event);
/// The annotation object used to annotate layers that are interested in mouse /// The annotation object used to annotate layers that are interested in mouse
/// movements. /// movements.
/// ///
/// This is added to a layer and managed by the [Listener] widget. /// This is added to a layer and managed by the [MouseRegion] widget.
class MouseTrackerAnnotation { class MouseTrackerAnnotation {
/// Creates an annotation that can be used to find layers interested in mouse /// Creates an annotation that can be used to find layers interested in mouse
/// movements. /// movements.
const MouseTrackerAnnotation({this.onEnter, this.onHover, this.onExit}); const MouseTrackerAnnotation({this.onEnter, this.onHover, this.onExit});
/// Triggered when a pointer has entered the bounding box of the annotated /// Triggered when a mouse pointer, with or without buttons pressed, has
/// layer. /// entered the annotated region.
///
/// This callback is triggered when the pointer has started to be contained
/// by the annotationed region for any reason.
///
/// More specifically, the callback is triggered by the following cases:
///
/// * A new annotated region has appeared under a pointer.
/// * An existing annotated region has moved to under a pointer.
/// * A new pointer has been added to somewhere within an annotated region.
/// * An existing pointer has moved into an annotated region.
///
/// This callback is not always matched by an [onExit]. If the render object
/// that owns the annotation is disposed while being hovered by a pointer,
/// the [onExit] callback of that annotation will never called, despite
/// the earlier call of [onEnter]. For more details, see [onExit].
///
/// See also:
///
/// * [MouseRegion.onEnter], which uses this callback.
/// * [onExit], which is triggered when a mouse pointer exits the region.
final PointerEnterEventListener onEnter; final PointerEnterEventListener onEnter;
/// Triggered when a pointer has moved within the bounding box of the /// Triggered when a pointer has moved within the annotated region without
/// annotated layer. /// buttons pressed.
///
/// This callback is triggered when:
///
/// * An annotation that did not contain the pointer has moved to under a
/// pointer that has no buttons pressed.
/// * A pointer has moved onto, or moved within an annotation without buttons
/// pressed.
///
/// This callback is not triggered when
///
/// * An annotation that is containing the pointer has moved, and still
/// contains the pointer.
final PointerHoverEventListener onHover; final PointerHoverEventListener onHover;
/// Triggered when a pointer has exited the bounding box of the annotated /// Triggered when a mouse pointer, with or without buttons pressed, has
/// layer. /// exited the annotated region when the annotated region still exists.
///
/// This callback is triggered when the pointer has stopped to be contained
/// by the region, except when it's caused by the removal of the render object
/// that owns the annotation. More specifically, the callback is triggered by
/// the following cases:
///
/// * An annotated region that used to contain a pointer has moved away.
/// * A pointer that used to be within an annotated region has been removed.
/// * A pointer that used to be within an annotated region has moved away.
///
/// And is __not__ triggered by the following case,
///
/// * An annotated region that used to contain a pointer has disappeared.
///
/// The last case is the only case when [onExit] does not match an earlier
/// [onEnter].
/// {@template flutter.mouseTracker.onExit}
/// This design is because the last case is very likely to be
/// handled improperly and cause exceptions (such as calling `setState` of the
/// disposed widget). There are a few ways to mitigate this limit:
///
/// * If the state of hovering is contained within a widget that
/// unconditionally attaches the annotation (as long as a mouse is
/// connected), then this will not be a concern, since when the annotation
/// is disposed the state is no longer used.
/// * If you're accessible to the condition that controls whether the
/// annotation is attached, then you can call the callback when that
/// condition goes from true to false.
/// * In the cases where the solutions above won't work, you can always
/// override [Widget.dispose] or [RenderObject.detach].
/// {@endtemplate}
///
/// Technically, whether [onExit] will be called is controlled by
/// [MouseTracker.attachAnnotation] and [MouseTracker.detachAnnotation].
///
/// See also:
///
/// * [MouseRegion.onExit], which uses this callback.
/// * [onEnter], which is triggered when a mouse pointer enters the region.
final PointerExitEventListener onExit; final PointerExitEventListener onExit;
@override @override
...@@ -69,39 +140,45 @@ class MouseTrackerAnnotation { ...@@ -69,39 +140,45 @@ class MouseTrackerAnnotation {
/// position. /// position.
typedef MouseDetectorAnnotationFinder = Iterable<MouseTrackerAnnotation> Function(Offset offset); typedef MouseDetectorAnnotationFinder = Iterable<MouseTrackerAnnotation> Function(Offset offset);
// Various states of each connected mouse device. typedef _UpdatedDeviceHandler = void Function(_MouseState mouseState, LinkedHashSet<MouseTrackerAnnotation> previousAnnotations);
//
// It is used by [MouseTracker] to compute which callbacks should be triggered // Various states of a connected mouse device used by [MouseTracker].
// by each event.
class _MouseState { class _MouseState {
_MouseState({ _MouseState({
@required PointerEvent mostRecentEvent, @required PointerAddedEvent initialEvent,
}) : assert(mostRecentEvent != null), }) : assert(initialEvent != null),
_mostRecentEvent = mostRecentEvent; _latestEvent = initialEvent;
// The list of annotations that contains this device during the last frame. // The list of annotations that contains this device.
// //
// It uses [LinkedHashSet] to keep the insertion order. // It uses [LinkedHashSet] to keep the insertion order.
LinkedHashSet<MouseTrackerAnnotation> lastAnnotations = LinkedHashSet<MouseTrackerAnnotation>(); LinkedHashSet<MouseTrackerAnnotation> get annotations => _annotations;
LinkedHashSet<MouseTrackerAnnotation> _annotations = LinkedHashSet<MouseTrackerAnnotation>();
// The most recent mouse event observed from this device. LinkedHashSet<MouseTrackerAnnotation> replaceAnnotations(LinkedHashSet<MouseTrackerAnnotation> value) {
// final LinkedHashSet<MouseTrackerAnnotation> previous = _annotations;
// The [mostRecentEvent] is never null. _annotations = value;
PointerEvent get mostRecentEvent => _mostRecentEvent; return previous;
PointerEvent _mostRecentEvent; }
set mostRecentEvent(PointerEvent value) {
// The most recently processed mouse event observed from this device.
PointerEvent get latestEvent => _latestEvent;
PointerEvent _latestEvent;
set latestEvent(PointerEvent value) {
assert(value != null); assert(value != null);
assert(value.device == _mostRecentEvent.device); _latestEvent = value;
_mostRecentEvent = value;
} }
int get device => _mostRecentEvent.device; int get device => latestEvent.device;
@override @override
String toString() { String toString() {
final String describeEvent = '${_mostRecentEvent.runtimeType}(device: ${_mostRecentEvent.device})'; String describeEvent(PointerEvent event) {
final String describeAnnotations = '[list of ${lastAnnotations.length}]'; return event == null ? 'null' : '${describeIdentity(event)}';
return '${describeIdentity(this)}(event: $describeEvent, annotations: $describeAnnotations)'; }
final String describeLatestEvent = 'latestEvent: ${describeEvent(latestEvent)}';
final String describeAnnotations = 'annotations: [list of ${annotations.length}]';
return '${describeIdentity(this)}($describeLatestEvent, $describeAnnotations)';
} }
} }
...@@ -114,6 +191,24 @@ class _MouseState { ...@@ -114,6 +191,24 @@ class _MouseState {
/// ///
/// An instance of [MouseTracker] is owned by the global singleton of /// An instance of [MouseTracker] is owned by the global singleton of
/// [RendererBinding]. /// [RendererBinding].
///
/// ### Details
///
/// The state of [MouseTracker] consists of 3 parts:
///
/// * The mouse devices that are connected.
/// * The annotations that are attached, i.e. whose owner render object is
/// painted on the screen.
/// * In which annotations each device is contained.
///
/// The states remain stable most of the time, and are only changed at the
/// following moments:
///
/// * An eligible [PointerEvent] has been observed, e.g. a device is added,
/// removed, or moved. In this case, the state related to this device will
/// be immediately updated.
/// * A frame has been painted. In this case, a callback will be scheduled for
/// the upcoming post-frame phase to update all devices.
class MouseTracker extends ChangeNotifier { class MouseTracker extends ChangeNotifier {
/// Creates a mouse tracker to keep track of mouse locations. /// Creates a mouse tracker to keep track of mouse locations.
/// ///
...@@ -152,168 +247,212 @@ class MouseTracker extends ChangeNotifier { ...@@ -152,168 +247,212 @@ class MouseTracker extends ChangeNotifier {
// mouse events from. // mouse events from.
final PointerRouter _router; final PointerRouter _router;
// The collection of annotations that are currently being tracked. It is
// operated on by [attachAnnotation] and [detachAnnotation].
final Set<MouseTrackerAnnotation> _trackedAnnotations = <MouseTrackerAnnotation>{};
// Tracks the state of connected mouse devices. // Tracks the state of connected mouse devices.
// //
// It is the source of truth for the list of connected mouse devices. // It is the source of truth for the list of connected mouse devices.
final Map<int, _MouseState> _mouseStates = <int, _MouseState>{}; final Map<int, _MouseState> _mouseStates = <int, _MouseState>{};
// Returns the mouse state of a device. If it doesn't exist, create one using // Whether an observed event might update a device.
// `mostRecentEvent`. static bool _shouldMarkStateDirty(_MouseState state, PointerEvent value) {
// if (state == null)
// The returned value is never null. return true;
_MouseState _guaranteeMouseState(int device, PointerEvent mostRecentEvent) { assert(value != null);
final _MouseState currentState = _mouseStates[device]; final PointerEvent lastEvent = state.latestEvent;
if (currentState == null) { assert(value.device == lastEvent.device);
_addMouseDevice(device, mostRecentEvent); // An Added can only follow a Removed, and a Removed can only be followed
} // by an Added.
final _MouseState result = currentState ?? _mouseStates[device]; assert((value is PointerAddedEvent) == (lastEvent is PointerRemovedEvent));
assert(result != null);
return result; // Ignore events that are unrelated to mouse tracking.
} if (value is PointerSignalEvent)
return false;
// The collection of annotations that are currently being tracked. return lastEvent is PointerAddedEvent
// It is operated on by [attachAnnotation] and [detachAnnotation]. || value is PointerRemovedEvent
final Set<MouseTrackerAnnotation> _trackedAnnotations = <MouseTrackerAnnotation>{}; || lastEvent.position != value.position;
bool get _hasAttachedAnnotations => _trackedAnnotations.isNotEmpty;
void _addMouseDevice(int device, PointerEvent event) {
final bool wasConnected = mouseIsConnected;
assert(!_mouseStates.containsKey(device));
_mouseStates[device] = _MouseState(mostRecentEvent: event);
// Schedule a check to enter annotations that might contain this pointer.
_checkDeviceUpdates(device: device);
if (mouseIsConnected != wasConnected) {
notifyListeners();
}
}
void _removeMouseDevice(int device, PointerEvent event) {
final bool wasConnected = mouseIsConnected;
assert(_mouseStates.containsKey(device));
final _MouseState disconnectedMouseState = _mouseStates.remove(device);
disconnectedMouseState.mostRecentEvent = event;
// Schedule a check to exit annotations that used to contain this pointer.
_checkDeviceUpdates(
device: device,
disconnectedMouseState: disconnectedMouseState,
);
if (mouseIsConnected != wasConnected) {
notifyListeners();
}
} }
// Handler for events coming from the PointerRouter. // Handler for events coming from the PointerRouter.
//
// If the event marks the device dirty, update the device immediately.
void _handleEvent(PointerEvent event) { void _handleEvent(PointerEvent event) {
if (event.kind != PointerDeviceKind.mouse) { if (event.kind != PointerDeviceKind.mouse)
return;
if (event is PointerSignalEvent)
return; return;
}
final int device = event.device; final int device = event.device;
if (event is PointerAddedEvent) { final _MouseState existingState = _mouseStates[device];
_addMouseDevice(device, event); if (!_shouldMarkStateDirty(existingState, event))
} else if (event is PointerRemovedEvent) { return;
_removeMouseDevice(device, event);
} else if (event is PointerHoverEvent) {
final _MouseState mouseState = _guaranteeMouseState(device, event);
final PointerEvent previousEvent = mouseState.mostRecentEvent;
mouseState.mostRecentEvent = event;
if (previousEvent is PointerAddedEvent || previousEvent.position != event.position) {
// Only send notifications if we have our first event, or if the
// location of the mouse has changed
_checkDeviceUpdates(device: device);
}
}
}
bool _scheduledPostFramePositionCheck = false; final PointerEvent previousEvent = existingState?.latestEvent;
// Schedules a position check at the end of this frame. _updateDevices(
// It is only called during a frame during which annotations have been added. targetEvent: event,
void _scheduleMousePositionCheck() { handleUpdatedDevice: (_MouseState mouseState, LinkedHashSet<MouseTrackerAnnotation> previousAnnotations) {
// If we're not tracking anything, then there is no point in registering a assert(mouseState.device == event.device);
// frame callback or scheduling a frame. By definition there are no active _dispatchDeviceCallbacks(
// annotations that need exiting, either. lastAnnotations: previousAnnotations,
if (!_scheduledPostFramePositionCheck) { nextAnnotations: mouseState.annotations,
_scheduledPostFramePositionCheck = true; handledEvent: previousEvent,
SchedulerBinding.instance.addPostFrameCallback((Duration duration) { unhandledEvent: event,
_checkAllDevicesUpdates(); trackedAnnotations: _trackedAnnotations,
_scheduledPostFramePositionCheck = false; );
}); },
} );
} }
// Collect the latest states of the given mouse device `device`, and call // Find the annotations that is hovered by the device of the `state`.
// interested callbacks.
// //
// The enter or exit events are called for annotations that the pointer // If the device is not connected or there are no annotations attached, empty
// enters or leaves, while hover events are always called for each // is returned without calling `annotationFinder`.
// annotations that the pointer stays in, even if the pointer has not moved LinkedHashSet<MouseTrackerAnnotation> _findAnnotations(_MouseState state) {
// since the last call. Therefore it's caller's responsibility to check if final Offset globalPosition = state.latestEvent.position;
// the pointer has moved. final int device = state.device;
return (_mouseStates.containsKey(device) && _trackedAnnotations.isNotEmpty)
? LinkedHashSet<MouseTrackerAnnotation>.from(annotationFinder(globalPosition))
: <MouseTrackerAnnotation>{};
}
static bool get _duringBuildPhase {
return SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks;
}
// Update all devices, despite observing no new events.
// //
// If `disconnectedMouseState` is provided, this state will be used instead, // This is called after a new frame, since annotations can be moved after
// but this mouse will be hovering no annotations. // every frame.
void _checkDeviceUpdates({ void _updateAllDevices() {
int device, _updateDevices(
_MouseState disconnectedMouseState, handleUpdatedDevice: (_MouseState mouseState, LinkedHashSet<MouseTrackerAnnotation> previousAnnotations) {
}) { _dispatchDeviceCallbacks(
final _MouseState mouseState = disconnectedMouseState ?? _mouseStates[device]; lastAnnotations: previousAnnotations,
final bool thisDeviceIsConnected = mouseState != disconnectedMouseState; nextAnnotations: mouseState.annotations,
assert(mouseState != null); handledEvent: mouseState.latestEvent,
unhandledEvent: mouseState.latestEvent,
final LinkedHashSet<MouseTrackerAnnotation> nextAnnotations = trackedAnnotations: _trackedAnnotations,
(_hasAttachedAnnotations && thisDeviceIsConnected) );
? LinkedHashSet<MouseTrackerAnnotation>.from( }
annotationFinder(mouseState.mostRecentEvent.position)
)
: <MouseTrackerAnnotation>{} as LinkedHashSet<MouseTrackerAnnotation>;
_dispatchDeviceCallbacks(
currentState: mouseState,
nextAnnotations: nextAnnotations,
); );
mouseState.lastAnnotations = nextAnnotations;
} }
// Collect the latest states of all mouse devices, and call interested bool _duringDeviceUpdate = false;
// callbacks. // Update device states with the change of a new event or a new frame, and
// trigger `handleUpdateDevice` for each dirty device.
//
// This method is called either when a new event is observed (`targetEvent`
// being non-null), or when no new event is observed but all devices are
// marked dirty due to a new frame. It means that it will not happen that all
// devices are marked dirty when a new event is unprocessed.
//
// This method is the moment where `_mouseState` is updated. Before
// this method, `_mouseState` is in sync with the state before the event or
// before the frame. During `handleUpdateDevice` and after this method,
// `_mouseState` is in sync with the state after the event or after the frame.
//
// The dirty devices are decided as follows: if `targetEvent` is not null, the
// dirty devices are the device that observed the event; otherwise all devices
// are dirty.
//
// This method first keeps `_mouseStates` up to date. More specifically,
// //
// For detailed behaviors, see [_checkDeviceUpdates]. // * If an event is observed, update `_mouseStates` by inserting or removing
void _checkAllDevicesUpdates() { // the state that corresponds to the event if needed, then update the
for (final int device in _mouseStates.keys) { // `latestEvent` property of this mouse state.
_checkDeviceUpdates(device: device); // * For each mouse state that will correspond to a dirty device, update the
// `annotations` property with the annotations the device is contained.
//
// Then, for each dirty device, `handleUpdatedDevice` is called with the
// updated state and the annotations before the update.
//
// Last, the method checks if `mouseIsConnected` has been changed, and notify
// listeners if needed.
void _updateDevices({
PointerEvent targetEvent,
@required _UpdatedDeviceHandler handleUpdatedDevice,
}) {
assert(handleUpdatedDevice != null);
assert(!_duringBuildPhase);
assert(!_duringDeviceUpdate);
final bool mouseWasConnected = mouseIsConnected;
// If new event is not null, only the device that observed this event is
// dirty. The target device's state is inserted into or removed from
// `_mouseStates` if needed, stored as `targetState`, and its
// `mostRecentDevice` is updated.
_MouseState targetState;
if (targetEvent != null) {
targetState = _mouseStates[targetEvent.device];
assert((targetState == null) == (targetEvent is PointerAddedEvent));
if (targetEvent is PointerAddedEvent) {
targetState = _MouseState(initialEvent: targetEvent);
_mouseStates[targetState.device] = targetState;
} else {
targetState.latestEvent = targetEvent;
// Update mouseState to the latest devices that have not been removed,
// so that [mouseIsConnected], which is decided by `_mouseStates`, is
// correct during the callbacks.
if (targetEvent is PointerRemovedEvent)
_mouseStates.remove(targetEvent.device);
}
}
assert((targetState == null) == (targetEvent == null));
assert(() {
_duringDeviceUpdate = true;
return true;
}());
// We can safely use `_mouseStates` here without worrying about the removed
// state, because `targetEvent` should be null when `_mouseStates` is used.
final Iterable<_MouseState> dirtyStates = targetEvent == null ? _mouseStates.values : <_MouseState>[targetState];
for (_MouseState dirtyState in dirtyStates) {
final LinkedHashSet<MouseTrackerAnnotation> nextAnnotations = _findAnnotations(dirtyState);
final LinkedHashSet<MouseTrackerAnnotation> lastAnnotations = dirtyState.replaceAnnotations(nextAnnotations);
handleUpdatedDevice(dirtyState, lastAnnotations);
} }
assert(() {
_duringDeviceUpdate = false;
return true;
}());
if (mouseWasConnected != mouseIsConnected)
notifyListeners();
} }
// Dispatch callbacks related to a device after all necessary information // Dispatch callbacks related to a device after all necessary information
// has been collected. // has been collected.
// //
// This function should not change the provided states, and should not access // The `unhandledEvent` can be null. Other arguments must not be null.
// information that is not provided in parameters (hence being static).
static void _dispatchDeviceCallbacks({ static void _dispatchDeviceCallbacks({
@required LinkedHashSet<MouseTrackerAnnotation> lastAnnotations,
@required LinkedHashSet<MouseTrackerAnnotation> nextAnnotations, @required LinkedHashSet<MouseTrackerAnnotation> nextAnnotations,
@required _MouseState currentState, @required PointerEvent handledEvent,
@required PointerEvent unhandledEvent,
@required Set<MouseTrackerAnnotation> trackedAnnotations,
}) { }) {
assert(lastAnnotations != null);
assert(nextAnnotations != null);
// handledEvent can be null
assert(unhandledEvent != null);
assert(trackedAnnotations != null);
// Order is important for mouse event callbacks. The `findAnnotations` // Order is important for mouse event callbacks. The `findAnnotations`
// returns annotations in the visual order from front to back. We call // returns annotations in the visual order from front to back. We call
// it the "visual order", and the opposite one "reverse visual order". // it the "visual order", and the opposite one "reverse visual order".
// The algorithm here is explained in // The algorithm here is explained in
// https://github.com/flutter/flutter/issues/41420 // https://github.com/flutter/flutter/issues/41420
// The `nextAnnotations` is annotations that contains this device in the
// coming frame in visual order.
// Order is preserved with the help of [LinkedHashSet].
final PointerEvent mostRecentEvent = currentState.mostRecentEvent;
// The `lastAnnotations` is annotations that contains this device in the
// previous frame in visual order.
final LinkedHashSet<MouseTrackerAnnotation> lastAnnotations = currentState.lastAnnotations;
// Send exit events in visual order. // Send exit events in visual order.
final Iterable<MouseTrackerAnnotation> exitingAnnotations = final Iterable<MouseTrackerAnnotation> exitingAnnotations =
lastAnnotations.difference(nextAnnotations); lastAnnotations.difference(nextAnnotations);
for (final MouseTrackerAnnotation annotation in exitingAnnotations) { for (final MouseTrackerAnnotation annotation in exitingAnnotations) {
if (annotation.onExit != null) { final bool attached = trackedAnnotations.contains(annotation);
annotation.onExit(PointerExitEvent.fromMouseEvent(mostRecentEvent)); // Exit is not sent if annotation is no longer attached, because this
// trigger may cause exceptions and has safer alternatives. See
// [MouseRegion.onExit] for details.
if (annotation.onExit != null && attached) {
annotation.onExit(PointerExitEvent.fromMouseEvent(unhandledEvent));
} }
} }
...@@ -321,25 +460,61 @@ class MouseTracker extends ChangeNotifier { ...@@ -321,25 +460,61 @@ class MouseTracker extends ChangeNotifier {
final Iterable<MouseTrackerAnnotation> enteringAnnotations = final Iterable<MouseTrackerAnnotation> enteringAnnotations =
nextAnnotations.difference(lastAnnotations).toList().reversed; nextAnnotations.difference(lastAnnotations).toList().reversed;
for (final MouseTrackerAnnotation annotation in enteringAnnotations) { for (final MouseTrackerAnnotation annotation in enteringAnnotations) {
assert(trackedAnnotations.contains(annotation));
if (annotation.onEnter != null) { if (annotation.onEnter != null) {
annotation.onEnter(PointerEnterEvent.fromMouseEvent(mostRecentEvent)); annotation.onEnter(PointerEnterEvent.fromMouseEvent(unhandledEvent));
} }
} }
// Send hover events in reverse visual order. // Send hover events in reverse visual order.
// For now the order between the hover events is designed this way for no // For now the order between the hover events is designed this way for no
// solid reasons but to keep it aligned with enter events for simplicity. // solid reasons but to keep it aligned with enter events for simplicity.
if (mostRecentEvent is PointerHoverEvent) { if (unhandledEvent is PointerHoverEvent) {
final Iterable<MouseTrackerAnnotation> hoveringAnnotations = final Iterable<MouseTrackerAnnotation> hoveringAnnotations =
nextAnnotations.toList().reversed; nextAnnotations.toList().reversed;
for (final MouseTrackerAnnotation annotation in hoveringAnnotations) { for (final MouseTrackerAnnotation annotation in hoveringAnnotations) {
if (annotation.onHover != null) { // Deduplicate: Trigger hover if it's a newly hovered annotation
annotation.onHover(mostRecentEvent); // or the position has changed
assert(trackedAnnotations.contains(annotation));
if (!lastAnnotations.contains(annotation)
|| handledEvent is! PointerHoverEvent
|| handledEvent.position != unhandledEvent.position) {
if (annotation.onHover != null) {
annotation.onHover(unhandledEvent);
}
} }
} }
} }
} }
bool _hasScheduledPostFrameCheck = false;
/// Mark all devices as dirty, and schedule a callback that is executed in the
/// upcoming post-frame phase to check their updates.
///
/// Checking a device means to collect the annotations that the pointer
/// hovers, and triggers necessary callbacks accordingly.
///
/// Although the actual callback belongs to the scheduler's post-frame phase,
/// this method must be called in persistent callback phase to ensure that
/// the callback is scheduled after every frame, since every frame can change
/// the position of annotations. Typically the method is called by
/// [RendererBinding]'s drawing method.
void schedulePostFrameCheck() {
assert(_duringBuildPhase);
assert(!_duringDeviceUpdate);
if (!_hasScheduledPostFrameCheck) {
_hasScheduledPostFrameCheck = true;
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
assert(_hasScheduledPostFrameCheck);
_hasScheduledPostFrameCheck = false;
_updateAllDevices();
});
}
}
/// Whether or not a mouse is connected and has produced events.
bool get mouseIsConnected => _mouseStates.isNotEmpty;
/// Checks if the given [MouseTrackerAnnotation] is attached to this /// Checks if the given [MouseTrackerAnnotation] is attached to this
/// [MouseTracker]. /// [MouseTracker].
/// ///
...@@ -350,56 +525,52 @@ class MouseTracker extends ChangeNotifier { ...@@ -350,56 +525,52 @@ class MouseTracker extends ChangeNotifier {
return _trackedAnnotations.contains(annotation); return _trackedAnnotations.contains(annotation);
} }
/// Whether or not a mouse is connected and has produced events. /// Notify [MouseTracker] that a new [MouseTrackerAnnotation] has started to
bool get mouseIsConnected => _mouseStates.isNotEmpty;
/// Notify [MouseTracker] that a new mouse tracker annotation has started to
/// take effect. /// take effect.
/// ///
/// This should be called as soon as the layer that owns this annotation is /// This method is typically called by the [RenderObject] that owns an
/// added to the layer tree. /// annotation, as soon as the render object is added to the render tree.
///
/// {@template flutter.mouseTracker.attachAnnotation}
/// Render objects that call this method might want to schedule a frame as
/// well, typically by calling [RenderObject.markNeedsPaint], because this
/// method does not cause any immediate effect, since the state it changes is
/// used during a post-frame callback or when handling certain pointer events.
///
/// ### About annotation attachment
/// ///
/// This triggers [MouseTracker] to schedule a mouse position check during the /// It is the responsibility of the render object that owns the annotation to
/// post frame to see if this new annotation might trigger enter events. /// maintain the attachment of the annotation. Whether an annotation is
/// attached should be kept in sync with whether its owner object is mounted,
/// which is used in the following ways:
/// ///
/// The [MouseTracker] also uses this to track the number of attached /// * If a pointer enters an annotation, it is asserted that the annotation
/// annotations, and will skip mouse position checks if there is no /// is attached.
/// annotations attached. /// * If a pointer stops being contained by an annotation,
/// the exit event is triggered only if the annotation is still attached.
/// This is to prevent exceptions caused calling setState of a disposed
/// widget. See [MouseTrackerAnnotation.onExit] for more details.
/// * The [MouseTracker] also uses the attachment to track the number of
/// attached annotations, and will skip mouse position checks if there is no
/// annotations attached.
/// {@endtemplate}
/// * Attaching an annotation that has been attached will assert.
void attachAnnotation(MouseTrackerAnnotation annotation) { void attachAnnotation(MouseTrackerAnnotation annotation) {
// Schedule a check so that we test this new annotation to see if any mouse assert(!_duringDeviceUpdate);
// is currently inside its region. It has to happen after the frame is assert(!_trackedAnnotations.contains(annotation));
// complete so that the annotation layer has been added before the check.
_trackedAnnotations.add(annotation); _trackedAnnotations.add(annotation);
if (mouseIsConnected) {
_scheduleMousePositionCheck();
}
} }
/// Notify [MouseTracker] that a mouse tracker annotation that was previously /// Notify [MouseTracker] that a mouse tracker annotation that was previously
/// attached has stopped taking effect. /// attached has stopped taking effect.
/// ///
/// This should be called as soon as the layer that owns this annotation is /// This method is typically called by the [RenderObject] that owns an
/// removed from the layer tree. An assertion error will be thrown if the /// annotation, as soon as the render object is removed from the render tree.
/// associated layer is not removed and receives another mouse hit. /// {@macro flutter.mouseTracker.attachAnnotation}
/// /// * Detaching an annotation that has not been attached will assert.
/// This triggers [MouseTracker] to perform a mouse position check immediately
/// to see if this annotation removal triggers any exit events.
///
/// The [MouseTracker] also uses this to track the number of attached
/// annotations, and will skip mouse position checks if there is no
/// annotations attached.
void detachAnnotation(MouseTrackerAnnotation annotation) { void detachAnnotation(MouseTrackerAnnotation annotation) {
_mouseStates.forEach((int device, _MouseState mouseState) { assert(!_duringDeviceUpdate);
if (mouseState.lastAnnotations.contains(annotation)) { assert(_trackedAnnotations.contains(annotation));
if (annotation.onExit != null) {
final PointerEvent event = mouseState.mostRecentEvent;
assert(event != null);
annotation.onExit(PointerExitEvent.fromMouseEvent(event));
}
mouseState.lastAnnotations.remove(annotation);
}
});
_trackedAnnotations.remove(annotation); _trackedAnnotations.remove(annotation);
} }
} }
...@@ -281,6 +281,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture ...@@ -281,6 +281,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture
void _handlePersistentFrameCallback(Duration timeStamp) { void _handlePersistentFrameCallback(Duration timeStamp) {
drawFrame(); drawFrame();
_mouseTracker.schedulePostFrameCheck();
} }
/// Pump the rendering pipeline to generate a frame. /// Pump the rendering pipeline to generate a frame.
......
...@@ -2685,7 +2685,8 @@ class RenderMouseRegion extends RenderProxyBox { ...@@ -2685,7 +2685,8 @@ class RenderMouseRegion extends RenderProxyBox {
_onHover(event); _onHover(event);
} }
/// Called when a pointer leaves the region (with or without buttons pressed). /// Called when a pointer leaves the region (with or without buttons pressed)
/// and the annotation is still attached.
PointerExitEventListener get onExit => _onExit; PointerExitEventListener get onExit => _onExit;
set onExit(PointerExitEventListener value) { set onExit(PointerExitEventListener value) {
if (_onExit != value) { if (_onExit != value) {
......
...@@ -2,9 +2,8 @@ ...@@ -2,9 +2,8 @@
// 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:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -550,10 +549,15 @@ class FocusableActionDetector extends StatefulWidget { ...@@ -550,10 +549,15 @@ class FocusableActionDetector extends StatefulWidget {
/// {@macro flutter.widgets.shortcuts.shortcuts} /// {@macro flutter.widgets.shortcuts.shortcuts}
final Map<LogicalKeySet, Intent> shortcuts; final Map<LogicalKeySet, Intent> shortcuts;
/// A function that will be called when the focus highlight should be shown or hidden. /// A function that will be called when the focus highlight should be shown or
/// hidden.
///
/// This method is not triggered at the unmount of the widget.
final ValueChanged<bool> onShowFocusHighlight; final ValueChanged<bool> onShowFocusHighlight;
/// A function that will be called when the hover highlight should be shown or hidden. /// A function that will be called when the hover highlight should be shown or hidden.
///
/// This method is not triggered at the unmount of the widget.
final ValueChanged<bool> onShowHoverHighlight; final ValueChanged<bool> onShowHoverHighlight;
/// A function that will be called when the focus changes. /// A function that will be called when the focus changes.
...@@ -574,7 +578,9 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -574,7 +578,9 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_updateHighlightMode(FocusManager.instance.highlightMode); SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
_updateHighlightMode(FocusManager.instance.highlightMode);
});
FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange); FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
...@@ -586,23 +592,22 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -586,23 +592,22 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
bool _canShowHighlight = false; bool _canShowHighlight = false;
void _updateHighlightMode(FocusHighlightMode mode) { void _updateHighlightMode(FocusHighlightMode mode) {
final bool couldShowHighlight = _canShowHighlight; _mayTriggerCallback(task: () {
switch (FocusManager.instance.highlightMode) { switch (FocusManager.instance.highlightMode) {
case FocusHighlightMode.touch: case FocusHighlightMode.touch:
_canShowHighlight = false; _canShowHighlight = false;
break; break;
case FocusHighlightMode.traditional: case FocusHighlightMode.traditional:
_canShowHighlight = true; _canShowHighlight = true;
break; break;
} }
if (couldShowHighlight != _canShowHighlight) { });
_handleShowFocusHighlight();
_handleShowHoverHighlight();
}
} }
/// Have to have this separate from the _updateHighlightMode because it gets // Have to have this separate from the _updateHighlightMode because it gets
/// called in initState, where things aren't mounted yet. // called in initState, where things aren't mounted yet.
// Since this method is a highlight mode listener, it is only called
// immediately following pointer events.
void _handleFocusHighlightModeChange(FocusHighlightMode mode) { void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
if (!mounted) { if (!mounted) {
return; return;
...@@ -614,36 +619,67 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> { ...@@ -614,36 +619,67 @@ class _FocusableActionDetectorState extends State<FocusableActionDetector> {
void _handleMouseEnter(PointerEnterEvent event) { void _handleMouseEnter(PointerEnterEvent event) {
assert(widget.onShowHoverHighlight != null); assert(widget.onShowHoverHighlight != null);
if (!_hovering) { if (!_hovering) {
// TODO(gspencergoog): remove scheduleMicrotask once MouseRegion event timing has changed. _mayTriggerCallback(task: () {
scheduleMicrotask(() { setState(() { _hovering = true; _handleShowHoverHighlight(); }); }); _hovering = true;
});
} }
} }
void _handleMouseExit(PointerExitEvent event) { void _handleMouseExit(PointerExitEvent event) {
assert(widget.onShowHoverHighlight != null); assert(widget.onShowHoverHighlight != null);
if (_hovering) { if (_hovering) {
// TODO(gspencergoog): remove scheduleMicrotask once MouseRegion event timing has changed. _mayTriggerCallback(task: () {
scheduleMicrotask(() { setState(() { _hovering = false; _handleShowHoverHighlight(); }); }); _hovering = false;
});
} }
} }
bool _focused = false; bool _focused = false;
void _handleFocusChange(bool focused) { void _handleFocusChange(bool focused) {
if (_focused != focused) { if (_focused != focused) {
setState(() { _mayTriggerCallback(task: () {
_focused = focused; _focused = focused;
_handleShowFocusHighlight();
widget.onFocusChange?.call(_focused);
}); });
widget.onFocusChange?.call(_focused);
} }
} }
void _handleShowHoverHighlight() { // Record old states, do `task` if not null, then compare old states with the
widget.onShowHoverHighlight?.call(_hovering && widget.enabled && _canShowHighlight); // new states, and trigger callbacks if necessary.
//
// The old states are collected from `oldWidget` if it is provided, or the
// current widget (before doing `task`) otherwise. The new states are always
// collected from the current widget.
void _mayTriggerCallback({VoidCallback task, FocusableActionDetector oldWidget}) {
bool shouldShowHoverHighlight(FocusableActionDetector target) {
return _hovering && target.enabled && _canShowHighlight;
}
bool shouldShowFocusHighlight(FocusableActionDetector target) {
return _focused && target.enabled && _canShowHighlight;
}
assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
final FocusableActionDetector oldTarget = oldWidget ?? widget;
final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget);
final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget);
if (task != null)
task();
final bool doShowHoverHighlight = shouldShowHoverHighlight(widget);
final bool doShowFocusHighlight = shouldShowFocusHighlight(widget);
if (didShowFocusHighlight != doShowFocusHighlight)
widget.onShowFocusHighlight?.call(doShowFocusHighlight);
if (didShowHoverHighlight != doShowHoverHighlight)
widget.onShowHoverHighlight?.call(doShowHoverHighlight);
} }
void _handleShowFocusHighlight() { @override
widget.onShowFocusHighlight?.call(_focused && widget.enabled && _canShowHighlight); void didUpdateWidget(FocusableActionDetector oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.enabled != oldWidget.enabled) {
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
_mayTriggerCallback(oldWidget: oldWidget);
});
}
} }
@override @override
......
...@@ -5842,18 +5842,71 @@ class MouseRegion extends SingleChildRenderObjectWidget { ...@@ -5842,18 +5842,71 @@ class MouseRegion extends SingleChildRenderObjectWidget {
}) : assert(opaque != null), }) : assert(opaque != null),
super(key: key, child: child); super(key: key, child: child);
/// Called when a mouse pointer (with or without buttons pressed) enters the /// Called when a mouse pointer, with or without buttons pressed, has
/// region defined by this widget, or when the widget appears under the /// entered this widget.
/// pointer. ///
/// This callback is triggered when the pointer has started to be contained
/// by the region of this widget. More specifically, the callback is triggered
/// by the following cases:
///
/// * This widget has appeared under a pointer.
/// * This widget has moved to under a pointer.
/// * A new pointer has been added to somewhere within this widget.
/// * An existing pointer has moved into this widget.
///
/// This callback is not always matched by an [onExit]. If the [MouseRegion]
/// is unmounted while being hovered by a pointer, the [onExit] of the widget
/// callback will never called, despite the earlier call of [onEnter]. For
/// more details, see [onExit].
///
/// See also:
///
/// * [onExit], which is triggered when a mouse pointer exits the region.
/// * [MouseTrackerAnnotation.onEnter], which is how this callback is
/// internally implemented.
final PointerEnterEventListener onEnter; final PointerEnterEventListener onEnter;
/// Called when a mouse pointer (with or without buttons pressed) changes /// Called when a mouse pointer changes position without buttons pressed, and
/// position, and the new position is within the region defined by this widget. /// the new position is within the region defined by this widget.
///
/// This callback is triggered when:
///
/// * An annotation that did not contain the pointer has moved to under a
/// pointer that has no buttons pressed.
/// * A pointer has moved onto, or moved within an annotation without buttons
/// pressed.
///
/// This callback is not triggered when
///
/// * An annotation that is containing the pointer has moved, and still
/// contains the pointer.
final PointerHoverEventListener onHover; final PointerHoverEventListener onHover;
/// Called when a mouse pointer (with or without buttons pressed) leaves the /// Called when a mouse pointer, with or without buttons pressed, has exited
/// region defined by this widget, or when the widget disappears from under /// this widget when the widget is still mounted.
/// the pointer. ///
/// This callback is triggered when the pointer has stopped to be contained
/// by the region of this widget, except when it's caused by the removal of
/// this widget. More specifically, the callback is triggered by
/// the following cases:
///
/// * This widget, which used to contain a pointer, has moved away.
/// * A pointer that used to be within this widget has been removed.
/// * A pointer that used to be within this widget has moved away.
///
/// And is __not__ triggered by the following case,
///
/// * This widget, which used to contain a pointer, has disappeared.
///
/// The last case is the only case when [onExit] does not match an earlier
/// [onEnter].
/// {@macro flutter.mouseTracker.onExit}
///
/// See also:
///
/// * [onEnter], which is triggered when a mouse pointer enters the region.
/// * [MouseTrackerAnnotation.onExit], which is how this callback is
/// internally implemented.
final PointerExitEventListener onExit; final PointerExitEventListener onExit;
/// Whether this widget should prevent other [MouseRegion]s visually behind it /// Whether this widget should prevent other [MouseRegion]s visually behind it
......
...@@ -2596,10 +2596,11 @@ void main() { ...@@ -2596,10 +2596,11 @@ void main() {
// Double tap at the end of text. // Double tap at the end of text.
final Offset textEndPos = textOffsetToPosition(tester, 11); // Position at the end of text. final Offset textEndPos = textOffsetToPosition(tester, 11); // Position at the end of text.
TestGesture gesture = await tester.startGesture( final TestGesture gesture = await tester.startGesture(
textEndPos, textEndPos,
kind: PointerDeviceKind.mouse, kind: PointerDeviceKind.mouse,
); );
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50));
await gesture.up(); await gesture.up();
await tester.pump(); await tester.pump();
...@@ -2614,11 +2615,7 @@ void main() { ...@@ -2614,11 +2615,7 @@ void main() {
final Offset hPos = textOffsetToPosition(tester, 9); // Position of 'h'. final Offset hPos = textOffsetToPosition(tester, 9); // Position of 'h'.
// Double tap on 'h' to select 'ghi'. // Double tap on 'h' to select 'ghi'.
gesture = await tester.startGesture( await gesture.down(hPos);
hPos,
kind: PointerDeviceKind.mouse,
);
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50));
await gesture.up(); await gesture.up();
await tester.pump(); await tester.pump();
......
...@@ -24,9 +24,24 @@ class _TestGestureFlutterBinding extends BindingBase ...@@ -24,9 +24,24 @@ class _TestGestureFlutterBinding extends BindingBase
postFrameCallbacks = <void Function(Duration)>[]; postFrameCallbacks = <void Function(Duration)>[];
} }
SchedulerPhase _overridePhase;
@override
SchedulerPhase get schedulerPhase => _overridePhase ?? super.schedulerPhase;
// Mannually schedule a postframe check.
//
// In real apps this is done by the renderer binding, but in tests we have to
// bypass the phase assertion of [MouseTracker.schedulePostFrameCheck].
void scheduleMouseTrackerPostFrameCheck() {
final SchedulerPhase lastPhase = _overridePhase;
_overridePhase = SchedulerPhase.persistentCallbacks;
mouseTracker.schedulePostFrameCheck();
_overridePhase = lastPhase;
}
List<void Function(Duration)> postFrameCallbacks; List<void Function(Duration)> postFrameCallbacks;
// Proxy post-frame callbacks // Proxy post-frame callbacks.
@override @override
void addPostFrameCallback(void Function(Duration) callback) { void addPostFrameCallback(void Function(Duration) callback) {
postFrameCallbacks.add(callback); postFrameCallbacks.add(callback);
...@@ -108,19 +123,18 @@ void main() { ...@@ -108,19 +123,18 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isFalse); expect(_mouseTracker.mouseIsConnected, isFalse);
// Enter // Pointer enters the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 0.0)), _pointerData(PointerChange.add, const Offset(0.0, 0.0)),
])); ]));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(1.0, 0.0)), const PointerEnterEvent(position: Offset(0.0, 0.0)),
const PointerHoverEvent(position: Offset(1.0, 0.0)),
])); ]));
expect(listenerLogs, <bool>[true]); expect(listenerLogs, <bool>[true]);
events.clear(); events.clear();
listenerLogs.clear(); listenerLogs.clear();
// Hover // Pointer hovers the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 101.0)), _pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
])); ]));
...@@ -131,7 +145,7 @@ void main() { ...@@ -131,7 +145,7 @@ void main() {
expect(listenerLogs, <bool>[]); expect(listenerLogs, <bool>[]);
events.clear(); events.clear();
// Remove // Pointer is removed while on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.remove, const Offset(1.0, 101.0)), _pointerData(PointerChange.remove, const Offset(1.0, 101.0)),
])); ]));
...@@ -142,13 +156,12 @@ void main() { ...@@ -142,13 +156,12 @@ void main() {
events.clear(); events.clear();
listenerLogs.clear(); listenerLogs.clear();
// Add again // Pointer is added on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 301.0)), _pointerData(PointerChange.add, const Offset(0.0, 301.0)),
])); ]));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(1.0, 301.0)), const PointerEnterEvent(position: Offset(0.0, 301.0)),
const PointerHoverEvent(position: Offset(1.0, 301.0)),
])); ]));
expect(listenerLogs, <bool>[true]); expect(listenerLogs, <bool>[true]);
events.clear(); events.clear();
...@@ -161,29 +174,31 @@ void main() { ...@@ -161,29 +174,31 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isFalse); expect(_mouseTracker.mouseIsConnected, isFalse);
// First mouse // The first mouse is added on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 0.0)),
_pointerData(PointerChange.hover, const Offset(0.0, 1.0)), _pointerData(PointerChange.hover, const Offset(0.0, 1.0)),
])); ]));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(0.0, 1.0)), const PointerEnterEvent(position: Offset(0.0, 0.0)),
const PointerHoverEvent(position: Offset(0.0, 1.0)), const PointerHoverEvent(position: Offset(0.0, 1.0)),
])); ]));
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Second mouse // The second mouse is added on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 401.0), device: 1),
_pointerData(PointerChange.hover, const Offset(1.0, 401.0), device: 1), _pointerData(PointerChange.hover, const Offset(1.0, 401.0), device: 1),
])); ]));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(1.0, 401.0), device: 1), const PointerEnterEvent(position: Offset(0.0, 401.0), device: 1),
const PointerHoverEvent(position: Offset(1.0, 401.0), device: 1), const PointerHoverEvent(position: Offset(1.0, 401.0), device: 1),
])); ]));
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// First mouse hover // The first mouse moves on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 101.0)), _pointerData(PointerChange.hover, const Offset(0.0, 101.0)),
])); ]));
...@@ -193,7 +208,7 @@ void main() { ...@@ -193,7 +208,7 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Second mouse hover // The second mouse moves on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 501.0), device: 1), _pointerData(PointerChange.hover, const Offset(1.0, 501.0), device: 1),
])); ]));
...@@ -203,7 +218,7 @@ void main() { ...@@ -203,7 +218,7 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// First mouse remove // The first mouse is removed while on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.remove, const Offset(0.0, 101.0)), _pointerData(PointerChange.remove, const Offset(0.0, 101.0)),
])); ]));
...@@ -213,7 +228,7 @@ void main() { ...@@ -213,7 +228,7 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Second mouse hover // The second mouse still moves on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 601.0), device: 1), _pointerData(PointerChange.hover, const Offset(1.0, 601.0), device: 1),
])); ]));
...@@ -223,7 +238,7 @@ void main() { ...@@ -223,7 +238,7 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Second mouse remove // The second mouse is removed while on the annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.remove, const Offset(1.0, 601.0), device: 1), _pointerData(PointerChange.remove, const Offset(1.0, 601.0), device: 1),
])); ]));
...@@ -234,8 +249,12 @@ void main() { ...@@ -234,8 +249,12 @@ void main() {
events.clear(); events.clear();
}); });
test('should handle detaching during the callback of exiting', () { test('should not flip out when attaching and detaching during callbacks', () {
bool isInHitRegion; // It is a common pattern that a callback that listens to the changes of
// [MouseTracker.mouseIsConnected] triggers annotation attaching and
// detaching. This test ensures that no exceptions are thrown for this
// pattern.
bool isInHitRegion = false;
final List<PointerEvent> events = <PointerEvent>[]; final List<PointerEvent> events = <PointerEvent>[];
final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
onEnter: (PointerEnterEvent event) => events.add(event), onEnter: (PointerEnterEvent event) => events.add(event),
...@@ -248,21 +267,39 @@ void main() { ...@@ -248,21 +267,39 @@ void main() {
} }
}); });
isInHitRegion = true; void mockMarkNeedsPaint() {
_mouseTracker.attachAnnotation(annotation); _binding.scheduleMouseTrackerPostFrameCheck();
}
final VoidCallback firstListener = () {
if (!_mouseTracker.mouseIsConnected) {
_mouseTracker.detachAnnotation(annotation);
isInHitRegion = false;
} else {
_mouseTracker.attachAnnotation(annotation);
isInHitRegion = true;
}
mockMarkNeedsPaint();
};
_mouseTracker.addListener(firstListener);
// Enter // The pointer is added onto the annotation, triggering attaching callback.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 0.0)), _pointerData(PointerChange.add, const Offset(1.0, 0.0)),
]));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
])); ]));
expect(_mouseTracker.mouseIsConnected, isTrue);
_binding.flushPostFrameCallbacks(Duration.zero);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(1.0, 0.0)), const PointerEnterEvent(position: Offset(1.0, 0.0)),
const PointerHoverEvent(position: Offset(1.0, 0.0)),
])); ]));
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Remove // The pointer is removed while on the annotation, triggering dettaching callback.
_mouseTracker.removeListener(firstListener);
_mouseTracker.addListener(() { _mouseTracker.addListener(() {
if (!_mouseTracker.mouseIsConnected) { if (!_mouseTracker.mouseIsConnected) {
_mouseTracker.detachAnnotation(annotation); _mouseTracker.detachAnnotation(annotation);
...@@ -309,9 +346,9 @@ void main() { ...@@ -309,9 +346,9 @@ void main() {
events.clear(); events.clear();
}); });
test('should detect enter or exit when annotations are attached or detached on the pointer', () { test('should correctly handle when the annotation is attached or detached on the pointer', () {
bool isInHitRegion; bool isInHitRegion;
final List<PointerEvent> events = <PointerEvent>[]; final List<Object> events = <PointerEvent>[];
final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
onEnter: (PointerEnterEvent event) => events.add(event), onEnter: (PointerEnterEvent event) => events.add(event),
onHover: (PointerHoverEvent event) => events.add(event), onHover: (PointerHoverEvent event) => events.add(event),
...@@ -325,7 +362,7 @@ void main() { ...@@ -325,7 +362,7 @@ void main() {
isInHitRegion = false; isInHitRegion = false;
// Connect a mouse when there is no annotation // Connect a mouse when there is no annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 100.0)), _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
])); ]));
...@@ -334,12 +371,14 @@ void main() { ...@@ -334,12 +371,14 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Attach an annotation // Attaching an annotation should trigger Enter event.
isInHitRegion = true; isInHitRegion = true;
_mouseTracker.attachAnnotation(annotation); _mouseTracker.attachAnnotation(annotation);
// No callbacks are triggered immediately
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
])); ]));
expect(_binding.postFrameCallbacks, hasLength(0));
_binding.scheduleMouseTrackerPostFrameCheck();
expect(_binding.postFrameCallbacks, hasLength(1)); expect(_binding.postFrameCallbacks, hasLength(1));
_binding.flushPostFrameCallbacks(Duration.zero); _binding.flushPostFrameCallbacks(Duration.zero);
...@@ -348,16 +387,161 @@ void main() { ...@@ -348,16 +387,161 @@ void main() {
])); ]));
events.clear(); events.clear();
// Detach the annotation // Detaching an annotation should not trigger events.
isInHitRegion = false; isInHitRegion = false;
_mouseTracker.detachAnnotation(annotation); _mouseTracker.detachAnnotation(annotation);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
]));
expect(_binding.postFrameCallbacks, hasLength(0));
_binding.scheduleMouseTrackerPostFrameCheck();
expect(_binding.postFrameCallbacks, hasLength(1));
_binding.flushPostFrameCallbacks(Duration.zero);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
]));
expect(_binding.postFrameCallbacks, hasLength(0));
});
test('should correctly handle when the annotation moves in or out of the pointer', () {
bool isInHitRegion;
final List<Object> events = <PointerEvent>[];
final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
onEnter: (PointerEnterEvent event) => events.add(event),
onHover: (PointerHoverEvent event) => events.add(event),
onExit: (PointerExitEvent event) => events.add(event),
);
_setUpMouseAnnotationFinder((Offset position) sync* {
if (isInHitRegion) {
yield annotation;
}
});
// Start with an annotation attached.
_mouseTracker.attachAnnotation(annotation);
isInHitRegion = false;
// Connect a mouse.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 100.0)),
]));
events.clear();
// During a frame, the annotation moves into the pointer.
isInHitRegion = true;
expect(_binding.postFrameCallbacks, hasLength(0));
_binding.scheduleMouseTrackerPostFrameCheck();
expect(_binding.postFrameCallbacks, hasLength(1));
_binding.flushPostFrameCallbacks(Duration.zero);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(0.0, 100.0)),
]));
events.clear();
expect(_binding.postFrameCallbacks, hasLength(0));
// During a frame, the annotation moves out of the pointer.
isInHitRegion = false;
expect(_binding.postFrameCallbacks, hasLength(0));
_binding.scheduleMouseTrackerPostFrameCheck();
expect(_binding.postFrameCallbacks, hasLength(1));
_binding.flushPostFrameCallbacks(Duration.zero);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerExitEvent(position: Offset(0.0, 100.0)), const PointerExitEvent(position: Offset(0.0, 100.0)),
])); ]));
expect(_binding.postFrameCallbacks, hasLength(0)); expect(_binding.postFrameCallbacks, hasLength(0));
}); });
test('should correctly stay quiet when annotations are attached or detached not on the pointer', () { test('should correctly handle when the pointer is added or removed on the annotation', () {
bool isInHitRegion;
final List<Object> events = <PointerEvent>[];
final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
onEnter: (PointerEnterEvent event) => events.add(event),
onHover: (PointerHoverEvent event) => events.add(event),
onExit: (PointerExitEvent event) => events.add(event),
);
_setUpMouseAnnotationFinder((Offset position) sync* {
if (isInHitRegion) {
yield annotation;
}
});
// Start with an annotation attached.
_mouseTracker.attachAnnotation(annotation);
isInHitRegion = false;
// Connect a mouse in the region. Should trigger Enter.
isInHitRegion = true;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 100.0)),
]));
expect(_binding.postFrameCallbacks, hasLength(0));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(0.0, 100.0)),
]));
events.clear();
// Disconnect the mouse from the region. Should trigger Exit.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.remove, const Offset(0.0, 100.0)),
]));
expect(_binding.postFrameCallbacks, hasLength(0));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerExitEvent(position: Offset(0.0, 100.0)),
]));
});
test('should correctly handle when the pointer moves in or out of the annotation', () {
bool isInHitRegion;
final List<Object> events = <PointerEvent>[];
final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
onEnter: (PointerEnterEvent event) => events.add(event),
onHover: (PointerHoverEvent event) => events.add(event),
onExit: (PointerExitEvent event) => events.add(event),
);
_setUpMouseAnnotationFinder((Offset position) sync* {
if (isInHitRegion) {
yield annotation;
}
});
// Start with annotation and mouse attached.
_mouseTracker.attachAnnotation(annotation);
isInHitRegion = false;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(200.0, 100.0)),
]));
expect(_binding.postFrameCallbacks, hasLength(0));
events.clear();
// Moves the mouse into the region. Should trigger Enter.
isInHitRegion = true;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 100.0)),
]));
expect(_binding.postFrameCallbacks, hasLength(0));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerEnterEvent(position: Offset(0.0, 100.0)),
const PointerHoverEvent(position: Offset(0.0, 100.0)),
]));
events.clear();
// Moves the mouse out of the region. Should trigger Exit.
isInHitRegion = false;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(200.0, 100.0)),
]));
expect(_binding.postFrameCallbacks, hasLength(0));
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
const PointerExitEvent(position: Offset(200.0, 100.0)),
]));
});
test('should correctly handle when annotation is attached or detached while not containing the pointer', () {
final List<PointerEvent> events = <PointerEvent>[]; final List<PointerEvent> events = <PointerEvent>[];
final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( final MouseTrackerAnnotation annotation = MouseTrackerAnnotation(
onEnter: (PointerEnterEvent event) => events.add(event), onEnter: (PointerEnterEvent event) => events.add(event),
...@@ -365,10 +549,10 @@ void main() { ...@@ -365,10 +549,10 @@ void main() {
onExit: (PointerExitEvent event) => events.add(event), onExit: (PointerExitEvent event) => events.add(event),
); );
_setUpMouseAnnotationFinder((Offset position) sync* { _setUpMouseAnnotationFinder((Offset position) sync* {
// This annotation is never in the region // This annotation is never in the region.
}); });
// Connect a mouse when there is no annotation // Connect a mouse when there is no annotation.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 100.0)), _pointerData(PointerChange.add, const Offset(0.0, 100.0)),
])); ]));
...@@ -377,10 +561,13 @@ void main() { ...@@ -377,10 +561,13 @@ void main() {
expect(_mouseTracker.mouseIsConnected, isTrue); expect(_mouseTracker.mouseIsConnected, isTrue);
events.clear(); events.clear();
// Attach an annotation out of region // Attaching an annotation should not trigger events.
_mouseTracker.attachAnnotation(annotation); _mouseTracker.attachAnnotation(annotation);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
])); ]));
expect(_binding.postFrameCallbacks, hasLength(0));
_binding.scheduleMouseTrackerPostFrameCheck();
expect(_binding.postFrameCallbacks, hasLength(1)); expect(_binding.postFrameCallbacks, hasLength(1));
_binding.flushPostFrameCallbacks(Duration.zero); _binding.flushPostFrameCallbacks(Duration.zero);
...@@ -388,12 +575,15 @@ void main() { ...@@ -388,12 +575,15 @@ void main() {
])); ]));
events.clear(); events.clear();
// Detach the annotation // Detaching an annotation should not trigger events.
_mouseTracker.detachAnnotation(annotation); _mouseTracker.detachAnnotation(annotation);
expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[ expect(events, _equalToEventsOnCriticalFields(<PointerEvent>[
])); ]));
expect(_binding.postFrameCallbacks, hasLength(0)); expect(_binding.postFrameCallbacks, hasLength(0));
_binding.scheduleMouseTrackerPostFrameCheck();
expect(_binding.postFrameCallbacks, hasLength(1));
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.remove, const Offset(0.0, 100.0)), _pointerData(PointerChange.remove, const Offset(0.0, 100.0)),
])); ]));
...@@ -418,6 +608,7 @@ void main() { ...@@ -418,6 +608,7 @@ void main() {
}); });
final ui.PointerDataPacket packet = ui.PointerDataPacket(data: <ui.PointerData>[ final ui.PointerDataPacket packet = ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 101.0)),
_pointerData(PointerChange.hover, const Offset(1.0, 101.0)), _pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
]); ]);
...@@ -429,7 +620,7 @@ void main() { ...@@ -429,7 +620,7 @@ void main() {
_mouseTracker.detachAnnotation(annotation2); _mouseTracker.detachAnnotation(annotation2);
isInHitRegionTwo = false; isInHitRegionTwo = false;
// Passes if no errors are thrown // Passes if no errors are thrown.
}); });
test('should not call annotationFinder when no annotations are attached', () { test('should not call annotationFinder when no annotations are attached', () {
...@@ -439,22 +630,19 @@ void main() { ...@@ -439,22 +630,19 @@ void main() {
int finderCalled = 0; int finderCalled = 0;
_setUpMouseAnnotationFinder((Offset position) sync* { _setUpMouseAnnotationFinder((Offset position) sync* {
finderCalled++; finderCalled++;
// This annotation is never in the region // This annotation is never in the region.
}); });
// When no annotations are attached, hovering should not call finder. // When no annotations are attached, hovering should not call finder.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 101.0)), _pointerData(PointerChange.add, const Offset(0.0, 101.0)),
])); ]));
expect(finderCalled, 0); expect(finderCalled, 0);
// Attaching should call finder during the post frame. // Attaching should not call finder.
_mouseTracker.attachAnnotation(annotation); _mouseTracker.attachAnnotation(annotation);
expect(finderCalled, 0);
_binding.flushPostFrameCallbacks(Duration.zero); _binding.flushPostFrameCallbacks(Duration.zero);
expect(finderCalled, 1); expect(finderCalled, 0);
finderCalled = 0;
// When annotations are attached, hovering should call finder. // When annotations are attached, hovering should call finder.
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
...@@ -463,9 +651,9 @@ void main() { ...@@ -463,9 +651,9 @@ void main() {
expect(finderCalled, 1); expect(finderCalled, 1);
finderCalled = 0; finderCalled = 0;
// Detaching an annotation should not call finder (because only history // Detaching an annotation should not call finder.
// records are needed).
_mouseTracker.detachAnnotation(annotation); _mouseTracker.detachAnnotation(annotation);
_binding.flushPostFrameCallbacks(Duration.zero);
expect(finderCalled, 0); expect(finderCalled, 0);
// When all annotations are detached, hovering should not call finder. // When all annotations are detached, hovering should not call finder.
...@@ -498,7 +686,7 @@ void main() { ...@@ -498,7 +686,7 @@ void main() {
onHover: (PointerHoverEvent event) => logs.add('hoverB'), onHover: (PointerHoverEvent event) => logs.add('hoverB'),
); );
_setUpMouseAnnotationFinder((Offset position) sync* { _setUpMouseAnnotationFinder((Offset position) sync* {
// Children's annotations come before parents' // Children's annotations come before parents'.
if (isInB) { if (isInB) {
yield annotationB; yield annotationB;
yield annotationA; yield annotationA;
...@@ -507,14 +695,14 @@ void main() { ...@@ -507,14 +695,14 @@ void main() {
_mouseTracker.attachAnnotation(annotationA); _mouseTracker.attachAnnotation(annotationA);
_mouseTracker.attachAnnotation(annotationB); _mouseTracker.attachAnnotation(annotationB);
// Starts out of A // Starts out of A.
isInB = false; isInB = false;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 1.0)), _pointerData(PointerChange.add, const Offset(0.0, 1.0)),
])); ]));
expect(logs, <String>[]); expect(logs, <String>[]);
// Moves into B within one frame // Moves into B within one frame.
isInB = true; isInB = true;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 10.0)), _pointerData(PointerChange.hover, const Offset(0.0, 10.0)),
...@@ -522,7 +710,7 @@ void main() { ...@@ -522,7 +710,7 @@ void main() {
expect(logs, <String>['enterA', 'enterB', 'hoverA', 'hoverB']); expect(logs, <String>['enterA', 'enterB', 'hoverA', 'hoverB']);
logs.clear(); logs.clear();
// Moves out of A within one frame // Moves out of A within one frame.
isInB = false; isInB = false;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 20.0)), _pointerData(PointerChange.hover, const Offset(0.0, 20.0)),
...@@ -562,16 +750,16 @@ void main() { ...@@ -562,16 +750,16 @@ void main() {
_mouseTracker.attachAnnotation(annotationA); _mouseTracker.attachAnnotation(annotationA);
_mouseTracker.attachAnnotation(annotationB); _mouseTracker.attachAnnotation(annotationB);
// Starts within A // Starts within A.
isInA = true; isInA = true;
isInB = false; isInB = false;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(0.0, 1.0)), _pointerData(PointerChange.add, const Offset(0.0, 1.0)),
])); ]));
expect(logs, <String>['enterA', 'hoverA']); expect(logs, <String>['enterA']);
logs.clear(); logs.clear();
// Moves into B within one frame // Moves into B within one frame.
isInA = false; isInA = false;
isInB = true; isInB = true;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
...@@ -580,7 +768,7 @@ void main() { ...@@ -580,7 +768,7 @@ void main() {
expect(logs, <String>['exitA', 'enterB', 'hoverB']); expect(logs, <String>['exitA', 'enterB', 'hoverB']);
logs.clear(); logs.clear();
// Moves into A within one frame // Moves into A within one frame.
isInA = true; isInA = true;
isInB = false; isInB = false;
ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[ ui.window.onPointerDataPacket(ui.PointerDataPacket(data: <ui.PointerData>[
......
...@@ -385,6 +385,7 @@ void main() { ...@@ -385,6 +385,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isTrue); expect(focusNode.hasPrimaryFocus, isTrue);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byKey(childKey))); await gesture.moveTo(tester.getCenter(find.byKey(childKey)));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
......
...@@ -522,4 +522,3 @@ void main() { ...@@ -522,4 +522,3 @@ void main() {
}); });
} }
...@@ -744,6 +744,7 @@ void main() { ...@@ -744,6 +744,7 @@ void main() {
// Start hovering // Start hovering
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byType(Switch))); await gesture.moveTo(tester.getCenter(find.byType(Switch)));
......
...@@ -82,10 +82,9 @@ void main() { ...@@ -82,10 +82,9 @@ void main() {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
final List<String> logs = <String>[]; final List<String> logs = <String>[];
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
// Start out of hoverTarget // Start out of hoverTarget
await gesture.moveTo(const Offset(100, 100)); await gesture.addPointer(location: const Offset(100, 100));
addTearDown(gesture.removePointer);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
......
...@@ -145,7 +145,7 @@ void main() { ...@@ -145,7 +145,7 @@ void main() {
expect(exit, isNotNull); expect(exit, isNotNull);
expect(exit.position, equals(const Offset(1.0, 1.0))); expect(exit.position, equals(const Offset(1.0, 1.0)));
}); });
testWidgets('detects pointer exit when widget disappears', (WidgetTester tester) async { testWidgets('does not detect pointer exit when widget disappears', (WidgetTester tester) async {
PointerEnterEvent enter; PointerEnterEvent enter;
PointerHoverEvent move; PointerHoverEvent move;
PointerExitEvent exit; PointerExitEvent exit;
...@@ -177,8 +177,7 @@ void main() { ...@@ -177,8 +177,7 @@ void main() {
height: 100.0, height: 100.0,
), ),
)); ));
expect(exit, isNotNull); expect(exit, isNull);
expect(exit.position, equals(const Offset(400.0, 300.0)));
expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse); expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse);
}); });
testWidgets('Hover works with nested listeners', (WidgetTester tester) async { testWidgets('Hover works with nested listeners', (WidgetTester tester) async {
...@@ -529,14 +528,15 @@ void main() { ...@@ -529,14 +528,15 @@ void main() {
); );
await tester.pump(); await tester.pump();
expect(HoverClientState.numEntries, equals(1)); expect(HoverClientState.numEntries, equals(1));
expect(HoverClientState.numExits, equals(1)); // Unmounting a MouseRegion doesn't trigger onExit
expect(HoverClientState.numExits, equals(0));
await tester.pumpWidget( await tester.pumpWidget(
const Center(child: HoverFeedback()), const Center(child: HoverFeedback()),
); );
await tester.pump(); await tester.pump();
expect(HoverClientState.numEntries, equals(2)); expect(HoverClientState.numEntries, equals(2));
expect(HoverClientState.numExits, equals(1)); expect(HoverClientState.numExits, equals(0));
}); });
testWidgets("Listener activate/deactivate don't duplicate annotations", (WidgetTester tester) async { testWidgets("Listener activate/deactivate don't duplicate annotations", (WidgetTester tester) async {
...@@ -559,14 +559,15 @@ void main() { ...@@ -559,14 +559,15 @@ void main() {
Center(child: Container(child: HoverFeedback(key: feedbackKey))), Center(child: Container(child: HoverFeedback(key: feedbackKey))),
); );
await tester.pump(); await tester.pump();
expect(HoverClientState.numEntries, equals(2)); expect(HoverClientState.numEntries, equals(1));
expect(HoverClientState.numExits, equals(1)); expect(HoverClientState.numExits, equals(0));
await tester.pumpWidget( await tester.pumpWidget(
Container(), Container(),
); );
await tester.pump(); await tester.pump();
expect(HoverClientState.numEntries, equals(2)); expect(HoverClientState.numEntries, equals(1));
expect(HoverClientState.numExits, equals(2)); // Unmounting a MouseRegion doesn't trigger onExit
expect(HoverClientState.numExits, equals(0));
}); });
testWidgets('Exit event when unplugging mouse should have a position', (WidgetTester tester) async { testWidgets('Exit event when unplugging mouse should have a position', (WidgetTester tester) async {
......
...@@ -172,8 +172,7 @@ void main() { ...@@ -172,8 +172,7 @@ void main() {
height: 100.0, height: 100.0,
), ),
)); ));
expect(exit, isNotNull); expect(exit, isNull);
expect(exit.position, equals(const Offset(400.0, 300.0)));
expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse); expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse);
}); });
...@@ -487,7 +486,6 @@ void main() { ...@@ -487,7 +486,6 @@ void main() {
expect(bottomLeft.dy - topLeft.dy, scaleFactor * localHeight); expect(bottomLeft.dy - topLeft.dy, scaleFactor * localHeight);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(); await gesture.addPointer();
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await gesture.moveTo(topLeft - const Offset(1, 1)); await gesture.moveTo(topLeft - const Offset(1, 1));
...@@ -516,7 +514,6 @@ void main() { ...@@ -516,7 +514,6 @@ void main() {
testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async { testWidgets('needsCompositing updates correctly and is respected', (WidgetTester tester) async {
// Pretend that we have a mouse connected. // Pretend that we have a mouse connected.
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(); await gesture.addPointer();
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
...@@ -602,7 +599,7 @@ void main() { ...@@ -602,7 +599,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
expect(numEntries, equals(1)); expect(numEntries, equals(1));
expect(numExits, equals(1)); expect(numExits, equals(0));
await tester.pumpWidget( await tester.pumpWidget(
Center( Center(
...@@ -613,13 +610,12 @@ void main() { ...@@ -613,13 +610,12 @@ void main() {
); );
await tester.pump(); await tester.pump();
expect(numEntries, equals(2)); expect(numEntries, equals(2));
expect(numExits, equals(1)); expect(numExits, equals(0));
}); });
testWidgets("MouseRegion activate/deactivate don't duplicate annotations", (WidgetTester tester) async { testWidgets("MouseRegion activate/deactivate don't duplicate annotations", (WidgetTester tester) async {
final GlobalKey feedbackKey = GlobalKey(); final GlobalKey feedbackKey = GlobalKey();
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(); await gesture.addPointer();
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
...@@ -651,14 +647,14 @@ void main() { ...@@ -651,14 +647,14 @@ void main() {
))), ))),
); );
await tester.pump(); await tester.pump();
expect(numEntries, equals(2)); expect(numEntries, equals(1));
expect(numExits, equals(1)); expect(numExits, equals(0));
await tester.pumpWidget( await tester.pumpWidget(
Container(), Container(),
); );
await tester.pump(); await tester.pump();
expect(numEntries, equals(2)); expect(numEntries, equals(1));
expect(numExits, equals(2)); expect(numExits, equals(0));
}); });
testWidgets('Exit event when unplugging mouse should have a position', (WidgetTester tester) async { testWidgets('Exit event when unplugging mouse should have a position', (WidgetTester tester) async {
...@@ -768,6 +764,151 @@ void main() { ...@@ -768,6 +764,151 @@ void main() {
expect(paintCount, 1); expect(paintCount, 1);
}); });
testWidgets('A MouseRegion mounted under the pointer should should take effect in the next postframe', (WidgetTester tester) async {
bool hovered = false;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget>[
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('unhover outer'), findsOneWidget);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget>[
HoverClient(
onHover: (bool value) { setState(() { hovered = value; }); },
child: Text(hovered ? 'hover inner' : 'unhover inner'),
),
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('unhover outer'), findsOneWidget);
expect(find.text('unhover inner'), findsOneWidget);
await tester.pump();
expect(find.text('hover outer'), findsOneWidget);
expect(find.text('hover inner'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('A MouseRegion unmounted under the pointer should not trigger state change', (WidgetTester tester) async {
bool hovered = true;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget>[
HoverClient(
onHover: (bool value) { setState(() { hovered = value; }); },
child: Text(hovered ? 'hover inner' : 'unhover inner'),
),
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('hover outer'), findsOneWidget);
expect(find.text('hover inner'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
expect(find.text('hover outer'), findsOneWidget);
expect(find.text('hover inner'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isFalse);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return _ColumnContainer(
children: <Widget> [
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('hover outer'), findsOneWidget);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('A MouseRegion moved into the mouse should take effect in the next postframe', (WidgetTester tester) async {
bool hovered = false;
final List<bool> logHovered = <bool>[];
bool moved = false;
StateSetter mySetState;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: const Offset(5, 5));
addTearDown(gesture.removePointer);
await tester.pumpWidget(
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
mySetState = setState;
return _ColumnContainer(
children: <Widget>[
Container(
height: 100,
width: 10,
alignment: moved ? Alignment.topLeft : Alignment.bottomLeft,
child: Container(
height: 10,
width: 10,
child: HoverClient(
onHover: (bool value) {
setState(() { hovered = value; });
logHovered.add(value);
},
child: Text(hovered ? 'hover inner' : 'unhover inner'),
),
),
),
Text(hovered ? 'hover outer' : 'unhover outer'),
],
);
}),
);
expect(find.text('unhover inner'), findsOneWidget);
expect(find.text('unhover outer'), findsOneWidget);
expect(logHovered, isEmpty);
expect(tester.binding.hasScheduledFrame, isFalse);
mySetState(() { moved = true; });
// The first frame is for the widget movement to take effect.
await tester.pump();
expect(find.text('unhover inner'), findsOneWidget);
expect(find.text('unhover outer'), findsOneWidget);
expect(logHovered, <bool>[true]);
logHovered.clear();
// The second frame is for the mouse hover to take effect.
await tester.pump();
expect(find.text('hover inner'), findsOneWidget);
expect(find.text('hover outer'), findsOneWidget);
expect(logHovered, isEmpty);
expect(tester.binding.hasScheduledFrame, isFalse);
});
group('MouseRegion respects opacity:', () { group('MouseRegion respects opacity:', () {
// A widget that contains 3 MouseRegions: // A widget that contains 3 MouseRegions:
...@@ -849,37 +990,37 @@ void main() { ...@@ -849,37 +990,37 @@ void main() {
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Move to the overlapping area // Move to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['enterA', 'enterB', 'enterC']); expect(logs, <String>['enterA', 'enterB', 'enterC']);
logs.clear(); logs.clear();
// Move to the B only area // Move to the B only area.
await gesture.moveTo(const Offset(25, 75)); await gesture.moveTo(const Offset(25, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitC']); expect(logs, <String>['exitC']);
logs.clear(); logs.clear();
// Move back to the overlapping area // Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['enterC']); expect(logs, <String>['enterC']);
logs.clear(); logs.clear();
// Move to the C only area // Move to the C only area.
await gesture.moveTo(const Offset(125, 75)); await gesture.moveTo(const Offset(125, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitB']); expect(logs, <String>['exitB']);
logs.clear(); logs.clear();
// Move back to the overlapping area // Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['enterB']); expect(logs, <String>['enterB']);
logs.clear(); logs.clear();
// Move out // Move out.
await gesture.moveTo(const Offset(160, 160)); await gesture.moveTo(const Offset(160, 160));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'exitB', 'exitA']); expect(logs, <String>['exitC', 'exitB', 'exitA']);
...@@ -897,37 +1038,37 @@ void main() { ...@@ -897,37 +1038,37 @@ void main() {
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Move to the overlapping area // Move to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['enterA', 'enterC']); expect(logs, <String>['enterA', 'enterC']);
logs.clear(); logs.clear();
// Move to the B only area // Move to the B only area.
await gesture.moveTo(const Offset(25, 75)); await gesture.moveTo(const Offset(25, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'enterB']); expect(logs, <String>['exitC', 'enterB']);
logs.clear(); logs.clear();
// Move back to the overlapping area // Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitB', 'enterC']); expect(logs, <String>['exitB', 'enterC']);
logs.clear(); logs.clear();
// Move to the C only area // Move to the C only area.
await gesture.moveTo(const Offset(125, 75)); await gesture.moveTo(const Offset(125, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>[]); expect(logs, <String>[]);
logs.clear(); logs.clear();
// Move back to the overlapping area // Move back to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>[]); expect(logs, <String>[]);
logs.clear(); logs.clear();
// Move out // Move out.
await gesture.moveTo(const Offset(160, 160)); await gesture.moveTo(const Offset(160, 160));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'exitA']); expect(logs, <String>['exitC', 'exitA']);
...@@ -945,13 +1086,13 @@ void main() { ...@@ -945,13 +1086,13 @@ void main() {
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Move to the overlapping area // Move to the overlapping area.
await gesture.moveTo(const Offset(75, 75)); await gesture.moveTo(const Offset(75, 75));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['enterA', 'enterC']); expect(logs, <String>['enterA', 'enterC']);
logs.clear(); logs.clear();
// Move out // Move out.
await gesture.moveTo(const Offset(160, 160)); await gesture.moveTo(const Offset(160, 160));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(logs, <String>['exitC', 'exitA']); expect(logs, <String>['exitC', 'exitA']);
...@@ -1061,7 +1202,7 @@ void main() { ...@@ -1061,7 +1202,7 @@ void main() {
}); });
} }
// This widget allows you to send a callback that is called during `onPaint. // This widget allows you to send a callback that is called during `onPaint`.
@immutable @immutable
class _PaintDelegateWidget extends SingleChildRenderObjectWidget { class _PaintDelegateWidget extends SingleChildRenderObjectWidget {
const _PaintDelegateWidget({ const _PaintDelegateWidget({
...@@ -1134,3 +1275,24 @@ class _HoverClientWithClosuresState extends State<_HoverClientWithClosures> { ...@@ -1134,3 +1275,24 @@ class _HoverClientWithClosuresState extends State<_HoverClientWithClosures> {
); );
} }
} }
// A column that aligns to the top left.
class _ColumnContainer extends StatelessWidget {
const _ColumnContainer({
@required this.children,
}) : assert(children != null);
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
}
...@@ -337,12 +337,16 @@ class TestGesture { ...@@ -337,12 +337,16 @@ class TestGesture {
_dispatcher = dispatcher, _dispatcher = dispatcher,
_hitTester = hitTester, _hitTester = hitTester,
_pointer = TestPointer(pointer, kind, device, buttons), _pointer = TestPointer(pointer, kind, device, buttons),
_added = false,
_result = null; _result = null;
/// Dispatch a pointer down event at the given `downLocation`, caching the /// Dispatch a pointer down event at the given `downLocation`, caching the
/// hit test result. /// hit test result.
Future<void> down(Offset downLocation) async { ///
/// If the pointer has not been added, an added event will be dispatched first.
Future<void> down(Offset downLocation) {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
await _ensureAdded(location: downLocation);
_result = _hitTester(downLocation); _result = _hitTester(downLocation);
return _dispatcher(_pointer.down(downLocation), _result); return _dispatcher(_pointer.down(downLocation), _result);
}); });
...@@ -350,9 +354,12 @@ class TestGesture { ...@@ -350,9 +354,12 @@ class TestGesture {
/// Dispatch a pointer down event at the given `downLocation`, caching the /// Dispatch a pointer down event at the given `downLocation`, caching the
/// hit test result with a custom down event. /// hit test result with a custom down event.
Future<void> downWithCustomEvent(Offset downLocation, PointerDownEvent event) async { ///
_pointer.setDownInfo(event, downLocation); /// If the pointer has not been added, an added event will be dispatched first.
Future<void> downWithCustomEvent(Offset downLocation, PointerDownEvent event) {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
await _ensureAdded(location: downLocation);
_pointer.setDownInfo(event, downLocation);
_result = _hitTester(downLocation); _result = _hitTester(downLocation);
return _dispatcher(event, _result); return _dispatcher(event, _result);
}); });
...@@ -362,10 +369,22 @@ class TestGesture { ...@@ -362,10 +369,22 @@ class TestGesture {
final HitTester _hitTester; final HitTester _hitTester;
final TestPointer _pointer; final TestPointer _pointer;
HitTestResult _result; HitTestResult _result;
bool _added;
Future<void> _ensureAdded({ Offset location }) async {
if (!_added) {
await addPointer(location: location ?? _pointer.location);
}
}
/// In a test, send a move event that moves the pointer by the given offset. /// In a test, send a move event that moves the pointer by the given offset.
///
/// If the pointer has not been added, and the subject event is not an added
/// event, an added event will be dispatched first.
@visibleForTesting @visibleForTesting
Future<void> updateWithCustomEvent(PointerEvent event, { Duration timeStamp = Duration.zero }) { Future<void> updateWithCustomEvent(PointerEvent event, { Duration timeStamp = Duration.zero }) async {
if (event is! PointerAddedEvent)
await _ensureAdded(location: event.position);
_pointer.setDownInfo(event, event.position); _pointer.setDownInfo(event, event.position);
return TestAsyncUtils.guard<void>(() { return TestAsyncUtils.guard<void>(() {
return _dispatcher(event, _result); return _dispatcher(event, _result);
...@@ -373,21 +392,34 @@ class TestGesture { ...@@ -373,21 +392,34 @@ class TestGesture {
} }
/// In a test, send a pointer add event for this pointer. /// In a test, send a pointer add event for this pointer.
///
/// If a pointer has been added, the pointer will be removed first.
Future<void> addPointer({ Duration timeStamp = Duration.zero, Offset location }) { Future<void> addPointer({ Duration timeStamp = Duration.zero, Offset location }) {
return TestAsyncUtils.guard<void>(() { return TestAsyncUtils.guard<void>(() async {
if (_added) {
await removePointer(timeStamp: timeStamp);
}
_added = true;
return _dispatcher(_pointer.addPointer(timeStamp: timeStamp, location: location ?? _pointer.location), null); return _dispatcher(_pointer.addPointer(timeStamp: timeStamp, location: location ?? _pointer.location), null);
}); });
} }
/// In a test, send a pointer remove event for this pointer. /// In a test, send a pointer remove event for this pointer.
///
/// If no pointer has been added, the call will be a no-op.
Future<void> removePointer({ Duration timeStamp = Duration.zero, Offset location }) { Future<void> removePointer({ Duration timeStamp = Duration.zero, Offset location }) {
return TestAsyncUtils.guard<void>(() { return TestAsyncUtils.guard<void>(() async {
return _dispatcher(_pointer.removePointer(timeStamp: timeStamp, location: location ?? _pointer.location), null); if (!_added)
return;
_added = false;
await _dispatcher(_pointer.removePointer(timeStamp: timeStamp, location: location ?? _pointer.location), null);
}); });
} }
/// Send a move event moving the pointer by the given offset. /// Send a move event moving the pointer by the given offset.
/// ///
/// If the pointer has not been added, an added event will be dispatched first.
///
/// If the pointer is down, then a move event is dispatched. If the pointer is /// 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 /// up, then a hover event is dispatched. Touch devices are not able to send
/// hover events. /// hover events.
...@@ -397,11 +429,14 @@ class TestGesture { ...@@ -397,11 +429,14 @@ class TestGesture {
/// Send a move event moving the pointer to the given location. /// Send a move event moving the pointer to the given location.
/// ///
/// If the pointer has not been added, an added event will be dispatched first.
///
/// If the pointer is down, then a move event is dispatched. If the pointer is /// 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 /// up, then a hover event is dispatched. Touch devices are not able to send
/// hover events. /// hover events.
Future<void> moveTo(Offset location, { Duration timeStamp = Duration.zero }) { Future<void> moveTo(Offset location, { Duration timeStamp = Duration.zero }) {
return TestAsyncUtils.guard<void>(() { return TestAsyncUtils.guard<void>(() async {
await _ensureAdded(location: location);
if (_pointer._isDown) { if (_pointer._isDown) {
assert(_result != null, assert(_result != null,
'Move events with the pointer down must be preceded by a down ' 'Move events with the pointer down must be preceded by a down '
...@@ -416,8 +451,11 @@ class TestGesture { ...@@ -416,8 +451,11 @@ class TestGesture {
} }
/// End the gesture by releasing the pointer. /// End the gesture by releasing the pointer.
///
/// If the pointer has not been added, an added event will be dispatched first.
Future<void> up() { Future<void> up() {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
await _ensureAdded();
assert(_pointer._isDown); assert(_pointer._isDown);
await _dispatcher(_pointer.up(), _result); await _dispatcher(_pointer.up(), _result);
assert(!_pointer._isDown); assert(!_pointer._isDown);
...@@ -428,8 +466,11 @@ class TestGesture { ...@@ -428,8 +466,11 @@ class TestGesture {
/// End the gesture by canceling the pointer (as would happen if the /// End the gesture by canceling the pointer (as would happen if the
/// system showed a modal dialog on top of the Flutter application, /// system showed a modal dialog on top of the Flutter application,
/// for instance). /// for instance).
///
/// If the pointer has not been added, an added event will be dispatched first.
Future<void> cancel() { Future<void> cancel() {
return TestAsyncUtils.guard<void>(() async { return TestAsyncUtils.guard<void>(() async {
await _ensureAdded();
assert(_pointer._isDown); assert(_pointer._isDown);
await _dispatcher(_pointer.cancel(), _result); await _dispatcher(_pointer.cancel(), _result);
assert(!_pointer._isDown); assert(!_pointer._isDown);
......
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