Unverified Commit 5922a40e authored by stuartmorgan's avatar stuartmorgan Committed by GitHub

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.
parent c78ccb0b
......@@ -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';
......
......@@ -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);
}
}
}
......
......@@ -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<Offset>('scrollDelta', scrollDelta));
}
}
/// The input from the pointer is no longer directed towards this receiver.
class PointerCancelEvent extends PointerEvent {
/// Creates a pointer cancel event.
......
// 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;
}
}
......@@ -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('<none>');
properties.add(IterableProperty<String>('listeners', listeners));
......
......@@ -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<String>('listeners', listeners, ifEmpty: '<none>'));
properties.add(EnumProperty<HitTestBehavior>('behavior', behavior));
}
......
......@@ -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<Scrollable> 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<Scrollable> 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),
),
),
),
),
......
......@@ -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>[
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));
});
}
// 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);
});
}
......@@ -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);
});
}
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment