[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:
parent
4705535548
commit
3e281cda44
@ -1039,20 +1039,32 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
|
|||||||
//
|
//
|
||||||
// TODO(dkwingsmt): Investigate whether we can configure the behavior for
|
// TODO(dkwingsmt): Investigate whether we can configure the behavior for
|
||||||
// `_viewTarget`. https://github.com/flutter/flutter/issues/157968
|
// `_viewTarget`. https://github.com/flutter/flutter/issues/157968
|
||||||
_addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent event) {
|
_addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent moveEvent) {
|
||||||
final int device = _getPointerId(event);
|
final int device = _getPointerId(moveEvent);
|
||||||
final _ButtonSanitizer sanitizer = _ensureSanitizer(device);
|
final _ButtonSanitizer sanitizer = _ensureSanitizer(device);
|
||||||
final List<ui.PointerData> pointerData = <ui.PointerData>[];
|
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) {
|
for (final DomPointerEvent event in expandedEvents) {
|
||||||
final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!.toInt());
|
final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!.toInt());
|
||||||
if (up != null) {
|
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());
|
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) {
|
_addPointerEventListener(_viewTarget, 'pointerleave', (DomPointerEvent event) {
|
||||||
@ -1106,12 +1118,17 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
|
|||||||
required List<ui.PointerData> data,
|
required List<ui.PointerData> data,
|
||||||
required DomPointerEvent event,
|
required DomPointerEvent event,
|
||||||
required _SanitizedDetails details,
|
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 ui.PointerDeviceKind kind = _pointerTypeToDeviceKind(event.pointerType!);
|
||||||
final double tilt = _computeHighestTilt(event);
|
final double tilt = _computeHighestTilt(event);
|
||||||
final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!);
|
final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!);
|
||||||
final num? pressure = event.pressure;
|
final num? pressure = event.pressure;
|
||||||
final ui.Offset offset = computeEventOffsetToTarget(event, _view);
|
final ui.Offset offset = computeEventOffsetToTarget(event, _view, eventTarget: eventTarget);
|
||||||
_pointerDataConverter.convert(
|
_pointerDataConverter.convert(
|
||||||
data,
|
data,
|
||||||
viewId: _view.viewId,
|
viewId: _view.viewId,
|
||||||
@ -1119,7 +1136,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
|
|||||||
timeStamp: timeStamp,
|
timeStamp: timeStamp,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
signalKind: ui.PointerSignalKind.none,
|
signalKind: ui.PointerSignalKind.none,
|
||||||
device: _getPointerId(event),
|
device: pointerId ?? _getPointerId(event),
|
||||||
physicalX: offset.dx * _view.devicePixelRatio,
|
physicalX: offset.dx * _view.devicePixelRatio,
|
||||||
physicalY: offset.dy * _view.devicePixelRatio,
|
physicalY: offset.dy * _view.devicePixelRatio,
|
||||||
buttons: details.buttons,
|
buttons: details.buttons,
|
||||||
|
@ -12,18 +12,23 @@ import '../text_editing/text_editing.dart';
|
|||||||
import '../vector_math.dart';
|
import '../vector_math.dart';
|
||||||
import '../window.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
|
/// 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.
|
/// to what the DOM would return if we had currentTarget readily available.
|
||||||
///
|
///
|
||||||
/// This needs an `actualTarget`, because the `event.currentTarget` (which is what
|
/// 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"
|
/// this would really need to use) gets lost when the `event` comes from a
|
||||||
/// event.
|
/// "coalesced" event (see https://github.com/flutter/flutter/issues/155987).
|
||||||
///
|
///
|
||||||
/// It also takes into account semantics being enabled to fix the case where
|
/// It also takes into account semantics being enabled to fix the case where
|
||||||
/// offsetX, offsetY == 0 (TalkBack events).
|
/// 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;
|
final DomElement actualTarget = view.dom.rootElement;
|
||||||
// On a TalkBack event
|
// On a TalkBack event
|
||||||
if (EngineSemantics.instance.semanticsEnabled && event.offsetX == 0 && event.offsetY == 0) {
|
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
|
// 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) {
|
if (isInput) {
|
||||||
final EditableTextGeometry? inputGeometry = textEditing.strategy.geometry;
|
final EditableTextGeometry? inputGeometry = textEditing.strategy.geometry;
|
||||||
if (inputGeometry != null) {
|
if (inputGeometry != null) {
|
||||||
|
@ -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(
|
test(
|
||||||
'correctly parses cancel event',
|
'correctly parses cancel event',
|
||||||
() {
|
() {
|
||||||
@ -3419,7 +3501,26 @@ mixin _ButtonedEventMixin on _BasicEventContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TouchDetails {
|
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 int? pointer;
|
||||||
final double? clientX;
|
final double? clientX;
|
||||||
@ -3478,6 +3579,10 @@ class _PointerEventContext extends _BasicEventContext
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
List<DomEvent> multiTouchDown(List<_TouchDetails> touches) {
|
List<DomEvent> multiTouchDown(List<_TouchDetails> touches) {
|
||||||
|
assert(
|
||||||
|
touches.every((_TouchDetails details) => details.coalescedEvents == null),
|
||||||
|
'Coalesced events are not allowed for pointerdown events.',
|
||||||
|
);
|
||||||
return touches
|
return touches
|
||||||
.map((_TouchDetails details) => _downWithFullDetails(
|
.map((_TouchDetails details) => _downWithFullDetails(
|
||||||
pointer: details.pointer,
|
pointer: details.pointer,
|
||||||
@ -3541,6 +3646,7 @@ class _PointerEventContext extends _BasicEventContext
|
|||||||
clientX: details.clientX,
|
clientX: details.clientX,
|
||||||
clientY: details.clientY,
|
clientY: details.clientY,
|
||||||
pointerType: 'touch',
|
pointerType: 'touch',
|
||||||
|
coalescedEvents: details.coalescedEvents,
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@ -3570,8 +3676,9 @@ class _PointerEventContext extends _BasicEventContext
|
|||||||
int? buttons,
|
int? buttons,
|
||||||
int? pointer,
|
int? pointer,
|
||||||
String? pointerType,
|
String? pointerType,
|
||||||
|
List<_CoalescedTouchDetails>? coalescedEvents,
|
||||||
}) {
|
}) {
|
||||||
return createDomPointerEvent('pointermove', <String, dynamic>{
|
final event = createDomPointerEvent('pointermove', <String, dynamic>{
|
||||||
'bubbles': true,
|
'bubbles': true,
|
||||||
'pointerId': pointer,
|
'pointerId': pointer,
|
||||||
'button': button,
|
'button': button,
|
||||||
@ -3580,6 +3687,26 @@ class _PointerEventContext extends _BasicEventContext
|
|||||||
'clientY': clientY,
|
'clientY': clientY,
|
||||||
'pointerType': pointerType,
|
'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
|
@override
|
||||||
@ -3620,6 +3747,10 @@ class _PointerEventContext extends _BasicEventContext
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
List<DomEvent> multiTouchUp(List<_TouchDetails> touches) {
|
List<DomEvent> multiTouchUp(List<_TouchDetails> touches) {
|
||||||
|
assert(
|
||||||
|
touches.every((_TouchDetails details) => details.coalescedEvents == null),
|
||||||
|
'Coalesced events are not allowed for pointerup events.',
|
||||||
|
);
|
||||||
return touches
|
return touches
|
||||||
.map((_TouchDetails details) => _upWithFullDetails(
|
.map((_TouchDetails details) => _upWithFullDetails(
|
||||||
pointer: details.pointer,
|
pointer: details.pointer,
|
||||||
@ -3670,6 +3801,10 @@ class _PointerEventContext extends _BasicEventContext
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
List<DomEvent> multiTouchCancel(List<_TouchDetails> touches) {
|
List<DomEvent> multiTouchCancel(List<_TouchDetails> touches) {
|
||||||
|
assert(
|
||||||
|
touches.every((_TouchDetails details) => details.coalescedEvents == null),
|
||||||
|
'Coalesced events are not allowed for pointercancel events.',
|
||||||
|
);
|
||||||
return touches
|
return touches
|
||||||
.map((_TouchDetails details) =>
|
.map((_TouchDetails details) =>
|
||||||
createDomPointerEvent('pointercancel', <String, dynamic>{
|
createDomPointerEvent('pointercancel', <String, dynamic>{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user