[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
|
||||
// `_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,
|
||||
|
@ -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) {
|
||||
|
@ -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>{
|
||||
|
Loading…
x
Reference in New Issue
Block a user