diff --git a/engine/src/flutter/lib/ui/pointer.dart b/engine/src/flutter/lib/ui/pointer.dart index fe99de4b90e..ba3ba5a275d 100644 --- a/engine/src/flutter/lib/ui/pointer.dart +++ b/engine/src/flutter/lib/ui/pointer.dart @@ -137,6 +137,9 @@ enum PointerSignalKind { unknown } +/// A function that implements the [PointerData.respond] method. +typedef PointerDataRespondCallback = void Function({bool allowPlatformDefault}); + /// Information about the state of a pointer. class PointerData { /// Creates an object that represents the state of a pointer. @@ -177,7 +180,8 @@ class PointerData { this.panDeltaY = 0.0, this.scale = 0.0, this.rotation = 0.0, - }); + PointerDataRespondCallback? onRespond, + }) : _onRespond = onRespond; /// The ID of the [FlutterView] this [PointerEvent] originated from. final int viewId; @@ -380,6 +384,36 @@ class PointerData { /// The current angle of the pan/zoom in radians, with 0.0 as the initial angle. final double rotation; + // An optional function that allows the framework to respond to the event + // that triggered this PointerData instance. + final PointerDataRespondCallback? _onRespond; + + /// Method that the framework/app can call to respond to the native event + /// that triggered this [PointerData]. + /// + /// 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. + /// + /// If `allowPlatformDefault` is never set to `true`, the Flutter engine will + /// consume the event, so it won't be seen by the platform. In the web, this + /// means that `preventDefault` will be called in the DOM event that triggered + /// the `PointerData`. See [Event: preventDefault() method in MDN][EpDmiMDN]. + /// + /// The implementation of this method is configured through the `onRespond` + /// parameter of the [PointerData] constructor. + /// + /// See also [PointerDataRespondCallback]. + /// + /// [EpDmiMDN]: https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault + void respond({required bool allowPlatformDefault}) { + if (_onRespond != null) { + _onRespond(allowPlatformDefault: allowPlatformDefault); + } + } + @override String toString() => 'PointerData(viewId: $viewId, x: $physicalX, y: $physicalY)'; diff --git a/engine/src/flutter/lib/web_ui/lib/pointer.dart b/engine/src/flutter/lib/web_ui/lib/pointer.dart index cd69ee6350d..6b32bde0544 100644 --- a/engine/src/flutter/lib/web_ui/lib/pointer.dart +++ b/engine/src/flutter/lib/web_ui/lib/pointer.dart @@ -34,6 +34,8 @@ enum PointerSignalKind { unknown } +typedef PointerDataRespondCallback = void Function({bool allowPlatformDefault}); + class PointerData { const PointerData({ this.viewId = 0, @@ -72,7 +74,8 @@ class PointerData { this.panDeltaY = 0.0, this.scale = 0.0, this.rotation = 0.0, - }); + PointerDataRespondCallback? onRespond, + }) : _onRespond = onRespond; final int viewId; final int embedderId; final Duration timeStamp; @@ -109,6 +112,13 @@ class PointerData { final double panDeltaY; final double scale; final double rotation; + final PointerDataRespondCallback? _onRespond; + + void respond({required bool allowPlatformDefault}) { + if (_onRespond != null) { + _onRespond(allowPlatformDefault: allowPlatformDefault); + } + } @override String toString() => 'PointerData(viewId: $viewId, x: $physicalX, y: $physicalY)'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart index d198bcfbb71..f0b2b75c8e5 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -499,6 +499,7 @@ abstract class _BaseAdapter { final List<_Listener> _listeners = <_Listener>[]; DomWheelEvent? _lastWheelEvent; bool _lastWheelEventWasTrackpad = false; + bool _lastWheelEventAllowedDefault = false; DomEventTarget get _viewTarget => _view.dom.rootElement; DomEventTarget get _globalTarget => _view.embeddingStrategy.globalEventTarget; @@ -706,6 +707,10 @@ mixin _WheelEventListenerMixin on _BaseAdapter { pressureMax: 1.0, scrollDeltaX: deltaX, scrollDeltaY: deltaY, + onRespond: ({bool allowPlatformDefault = false}) { + // Once `allowPlatformDefault` is `true`, never go back to `false`! + _lastWheelEventAllowedDefault |= allowPlatformDefault; + }, ); } _lastWheelEvent = event; @@ -722,17 +727,21 @@ mixin _WheelEventListenerMixin on _BaseAdapter { )); } - void _handleWheelEvent(DomEvent e) { - assert(domInstanceOfString(e, 'WheelEvent')); - final DomWheelEvent event = e as DomWheelEvent; + void _handleWheelEvent(DomEvent event) { + assert(domInstanceOfString(event, 'WheelEvent')); if (_debugLogPointerEvents) { print(event.type); } - _callback(e, _convertWheelEventToPointerData(event)); - // Prevent default so mouse wheel event doesn't get converted to - // a scroll event that semantic nodes would process. - // - event.preventDefault(); + _lastWheelEventAllowedDefault = false; + // [ui.PointerData] can set the `_lastWheelEventAllowedDefault` variable + // to true, when the framework says so. See the implementation of `respond` + // when creating the PointerData object above. + _callback(event, _convertWheelEventToPointerData(event as DomWheelEvent)); + // This works because the `_callback` is handled synchronously in the + // framework, so it's able to modify `_lastWheelEventAllowedDefault`. + if (!_lastWheelEventAllowedDefault) { + event.preventDefault(); + } } /// For browsers that report delta line instead of pixels such as FireFox diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart index 54fdef946b1..e52b50232f8 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_converter.dart @@ -117,6 +117,7 @@ class PointerDataConverter { required double scrollDeltaX, required double scrollDeltaY, required double scale, + ui.PointerDataRespondCallback? onRespond, }) { assert(globalPointerState.pointers.containsKey(device)); final _PointerDeviceState state = globalPointerState.pointers[device]!; @@ -154,6 +155,7 @@ class PointerDataConverter { scrollDeltaX: scrollDeltaX, scrollDeltaY: scrollDeltaY, scale: scale, + onRespond: onRespond, ); } @@ -263,6 +265,7 @@ class PointerDataConverter { double scrollDeltaX = 0.0, double scrollDeltaY = 0.0, double scale = 1.0, + ui.PointerDataRespondCallback? onRespond, }) { if (_debugLogPointerConverter) { print('>> view=$viewId device=$device change=$change buttons=$buttons'); @@ -796,6 +799,7 @@ class PointerDataConverter { scrollDeltaX: scrollDeltaX, scrollDeltaY: scrollDeltaY, scale: scale, + onRespond: onRespond, ) ); case ui.PointerSignalKind.none: diff --git a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart index b6c33f3efc0..04cbaeba92c 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart @@ -700,6 +700,74 @@ void testMain() { }, ); + test('wheel event - preventDefault called', () { + // Synthesize a 'wheel' event. + final DomEvent event = _PointerEventContext().wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 10, + deltaY: 0, + ); + rootElement.dispatchEvent(event); + // Check that the engine called `preventDefault` on the event. + expect(event.defaultPrevented, isTrue); + }); + + test('wheel event - framework can stop preventDefault (allowPlatformDefault)', () { + // The framework calls `data.respond(allowPlatformDefault: true)` + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packet.data.where( + (ui.PointerData datum) => datum.signalKind == ui.PointerSignalKind.scroll + ).forEach( + (ui.PointerData datum) { + datum.respond(allowPlatformDefault: true); + } + ); + }; + + // Synthesize a 'wheel' event. + final DomEvent event = _PointerEventContext().wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 10, + deltaY: 0, + ); + rootElement.dispatchEvent(event); + + // Check that the engine did NOT call `preventDefault` on the event. + expect(event.defaultPrevented, isFalse); + }); + + test('wheel event - once allowPlatformDefault is set to true, it cannot be rolled back', () { + // The framework calls `data.respond(allowPlatformDefault: true)` + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packet.data.where( + (ui.PointerData datum) => datum.signalKind == ui.PointerSignalKind.scroll + ).forEach( + (ui.PointerData datum) { + datum.respond(allowPlatformDefault: false); + datum.respond(allowPlatformDefault: true); + datum.respond(allowPlatformDefault: false); + } + ); + }; + + // Synthesize a 'wheel' event. + final DomEvent event = _PointerEventContext().wheel( + buttons: 0, + clientX: 10, + clientY: 10, + deltaX: 10, + deltaY: 0, + ); + rootElement.dispatchEvent(event); + + // Check that the engine did NOT call `preventDefault` on the event. + expect(event.defaultPrevented, isFalse); + }); + test( 'does synthesize add or hover or move for scroll', () { @@ -3114,6 +3182,9 @@ mixin _ButtonedEventMixin on _BasicEventContext { if (wheelDeltaX != null) 'wheelDeltaX': wheelDeltaX, if (wheelDeltaY != null) 'wheelDeltaY': wheelDeltaY, 'ctrlKey': ctrlKey, + 'cancelable': true, + 'bubbles': true, + 'composed': true, }); // timeStamp can't be set in the constructor, need to override the getter. if (timeStamp != null) { diff --git a/engine/src/flutter/web_sdk/test/api_conform_test.dart b/engine/src/flutter/web_sdk/test/api_conform_test.dart index b3368b02159..f93c67a23fb 100644 --- a/engine/src/flutter/web_sdk/test/api_conform_test.dart +++ b/engine/src/flutter/web_sdk/test/api_conform_test.dart @@ -241,12 +241,22 @@ void main() { i < uiTypeDef.functionType!.parameters.parameters.length && i < webTypeDef.functionType!.parameters.parameters.length; i++) { - final SimpleFormalParameter uiParam = - (uiTypeDef.type as GenericFunctionType).parameters.parameters[i] - as SimpleFormalParameter; - final SimpleFormalParameter webParam = - (webTypeDef.type as GenericFunctionType).parameters.parameters[i] - as SimpleFormalParameter; + final FormalParameter uiFormalParam = + (uiTypeDef.type as GenericFunctionType).parameters.parameters[i]; + final FormalParameter webFormalParam = + (webTypeDef.type as GenericFunctionType).parameters.parameters[i]; + + if (uiFormalParam.runtimeType != webFormalParam.runtimeType) { + failed = true; + print('Warning: lib/ui/ui.dart $typeDefName parameter $i ' + '${uiFormalParam.name!.lexeme}} is of type ${uiFormalParam.runtimeType}, but of ${webFormalParam.runtimeType} in lib/web_ui/ui.dart.'); + } + + // This is not entirely true and can break, but this way we can support both positional and named params + // (The assumption that the parameter of a DefaultFormalParameter is a SimpleFormalParameter is a stretch) + final SimpleFormalParameter uiParam = ((uiFormalParam is DefaultFormalParameter) ? uiFormalParam.parameter : uiFormalParam) as SimpleFormalParameter; + final SimpleFormalParameter webParam = ((webFormalParam is DefaultFormalParameter) ? webFormalParam.parameter : uiFormalParam) as SimpleFormalParameter; + if (webParam.name == null) { failed = true; print('Warning: lib/web_ui/ui.dart $typeDefName parameter $i should have name.');