[web] Adds allowPlatformDefault for wheel signals. (flutter/engine#51566)

Adds a function to each 'wheel' DataPacket sent to the framework so it can signal whether to `allowPlatformDefault` or not.

The current default is to always `preventDefault` on browser events that get sent to the framework.

This PR enables the framework to call a method on the `DataPacket`s to `allowPlatformDefault: true`, if the framework won't handle the Signal (signals are handled synchronously on the framework).

This lets the engine "wait" for the framework to decide whether to `preventDefault` on a `wheel` event or not.

## Issues

* Needed for: https://github.com/flutter/flutter/issues/139263

## Tests

* Added unit tests for the feature in the engine repo, veryfing whether the event has had its `defaultPrevented` or not.
* Manually tested in a demo app (see below)

## Demo

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

<details>
<summary>

## Previous approaches

</summary>

1. Add a `handled` bool property to `PointerDataPacket` that the framework can write to (brittle)
2. Modifications to the `PlatformDispatcher` so the framework can `acknowledgePointerData` with a `PointerDataResponse` (fffffatttt change)
3. `acknowledge` function in `PointerDataPacket`

</details>

> [!IMPORTANT]
> * Related: https://github.com/flutter/flutter/pull/145500

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
This commit is contained in:
David Iglesias 2024-06-05 16:25:53 -07:00 committed by GitHub
parent 8d062af40c
commit 6eacdf2be8
6 changed files with 154 additions and 16 deletions

View File

@ -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)';

View File

@ -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)';

View File

@ -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

View File

@ -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:

View File

@ -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) {

View File

@ -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.');