[web] Notify engine of handled PointerScrollEvents. (#145500)

Notifies the engine when `PointerSignalEvents` have been ignored by the framework, through the `ui.PointerData.respond` method.

This allows the web to "preventDefault" (or not) on `wheel` events.

## Issues

* Fixes (partially): https://github.com/flutter/flutter/issues/139263

## Tests

* Added tests to ensure `respond` is called at the right time, with the right value.

## Demo

* https://dit-multiview-scroll.web.app

<details>
<summary>

## Previous versions

</summary>

1. Modified `PointerScrollEvent`, not shippable.
2. Modified when events were handled, instead of the opposite.

</details>
This commit is contained in:
David Iglesias 2024-06-10 10:52:58 -07:00 committed by GitHub
parent 5efb67b7c1
commit 0c2ee845fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 130 additions and 4 deletions

View File

@ -283,6 +283,7 @@ abstract final class PointerEventConverter {
position: position, position: position,
scrollDelta: scrollDelta, scrollDelta: scrollDelta,
embedderId: datum.embedderId, embedderId: datum.embedderId,
onRespond: datum.respond,
); );
case ui.PointerSignalKind.scrollInertiaCancel: case ui.PointerSignalKind.scrollInertiaCancel:
return PointerScrollInertiaCancelEvent( return PointerScrollInertiaCancelEvent(

View File

@ -1717,7 +1717,7 @@ class _TransformedPointerUpEvent extends _TransformedPointerEvent with _CopyPoin
/// events in a widget tree. /// events in a widget tree.
/// * [PointerSignalResolver], which provides an opt-in mechanism whereby /// * [PointerSignalResolver], which provides an opt-in mechanism whereby
/// participating agents may disambiguate an event's target. /// participating agents may disambiguate an event's target.
abstract class PointerSignalEvent extends PointerEvent { abstract class PointerSignalEvent extends PointerEvent with _RespondablePointerEvent {
/// Abstract const constructor. This constructor enables subclasses to provide /// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions. /// const constructors so that they can be used in const expressions.
const PointerSignalEvent({ const PointerSignalEvent({
@ -1731,6 +1731,27 @@ abstract class PointerSignalEvent extends PointerEvent {
}); });
} }
/// A function that implements the [PointerSignalEvent.respond] method.
typedef RespondPointerEventCallback = void Function({required bool allowPlatformDefault});
mixin _RespondablePointerEvent on PointerEvent {
/// Sends a response to the native embedder for the [PointerSignalEvent].
///
/// The parameter [allowPlatformDefault] allows the platform to perform the
/// default action associated with the native event when it's set to `true`.
///
/// This method can be called any number of times, but once `allowPlatformDefault`
/// is set to `true`, it can't be set to `false` again.
///
/// The implementation of this method is configured through the `onRespond`
/// parameter of the [PointerSignalEvent] constructor.
///
/// See also [RespondPointerEventCallback].
void respond({
required bool allowPlatformDefault,
}) {}
}
mixin _CopyPointerScrollEvent on PointerEvent { mixin _CopyPointerScrollEvent on PointerEvent {
/// The amount to scroll, in logical pixels. /// The amount to scroll, in logical pixels.
Offset get scrollDelta; Offset get scrollDelta;
@ -1760,6 +1781,7 @@ mixin _CopyPointerScrollEvent on PointerEvent {
double? tilt, double? tilt,
bool? synthesized, bool? synthesized,
int? embedderId, int? embedderId,
RespondPointerEventCallback? onRespond,
}) { }) {
return PointerScrollEvent( return PointerScrollEvent(
viewId: viewId ?? this.viewId, viewId: viewId ?? this.viewId,
@ -1769,6 +1791,7 @@ mixin _CopyPointerScrollEvent on PointerEvent {
position: position ?? this.position, position: position ?? this.position,
scrollDelta: scrollDelta, scrollDelta: scrollDelta,
embedderId: embedderId ?? this.embedderId, embedderId: embedderId ?? this.embedderId,
onRespond: onRespond ?? (this as PointerScrollEvent).respond,
).transformed(transform); ).transformed(transform);
} }
} }
@ -1794,7 +1817,8 @@ class PointerScrollEvent extends PointerSignalEvent with _PointerEventDescriptio
super.position, super.position,
this.scrollDelta = Offset.zero, this.scrollDelta = Offset.zero,
super.embedderId, super.embedderId,
}); RespondPointerEventCallback? onRespond,
}) : _onRespond = onRespond;
@override @override
final Offset scrollDelta; final Offset scrollDelta;
@ -1812,6 +1836,15 @@ class PointerScrollEvent extends PointerSignalEvent with _PointerEventDescriptio
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Offset>('scrollDelta', scrollDelta)); properties.add(DiagnosticsProperty<Offset>('scrollDelta', scrollDelta));
} }
final RespondPointerEventCallback? _onRespond;
@override
void respond({required bool allowPlatformDefault}) {
if (_onRespond != null) {
_onRespond!(allowPlatformDefault: allowPlatformDefault);
}
}
} }
class _TransformedPointerScrollEvent extends _TransformedPointerEvent with _CopyPointerScrollEvent implements PointerScrollEvent { class _TransformedPointerScrollEvent extends _TransformedPointerEvent with _CopyPointerScrollEvent implements PointerScrollEvent {
@ -1834,6 +1867,14 @@ class _TransformedPointerScrollEvent extends _TransformedPointerEvent with _Copy
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Offset>('scrollDelta', scrollDelta)); properties.add(DiagnosticsProperty<Offset>('scrollDelta', scrollDelta));
} }
@override
RespondPointerEventCallback? get _onRespond => original._onRespond;
@override
void respond({required bool allowPlatformDefault}) {
original.respond(allowPlatformDefault: allowPlatformDefault);
}
} }
mixin _CopyPointerScrollInertiaCancelEvent on PointerEvent { mixin _CopyPointerScrollInertiaCancelEvent on PointerEvent {
@ -1905,7 +1946,7 @@ class PointerScrollInertiaCancelEvent extends PointerSignalEvent with _PointerEv
} }
} }
class _TransformedPointerScrollInertiaCancelEvent extends _TransformedPointerEvent with _CopyPointerScrollInertiaCancelEvent implements PointerScrollInertiaCancelEvent { class _TransformedPointerScrollInertiaCancelEvent extends _TransformedPointerEvent with _CopyPointerScrollInertiaCancelEvent, _RespondablePointerEvent implements PointerScrollInertiaCancelEvent {
_TransformedPointerScrollInertiaCancelEvent(this.original, this.transform); _TransformedPointerScrollInertiaCancelEvent(this.original, this.transform);
@override @override
@ -1996,7 +2037,7 @@ class PointerScaleEvent extends PointerSignalEvent with _PointerEventDescription
} }
} }
class _TransformedPointerScaleEvent extends _TransformedPointerEvent with _CopyPointerScaleEvent implements PointerScaleEvent { class _TransformedPointerScaleEvent extends _TransformedPointerEvent with _CopyPointerScaleEvent, _RespondablePointerEvent implements PointerScaleEvent {
_TransformedPointerScaleEvent(this.original, this.transform); _TransformedPointerScaleEvent(this.original, this.transform);
@override @override

View File

@ -96,6 +96,11 @@ class PointerSignalResolver {
void resolve(PointerSignalEvent event) { void resolve(PointerSignalEvent event) {
if (_firstRegisteredCallback == null) { if (_firstRegisteredCallback == null) {
assert(_currentEvent == null); assert(_currentEvent == null);
// Nothing in the framework/app wants to handle the `event`. Allow the
// platform to trigger any default native actions.
event.respond(
allowPlatformDefault: true
);
return; return;
} }
assert(_isSameEvent(_currentEvent!, event)); assert(_isSameEvent(_currentEvent!, event));

View File

@ -916,6 +916,9 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
void _receivedPointerSignal(PointerSignalEvent event) { void _receivedPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent && _position != null) { if (event is PointerScrollEvent && _position != null) {
if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) { if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) {
// The handler won't use the `event`, so allow the platform to trigger
// any default native actions.
event.respond(allowPlatformDefault: true);
return; return;
} }
final double delta = _pointerSignalEventDelta(event); final double delta = _pointerSignalEventDelta(event);
@ -923,7 +926,11 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
// Only express interest in the event if it would actually result in a scroll. // Only express interest in the event if it would actually result in a scroll.
if (delta != 0.0 && targetScrollOffset != position.pixels) { if (delta != 0.0 && targetScrollOffset != position.pixels) {
GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll); GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
return;
} }
// The `event` won't result in a scroll, so allow the platform to trigger
// any default native actions.
event.respond(allowPlatformDefault: true);
} else if (event is PointerScrollInertiaCancelEvent) { } else if (event is PointerScrollInertiaCancelEvent) {
position.pointerScroll(0); position.pointerScroll(0);
// Don't use the pointer signal resolver, all hit-tested scrollables should stop. // Don't use the pointer signal resolver, all hit-tested scrollables should stop.

View File

@ -42,6 +42,19 @@ void main() {
tester.resolver.resolve(tester.event); tester.resolver.resolve(tester.event);
}); });
test('Resolving with no entries should notify engine of no-op', () {
bool allowedPlatformDefault = false;
final PointerSignalTester tester = PointerSignalTester();
tester.event = PointerScrollEvent(
onRespond: ({required bool allowPlatformDefault}) {
allowedPlatformDefault = allowPlatformDefault;
},
);
tester.resolver.resolve(tester.event);
expect(allowedPlatformDefault, isTrue,
reason: 'Should have called respond with allowPlatformDefault: true');
});
test('First entry should always win', () { test('First entry should always win', () {
final PointerSignalTester tester = PointerSignalTester(); final PointerSignalTester tester = PointerSignalTester();
final TestPointerSignalListener first = tester.addListener(); final TestPointerSignalListener first = tester.addListener();

View File

@ -453,6 +453,63 @@ void main() {
expect(getScrollOffset(tester), 0.0); expect(getScrollOffset(tester), 0.0);
}); });
testWidgets('Engine is notified of ignored pointer signals (no scroll physics)', (WidgetTester tester) async {
await pumpTest(tester, debugDefaultTargetPlatformOverride, scrollable: false);
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);
bool allowedPlatformDefault = false;
await tester.sendEventToBinding(
testPointer.scroll(
const Offset(0.0, 20.0),
onRespond: ({required bool allowPlatformDefault}) {
allowedPlatformDefault = allowPlatformDefault;
},
));
expect(allowedPlatformDefault, isTrue,
reason: 'Engine should be notified of ignored scroll pointer signals.');
}, variant: TargetPlatformVariant.all());
testWidgets('Engine is notified of rejected scroll events (wrong direction)', (WidgetTester tester) async {
await pumpTest(
tester,
debugDefaultTargetPlatformOverride,
scrollDirection: Axis.horizontal,
);
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);
// Horizontal input is accepted
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendEventToBinding(
testPointer.scroll(
const Offset(0.0, 10.0),
onRespond: ({required bool allowPlatformDefault}) {
fail('The engine should not be notified when the scroll is accepted.');
},
));
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
await tester.pump();
// Vertical input not accepted
bool allowedPlatformDefault = false;
await tester.sendEventToBinding(
testPointer.scroll(
const Offset(0.0, 20.0),
onRespond: ({required bool allowPlatformDefault}) {
allowedPlatformDefault = allowPlatformDefault;
},
));
expect(allowedPlatformDefault, isTrue,
reason: 'Engine should be notified when scroll is rejected by the scrollable.');
}, variant: TargetPlatformVariant.all());
testWidgets('Holding scroll and Scroll pointer signal will update ScrollDirection.forward / ScrollDirection.reverse', (WidgetTester tester) async { testWidgets('Holding scroll and Scroll pointer signal will update ScrollDirection.forward / ScrollDirection.reverse', (WidgetTester tester) async {
ScrollDirection? lastUserScrollingDirection; ScrollDirection? lastUserScrollingDirection;

View File

@ -288,6 +288,7 @@ class TestPointer {
PointerScrollEvent scroll( PointerScrollEvent scroll(
Offset scrollDelta, { Offset scrollDelta, {
Duration timeStamp = Duration.zero, Duration timeStamp = Duration.zero,
RespondPointerEventCallback? onRespond,
}) { }) {
assert(kind != PointerDeviceKind.touch, "Touch pointers can't generate pointer signal events"); assert(kind != PointerDeviceKind.touch, "Touch pointers can't generate pointer signal events");
assert(location != null); assert(location != null);
@ -297,6 +298,7 @@ class TestPointer {
device: _device, device: _device,
position: location!, position: location!,
scrollDelta: scrollDelta, scrollDelta: scrollDelta,
onRespond: onRespond,
); );
} }