diff --git a/packages/flutter/lib/gestures.dart b/packages/flutter/lib/gestures.dart index 04f23e5c627..7e308941fdd 100644 --- a/packages/flutter/lib/gestures.dart +++ b/packages/flutter/lib/gestures.dart @@ -25,6 +25,7 @@ export 'src/gestures/mouse_tracking.dart'; 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 796d33c653c..8c7bdf8f7a8 100644 --- a/packages/flutter/lib/src/gestures/binding.dart +++ b/packages/flutter/lib/src/gestures/binding.dart @@ -14,6 +14,7 @@ import 'debug.dart'; 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 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H /// 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 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H 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 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H 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 ddb7321fa08..0364c59489c 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 @@ class PointerEventConverter { 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 @@ class PointerEventConverter { 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 @@ class PointerEventConverter { 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 @@ class PointerEventConverter { 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 099ef69fff0..e75608e8382 100644 --- a/packages/flutter/lib/src/gestures/events.dart +++ b/packages/flutter/lib/src/gestures/events.dart @@ -790,6 +790,65 @@ class PointerUpEvent extends PointerEvent { ); } +/// 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('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 00000000000..c70a01dfe2d --- /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 064cb6b1cd7..f5bb6d0c797 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -2488,6 +2488,11 @@ typedef PointerUpEventListener = void Function(PointerUpEvent event); /// 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 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { PointerExitEventListener onPointerExit, this.onPointerUp, this.onPointerCancel, + this.onPointerSignal, HitTestBehavior behavior = HitTestBehavior.deferToChild, RenderBox child, }) : _onPointerEnter = onPointerEnter, @@ -2579,6 +2585,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { /// 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 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { 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 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { listeners.add('up'); if (onPointerCancel != null) listeners.add('cancel'); + if (onPointerSignal != null) + listeners.add('signal'); if (listeners.isEmpty) listeners.add(''); properties.add(IterableProperty('listeners', listeners)); diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index ba087154912..e543d2bdfec 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -5240,6 +5240,7 @@ class Listener extends SingleChildRenderObjectWidget { this.onPointerHover, this.onPointerUp, this.onPointerCancel, + this.onPointerSignal, this.behavior = HitTestBehavior.deferToChild, Widget child, }) : assert(behavior != null), @@ -5288,6 +5289,9 @@ class Listener extends SingleChildRenderObjectWidget { /// 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 @@ class Listener extends SingleChildRenderObjectWidget { onPointerExit: onPointerExit, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, + onPointerSignal: onPointerSignal, behavior: behavior, ); } @@ -5315,6 +5320,7 @@ class Listener extends SingleChildRenderObjectWidget { ..onPointerExit = onPointerExit ..onPointerUp = onPointerUp ..onPointerCancel = onPointerCancel + ..onPointerSignal = onPointerSignal ..behavior = behavior; } @@ -5336,6 +5342,8 @@ class Listener extends SingleChildRenderObjectWidget { listeners.add('up'); if (onPointerCancel != null) listeners.add('cancel'); + if (onPointerSignal != null) + listeners.add('signal'); properties.add(IterableProperty('listeners', listeners, ifEmpty: '')); properties.add(EnumProperty('behavior', behavior)); } diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index f6c344e3576..1d3c208f15f 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 @@ class ScrollableState extends State with TickerProviderStateMixin _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 @@ class ScrollableState extends State with TickerProviderStateMixin 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 d9424ada153..31f1477f868 100644 --- a/packages/flutter/test/gestures/gesture_binding_test.dart +++ b/packages/flutter/test/gestures/gesture_binding_test.dart @@ -203,4 +203,50 @@ void main() { 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(change: ui.PointerChange.add), + ui.PointerData(change: ui.PointerChange.hover, signalKind: ui.PointerSignalKind.scroll), + ] + ); + + final List 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(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 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 00000000000..5eda48663f4 --- /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 a17018108b6..dfb6ec43c9b 100644 --- a/packages/flutter/test/widgets/keep_alive_test.dart +++ b/packages/flutter/test/widgets/keep_alive_test.dart @@ -236,92 +236,99 @@ void main() { ' │ semantic boundary\n' ' │ size: Size(800.0, 600.0)\n' ' │\n' - ' └─child: RenderSemanticsGestureHandler#00000\n' + ' └─child: RenderPointerListener#00000\n' ' │ parentData: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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 @@ void main() { ' │ semantic boundary\n' ' │ size: Size(800.0, 600.0)\n' ' │\n' - ' └─child: RenderSemanticsGestureHandler#00000\n' + ' └─child: RenderPointerListener#00000\n' ' │ parentData: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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: (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 83b69cf14a9..cc21072a291 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 @@ void main() { 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 69ec5c5599a..2541014f3c5 100644 --- a/packages/flutter_test/lib/src/test_pointer.dart +++ b/packages/flutter_test/lib/src/test_pointer.dart @@ -169,6 +169,26 @@ class TestPointer { 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