Add support for scrollwheels (#22762)

Adds support for discrete scroll events, such as those sent by a scroll wheel.

Includes the plumbing to convert, dispatch, and handle these events, as well as
Scrollable support for consuming them.
diff --git a/packages/flutter/lib/gestures.dart b/packages/flutter/lib/gestures.dart
index 04f23e5..7e30894 100644
--- a/packages/flutter/lib/gestures.dart
+++ b/packages/flutter/lib/gestures.dart
@@ -25,6 +25,7 @@
 export 'src/gestures/multidrag.dart';
 export 'src/gestures/multitap.dart';
 export 'src/gestures/pointer_router.dart';
+export 'src/gestures/pointer_signal_resolver.dart';
 export 'src/gestures/recognizer.dart';
 export 'src/gestures/scale.dart';
 export 'src/gestures/tap.dart';
diff --git a/packages/flutter/lib/src/gestures/binding.dart b/packages/flutter/lib/src/gestures/binding.dart
index 796d33c..8c7bdf8 100644
--- a/packages/flutter/lib/src/gestures/binding.dart
+++ b/packages/flutter/lib/src/gestures/binding.dart
@@ -14,6 +14,7 @@
 import 'events.dart';
 import 'hit_test.dart';
 import 'pointer_router.dart';
+import 'pointer_signal_resolver.dart';
 
 /// A binding for the gesture subsystem.
 ///
@@ -108,6 +109,10 @@
   /// pointer events.
   final GestureArenaManager gestureArena = GestureArenaManager();
 
+  /// The resolver used for determining which widget handles a pointer
+  /// signal event.
+  final PointerSignalResolver pointerSignalResolver = PointerSignalResolver();
+
   /// State for all pointers which are currently down.
   ///
   /// The state of hovering pointers is not tracked because that would require
@@ -117,11 +122,13 @@
   void _handlePointerEvent(PointerEvent event) {
     assert(!locked);
     HitTestResult hitTestResult;
-    if (event is PointerDownEvent) {
+    if (event is PointerDownEvent || event is PointerSignalEvent) {
       assert(!_hitTests.containsKey(event.pointer));
       hitTestResult = HitTestResult();
       hitTest(hitTestResult, event.position);
-      _hitTests[event.pointer] = hitTestResult;
+      if (event is PointerDownEvent) {
+        _hitTests[event.pointer] = hitTestResult;
+      }
       assert(() {
         if (debugPrintHitTestResults)
           debugPrint('$event: $hitTestResult');
@@ -216,6 +223,8 @@
       gestureArena.close(event.pointer);
     } else if (event is PointerUpEvent) {
       gestureArena.sweep(event.pointer);
+    } else if (event is PointerSignalEvent) {
+      pointerSignalResolver.resolve(event);
     }
   }
 }
diff --git a/packages/flutter/lib/src/gestures/converter.dart b/packages/flutter/lib/src/gestures/converter.dart
index ddb7321..0364c59 100644
--- a/packages/flutter/lib/src/gestures/converter.dart
+++ b/packages/flutter/lib/src/gestures/converter.dart
@@ -2,7 +2,7 @@
 // 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 show PointerData, PointerChange;
+import 'dart:ui' as ui show PointerData, PointerChange, PointerSignalKind;
 
 import 'package:flutter/foundation.dart' show visibleForTesting;
 
@@ -82,32 +82,11 @@
       final Duration timeStamp = datum.timeStamp;
       final PointerDeviceKind kind = datum.kind;
       assert(datum.change != null);
-      switch (datum.change) {
-        case ui.PointerChange.add:
-          assert(!_pointers.containsKey(datum.device));
-          final _PointerState state = _ensureStateForPointer(datum, position);
-          assert(state.lastPosition == position);
-          yield PointerAddedEvent(
-            timeStamp: timeStamp,
-            kind: kind,
-            device: datum.device,
-            position: position,
-            obscured: datum.obscured,
-            pressureMin: datum.pressureMin,
-            pressureMax: datum.pressureMax,
-            distance: datum.distance,
-            distanceMax: datum.distanceMax,
-            radiusMin: radiusMin,
-            radiusMax: radiusMax,
-            orientation: datum.orientation,
-            tilt: datum.tilt,
-          );
-          break;
-        case ui.PointerChange.hover:
-          final bool alreadyAdded = _pointers.containsKey(datum.device);
-          final _PointerState state = _ensureStateForPointer(datum, position);
-          assert(!state.down);
-          if (!alreadyAdded) {
+      if (datum.signalKind == null || datum.signalKind == ui.PointerSignalKind.none) {
+        switch (datum.change) {
+          case ui.PointerChange.add:
+            assert(!_pointers.containsKey(datum.device));
+            final _PointerState state = _ensureStateForPointer(datum, position);
             assert(state.lastPosition == position);
             yield PointerAddedEvent(
               timeStamp: timeStamp,
@@ -124,57 +103,29 @@
               orientation: datum.orientation,
               tilt: datum.tilt,
             );
-          }
-          final Offset offset = position - state.lastPosition;
-          state.lastPosition = position;
-          yield PointerHoverEvent(
-            timeStamp: timeStamp,
-            kind: kind,
-            device: datum.device,
-            position: position,
-            delta: offset,
-            buttons: datum.buttons,
-            obscured: datum.obscured,
-            pressureMin: datum.pressureMin,
-            pressureMax: datum.pressureMax,
-            distance: datum.distance,
-            distanceMax: datum.distanceMax,
-            size: datum.size,
-            radiusMajor: radiusMajor,
-            radiusMinor: radiusMinor,
-            radiusMin: radiusMin,
-            radiusMax: radiusMax,
-            orientation: datum.orientation,
-            tilt: datum.tilt,
-          );
-          state.lastPosition = position;
-          break;
-        case ui.PointerChange.down:
-          final bool alreadyAdded = _pointers.containsKey(datum.device);
-          final _PointerState state = _ensureStateForPointer(datum, position);
-          assert(!state.down);
-          if (!alreadyAdded) {
-            assert(state.lastPosition == position);
-            yield PointerAddedEvent(
-              timeStamp: timeStamp,
-              kind: kind,
-              device: datum.device,
-              position: position,
-              obscured: datum.obscured,
-              pressureMin: datum.pressureMin,
-              pressureMax: datum.pressureMax,
-              distance: datum.distance,
-              distanceMax: datum.distanceMax,
-              radiusMin: radiusMin,
-              radiusMax: radiusMax,
-              orientation: datum.orientation,
-              tilt: datum.tilt,
-            );
-          }
-          if (state.lastPosition != position) {
-            // Not all sources of pointer packets respect the invariant that
-            // they hover the pointer to the down location before sending the
-            // down event. We restore the invariant here for our clients.
+            break;
+          case ui.PointerChange.hover:
+            final bool alreadyAdded = _pointers.containsKey(datum.device);
+            final _PointerState state = _ensureStateForPointer(datum, position);
+            assert(!state.down);
+            if (!alreadyAdded) {
+              assert(state.lastPosition == position);
+              yield PointerAddedEvent(
+                timeStamp: timeStamp,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                obscured: datum.obscured,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distance: datum.distance,
+                distanceMax: datum.distanceMax,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+              );
+            }
             final Offset offset = position - state.lastPosition;
             state.lastPosition = position;
             yield PointerHoverEvent(
@@ -196,76 +147,90 @@
               radiusMax: radiusMax,
               orientation: datum.orientation,
               tilt: datum.tilt,
-              synthesized: true,
             );
             state.lastPosition = position;
-          }
-          state.startNewPointer();
-          state.setDown();
-          yield PointerDownEvent(
-            timeStamp: timeStamp,
-            pointer: state.pointer,
-            kind: kind,
-            device: datum.device,
-            position: position,
-            buttons: datum.buttons,
-            obscured: datum.obscured,
-            pressure: datum.pressure,
-            pressureMin: datum.pressureMin,
-            pressureMax: datum.pressureMax,
-            distanceMax: datum.distanceMax,
-            size: datum.size,
-            radiusMajor: radiusMajor,
-            radiusMinor: radiusMinor,
-            radiusMin: radiusMin,
-            radiusMax: radiusMax,
-            orientation: datum.orientation,
-            tilt: datum.tilt,
-          );
-          break;
-        case ui.PointerChange.move:
-          // If the service starts supporting hover pointers, then it must also
-          // start sending us ADDED and REMOVED data points.
-          // See also: https://github.com/flutter/flutter/issues/720
-          assert(_pointers.containsKey(datum.device));
-          final _PointerState state = _pointers[datum.device];
-          assert(state.down);
-          final Offset offset = position - state.lastPosition;
-          state.lastPosition = position;
-          yield PointerMoveEvent(
-            timeStamp: timeStamp,
-            pointer: state.pointer,
-            kind: kind,
-            device: datum.device,
-            position: position,
-            delta: offset,
-            buttons: datum.buttons,
-            obscured: datum.obscured,
-            pressure: datum.pressure,
-            pressureMin: datum.pressureMin,
-            pressureMax: datum.pressureMax,
-            distanceMax: datum.distanceMax,
-            size: datum.size,
-            radiusMajor: radiusMajor,
-            radiusMinor: radiusMinor,
-            radiusMin: radiusMin,
-            radiusMax: radiusMax,
-            orientation: datum.orientation,
-            tilt: datum.tilt,
-            platformData: datum.platformData,
-          );
-          break;
-        case ui.PointerChange.up:
-        case ui.PointerChange.cancel:
-          assert(_pointers.containsKey(datum.device));
-          final _PointerState state = _pointers[datum.device];
-          assert(state.down);
-          if (position != state.lastPosition) {
-            // Not all sources of pointer packets respect the invariant that
-            // they move the pointer to the up location before sending the up
-            // event. For example, in the iOS simulator, of you drag outside the
-            // window, you'll get a stream of pointers that violates that
-            // invariant. We restore the invariant here for our clients.
+            break;
+          case ui.PointerChange.down:
+            final bool alreadyAdded = _pointers.containsKey(datum.device);
+            final _PointerState state = _ensureStateForPointer(datum, position);
+            assert(!state.down);
+            if (!alreadyAdded) {
+              assert(state.lastPosition == position);
+              yield PointerAddedEvent(
+                timeStamp: timeStamp,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                obscured: datum.obscured,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distance: datum.distance,
+                distanceMax: datum.distanceMax,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+              );
+            }
+            if (state.lastPosition != position) {
+              // Not all sources of pointer packets respect the invariant that
+              // they hover the pointer to the down location before sending the
+              // down event. We restore the invariant here for our clients.
+              final Offset offset = position - state.lastPosition;
+              state.lastPosition = position;
+              yield PointerHoverEvent(
+                timeStamp: timeStamp,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                delta: offset,
+                buttons: datum.buttons,
+                obscured: datum.obscured,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distance: datum.distance,
+                distanceMax: datum.distanceMax,
+                size: datum.size,
+                radiusMajor: radiusMajor,
+                radiusMinor: radiusMinor,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+                synthesized: true,
+              );
+              state.lastPosition = position;
+            }
+            state.startNewPointer();
+            state.setDown();
+            yield PointerDownEvent(
+              timeStamp: timeStamp,
+              pointer: state.pointer,
+              kind: kind,
+              device: datum.device,
+              position: position,
+              buttons: datum.buttons,
+              obscured: datum.obscured,
+              pressure: datum.pressure,
+              pressureMin: datum.pressureMin,
+              pressureMax: datum.pressureMax,
+              distanceMax: datum.distanceMax,
+              size: datum.size,
+              radiusMajor: radiusMajor,
+              radiusMinor: radiusMinor,
+              radiusMin: radiusMin,
+              radiusMax: radiusMax,
+              orientation: datum.orientation,
+              tilt: datum.tilt,
+            );
+            break;
+          case ui.PointerChange.move:
+            // If the service starts supporting hover pointers, then it must also
+            // start sending us ADDED and REMOVED data points.
+            // See also: https://github.com/flutter/flutter/issues/720
+            assert(_pointers.containsKey(datum.device));
+            final _PointerState state = _pointers[datum.device];
+            assert(state.down);
             final Offset offset = position - state.lastPosition;
             state.lastPosition = position;
             yield PointerMoveEvent(
@@ -288,95 +253,208 @@
               radiusMax: radiusMax,
               orientation: datum.orientation,
               tilt: datum.tilt,
-              synthesized: true,
+              platformData: datum.platformData,
             );
-            state.lastPosition = position;
-          }
-          assert(position == state.lastPosition);
-          state.setUp();
-          if (datum.change == ui.PointerChange.up) {
-            yield PointerUpEvent(
+            break;
+          case ui.PointerChange.up:
+          case ui.PointerChange.cancel:
+            assert(_pointers.containsKey(datum.device));
+            final _PointerState state = _pointers[datum.device];
+            assert(state.down);
+            if (position != state.lastPosition) {
+              // Not all sources of pointer packets respect the invariant that
+              // they move the pointer to the up location before sending the up
+              // event. For example, in the iOS simulator, of you drag outside the
+              // window, you'll get a stream of pointers that violates that
+              // invariant. We restore the invariant here for our clients.
+              final Offset offset = position - state.lastPosition;
+              state.lastPosition = position;
+              yield PointerMoveEvent(
+                timeStamp: timeStamp,
+                pointer: state.pointer,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                delta: offset,
+                buttons: datum.buttons,
+                obscured: datum.obscured,
+                pressure: datum.pressure,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distanceMax: datum.distanceMax,
+                size: datum.size,
+                radiusMajor: radiusMajor,
+                radiusMinor: radiusMinor,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+                synthesized: true,
+              );
+              state.lastPosition = position;
+            }
+            assert(position == state.lastPosition);
+            state.setUp();
+            if (datum.change == ui.PointerChange.up) {
+              yield PointerUpEvent(
+                timeStamp: timeStamp,
+                pointer: state.pointer,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                buttons: datum.buttons,
+                obscured: datum.obscured,
+                pressure: datum.pressure,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distance: datum.distance,
+                distanceMax: datum.distanceMax,
+                size: datum.size,
+                radiusMajor: radiusMajor,
+                radiusMinor: radiusMinor,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+              );
+            } else {
+              yield PointerCancelEvent(
+                timeStamp: timeStamp,
+                pointer: state.pointer,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                buttons: datum.buttons,
+                obscured: datum.obscured,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distance: datum.distance,
+                distanceMax: datum.distanceMax,
+                size: datum.size,
+                radiusMajor: radiusMajor,
+                radiusMinor: radiusMinor,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+              );
+            }
+            break;
+          case ui.PointerChange.remove:
+            assert(_pointers.containsKey(datum.device));
+            final _PointerState state = _pointers[datum.device];
+            if (state.down) {
+              yield PointerCancelEvent(
+                timeStamp: timeStamp,
+                pointer: state.pointer,
+                kind: kind,
+                device: datum.device,
+                position: position,
+                buttons: datum.buttons,
+                obscured: datum.obscured,
+                pressureMin: datum.pressureMin,
+                pressureMax: datum.pressureMax,
+                distance: datum.distance,
+                distanceMax: datum.distanceMax,
+                size: datum.size,
+                radiusMajor: radiusMajor,
+                radiusMinor: radiusMinor,
+                radiusMin: radiusMin,
+                radiusMax: radiusMax,
+                orientation: datum.orientation,
+                tilt: datum.tilt,
+              );
+            }
+            _pointers.remove(datum.device);
+            yield PointerRemovedEvent(
               timeStamp: timeStamp,
-              pointer: state.pointer,
               kind: kind,
               device: datum.device,
-              position: position,
-              buttons: datum.buttons,
-              obscured: datum.obscured,
-              pressure: datum.pressure,
-              pressureMin: datum.pressureMin,
-              pressureMax: datum.pressureMax,
-              distance: datum.distance,
-              distanceMax: datum.distanceMax,
-              size: datum.size,
-              radiusMajor: radiusMajor,
-              radiusMinor: radiusMinor,
-              radiusMin: radiusMin,
-              radiusMax: radiusMax,
-              orientation: datum.orientation,
-              tilt: datum.tilt,
-            );
-          } else {
-            yield PointerCancelEvent(
-              timeStamp: timeStamp,
-              pointer: state.pointer,
-              kind: kind,
-              device: datum.device,
-              position: position,
-              buttons: datum.buttons,
               obscured: datum.obscured,
               pressureMin: datum.pressureMin,
               pressureMax: datum.pressureMax,
-              distance: datum.distance,
               distanceMax: datum.distanceMax,
-              size: datum.size,
-              radiusMajor: radiusMajor,
-              radiusMinor: radiusMinor,
               radiusMin: radiusMin,
               radiusMax: radiusMax,
-              orientation: datum.orientation,
-              tilt: datum.tilt,
             );
-          }
-          break;
-        case ui.PointerChange.remove:
-          assert(_pointers.containsKey(datum.device));
-          final _PointerState state = _pointers[datum.device];
-          if (state.down) {
-            yield PointerCancelEvent(
+            break;
+        }
+      } else {
+        switch (datum.signalKind) {
+          case ui.PointerSignalKind.scroll:
+            // Devices must be added before they send scroll events.
+            assert(_pointers.containsKey(datum.device));
+            final _PointerState state = _ensureStateForPointer(datum, position);
+            if (state.lastPosition != position) {
+              // Synthesize a hover/move of the pointer to the scroll location
+              // before sending the scroll event, if necessary, so that clients
+              // don't have to worry about native ordering of hover and scroll
+              // events.
+              final Offset offset = position - state.lastPosition;
+              state.lastPosition = position;
+              if (state.down) {
+                yield PointerMoveEvent(
+                  timeStamp: timeStamp,
+                  pointer: state.pointer,
+                  kind: kind,
+                  device: datum.device,
+                  position: position,
+                  delta: offset,
+                  buttons: datum.buttons,
+                  obscured: datum.obscured,
+                  pressureMin: datum.pressureMin,
+                  pressureMax: datum.pressureMax,
+                  distanceMax: datum.distanceMax,
+                  size: datum.size,
+                  radiusMajor: radiusMajor,
+                  radiusMinor: radiusMinor,
+                  radiusMin: radiusMin,
+                  radiusMax: radiusMax,
+                  orientation: datum.orientation,
+                  tilt: datum.tilt,
+                  synthesized: true,
+                );
+              } else {
+                yield PointerHoverEvent(
+                  timeStamp: timeStamp,
+                  kind: kind,
+                  device: datum.device,
+                  position: position,
+                  delta: offset,
+                  buttons: datum.buttons,
+                  obscured: datum.obscured,
+                  pressureMin: datum.pressureMin,
+                  pressureMax: datum.pressureMax,
+                  distance: datum.distance,
+                  distanceMax: datum.distanceMax,
+                  size: datum.size,
+                  radiusMajor: radiusMajor,
+                  radiusMinor: radiusMinor,
+                  radiusMin: radiusMin,
+                  radiusMax: radiusMax,
+                  orientation: datum.orientation,
+                  tilt: datum.tilt,
+                  synthesized: true,
+                );
+              }
+            }
+            final Offset scrollDelta =
+                Offset(datum.scrollDeltaX, datum.scrollDeltaY) / devicePixelRatio;
+            yield PointerScrollEvent(
               timeStamp: timeStamp,
-              pointer: state.pointer,
               kind: kind,
               device: datum.device,
               position: position,
-              buttons: datum.buttons,
-              obscured: datum.obscured,
-              pressureMin: datum.pressureMin,
-              pressureMax: datum.pressureMax,
-              distance: datum.distance,
-              distanceMax: datum.distanceMax,
-              size: datum.size,
-              radiusMajor: radiusMajor,
-              radiusMinor: radiusMinor,
-              radiusMin: radiusMin,
-              radiusMax: radiusMax,
-              orientation: datum.orientation,
-              tilt: datum.tilt,
+              scrollDelta: scrollDelta,
             );
-          }
-          _pointers.remove(datum.device);
-          yield PointerRemovedEvent(
-            timeStamp: timeStamp,
-            kind: kind,
-            device: datum.device,
-            obscured: datum.obscured,
-            pressureMin: datum.pressureMin,
-            pressureMax: datum.pressureMax,
-            distanceMax: datum.distanceMax,
-            radiusMin: radiusMin,
-            radiusMax: radiusMax,
-          );
-          break;
+            break;
+          case ui.PointerSignalKind.none:
+            assert(false); // This branch should already have 'none' filtered out.
+            break;
+          case ui.PointerSignalKind.unknown:
+            // Ignore unknown signals.
+            break;
+        }
       }
     }
   }
diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart
index 099ef69..e75608e 100644
--- a/packages/flutter/lib/src/gestures/events.dart
+++ b/packages/flutter/lib/src/gestures/events.dart
@@ -790,6 +790,65 @@
   );
 }
 
+/// An event that corresponds to a discrete pointer signal.
+///
+/// Pointer signals are events that originate from the pointer but don't change
+/// the state of the pointer itself, and are discrete rather than needing to be
+/// interpreted in the context of a series of events.
+abstract class PointerSignalEvent extends PointerEvent {
+  /// Abstract const constructor. This constructor enables subclasses to provide
+  /// const constructors so that they can be used in const expressions.
+  const PointerSignalEvent({
+    Duration timeStamp = Duration.zero,
+    int pointer = 0,
+    PointerDeviceKind kind = PointerDeviceKind.mouse,
+    int device = 0,
+    Offset position = Offset.zero,
+  }) : super(
+    timeStamp: timeStamp,
+    pointer: pointer,
+    kind: kind,
+    device: device,
+    position: position,
+  );
+}
+
+/// The pointer issued a scroll event.
+///
+/// Scrolling the scroll wheel on a mouse is an example of an event that
+/// would create a [PointerScrollEvent].
+class PointerScrollEvent extends PointerSignalEvent {
+  /// Creates a pointer scroll event.
+  ///
+  /// All of the arguments must be non-null.
+  const PointerScrollEvent({
+    Duration timeStamp = Duration.zero,
+    PointerDeviceKind kind = PointerDeviceKind.mouse,
+    int device = 0,
+    Offset position = Offset.zero,
+    this.scrollDelta = Offset.zero,
+  }) : assert(timeStamp != null),
+       assert(kind != null),
+       assert(device != null),
+       assert(position != null),
+       assert(scrollDelta != null),
+       super(
+    timeStamp: timeStamp,
+    kind: kind,
+    device: device,
+    position: position,
+  );
+
+  /// The amount to scroll, in logical pixels.
+  final Offset scrollDelta;
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties.add(DiagnosticsProperty<Offset>('scrollDelta', scrollDelta));
+  }
+}
+
 /// The input from the pointer is no longer directed towards this receiver.
 class PointerCancelEvent extends PointerEvent {
   /// Creates a pointer cancel event.
diff --git a/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart b/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart
new file mode 100644
index 0000000..c70a01d
--- /dev/null
+++ b/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart
@@ -0,0 +1,68 @@
+// Copyright 2019 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 'package:flutter/foundation.dart';
+
+import 'events.dart';
+
+/// The callback to register with a [PointerSignalResolver] to express
+/// interest in a pointer signal event.
+typedef PointerSignalResolvedCallback = void Function(PointerSignalEvent event);
+
+/// An resolver for pointer signal events.
+///
+/// Objects interested in a [PointerSignalEvent] should register a callback to
+/// be called if they should handle the event. The resolver's purpose is to
+/// ensure that the same pointer signal is not handled by multiple objects in
+/// a hierarchy.
+///
+/// Pointer signals are immediate, so unlike a gesture arena it always resolves
+/// at the end of event dispatch. The first callback registered will be the one
+/// that is called.
+class PointerSignalResolver {
+  PointerSignalResolvedCallback _firstRegisteredCallback;
+
+  PointerSignalEvent _currentEvent;
+
+  /// Registers interest in handling [event].
+  void register(PointerSignalEvent event, PointerSignalResolvedCallback callback) {
+    assert(event != null);
+    assert(callback != null);
+    assert(_currentEvent == null || _currentEvent == event);
+    if (_firstRegisteredCallback != null) {
+      return;
+    }
+    _currentEvent = event;
+    _firstRegisteredCallback = callback;
+  }
+
+  /// Resolves the event, calling the first registered callback if there was
+  /// one.
+  ///
+  /// Called after the framework has finished dispatching the pointer signal
+  /// event.
+  void resolve(PointerSignalEvent event) {
+    if (_firstRegisteredCallback == null) {
+      assert(_currentEvent == null);
+      return;
+    }
+    assert(_currentEvent == event);
+    try {
+    _firstRegisteredCallback(event);
+    } catch (exception, stack) {
+      FlutterError.reportError(FlutterErrorDetails(
+        exception: exception,
+        stack: stack,
+        library: 'gesture library',
+        context: 'while resolving a PointerSignalEvent',
+        informationCollector: (StringBuffer information) {
+          information.writeln('Event:');
+          information.write('  $event');
+        }
+      ));
+    }
+    _firstRegisteredCallback = null;
+    _currentEvent = null;
+  }
+}
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart
index 064cb6b..f5bb6d0 100644
--- a/packages/flutter/lib/src/rendering/proxy_box.dart
+++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -2488,6 +2488,11 @@
 /// Used by [Listener] and [RenderPointerListener].
 typedef PointerCancelEventListener = void Function(PointerCancelEvent event);
 
+/// Signature for listening to [PointerSignalEvent] events.
+///
+/// Used by [Listener] and [RenderPointerListener].
+typedef PointerSignalEventListener = void Function(PointerSignalEvent event);
+
 /// Calls callbacks in response to pointer events.
 ///
 /// If it has a child, defers to the child for sizing behavior.
@@ -2509,6 +2514,7 @@
     PointerExitEventListener onPointerExit,
     this.onPointerUp,
     this.onPointerCancel,
+    this.onPointerSignal,
     HitTestBehavior behavior = HitTestBehavior.deferToChild,
     RenderBox child,
   })  : _onPointerEnter = onPointerEnter,
