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