[web] Work around wrong pointerId in coalesced events in iOS Safari 18.2 (flutter/engine#56719)

In iOS 18.2, Safari [added support](https://developer.apple.com/documentation/safari-release-notes/safari-18_2-release-notes#Web-API) for the [`getCoalescedEvents`](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/getCoalescedEvents) API.

That being said, the API seems to be incomplete (or at least doesn't match other browsers' behavior). The coalesced events lack a [`pointerId`](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerId) and [`target`](https://developer.mozilla.org/en-US/docs/Web/API/Event/target) properties.

I'm not sure if this issue will be fixed in the stable release of iOS 18.2, so in the meantime, this PR implements a workaround to avoid this issue.

Fixes https://github.com/flutter/flutter/issues/158299
Fixes https://github.com/flutter/flutter/issues/155987
This commit is contained in:
Mouad Debbar 2024-11-21 09:33:28 -05:00 committed by GitHub
parent 4705535548
commit 3e281cda44
3 changed files with 174 additions and 16 deletions

View File

@ -1039,20 +1039,32 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
//
// TODO(dkwingsmt): Investigate whether we can configure the behavior for
// `_viewTarget`. https://github.com/flutter/flutter/issues/157968
_addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent event) {
final int device = _getPointerId(event);
_addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent moveEvent) {
final int device = _getPointerId(moveEvent);
final _ButtonSanitizer sanitizer = _ensureSanitizer(device);
final List<ui.PointerData> pointerData = <ui.PointerData>[];
final List<DomPointerEvent> expandedEvents = _expandEvents(event);
final List<DomPointerEvent> expandedEvents = _expandEvents(moveEvent);
for (final DomPointerEvent event in expandedEvents) {
final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!.toInt());
if (up != null) {
_convertEventsToPointerData(data: pointerData, event: event, details: up);
_convertEventsToPointerData(
data: pointerData,
event: event,
details: up,
pointerId: device,
eventTarget: moveEvent.target,
);
}
final _SanitizedDetails move = sanitizer.sanitizeMoveEvent(buttons: event.buttons!.toInt());
_convertEventsToPointerData(data: pointerData, event: event, details: move);
_convertEventsToPointerData(
data: pointerData,
event: event,
details: move,
pointerId: device,
eventTarget: moveEvent.target,
);
}
_callback(event, pointerData);
_callback(moveEvent, pointerData);
});
_addPointerEventListener(_viewTarget, 'pointerleave', (DomPointerEvent event) {
@ -1106,12 +1118,17 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
required List<ui.PointerData> data,
required DomPointerEvent event,
required _SanitizedDetails details,
// `pointerId` and `eventTarget` are optional but useful when it's not
// desired to get those values from the event object. For example, when the
// event is a coalesced event.
int? pointerId,
DomEventTarget? eventTarget,
}) {
final ui.PointerDeviceKind kind = _pointerTypeToDeviceKind(event.pointerType!);
final double tilt = _computeHighestTilt(event);
final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!);
final num? pressure = event.pressure;
final ui.Offset offset = computeEventOffsetToTarget(event, _view);
final ui.Offset offset = computeEventOffsetToTarget(event, _view, eventTarget: eventTarget);
_pointerDataConverter.convert(
data,
viewId: _view.viewId,
@ -1119,7 +1136,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
timeStamp: timeStamp,
kind: kind,
signalKind: ui.PointerSignalKind.none,
device: _getPointerId(event),
device: pointerId ?? _getPointerId(event),
physicalX: offset.dx * _view.devicePixelRatio,
physicalY: offset.dy * _view.devicePixelRatio,
buttons: details.buttons,

View File

@ -12,18 +12,23 @@ import '../text_editing/text_editing.dart';
import '../vector_math.dart';
import '../window.dart';
/// Returns an [ui.Offset] of the position of [event], relative to the position of [actualTarget].
/// Returns an [ui.Offset] of the position of [event], relative to the position
/// of the Flutter [view].
///
/// The offset is *not* multiplied by DPR or anything else, it's the closest
/// to what the DOM would return if we had currentTarget readily available.
///
/// This needs an `actualTarget`, because the `event.currentTarget` (which is what
/// this would really need to use) gets lost when the `event` comes from a "coalesced"
/// event.
/// This needs an `eventTarget`, because the `event.target` (which is what
/// this would really need to use) gets lost when the `event` comes from a
/// "coalesced" event (see https://github.com/flutter/flutter/issues/155987).
///
/// It also takes into account semantics being enabled to fix the case where
/// offsetX, offsetY == 0 (TalkBack events).
ui.Offset computeEventOffsetToTarget(DomMouseEvent event, EngineFlutterView view) {
ui.Offset computeEventOffsetToTarget(
DomMouseEvent event,
EngineFlutterView view, {
DomEventTarget? eventTarget,
}) {
final DomElement actualTarget = view.dom.rootElement;
// On a TalkBack event
if (EngineSemantics.instance.semanticsEnabled && event.offsetX == 0 && event.offsetY == 0) {
@ -31,7 +36,8 @@ ui.Offset computeEventOffsetToTarget(DomMouseEvent event, EngineFlutterView view
}
// On one of our text-editing nodes
final bool isInput = view.dom.textEditingHost.contains(event.target! as DomNode);
eventTarget ??= event.target!;
final bool isInput = view.dom.textEditingHost.contains(eventTarget as DomNode);
if (isInput) {
final EditableTextGeometry? inputGeometry = textEditing.strategy.geometry;
if (inputGeometry != null) {

View File

@ -2609,6 +2609,88 @@ void testMain() {
},
);
test('ignores pointerId on coalesced events', () {
final _MultiPointerEventMixin context = _PointerEventContext();
final List<ui.PointerDataPacket> packets = <ui.PointerDataPacket>[];
List<ui.PointerData> data;
ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) {
packets.add(packet);
};
context.multiTouchDown(const <_TouchDetails>[
_TouchDetails(pointer: 52, clientX: 100, clientY: 101),
]).forEach(rootElement.dispatchEvent);
expect(packets.length, 1);
data = packets.single.data;
expect(data, hasLength(2));
expect(data[0].change, equals(ui.PointerChange.add));
expect(data[0].synthesized, isTrue);
expect(data[0].device, equals(52));
expect(data[0].physicalX, equals(100 * dpi));
expect(data[0].physicalY, equals(101 * dpi));
expect(data[1].change, equals(ui.PointerChange.down));
expect(data[1].device, equals(52));
expect(data[1].buttons, equals(1));
expect(data[1].physicalX, equals(100 * dpi));
expect(data[1].physicalY, equals(101 * dpi));
expect(data[1].physicalDeltaX, equals(0));
expect(data[1].physicalDeltaY, equals(0));
packets.clear();
// Pointer move with coaleasced events
context.multiTouchMove(const <_TouchDetails>[
_TouchDetails(pointer: 52, coalescedEvents: <_CoalescedTouchDetails>[
_CoalescedTouchDetails(pointer: 0, clientX: 301, clientY: 302),
_CoalescedTouchDetails(pointer: 0, clientX: 401, clientY: 402),
]),
]).forEach(rootElement.dispatchEvent);
expect(packets.length, 1);
data = packets.single.data;
expect(data, hasLength(2));
expect(data[0].change, equals(ui.PointerChange.move));
expect(data[0].device, equals(52));
expect(data[0].buttons, equals(1));
expect(data[0].physicalX, equals(301 * dpi));
expect(data[0].physicalY, equals(302 * dpi));
expect(data[0].physicalDeltaX, equals(201 * dpi));
expect(data[0].physicalDeltaY, equals(201 * dpi));
expect(data[1].change, equals(ui.PointerChange.move));
expect(data[1].device, equals(52));
expect(data[1].buttons, equals(1));
expect(data[1].physicalX, equals(401 * dpi));
expect(data[1].physicalY, equals(402 * dpi));
expect(data[1].physicalDeltaX, equals(100 * dpi));
expect(data[1].physicalDeltaY, equals(100 * dpi));
packets.clear();
// Pointer up
context.multiTouchUp(const <_TouchDetails>[
_TouchDetails(pointer: 52, clientX: 401, clientY: 402),
]).forEach(rootElement.dispatchEvent);
expect(packets, hasLength(1));
expect(packets[0].data, hasLength(2));
expect(packets[0].data[0].change, equals(ui.PointerChange.up));
expect(packets[0].data[0].device, equals(52));
expect(packets[0].data[0].buttons, equals(0));
expect(packets[0].data[0].physicalX, equals(401 * dpi));
expect(packets[0].data[0].physicalY, equals(402 * dpi));
expect(packets[0].data[0].physicalDeltaX, equals(0));
expect(packets[0].data[0].physicalDeltaY, equals(0));
expect(packets[0].data[1].change, equals(ui.PointerChange.remove));
expect(packets[0].data[1].device, equals(52));
expect(packets[0].data[1].buttons, equals(0));
expect(packets[0].data[1].physicalX, equals(401 * dpi));
expect(packets[0].data[1].physicalY, equals(402 * dpi));
expect(packets[0].data[1].physicalDeltaX, equals(0));
expect(packets[0].data[1].physicalDeltaY, equals(0));
packets.clear();
});
test(
'correctly parses cancel event',
() {
@ -3419,7 +3501,26 @@ mixin _ButtonedEventMixin on _BasicEventContext {
}
class _TouchDetails {
const _TouchDetails({this.pointer, this.clientX, this.clientY});
const _TouchDetails({
this.pointer,
this.clientX,
this.clientY,
this.coalescedEvents,
});
final int? pointer;
final double? clientX;
final double? clientY;
final List<_CoalescedTouchDetails>? coalescedEvents;
}
class _CoalescedTouchDetails {
const _CoalescedTouchDetails({
this.pointer,
this.clientX,
this.clientY,
});
final int? pointer;
final double? clientX;
@ -3478,6 +3579,10 @@ class _PointerEventContext extends _BasicEventContext
@override
List<DomEvent> multiTouchDown(List<_TouchDetails> touches) {
assert(
touches.every((_TouchDetails details) => details.coalescedEvents == null),
'Coalesced events are not allowed for pointerdown events.',
);
return touches
.map((_TouchDetails details) => _downWithFullDetails(
pointer: details.pointer,
@ -3541,6 +3646,7 @@ class _PointerEventContext extends _BasicEventContext
clientX: details.clientX,
clientY: details.clientY,
pointerType: 'touch',
coalescedEvents: details.coalescedEvents,
))
.toList();
}
@ -3570,8 +3676,9 @@ class _PointerEventContext extends _BasicEventContext
int? buttons,
int? pointer,
String? pointerType,
List<_CoalescedTouchDetails>? coalescedEvents,
}) {
return createDomPointerEvent('pointermove', <String, dynamic>{
final event = createDomPointerEvent('pointermove', <String, dynamic>{
'bubbles': true,
'pointerId': pointer,
'button': button,
@ -3580,6 +3687,26 @@ class _PointerEventContext extends _BasicEventContext
'clientY': clientY,
'pointerType': pointerType,
});
if (coalescedEvents != null) {
// There's no JS API for setting coalesced events, so we need to
// monkey-patch the `getCoalescedEvents` method to return what we want.
final coalescedEventJs = coalescedEvents
.map((_CoalescedTouchDetails details) => _moveWithFullDetails(
pointer: details.pointer,
button: button,
buttons: buttons,
clientX: details.clientX,
clientY: details.clientY,
pointerType: 'touch',
)).toJSAnyDeep;
js_util.setProperty(event, 'getCoalescedEvents', js_util.allowInterop(() {
return coalescedEventJs;
}));
}
return event;
}
@override
@ -3620,6 +3747,10 @@ class _PointerEventContext extends _BasicEventContext
@override
List<DomEvent> multiTouchUp(List<_TouchDetails> touches) {
assert(
touches.every((_TouchDetails details) => details.coalescedEvents == null),
'Coalesced events are not allowed for pointerup events.',
);
return touches
.map((_TouchDetails details) => _upWithFullDetails(
pointer: details.pointer,
@ -3670,6 +3801,10 @@ class _PointerEventContext extends _BasicEventContext
@override
List<DomEvent> multiTouchCancel(List<_TouchDetails> touches) {
assert(
touches.every((_TouchDetails details) => details.coalescedEvents == null),
'Coalesced events are not allowed for pointercancel events.',
);
return touches
.map((_TouchDetails details) =>
createDomPointerEvent('pointercancel', <String, dynamic>{