@@ -2579,6 +2585,9 @@
   /// no longer directed towards this receiver.
   PointerCancelEventListener onPointerCancel;
 
+  /// Called when a pointer signal occures over this object.
+  PointerSignalEventListener onPointerSignal;
+
   // Object used for annotation of the layer used for hover hit detection.
   MouseTrackerAnnotation _hoverAnnotation;
 
@@ -2647,6 +2656,8 @@
       return onPointerUp(event);
     if (onPointerCancel != null && event is PointerCancelEvent)
       return onPointerCancel(event);
+    if (onPointerSignal != null && event is PointerSignalEvent)
+      return onPointerSignal(event);
   }
 
   @override
@@ -2667,6 +2678,8 @@
       listeners.add('up');
     if (onPointerCancel != null)
       listeners.add('cancel');
+    if (onPointerSignal != null)
+      listeners.add('signal');
     if (listeners.isEmpty)
       listeners.add('<none>');
     properties.add(IterableProperty<String>('listeners', listeners));
diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart
index ba08715..e543d2b 100644
--- a/packages/flutter/lib/src/widgets/basic.dart
+++ b/packages/flutter/lib/src/widgets/basic.dart
@@ -5240,6 +5240,7 @@
     this.onPointerHover,
     this.onPointerUp,
     this.onPointerCancel,
