diff --git a/packages/flutter/lib/src/gestures/converter.dart b/packages/flutter/lib/src/gestures/converter.dart index 21095d1e58..ff80127a81 100644 --- a/packages/flutter/lib/src/gestures/converter.dart +++ b/packages/flutter/lib/src/gestures/converter.dart @@ -283,6 +283,7 @@ abstract final class PointerEventConverter { position: position, scrollDelta: scrollDelta, embedderId: datum.embedderId, + onRespond: datum.respond, ); case ui.PointerSignalKind.scrollInertiaCancel: return PointerScrollInertiaCancelEvent( diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart index a8ef8c665c..2f908f870c 100644 --- a/packages/flutter/lib/src/gestures/events.dart +++ b/packages/flutter/lib/src/gestures/events.dart @@ -1717,7 +1717,7 @@ class _TransformedPointerUpEvent extends _TransformedPointerEvent with _CopyPoin /// events in a widget tree. /// * [PointerSignalResolver], which provides an opt-in mechanism whereby /// 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 /// const constructors so that they can be used in const expressions. 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 { /// The amount to scroll, in logical pixels. Offset get scrollDelta; @@ -1760,6 +1781,7 @@ mixin _CopyPointerScrollEvent on PointerEvent { double? tilt, bool? synthesized, int? embedderId, + RespondPointerEventCallback? onRespond, }) { return PointerScrollEvent( viewId: viewId ?? this.viewId, @@ -1769,6 +1791,7 @@ mixin _CopyPointerScrollEvent on PointerEvent { position: position ?? this.position, scrollDelta: scrollDelta, embedderId: embedderId ?? this.embedderId, + onRespond: onRespond ?? (this as PointerScrollEvent).respond, ).transformed(transform); } } @@ -1794,7 +1817,8 @@ class PointerScrollEvent extends PointerSignalEvent with _PointerEventDescriptio super.position, this.scrollDelta = Offset.zero, super.embedderId, - }); + RespondPointerEventCallback? onRespond, + }) : _onRespond = onRespond; @override final Offset scrollDelta; @@ -1812,6 +1836,15 @@ class PointerScrollEvent extends PointerSignalEvent with _PointerEventDescriptio super.debugFillProperties(properties); properties.add(DiagnosticsProperty('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 { @@ -1834,6 +1867,14 @@ class _TransformedPointerScrollEvent extends _TransformedPointerEvent with _Copy super.debugFillProperties(properties); properties.add(DiagnosticsProperty('scrollDelta', scrollDelta)); } + + @override + RespondPointerEventCallback? get _onRespond => original._onRespond; + + @override + void respond({required bool allowPlatformDefault}) { + original.respond(allowPlatformDefault: allowPlatformDefault); + } } 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); @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); @override diff --git a/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart b/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart index 2e4b6709d3..40168dc2f6 100644 --- a/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart +++ b/packages/flutter/lib/src/gestures/pointer_signal_resolver.dart @@ -96,6 +96,11 @@ class PointerSignalResolver { void resolve(PointerSignalEvent event) { if (_firstRegisteredCallback == 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; } assert(_isSameEvent(_currentEvent!, event)); diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 7cedc263fb..551ec9792c 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -916,6 +916,9 @@ class ScrollableState extends State with TickerProviderStateMixin, R void _receivedPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent && _position != null) { 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; } final double delta = _pointerSignalEventDelta(event); @@ -923,7 +926,11 @@ class ScrollableState extends State with TickerProviderStateMixin, R // Only express interest in the event if it would actually result in a scroll. if (delta != 0.0 && targetScrollOffset != position.pixels) { 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) { position.pointerScroll(0); // Don't use the pointer signal resolver, all hit-tested scrollables should stop. diff --git a/packages/flutter/test/gestures/pointer_signal_resolver_test.dart b/packages/flutter/test/gestures/pointer_signal_resolver_test.dart index f4353c8b87..64f96c0b5f 100644 --- a/packages/flutter/test/gestures/pointer_signal_resolver_test.dart +++ b/packages/flutter/test/gestures/pointer_signal_resolver_test.dart @@ -42,6 +42,19 @@ void main() { 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', () { final PointerSignalTester tester = PointerSignalTester(); final TestPointerSignalListener first = tester.addListener(); diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index d69a759533..64c9068433 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -453,6 +453,63 @@ void main() { 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 { ScrollDirection? lastUserScrollingDirection; diff --git a/packages/flutter_test/lib/src/test_pointer.dart b/packages/flutter_test/lib/src/test_pointer.dart index 436e866e4c..0ac582e25b 100644 --- a/packages/flutter_test/lib/src/test_pointer.dart +++ b/packages/flutter_test/lib/src/test_pointer.dart @@ -288,6 +288,7 @@ class TestPointer { PointerScrollEvent scroll( Offset scrollDelta, { Duration timeStamp = Duration.zero, + RespondPointerEventCallback? onRespond, }) { assert(kind != PointerDeviceKind.touch, "Touch pointers can't generate pointer signal events"); assert(location != null); @@ -297,6 +298,7 @@ class TestPointer { device: _device, position: location!, scrollDelta: scrollDelta, + onRespond: onRespond, ); }