Implement hover support for mouse pointers. (#24830)

This implements support for hovering mouse pointers, so that mice connected to Android devices, and ChromeOS devices running Android apps will work properly.

It teaches flutter_test about hover events, which required changing how they are created and used.

Also modifies AnnotatedRegion to allow a region that can be located someplace other than just the origin.

Along with tests for all of the above.

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