+    this.onPointerSignal,
     this.behavior = HitTestBehavior.deferToChild,
     Widget child,
   }) : assert(behavior != null),
@@ -5288,6 +5289,9 @@
   /// no longer directed towards this receiver.
   final PointerCancelEventListener onPointerCancel;
 
+  /// Called when a pointer signal occurs over this object.
+  final PointerSignalEventListener onPointerSignal;
+
   /// How to behave during hit testing.
   final HitTestBehavior behavior;
 
@@ -5301,6 +5305,7 @@
       onPointerExit: onPointerExit,
       onPointerUp: onPointerUp,
       onPointerCancel: onPointerCancel,
+      onPointerSignal: onPointerSignal,
       behavior: behavior,
     );
   }
@@ -5315,6 +5320,7 @@
       ..onPointerExit = onPointerExit
       ..onPointerUp = onPointerUp
       ..onPointerCancel = onPointerCancel
+      ..onPointerSignal = onPointerSignal
       ..behavior = behavior;
   }
 
@@ -5336,6 +5342,8 @@
       listeners.add('up');
     if (onPointerCancel != null)
       listeners.add('cancel');
+    if (onPointerSignal != null)
+      listeners.add('signal');
     properties.add(IterableProperty<String>('listeners', listeners, ifEmpty: '<none>'));
     properties.add(EnumProperty<HitTestBehavior>('behavior', behavior));
   }
diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart
index f6c344e..1d3c208 100644
--- a/packages/flutter/lib/src/widgets/scrollable.dart
+++ b/packages/flutter/lib/src/widgets/scrollable.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:math' as math;
 import 'dart:ui';
 
 import 'package:flutter/gestures.dart';
@@ -520,6 +521,35 @@
     _drag = null;
   }
 
+  // SCROLL WHEEL
+
+  // Returns the offset that should result from applying [event] to the current
+  // position, taking min/max scroll extent into account.
+  double _targetScrollOffsetForPointerScroll(PointerScrollEvent event) {
+    final double delta = widget.axis == Axis.horizontal
+        ? event.scrollDelta.dx
+        : event.scrollDelta.dy;
+    return math.min(math.max(position.pixels + delta, position.minScrollExtent),
+        position.maxScrollExtent);
+  }
+
+  void _receivedPointerSignal(PointerSignalEvent event) {
+    if (event is PointerScrollEvent && position != null) {
+      final double targetScrollOffset = _targetScrollOffsetForPointerScroll(event);
+      // Only express interest in the event if it would actually result in a scroll.
+      if (targetScrollOffset != position.pixels) {
+        GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
+      }
+    }
+  }
+
+  void _handlePointerScroll(PointerEvent event) {
+    assert(event is PointerScrollEvent);
+    final double targetScrollOffset = _targetScrollOffsetForPointerScroll(event);
+    if (targetScrollOffset != position.pixels) {
+      position.jumpTo(targetScrollOffset);
+    }
+  }
 
   // DESCRIPTION
 
@@ -538,18 +568,21 @@
       scrollable: this,
       position: position,
       // TODO(ianh): Having all these global keys is sad.
-      child: RawGestureDetector(
-        key: _gestureDetectorKey,
-        gestures: _gestureRecognizers,
-        behavior: HitTestBehavior.opaque,
-        excludeFromSemantics: widget.excludeFromSemantics,
-        child: Semantics(
-          explicitChildNodes: !widget.excludeFromSemantics,
-          child: IgnorePointer(
-            key: _ignorePointerKey,
-            ignoring: _shouldIgnorePointer,
-            ignoringSemantics: false,
-            child: widget.viewportBuilder(context, position),
+      child: Listener(
+        onPointerSignal: _receivedPointerSignal,
+        child: RawGestureDetector(
+          key: _gestureDetectorKey,
+          gestures: _gestureRecognizers,
+          behavior: HitTestBehavior.opaque,
+          excludeFromSemantics: widget.excludeFromSemantics,
+          child: Semantics(
+            explicitChildNodes: !widget.excludeFromSemantics,
+            child: IgnorePointer(
+              key: _ignorePointerKey,
+              ignoring: _shouldIgnorePointer,
+              ignoringSemantics: false,
+              child: widget.viewportBuilder(context, position),
+            ),
           ),
         ),
       ),
diff --git a/packages/flutter/test/gestures/gesture_binding_test.dart b/packages/flutter/test/gestures/gesture_binding_test.dart
index d9424ad..31f1477 100644
--- a/packages/flutter/test/gestures/gesture_binding_test.dart
+++ b/packages/flutter/test/gestures/gesture_binding_test.dart
@@ -203,4 +203,50 @@
     expect(events[3].runtimeType, equals(PointerCancelEvent));
     expect(events[4].runtimeType, equals(PointerRemovedEvent));
   });
+
+  test('Can expand pointer scroll events', () {
+    const ui.PointerDataPacket packet = ui.PointerDataPacket(
+        data: <ui.PointerData>[
+          ui.PointerData(change: ui.PointerChange.add),
+          ui.PointerData(change: ui.PointerChange.hover, signalKind: ui.PointerSignalKind.scroll),
+        ]
+    );
+
+    final List<PointerEvent> events = PointerEventConverter.expand(
+      packet.data, ui.window.devicePixelRatio).toList();
+
+    expect(events.length, 2);
+    expect(events[0].runtimeType, equals(PointerAddedEvent));
+    expect(events[1].runtimeType, equals(PointerScrollEvent));
+  });
+
+  test('Synthetic hover/move for misplaced scrolls', () {
+    final Offset lastLocation = const Offset(10.0, 10.0) * ui.window.devicePixelRatio;
+    const Offset unexpectedOffset = Offset(5.0, 7.0);
+    final Offset scrollLocation = lastLocation + unexpectedOffset * ui.window.devicePixelRatio;
+    final ui.PointerDataPacket packet = ui.PointerDataPacket(
+      data: <ui.PointerData>[
+        ui.PointerData(change: ui.PointerChange.add, physicalX: lastLocation.dx, physicalY: lastLocation.dy),
+        ui.PointerData(change: ui.PointerChange.hover, physicalX: scrollLocation.dx, physicalY: scrollLocation.dy, signalKind: ui.PointerSignalKind.scroll),
+        // Move back to starting location, click, and repeat to test mouse-down version.
+        ui.PointerData(change: ui.PointerChange.hover, physicalX: lastLocation.dx, physicalY: lastLocation.dy),
+        ui.PointerData(change: ui.PointerChange.down, physicalX: lastLocation.dx, physicalY: lastLocation.dy),
+        ui.PointerData(change: ui.PointerChange.hover, physicalX: scrollLocation.dx, physicalY: scrollLocation.dy, signalKind: ui.PointerSignalKind.scroll),
+      ]
+    );
+
+    final List<PointerEvent> events = PointerEventConverter.expand(
+      packet.data, ui.window.devicePixelRatio).toList();
+
+    expect(events.length, 7);
+    expect(events[0].runtimeType, equals(PointerAddedEvent));
+    expect(events[1].runtimeType, equals(PointerHoverEvent));
+    expect(events[1].delta, equals(unexpectedOffset));
+    expect(events[2].runtimeType, equals(PointerScrollEvent));
+    expect(events[3].runtimeType, equals(PointerHoverEvent));
+    expect(events[4].runtimeType, equals(PointerDownEvent));
+    expect(events[5].runtimeType, equals(PointerMoveEvent));
+    expect(events[5].delta, equals(unexpectedOffset));
+    expect(events[6].runtimeType, equals(PointerScrollEvent));
+  });
 }
diff --git a/packages/flutter/test/gestures/pointer_signal_resolver_test.dart b/packages/flutter/test/gestures/pointer_signal_resolver_test.dart
new file mode 100644
index 0000000..5eda486
--- /dev/null
+++ b/packages/flutter/test/gestures/pointer_signal_resolver_test.dart
@@ -0,0 +1,70 @@
+// Copyright 2019 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 'package:flutter/gestures.dart';
+
+import '../flutter_test_alternative.dart';
+
+class TestPointerSignalListener {
+  TestPointerSignalListener(this.event);
+
+  final PointerSignalEvent event;
+  bool callbackRan = false;
+
+  void callback(PointerSignalEvent event) {
+    expect(event, equals(this.event));
+    expect(callbackRan, isFalse);
+    callbackRan = true;
+  }
+}
+
+class PointerSignalTester {
+  final PointerSignalResolver resolver = PointerSignalResolver();
+  PointerSignalEvent event = const PointerScrollEvent();
+
+  TestPointerSignalListener addListener() {
+    final TestPointerSignalListener listener = TestPointerSignalListener(event);
+    resolver.register(event, listener.callback);
+    return listener;
+  }
+
+  /// Simulates a new event dispatch cycle by resolving the current event and
+  /// setting a new event to use for future calls.
+  void resolve() {
+    resolver.resolve(event);
+    event = const PointerScrollEvent();
+  }
+}
+
+void main() {
+  test('Resolving with no entries should be a no-op', () {
+    final PointerSignalTester tester = PointerSignalTester();
+    tester.resolver.resolve(tester.event);
+  });
+
+  test('First entry should always win', () {
+    final PointerSignalTester tester = PointerSignalTester();
+    final TestPointerSignalListener first = tester.addListener();
+    final TestPointerSignalListener second = tester.addListener();
+    tester.resolve();
+    expect(first.callbackRan, isTrue);
+    expect(second.callbackRan, isFalse);
+  });
+
+  test('Re-use after resolve should work', () {
+    final PointerSignalTester tester = PointerSignalTester();
+    final TestPointerSignalListener first = tester.addListener();
+    final TestPointerSignalListener second = tester.addListener();
+    tester.resolve();
+    expect(first.callbackRan, isTrue);
+    expect(second.callbackRan, isFalse);
+
+    final TestPointerSignalListener newEventListener = tester.addListener();
+    tester.resolve();
+    expect(newEventListener.callbackRan, isTrue);
+    // Nothing should have changed for the previous event's listeners.
+    expect(first.callbackRan, isTrue);
+    expect(second.callbackRan, isFalse);
+  });
+}
diff --git a/packages/flutter/test/widgets/keep_alive_test.dart b/packages/flutter/test/widgets/keep_alive_test.dart
index a170181..dfb6ec4 100644
--- a/packages/flutter/test/widgets/keep_alive_test.dart
+++ b/packages/flutter/test/widgets/keep_alive_test.dart
@@ -236,92 +236,99 @@
       '         │ semantic boundary\n'
       '         │ size: Size(800.0, 600.0)\n'
       '         │\n'
