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'; ...@@ -25,6 +25,7 @@ export 'src/gestures/mouse_tracking.dart';
export 'src/gestures/multidrag.dart'; export 'src/gestures/multidrag.dart';
export 'src/gestures/multitap.dart'; export 'src/gestures/multitap.dart';
export 'src/gestures/pointer_router.dart'; export 'src/gestures/pointer_router.dart';
export 'src/gestures/pointer_signal_resolver.dart';
export 'src/gestures/recognizer.dart'; export 'src/gestures/recognizer.dart';
export 'src/gestures/scale.dart'; export 'src/gestures/scale.dart';
export 'src/gestures/tap.dart'; export 'src/gestures/tap.dart';
......
...@@ -14,6 +14,7 @@ import 'debug.dart'; ...@@ -14,6 +14,7 @@ import 'debug.dart';
import 'events.dart'; import 'events.dart';
import 'hit_test.dart'; import 'hit_test.dart';
import 'pointer_router.dart'; import 'pointer_router.dart';
import 'pointer_signal_resolver.dart';
/// A binding for the gesture subsystem. /// A binding for the gesture subsystem.
/// ///
...@@ -108,6 +109,10 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H ...@@ -108,6 +109,10 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
/// pointer events. /// pointer events.
final GestureArenaManager gestureArena = GestureArenaManager(); 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. /// State for all pointers which are currently down.
/// ///
/// The state of hovering pointers is not tracked because that would require /// The state of hovering pointers is not tracked because that would require
...@@ -117,11 +122,13 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H ...@@ -117,11 +122,13 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
void _handlePointerEvent(PointerEvent event) { void _handlePointerEvent(PointerEvent event) {
assert(!locked); assert(!locked);
HitTestResult hitTestResult; HitTestResult hitTestResult;
if (event is PointerDownEvent) { if (event is PointerDownEvent || event is PointerSignalEvent) {
assert(!_hitTests.containsKey(event.pointer)); assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult(); hitTestResult = HitTestResult();
hitTest(hitTestResult, event.position); hitTest(hitTestResult, event.position);
_hitTests[event.pointer] = hitTestResult; if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
assert(() { assert(() {
if (debugPrintHitTestResults) if (debugPrintHitTestResults)
debugPrint('$event: $hitTestResult'); debugPrint('$event: $hitTestResult');
...@@ -216,6 +223,8 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H ...@@ -216,6 +223,8 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
gestureArena.close(event.pointer); gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) { } else if (event is PointerUpEvent) {
gestureArena.sweep(event.pointer); gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
} }
} }
} }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // 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; import 'package:flutter/foundation.dart' show visibleForTesting;
...@@ -82,32 +82,11 @@ class PointerEventConverter { ...@@ -82,32 +82,11 @@ class PointerEventConverter {
final Duration timeStamp = datum.timeStamp; final Duration timeStamp = datum.timeStamp;
final PointerDeviceKind kind = datum.kind; final PointerDeviceKind kind = datum.kind;
assert(datum.change != null); assert(datum.change != null);
switch (datum.change) { if (datum.signalKind == null || datum.signalKind == ui.PointerSignalKind.none) {
case ui.PointerChange.add: switch (datum.change) {
assert(!_pointers.containsKey(datum.device)); case ui.PointerChange.add:
final _PointerState state = _ensureStateForPointer(datum, position); assert(!_pointers.containsKey(datum.device));
assert(state.lastPosition == position); final _PointerState state = _ensureStateForPointer(datum, 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) {
assert(state.lastPosition == position); assert(state.lastPosition == position);
yield PointerAddedEvent( yield PointerAddedEvent(
timeStamp: timeStamp, timeStamp: timeStamp,
...@@ -124,57 +103,29 @@ class PointerEventConverter { ...@@ -124,57 +103,29 @@ class PointerEventConverter {
orientation: datum.orientation, orientation: datum.orientation,
tilt: datum.tilt, tilt: datum.tilt,
); );
} break;
final Offset offset = position - state.lastPosition; case ui.PointerChange.hover:
state.lastPosition = position; final bool alreadyAdded = _pointers.containsKey(datum.device);
yield PointerHoverEvent( final _PointerState state = _ensureStateForPointer(datum, position);
timeStamp: timeStamp, assert(!state.down);
kind: kind, if (!alreadyAdded) {
device: datum.device, assert(state.lastPosition == position);
position: position, yield PointerAddedEvent(
delta: offset, timeStamp: timeStamp,
buttons: datum.buttons, kind: kind,
obscured: datum.obscured, device: datum.device,
pressureMin: datum.pressureMin, position: position,
pressureMax: datum.pressureMax, obscured: datum.obscured,
distance: datum.distance, pressureMin: datum.pressureMin,
distanceMax: datum.distanceMax, pressureMax: datum.pressureMax,
size: datum.size, distance: datum.distance,
radiusMajor: radiusMajor, distanceMax: datum.distanceMax,
radiusMinor: radiusMinor, radiusMin: radiusMin,
radiusMin: radiusMin, radiusMax: radiusMax,
radiusMax: radiusMax, orientation: datum.orientation,
orientation: datum.orientation, tilt: datum.tilt,
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.
final Offset offset = position - state.lastPosition; final Offset offset = position - state.lastPosition;
state.lastPosition = position; state.lastPosition = position;
yield PointerHoverEvent( yield PointerHoverEvent(
...@@ -196,85 +147,68 @@ class PointerEventConverter { ...@@ -196,85 +147,68 @@ class PointerEventConverter {
radiusMax: radiusMax, radiusMax: radiusMax,
orientation: datum.orientation, orientation: datum.orientation,
tilt: datum.tilt, tilt: datum.tilt,
synthesized: true,
); );
state.lastPosition = position; state.lastPosition = position;
} break;
state.startNewPointer(); case ui.PointerChange.down:
state.setDown(); final bool alreadyAdded = _pointers.containsKey(datum.device);
yield PointerDownEvent( final _PointerState state = _ensureStateForPointer(datum, position);
timeStamp: timeStamp, assert(!state.down);
pointer: state.pointer, if (!alreadyAdded) {
kind: kind, assert(state.lastPosition == position);
device: datum.device, yield PointerAddedEvent(
position: position, timeStamp: timeStamp,
buttons: datum.buttons, kind: kind,
obscured: datum.obscured, device: datum.device,
pressure: datum.pressure, position: position,
pressureMin: datum.pressureMin, obscured: datum.obscured,
pressureMax: datum.pressureMax, pressureMin: datum.pressureMin,
distanceMax: datum.distanceMax, pressureMax: datum.pressureMax,
size: datum.size, distance: datum.distance,
radiusMajor: radiusMajor, distanceMax: datum.distanceMax,
radiusMinor: radiusMinor, radiusMin: radiusMin,
radiusMin: radiusMin, radiusMax: radiusMax,
radiusMax: radiusMax, orientation: datum.orientation,
orientation: datum.orientation, tilt: datum.tilt,
tilt: datum.tilt, );
); }
break; if (state.lastPosition != position) {
case ui.PointerChange.move: // Not all sources of pointer packets respect the invariant that
// If the service starts supporting hover pointers, then it must also // they hover the pointer to the down location before sending the
// start sending us ADDED and REMOVED data points. // down event. We restore the invariant here for our clients.
// See also: https://github.com/flutter/flutter/issues/720 final Offset offset = position - state.lastPosition;
assert(_pointers.containsKey(datum.device)); state.lastPosition = position;
final _PointerState state = _pointers[datum.device]; yield PointerHoverEvent(
assert(state.down); timeStamp: timeStamp,
final Offset offset = position - state.lastPosition; kind: kind,
state.lastPosition = position; device: datum.device,
yield PointerMoveEvent( position: position,
timeStamp: timeStamp, delta: offset,
pointer: state.pointer, buttons: datum.buttons,
kind: kind, obscured: datum.obscured,
device: datum.device, pressureMin: datum.pressureMin,
position: position, pressureMax: datum.pressureMax,
delta: offset, distance: datum.distance,
buttons: datum.buttons, distanceMax: datum.distanceMax,
obscured: datum.obscured, size: datum.size,
pressure: datum.pressure, radiusMajor: radiusMajor,
pressureMin: datum.pressureMin, radiusMinor: radiusMinor,
pressureMax: datum.pressureMax, radiusMin: radiusMin,
distanceMax: datum.distanceMax, radiusMax: radiusMax,
size: datum.size, orientation: datum.orientation,
radiusMajor: radiusMajor, tilt: datum.tilt,
radiusMinor: radiusMinor, synthesized: true,
radiusMin: radiusMin, );
radiusMax: radiusMax, state.lastPosition = position;
orientation: datum.orientation, }
tilt: datum.tilt, state.startNewPointer();
platformData: datum.platformData, state.setDown();
); yield PointerDownEvent(
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, timeStamp: timeStamp,
pointer: state.pointer, pointer: state.pointer,
kind: kind, kind: kind,
device: datum.device, device: datum.device,
position: position, position: position,
delta: offset,
buttons: datum.buttons, buttons: datum.buttons,
obscured: datum.obscured, obscured: datum.obscured,
pressure: datum.pressure, pressure: datum.pressure,
...@@ -288,25 +222,29 @@ class PointerEventConverter { ...@@ -288,25 +222,29 @@ class PointerEventConverter {
radiusMax: radiusMax, radiusMax: radiusMax,
orientation: datum.orientation, orientation: datum.orientation,
tilt: datum.tilt, tilt: datum.tilt,
synthesized: true,
); );
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; state.lastPosition = position;
} yield PointerMoveEvent(
assert(position == state.lastPosition);
state.setUp();
if (datum.change == ui.PointerChange.up) {
yield PointerUpEvent(
timeStamp: timeStamp, timeStamp: timeStamp,
pointer: state.pointer, pointer: state.pointer,
kind: kind, kind: kind,
device: datum.device, device: datum.device,
position: position, position: position,
delta: offset,
buttons: datum.buttons, buttons: datum.buttons,
obscured: datum.obscured, obscured: datum.obscured,
pressure: datum.pressure, pressure: datum.pressure,
pressureMin: datum.pressureMin, pressureMin: datum.pressureMin,
pressureMax: datum.pressureMax, pressureMax: datum.pressureMax,
distance: datum.distance,
distanceMax: datum.distanceMax, distanceMax: datum.distanceMax,
size: datum.size, size: datum.size,
radiusMajor: radiusMajor, radiusMajor: radiusMajor,
...@@ -315,68 +253,208 @@ class PointerEventConverter { ...@@ -315,68 +253,208 @@ class PointerEventConverter {
radiusMax: radiusMax, radiusMax: radiusMax,
orientation: datum.orientation, orientation: datum.orientation,
tilt: datum.tilt, tilt: datum.tilt,
platformData: datum.platformData,
); );
} else { break;
yield PointerCancelEvent( 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, timeStamp: timeStamp,
pointer: state.pointer,
kind: kind, kind: kind,
device: datum.device, device: datum.device,
position: position,
buttons: datum.buttons,
obscured: datum.obscured, obscured: datum.obscured,
pressureMin: datum.pressureMin, pressureMin: datum.pressureMin,
pressureMax: datum.pressureMax, pressureMax: datum.pressureMax,
distance: datum.distance,
distanceMax: datum.distanceMax, distanceMax: datum.distanceMax,
size: datum.size,
radiusMajor: radiusMajor,
radiusMinor: radiusMinor,
radiusMin: radiusMin, radiusMin: radiusMin,
radiusMax: radiusMax, radiusMax: radiusMax,
orientation: datum.orientation,
tilt: datum.tilt,
); );
} break;
break; }
case ui.PointerChange.remove: } else {
assert(_pointers.containsKey(datum.device)); switch (datum.signalKind) {
final _PointerState state = _pointers[datum.device]; case ui.PointerSignalKind.scroll:
if (state.down) { // Devices must be added before they send scroll events.
yield PointerCancelEvent( 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, timeStamp: timeStamp,
pointer: state.pointer,
kind: kind, kind: kind,
device: datum.device, device: datum.device,
position: position, position: position,
buttons: datum.buttons, scrollDelta: scrollDelta,
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;
_pointers.remove(datum.device); case ui.PointerSignalKind.none:
yield PointerRemovedEvent( assert(false); // This branch should already have 'none' filtered out.
timeStamp: timeStamp, break;
kind: kind, case ui.PointerSignalKind.unknown:
device: datum.device, // Ignore unknown signals.
obscured: datum.obscured, break;
pressureMin: datum.pressureMin, }
pressureMax: datum.pressureMax,
distanceMax: datum.distanceMax,
radiusMin: radiusMin,
radiusMax: radiusMax,
);
break;
} }
} }
} }
......
...@@ -790,6 +790,65 @@ class PointerUpEvent extends PointerEvent { ...@@ -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. /// The input from the pointer is no longer directed towards this receiver.
class PointerCancelEvent extends PointerEvent { class PointerCancelEvent extends PointerEvent {
/// Creates a pointer cancel event. /// 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); ...@@ -2488,6 +2488,11 @@ typedef PointerUpEventListener = void Function(PointerUpEvent event);
/// Used by [Listener] and [RenderPointerListener]. /// Used by [Listener] and [RenderPointerListener].
typedef PointerCancelEventListener = void Function(PointerCancelEvent event); 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. /// Calls callbacks in response to pointer events.
/// ///
/// If it has a child, defers to the child for sizing behavior. /// If it has a child, defers to the child for sizing behavior.
...@@ -2509,6 +2514,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2509,6 +2514,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
PointerExitEventListener onPointerExit, PointerExitEventListener onPointerExit,
this.onPointerUp, this.onPointerUp,
this.onPointerCancel, this.onPointerCancel,
this.onPointerSignal,
HitTestBehavior behavior = HitTestBehavior.deferToChild, HitTestBehavior behavior = HitTestBehavior.deferToChild,
RenderBox child, RenderBox child,
}) : _onPointerEnter = onPointerEnter, }) : _onPointerEnter = onPointerEnter,
...@@ -2579,6 +2585,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2579,6 +2585,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
/// no longer directed towards this receiver. /// no longer directed towards this receiver.
PointerCancelEventListener onPointerCancel; 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. // Object used for annotation of the layer used for hover hit detection.
MouseTrackerAnnotation _hoverAnnotation; MouseTrackerAnnotation _hoverAnnotation;
...@@ -2647,6 +2656,8 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2647,6 +2656,8 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
return onPointerUp(event); return onPointerUp(event);
if (onPointerCancel != null && event is PointerCancelEvent) if (onPointerCancel != null && event is PointerCancelEvent)
return onPointerCancel(event); return onPointerCancel(event);
if (onPointerSignal != null && event is PointerSignalEvent)
return onPointerSignal(event);
} }
@override @override
...@@ -2667,6 +2678,8 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2667,6 +2678,8 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
listeners.add('up'); listeners.add('up');
if (onPointerCancel != null) if (onPointerCancel != null)
listeners.add('cancel'); listeners.add('cancel');
if (onPointerSignal != null)
listeners.add('signal');
if (listeners.isEmpty) if (listeners.isEmpty)
listeners.add('<none>'); listeners.add('<none>');
properties.add(IterableProperty<String>('listeners', listeners)); properties.add(IterableProperty<String>('listeners', listeners));
......
...@@ -5240,6 +5240,7 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5240,6 +5240,7 @@ class Listener extends SingleChildRenderObjectWidget {
this.onPointerHover, this.onPointerHover,
this.onPointerUp, this.onPointerUp,
this.onPointerCancel, this.onPointerCancel,
this.onPointerSignal,
this.behavior = HitTestBehavior.deferToChild, this.behavior = HitTestBehavior.deferToChild,
Widget child, Widget child,
}) : assert(behavior != null), }) : assert(behavior != null),
...@@ -5288,6 +5289,9 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5288,6 +5289,9 @@ class Listener extends SingleChildRenderObjectWidget {
/// no longer directed towards this receiver. /// no longer directed towards this receiver.
final PointerCancelEventListener onPointerCancel; final PointerCancelEventListener onPointerCancel;
/// Called when a pointer signal occurs over this object.
final PointerSignalEventListener onPointerSignal;
/// How to behave during hit testing. /// How to behave during hit testing.
final HitTestBehavior behavior; final HitTestBehavior behavior;
...@@ -5301,6 +5305,7 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5301,6 +5305,7 @@ class Listener extends SingleChildRenderObjectWidget {
onPointerExit: onPointerExit, onPointerExit: onPointerExit,
onPointerUp: onPointerUp, onPointerUp: onPointerUp,
onPointerCancel: onPointerCancel, onPointerCancel: onPointerCancel,
onPointerSignal: onPointerSignal,
behavior: behavior, behavior: behavior,
); );
} }
...@@ -5315,6 +5320,7 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5315,6 +5320,7 @@ class Listener extends SingleChildRenderObjectWidget {
..onPointerExit = onPointerExit ..onPointerExit = onPointerExit
..onPointerUp = onPointerUp ..onPointerUp = onPointerUp
..onPointerCancel = onPointerCancel ..onPointerCancel = onPointerCancel
..onPointerSignal = onPointerSignal
..behavior = behavior; ..behavior = behavior;
} }
...@@ -5336,6 +5342,8 @@ class Listener extends SingleChildRenderObjectWidget { ...@@ -5336,6 +5342,8 @@ class Listener extends SingleChildRenderObjectWidget {
listeners.add('up'); listeners.add('up');
if (onPointerCancel != null) if (onPointerCancel != null)
listeners.add('cancel'); listeners.add('cancel');
if (onPointerSignal != null)
listeners.add('signal');
properties.add(IterableProperty<String>('listeners', listeners, ifEmpty: '<none>')); properties.add(IterableProperty<String>('listeners', listeners, ifEmpty: '<none>'));
properties.add(EnumProperty<HitTestBehavior>('behavior', behavior)); properties.add(EnumProperty<HitTestBehavior>('behavior', behavior));
} }
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
...@@ -520,6 +521,35 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -520,6 +521,35 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
_drag = null; _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 // DESCRIPTION
...@@ -538,18 +568,21 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -538,18 +568,21 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
scrollable: this, scrollable: this,
position: position, position: position,
// TODO(ianh): Having all these global keys is sad. // TODO(ianh): Having all these global keys is sad.
child: RawGestureDetector( child: Listener(
key: _gestureDetectorKey, onPointerSignal: _receivedPointerSignal,
gestures: _gestureRecognizers, child: RawGestureDetector(
behavior: HitTestBehavior.opaque, key: _gestureDetectorKey,
excludeFromSemantics: widget.excludeFromSemantics, gestures: _gestureRecognizers,
child: Semantics( behavior: HitTestBehavior.opaque,
explicitChildNodes: !widget.excludeFromSemantics, excludeFromSemantics: widget.excludeFromSemantics,
child: IgnorePointer( child: Semantics(
key: _ignorePointerKey, explicitChildNodes: !widget.excludeFromSemantics,
ignoring: _shouldIgnorePointer, child: IgnorePointer(
ignoringSemantics: false, key: _ignorePointerKey,
child: widget.viewportBuilder(context, position), ignoring: _shouldIgnorePointer,
ignoringSemantics: false,
child: widget.viewportBuilder(context, position),
),
), ),
), ),
), ),
......
...@@ -203,4 +203,50 @@ void main() { ...@@ -203,4 +203,50 @@ void main() {
expect(events[3].runtimeType, equals(PointerCancelEvent)); expect(events[3].runtimeType, equals(PointerCancelEvent));
expect(events[4].runtimeType, equals(PointerRemovedEvent)); 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);
});
}
...@@ -236,92 +236,99 @@ void main() { ...@@ -236,92 +236,99 @@ void main() {
' │ semantic boundary\n' ' │ semantic boundary\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │\n' ' │\n'
' └─child: RenderSemanticsGestureHandler#00000\n' ' └─child: RenderPointerListener#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ gestures: vertical scroll\n' ' │ behavior: deferToChild\n'
' │ listeners: signal\n'
' │\n' ' │\n'
' └─child: RenderPointerListener#00000\n' ' └─child: RenderSemanticsGestureHandler#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ behavior: opaque\n' ' │ gestures: vertical scroll\n'
' │ listeners: down\n'
' │\n' ' │\n'
' └─child: RenderSemanticsAnnotations#00000\n' ' └─child: RenderPointerListener#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ behavior: opaque\n'
' │ listeners: down\n'
' │\n' ' │\n'
' └─child: RenderIgnorePointer#00000\n' ' └─child: RenderSemanticsAnnotations#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ ignoring: false\n'
' │ ignoringSemantics: false\n'
' │\n' ' │\n'
' └─child: RenderViewport#00000\n' ' └─child: RenderIgnorePointer#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ layer: OffsetLayer#00000\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ axisDirection: down\n' ' │ ignoring: false\n'
' │ crossAxisDirection: right\n' ' │ ignoringSemantics: false\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' ' │\n'
' └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n' ' └─child: RenderViewport#00000\n'
' │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: SliverConstraints(AxisDirection.down,\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n' ' │ layer: OffsetLayer#00000\n'
' │ 0.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n' ' │ size: Size(800.0, 600.0)\n'
' │ crossAxisDirection: AxisDirection.right,\n' ' │ axisDirection: down\n'
' │ viewportMainAxisExtent: 600.0, remainingCacheExtent: 850.0\n' ' │ crossAxisDirection: right\n'
' │ cacheOrigin: 0.0 )\n' ' │ offset: ScrollPositionWithSingleContext#00000(offset: 0.0, range:\n'
' │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n' ' │ 0.0..39400.0, viewport: 600.0, ScrollableState,\n'
' │ 600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n' ' │ AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
' │ cacheExtent: 850.0)\n' ' │ IdleScrollActivity#00000, ScrollDirection.idle)\n'
' │ currently live children: 0 to 2\n' ' │ anchor: 0.0\n'
' │\n' ' │\n'
' ├─child with index 0: RenderLimitedBox#00000\n' ' └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
' │ │ parentData: index=0; layoutOffset=0.0\n' ' │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ constraints: SliverConstraints(AxisDirection.down,\n'
' │ │ size: Size(800.0, 400.0)\n' ' │ GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
' │ │ maxWidth: 400.0\n' ' │ 0.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
' │ │ maxHeight: 400.0\n' ' │ crossAxisDirection: AxisDirection.right,\n'
' │ │\n' ' │ viewportMainAxisExtent: 600.0, remainingCacheExtent: 850.0\n'
' │ └─child: RenderCustomPaint#00000\n' ' │ cacheOrigin: 0.0 )\n'
' │ parentData: <none> (can use size)\n' ' │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
' │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ 600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n'
' │ size: Size(800.0, 400.0)\n' ' │ cacheExtent: 850.0)\n'
' │\n' ' │ currently live children: 0 to 2\n'
' ├─child with index 1: RenderLimitedBox#00000\n' // <----- no dashed line starts here ' │\n'
' │ │ parentData: index=1; layoutOffset=400.0\n' ' ├─child with index 0: RenderLimitedBox#00000\n'
' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ │ parentData: index=0; layoutOffset=0.0\n'
' │ │ size: Size(800.0, 400.0)\n' ' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' │ │ maxWidth: 400.0\n' ' │ │ size: Size(800.0, 400.0)\n'
' │ │ maxHeight: 400.0\n' ' │ │ maxWidth: 400.0\n'
' │ │\n' ' │ │ maxHeight: 400.0\n'
' │ └─child: RenderCustomPaint#00000\n' ' │ │\n'
' │ parentData: <none> (can use size)\n' ' │ └─child: RenderCustomPaint#00000\n'
' │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ parentData: <none> (can use size)\n'
' │ size: Size(800.0, 400.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' │\n' ' │ size: Size(800.0, 400.0)\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' ' │\n'
' └─child: RenderCustomPaint#00000 NEEDS-PAINT\n' ' ├─child with index 1: RenderLimitedBox#00000\n' // <----- no dashed line starts here
' parentData: <none> (can use size)\n' ' │ │ parentData: index=1; layoutOffset=400.0\n'
' constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' size: Size(800.0, 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); const GlobalObjectKey<_LeafState>(0).currentState.setKeepAlive(true);
await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
...@@ -365,128 +372,135 @@ void main() { ...@@ -365,128 +372,135 @@ void main() {
' │ semantic boundary\n' ' │ semantic boundary\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │\n' ' │\n'
' └─child: RenderSemanticsGestureHandler#00000\n' ' └─child: RenderPointerListener#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ gestures: vertical scroll\n' ' │ behavior: deferToChild\n'
' │ listeners: signal\n'
' │\n' ' │\n'
' └─child: RenderPointerListener#00000\n' ' └─child: RenderSemanticsGestureHandler#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ behavior: opaque\n' ' │ gestures: vertical scroll\n'
' │ listeners: down\n'
' │\n' ' │\n'
' └─child: RenderSemanticsAnnotations#00000\n' ' └─child: RenderPointerListener#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ behavior: opaque\n'
' │ listeners: down\n'
' │\n' ' │\n'
' └─child: RenderIgnorePointer#00000\n' ' └─child: RenderSemanticsAnnotations#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ ignoring: false\n'
' │ ignoringSemantics: false\n'
' │\n' ' │\n'
' └─child: RenderViewport#00000\n' ' └─child: RenderIgnorePointer#00000\n'
' │ parentData: <none> (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ layer: OffsetLayer#00000\n'
' │ size: Size(800.0, 600.0)\n' ' │ size: Size(800.0, 600.0)\n'
' │ axisDirection: down\n' ' │ ignoring: false\n'
' │ crossAxisDirection: right\n' ' │ ignoringSemantics: false\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' ' │\n'
' └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n' ' └─child: RenderViewport#00000\n'
' │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n' ' │ parentData: <none> (can use size)\n'
' │ constraints: SliverConstraints(AxisDirection.down,\n' ' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n' ' │ layer: OffsetLayer#00000\n'
' │ 2000.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n' ' │ size: Size(800.0, 600.0)\n'
' │ crossAxisDirection: AxisDirection.right,\n' ' │ axisDirection: down\n'
' │ viewportMainAxisExtent: 600.0, remainingCacheExtent: 1100.0\n' ' │ crossAxisDirection: right\n'
' │ cacheOrigin: -250.0 )\n' ' │ offset: ScrollPositionWithSingleContext#00000(offset: 2000.0,\n'
' │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n' ' │ range: 0.0..39400.0, viewport: 600.0, ScrollableState,\n'
' │ 600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n' ' │ AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n'
' │ cacheExtent: 1100.0)\n' ' │ IdleScrollActivity#00000, ScrollDirection.idle)\n'
' │ currently live children: 4 to 7\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' ' │\n'
' ├─child with index 5: RenderLimitedBox#00000\n' // <----- this is index 5, not 0 ' └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n'
' │ │ parentData: index=5; layoutOffset=2000.0\n' ' │ parentData: paintOffset=Offset(0.0, 0.0) (can use size)\n'
' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ constraints: SliverConstraints(AxisDirection.down,\n'
' │ │ size: Size(800.0, 400.0)\n' ' │ GrowthDirection.forward, ScrollDirection.idle, scrollOffset:\n'
' │ │ maxWidth: 400.0\n' ' │ 2000.0, remainingPaintExtent: 600.0, crossAxisExtent: 800.0,\n'
' │ │ maxHeight: 400.0\n' ' │ crossAxisDirection: AxisDirection.right,\n'
' │ │\n' ' │ viewportMainAxisExtent: 600.0, remainingCacheExtent: 1100.0\n'
' │ └─child: RenderCustomPaint#00000\n' ' │ cacheOrigin: -250.0 )\n'
' │ parentData: <none> (can use size)\n' ' │ geometry: SliverGeometry(scrollExtent: 40000.0, paintExtent:\n'
' │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ 600.0, maxPaintExtent: 40000.0, hasVisualOverflow: true,\n'
' │ size: Size(800.0, 400.0)\n' ' │ cacheExtent: 1100.0)\n'
' │\n' ' │ currently live children: 4 to 7\n'
' ├─child with index 6: RenderLimitedBox#00000\n' ' │\n'
' │ │ parentData: index=6; layoutOffset=2400.0\n' ' ├─child with index 4: RenderLimitedBox#00000 NEEDS-PAINT\n'
' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ │ parentData: index=4; layoutOffset=1600.0\n'
' │ │ size: Size(800.0, 400.0)\n' ' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' │ │ maxWidth: 400.0\n' ' │ │ size: Size(800.0, 400.0)\n'
' │ │ maxHeight: 400.0\n' ' │ │ maxWidth: 400.0\n'
' │ │\n' ' │ │ maxHeight: 400.0\n'
' │ └─child: RenderCustomPaint#00000\n' ' │ │\n'
' │ parentData: <none> (can use size)\n' ' │ └─child: RenderCustomPaint#00000 NEEDS-PAINT\n'
' │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ parentData: <none> (can use size)\n'
' │ size: Size(800.0, 400.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' │\n' ' │ size: Size(800.0, 400.0)\n'
' ├─child with index 7: RenderLimitedBox#00000 NEEDS-PAINT\n' ' │\n'
' ╎ │ parentData: index=7; layoutOffset=2800.0\n' ' ├─child with index 5: RenderLimitedBox#00000\n' // <----- this is index 5, not 0
' ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ │ parentData: index=5; layoutOffset=2000.0\n'
' ╎ │ size: Size(800.0, 400.0)\n' ' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' ╎ │ maxWidth: 400.0\n' ' │ │ size: Size(800.0, 400.0)\n'
' ╎ │ maxHeight: 400.0\n' ' │ │ maxWidth: 400.0\n'
' ╎ │\n' ' │ │ maxHeight: 400.0\n'
' ╎ └─child: RenderCustomPaint#00000 NEEDS-PAINT\n' ' │ │\n'
' ╎ parentData: <none> (can use size)\n' ' │ └─child: RenderCustomPaint#00000\n'
' ╎ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ parentData: <none> (can use size)\n'
' ╎ size: Size(800.0, 400.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' ╎\n' ' │ size: Size(800.0, 400.0)\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 ' │\n'
' ╎ │ parentData: index=0; keepAlive; layoutOffset=0.0\n' ' ├─child with index 6: RenderLimitedBox#00000\n'
' ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ │ parentData: index=6; layoutOffset=2400.0\n'
' ╎ │ size: Size(800.0, 400.0)\n' ' │ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' ╎ │ maxWidth: 400.0\n' ' │ │ size: Size(800.0, 400.0)\n'
' ╎ │ maxHeight: 400.0\n' ' │ │ maxWidth: 400.0\n'
' ╎ │\n' ' │ │ maxHeight: 400.0\n'
' ╎ └─child: RenderCustomPaint#00000\n' ' │ │\n'
' ╎ parentData: <none> (can use size)\n' ' │ └─child: RenderCustomPaint#00000\n'
' ╎ constraints: BoxConstraints(w=800.0, h=400.0)\n' ' │ parentData: <none> (can use size)\n'
' ╎ size: Size(800.0, 400.0)\n' ' │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' ╎\n' // <----- dashed line ends here ' │ size: Size(800.0, 400.0)\n'
' └╌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' ' │\n'
' └─child: RenderCustomPaint#00000\n' ' ├─child with index 7: RenderLimitedBox#00000 NEEDS-PAINT\n'
' parentData: <none> (can use size)\n' ' ╎ │ parentData: index=7; layoutOffset=2800.0\n'
' constraints: BoxConstraints(w=800.0, h=400.0)\n' ' ╎ │ constraints: BoxConstraints(w=800.0, h=400.0)\n'
' size: Size(800.0, 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'
)); ));
}); });
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
...@@ -220,4 +222,18 @@ void main() { ...@@ -220,4 +222,18 @@ void main() {
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180));
expect(getScrollOffset(tester), 32.5); 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 { ...@@ -169,6 +169,26 @@ class TestPointer {
delta: delta, 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 /// 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