-      '         └─child: RenderSemanticsGestureHandler#00000\n'
+      '         └─child: RenderPointerListener#00000\n'
       '           │ parentData: <none> (can use size)\n'
       '           │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '           │ size: Size(800.0, 600.0)\n'
-      '           │ gestures: vertical scroll\n'
+      '           │ behavior: deferToChild\n'
+      '           │ listeners: signal\n'
       '           │\n'
-      '           └─child: RenderPointerListener#00000\n'
+      '           └─child: RenderSemanticsGestureHandler#00000\n'
       '             │ parentData: <none> (can use size)\n'
       '             │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '             │ size: Size(800.0, 600.0)\n'
-      '             │ behavior: opaque\n'
-      '             │ listeners: down\n'
+      '             │ gestures: vertical scroll\n'
       '             │\n'
-      '             └─child: RenderSemanticsAnnotations#00000\n'
+      '             └─child: RenderPointerListener#00000\n'
       '               │ parentData: <none> (can use size)\n'
       '               │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '               │ size: Size(800.0, 600.0)\n'
+      '               │ behavior: opaque\n'
+      '               │ listeners: down\n'
       '               │\n'
-      '               └─child: RenderIgnorePointer#00000\n'
+      '               └─child: RenderSemanticsAnnotations#00000\n'
       '                 │ parentData: <none> (can use size)\n'
       '                 │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '                 │ size: Size(800.0, 600.0)\n'
-      '                 │ ignoring: false\n'
-      '                 │ ignoringSemantics: false\n'
       '                 │\n'
-      '                 └─child: RenderViewport#00000\n'
+      '                 └─child: RenderIgnorePointer#00000\n'
       '                   │ parentData: <none> (can use size)\n'
       '                   │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
-      '                   │ layer: OffsetLayer#00000\n'
       '                   │ size: Size(800.0, 600.0)\n'
-      '                   │ axisDirection: down\n'
-      '                   │ crossAxisDirection: right\n'
-      '                   │ offset: ScrollPositionWithSingleContext#00000(offset: 0.0, range:\n'
-      '                   │   0.0..39400.0, viewport: 600.0, ScrollableState,\n'
-      '                   │   AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
-      '                   │   IdleScrollActivity#00000, ScrollDirection.idle)\n'
-      '                   │ anchor: 0.0\n'
+      '                   │ ignoring: false\n'
+      '                   │ ignoringSemantics: false\n'
       '                   │\n'
-      '                   └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
-      '                     │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
-      '                     │ constraints: SliverConstraints(AxisDirection.down,\n'
-      '                     │   GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
-      '                     │   0.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
-      '                     │   crossAxisDirection: AxisDirection.right,\n'
-      '                     │   viewportMainAxisExtent: 600.0, remainingCacheExtent: 850.0\n'
-      '                     │   cacheOrigin: 0.0 )\n'
-      '                     │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
-      '                     │   600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n'
-      '                     │   cacheExtent: 850.0)\n'
-      '                     │ currently live children: 0 to 2\n'
+      '                   └─child: RenderViewport#00000\n'
+      '                     │ parentData: <none> (can use size)\n'
+      '                     │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '                     │ layer: OffsetLayer#00000\n'
+      '                     │ size: Size(800.0, 600.0)\n'
+      '                     │ axisDirection: down\n'
+      '                     │ crossAxisDirection: right\n'
+      '                     │ offset: ScrollPositionWithSingleContext#00000(offset: 0.0, range:\n'
+      '                     │   0.0..39400.0, viewport: 600.0, ScrollableState,\n'
+      '                     │   AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
+      '                     │   IdleScrollActivity#00000, ScrollDirection.idle)\n'
+      '                     │ anchor: 0.0\n'
       '                     │\n'
-      '                     ├─child with index 0: RenderLimitedBox#00000\n'
-      '                     │ │ parentData: index=0; layoutOffset=0.0\n'
-      '                     │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │ │ size: Size(800.0, 400.0)\n'
-      '                     │ │ maxWidth: 400.0\n'
-      '                     │ │ maxHeight: 400.0\n'
-      '                     │ │\n'
-      '                     │ └─child: RenderCustomPaint#00000\n'
-      '                     │     parentData: <none> (can use size)\n'
-      '                     │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │     size: Size(800.0, 400.0)\n'
-      '                     │\n'
-      '                     ├─child with index 1: RenderLimitedBox#00000\n'                                       // <----- no dashed line starts here
-      '                     │ │ parentData: index=1; layoutOffset=400.0\n'
-      '                     │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │ │ size: Size(800.0, 400.0)\n'
-      '                     │ │ maxWidth: 400.0\n'
-      '                     │ │ maxHeight: 400.0\n'
-      '                     │ │\n'
-      '                     │ └─child: RenderCustomPaint#00000\n'
-      '                     │     parentData: <none> (can use size)\n'
-      '                     │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │     size: Size(800.0, 400.0)\n'
-      '                     │\n'
-      '                     └─child with index 2: RenderLimitedBox#00000 NEEDS-PAINT\n'
-      '                       │ parentData: index=2; layoutOffset=800.0\n'
-      '                       │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                       │ size: Size(800.0, 400.0)\n'
-      '                       │ maxWidth: 400.0\n'
-      '                       │ maxHeight: 400.0\n'
+      '                     └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
+      '                       │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
+      '                       │ constraints: SliverConstraints(AxisDirection.down,\n'
+      '                       │   GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
+      '                       │   0.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
+      '                       │   crossAxisDirection: AxisDirection.right,\n'
+      '                       │   viewportMainAxisExtent: 600.0, remainingCacheExtent: 850.0\n'
+      '                       │   cacheOrigin: 0.0 )\n'
+      '                       │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
+      '                       │   600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n'
+      '                       │   cacheExtent: 850.0)\n'
+      '                       │ currently live children: 0 to 2\n'
       '                       │\n'
-      '                       └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
-      '                           parentData: <none> (can use size)\n'
-      '                           constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                           size: Size(800.0, 400.0)\n'
+      '                       ├─child with index 0: RenderLimitedBox#00000\n'
+      '                       │ │ parentData: index=0; layoutOffset=0.0\n'
+      '                       │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │ │ size: Size(800.0, 400.0)\n'
+      '                       │ │ maxWidth: 400.0\n'
+      '                       │ │ maxHeight: 400.0\n'
+      '                       │ │\n'
+      '                       │ └─child: RenderCustomPaint#00000\n'
+      '                       │     parentData: <none> (can use size)\n'
+      '                       │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │     size: Size(800.0, 400.0)\n'
+      '                       │\n'
+      '                       ├─child with index 1: RenderLimitedBox#00000\n'                                     // <----- no dashed line starts here
+      '                       │ │ parentData: index=1; layoutOffset=400.0\n'
+      '                       │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │ │ size: Size(800.0, 400.0)\n'
+      '                       │ │ maxWidth: 400.0\n'
+      '                       │ │ maxHeight: 400.0\n'
+      '                       │ │\n'
+      '                       │ └─child: RenderCustomPaint#00000\n'
+      '                       │     parentData: <none> (can use size)\n'
+      '                       │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │     size: Size(800.0, 400.0)\n'
+      '                       │\n'
+      '                       └─child with index 2: RenderLimitedBox#00000 NEEDS-PAINT\n'
+      '                         │ parentData: index=2; layoutOffset=800.0\n'
+      '                         │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                         │ size: Size(800.0, 400.0)\n'
+      '                         │ maxWidth: 400.0\n'
+      '                         │ maxHeight: 400.0\n'
+      '                         │\n'
+      '                         └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
+      '                             parentData: <none> (can use size)\n'
+      '                             constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                             size: Size(800.0, 400.0)\n'
     ));
     const GlobalObjectKey<_LeafState>(0).currentState.setKeepAlive(true);
     await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
@@ -365,128 +372,135 @@
       '         │ semantic boundary\n'
       '         │ size: Size(800.0, 600.0)\n'
       '         │\n'
-      '         └─child: RenderSemanticsGestureHandler#00000\n'
+      '         └─child: RenderPointerListener#00000\n'
       '           │ parentData: <none> (can use size)\n'
       '           │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '           │ size: Size(800.0, 600.0)\n'
-      '           │ gestures: vertical scroll\n'
+      '           │ behavior: deferToChild\n'
+      '           │ listeners: signal\n'
       '           │\n'
-      '           └─child: RenderPointerListener#00000\n'
+      '           └─child: RenderSemanticsGestureHandler#00000\n'
       '             │ parentData: <none> (can use size)\n'
       '             │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '             │ size: Size(800.0, 600.0)\n'
-      '             │ behavior: opaque\n'
-      '             │ listeners: down\n'
+      '             │ gestures: vertical scroll\n'
       '             │\n'
-      '             └─child: RenderSemanticsAnnotations#00000\n'
+      '             └─child: RenderPointerListener#00000\n'
       '               │ parentData: <none> (can use size)\n'
       '               │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '               │ size: Size(800.0, 600.0)\n'
+      '               │ behavior: opaque\n'
+      '               │ listeners: down\n'
       '               │\n'
-      '               └─child: RenderIgnorePointer#00000\n'
+      '               └─child: RenderSemanticsAnnotations#00000\n'
       '                 │ parentData: <none> (can use size)\n'
       '                 │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
       '                 │ size: Size(800.0, 600.0)\n'
-      '                 │ ignoring: false\n'
-      '                 │ ignoringSemantics: false\n'
       '                 │\n'
-      '                 └─child: RenderViewport#00000\n'
+      '                 └─child: RenderIgnorePointer#00000\n'
       '                   │ parentData: <none> (can use size)\n'
       '                   │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
-      '                   │ layer: OffsetLayer#00000\n'
       '                   │ size: Size(800.0, 600.0)\n'
-      '                   │ axisDirection: down\n'
-      '                   │ crossAxisDirection: right\n'
-      '                   │ offset: ScrollPositionWithSingleContext#00000(offset: 2000.0,\n'
-      '                   │   range: 0.0..39400.0, viewport: 600.0, ScrollableState,\n'
-      '                   │   AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
-      '                   │   IdleScrollActivity#00000, ScrollDirection.idle)\n'
-      '                   │ anchor: 0.0\n'
+      '                   │ ignoring: false\n'
+      '                   │ ignoringSemantics: false\n'
       '                   │\n'
-      '                   └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
-      '                     │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
-      '                     │ constraints: SliverConstraints(AxisDirection.down,\n'
-      '                     │   GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
-      '                     │   2000.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
-      '                     │   crossAxisDirection: AxisDirection.right,\n'
-      '                     │   viewportMainAxisExtent: 600.0, remainingCacheExtent: 1100.0\n'
-      '                     │   cacheOrigin: -250.0 )\n'
-      '                     │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
-      '                     │   600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n'
-      '                     │   cacheExtent: 1100.0)\n'
-      '                     │ currently live children: 4 to 7\n'
+      '                   └─child: RenderViewport#00000\n'
+      '                     │ parentData: <none> (can use size)\n'
+      '                     │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
+      '                     │ layer: OffsetLayer#00000\n'
+      '                     │ size: Size(800.0, 600.0)\n'
+      '                     │ axisDirection: down\n'
+      '                     │ crossAxisDirection: right\n'
+      '                     │ offset: ScrollPositionWithSingleContext#00000(offset: 2000.0,\n'
+      '                     │   range: 0.0..39400.0, viewport: 600.0, ScrollableState,\n'
+      '                     │   AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
+      '                     │   IdleScrollActivity#00000, ScrollDirection.idle)\n'
+      '                     │ anchor: 0.0\n'
       '                     │\n'
-      '                     ├─child with index 4: RenderLimitedBox#00000 NEEDS-PAINT\n'
-      '                     │ │ parentData: index=4; layoutOffset=1600.0\n'
-      '                     │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │ │ size: Size(800.0, 400.0)\n'
-      '                     │ │ maxWidth: 400.0\n'
-      '                     │ │ maxHeight: 400.0\n'
-      '                     │ │\n'
-      '                     │ └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
-      '                     │     parentData: <none> (can use size)\n'
-      '                     │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │     size: Size(800.0, 400.0)\n'
-      '                     │\n'
-      '                     ├─child with index 5: RenderLimitedBox#00000\n'                                       // <----- this is index 5, not 0
-      '                     │ │ parentData: index=5; layoutOffset=2000.0\n'
-      '                     │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │ │ size: Size(800.0, 400.0)\n'
-      '                     │ │ maxWidth: 400.0\n'
-      '                     │ │ maxHeight: 400.0\n'
-      '                     │ │\n'
-      '                     │ └─child: RenderCustomPaint#00000\n'
-      '                     │     parentData: <none> (can use size)\n'
-      '                     │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │     size: Size(800.0, 400.0)\n'
-      '                     │\n'
-      '                     ├─child with index 6: RenderLimitedBox#00000\n'
-      '                     │ │ parentData: index=6; layoutOffset=2400.0\n'
-      '                     │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │ │ size: Size(800.0, 400.0)\n'
-      '                     │ │ maxWidth: 400.0\n'
-      '                     │ │ maxHeight: 400.0\n'
-      '                     │ │\n'
-      '                     │ └─child: RenderCustomPaint#00000\n'
-      '                     │     parentData: <none> (can use size)\n'
-      '                     │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     │     size: Size(800.0, 400.0)\n'
-      '                     │\n'
-      '                     ├─child with index 7: RenderLimitedBox#00000 NEEDS-PAINT\n'
-      '                     ╎ │ parentData: index=7; layoutOffset=2800.0\n'
-      '                     ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     ╎ │ size: Size(800.0, 400.0)\n'
-      '                     ╎ │ maxWidth: 400.0\n'
-      '                     ╎ │ maxHeight: 400.0\n'
-      '                     ╎ │\n'
-      '                     ╎ └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
-      '                     ╎     parentData: <none> (can use size)\n'
-      '                     ╎     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     ╎     size: Size(800.0, 400.0)\n'
-      '                     ╎\n'
-      '                     ╎╌child with index 0 (kept alive but not laid out): RenderLimitedBox#00000\n'                 // <----- this one is index 0 and is marked as being kept alive but not laid out
-      '                     ╎ │ parentData: index=0; keepAlive; layoutOffset=0.0\n'
-      '                     ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     ╎ │ size: Size(800.0, 400.0)\n'
-      '                     ╎ │ maxWidth: 400.0\n'
-      '                     ╎ │ maxHeight: 400.0\n'
-      '                     ╎ │\n'
-      '                     ╎ └─child: RenderCustomPaint#00000\n'
-      '                     ╎     parentData: <none> (can use size)\n'
-      '                     ╎     constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                     ╎     size: Size(800.0, 400.0)\n'
-      '                     ╎\n'                                                                                  // <----- dashed line ends here
-      '                     └╌child with index 3 (kept alive but not laid out): RenderLimitedBox#00000\n'
-      '                       │ parentData: index=3; keepAlive; layoutOffset=1200.0\n'
-      '                       │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                       │ size: Size(800.0, 400.0)\n'
-      '                       │ maxWidth: 400.0\n'
-      '                       │ maxHeight: 400.0\n'
+      '                     └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
+      '                       │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
+      '                       │ constraints: SliverConstraints(AxisDirection.down,\n'
+      '                       │   GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
+      '                       │   2000.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
+      '                       │   crossAxisDirection: AxisDirection.right,\n'
+      '                       │   viewportMainAxisExtent: 600.0, remainingCacheExtent: 1100.0\n'
+      '                       │   cacheOrigin: -250.0 )\n'
+      '                       │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
+      '                       │   600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n'
+      '                       │   cacheExtent: 1100.0)\n'
+      '                       │ currently live children: 4 to 7\n'
       '                       │\n'
-      '                       └─child: RenderCustomPaint#00000\n'
-      '                           parentData: <none> (can use size)\n'
-      '                           constraints: BoxConstraints(w=800.0, h=400.0)\n'
-      '                           size: Size(800.0, 400.0)\n'
+      '                       ├─child with index 4: RenderLimitedBox#00000 NEEDS-PAINT\n'
+      '                       │ │ parentData: index=4; layoutOffset=1600.0\n'
+      '                       │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │ │ size: Size(800.0, 400.0)\n'
+      '                       │ │ maxWidth: 400.0\n'
+      '                       │ │ maxHeight: 400.0\n'
+      '                       │ │\n'
+      '                       │ └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
+      '                       │     parentData: <none> (can use size)\n'
+      '                       │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │     size: Size(800.0, 400.0)\n'
+      '                       │\n'
+      '                       ├─child with index 5: RenderLimitedBox#00000\n'                                     // <----- this is index 5, not 0
+      '                       │ │ parentData: index=5; layoutOffset=2000.0\n'
+      '                       │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │ │ size: Size(800.0, 400.0)\n'
+      '                       │ │ maxWidth: 400.0\n'
+      '                       │ │ maxHeight: 400.0\n'
+      '                       │ │\n'
+      '                       │ └─child: RenderCustomPaint#00000\n'
+      '                       │     parentData: <none> (can use size)\n'
+      '                       │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │     size: Size(800.0, 400.0)\n'
+      '                       │\n'
+      '                       ├─child with index 6: RenderLimitedBox#00000\n'
+      '                       │ │ parentData: index=6; layoutOffset=2400.0\n'
+      '                       │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │ │ size: Size(800.0, 400.0)\n'
+      '                       │ │ maxWidth: 400.0\n'
+      '                       │ │ maxHeight: 400.0\n'
+      '                       │ │\n'
+      '                       │ └─child: RenderCustomPaint#00000\n'
+      '                       │     parentData: <none> (can use size)\n'
+      '                       │     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       │     size: Size(800.0, 400.0)\n'
+      '                       │\n'
+      '                       ├─child with index 7: RenderLimitedBox#00000 NEEDS-PAINT\n'
+      '                       ╎ │ parentData: index=7; layoutOffset=2800.0\n'
+      '                       ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       ╎ │ size: Size(800.0, 400.0)\n'
+      '                       ╎ │ maxWidth: 400.0\n'
+      '                       ╎ │ maxHeight: 400.0\n'
+      '                       ╎ │\n'
+      '                       ╎ └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
+      '                       ╎     parentData: <none> (can use size)\n'
+      '                       ╎     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       ╎     size: Size(800.0, 400.0)\n'
+      '                       ╎\n'
+      '                       ╎╌child with index 0 (kept alive but not laid out): RenderLimitedBox#00000\n'               // <----- this one is index 0 and is marked as being kept alive but not laid out
+      '                       ╎ │ parentData: index=0; keepAlive; layoutOffset=0.0\n'
+      '                       ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       ╎ │ size: Size(800.0, 400.0)\n'
+      '                       ╎ │ maxWidth: 400.0\n'
+      '                       ╎ │ maxHeight: 400.0\n'
+      '                       ╎ │\n'
+      '                       ╎ └─child: RenderCustomPaint#00000\n'
+      '                       ╎     parentData: <none> (can use size)\n'
+      '                       ╎     constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                       ╎     size: Size(800.0, 400.0)\n'
+      '                       ╎\n'                                                                                // <----- dashed line ends here
+      '                       └╌child with index 3 (kept alive but not laid out): RenderLimitedBox#00000\n'
+      '                         │ parentData: index=3; keepAlive; layoutOffset=1200.0\n'
+      '                         │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                         │ size: Size(800.0, 400.0)\n'
+      '                         │ maxWidth: 400.0\n'
+      '                         │ maxHeight: 400.0\n'
+      '                         │\n'
+      '                         └─child: RenderCustomPaint#00000\n'
+      '                             parentData: <none> (can use size)\n'
+      '                             constraints: BoxConstraints(w=800.0, h=400.0)\n'
+      '                             size: Size(800.0, 400.0)\n'
     ));
   });
 
diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart
index 83b69cf..cc21072 100644
--- a/packages/flutter/test/widgets/scrollable_test.dart
+++ b/packages/flutter/test/widgets/scrollable_test.dart
@@ -2,6 +2,8 @@
 // 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_test/flutter_test.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -220,4 +222,18 @@
     await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180));
     expect(getScrollOffset(tester), 32.5);
   });
+
+  testWidgets('Scroll pointer signals are handled', (WidgetTester tester) async {
+    await pumpTest(tester, TargetPlatform.fuchsia);
+    final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport));
+    final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse);
+    // Create a hover event so that |testPointer| has a location when generating the scroll.
+    testPointer.hover(scrollEventLocation);
+    final HitTestResult result = tester.hitTestOnBinding(scrollEventLocation);
+    await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)), result);
+    expect(getScrollOffset(tester), 20.0);
+    // Pointer signals should not cause overscroll.
+    await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0)), result);
+    expect(getScrollOffset(tester), 0.0);
+  });
 }
diff --git a/packages/flutter_test/lib/src/test_pointer.dart b/packages/flutter_test/lib/src/test_pointer.dart
index 69ec5c5..2541014 100644
--- a/packages/flutter_test/lib/src/test_pointer.dart
+++ b/packages/flutter_test/lib/src/test_pointer.dart
@@ -169,6 +169,26 @@
       delta: delta,
     );
   }
+
+  /// Create a [PointerScrollEvent] (e.g., scroll wheel scroll; not finger-drag
+  /// scroll) with the given delta.
+  ///
+  /// By default, the time stamp on the event is [Duration.zero]. You can give a
+  /// specific time stamp by passing the `timeStamp` argument.
+  PointerScrollEvent scroll(
+    Offset scrollDelta, {
+    Duration timeStamp = Duration.zero,
+  }) {
+    assert(scrollDelta != null);
+    assert(timeStamp != null);
+    assert(kind != PointerDeviceKind.touch, "Touch pointers can't generate pointer signal events");
+    return PointerScrollEvent(
+      timeStamp: timeStamp,
+      kind: kind,
+      position: location,
+      scrollDelta: scrollDelta,
+    );
+  }
 }
 
 /// Signature for a callback that can dispatch events and returns a future that