Skip to content

Commit 3e281cd

Browse files
authored
[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 flutter#158299 Fixes flutter#155987
1 parent 4705535 commit 3e281cd

File tree

3 files changed

+174
-16
lines changed

3 files changed

+174
-16
lines changed

engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,20 +1039,32 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
10391039
//
10401040
// TODO(dkwingsmt): Investigate whether we can configure the behavior for
10411041
// `_viewTarget`. https://github.com/flutter/flutter/issues/157968
1042-
_addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent event) {
1043-
final int device = _getPointerId(event);
1042+
_addPointerEventListener(_globalTarget, 'pointermove', (DomPointerEvent moveEvent) {
1043+
final int device = _getPointerId(moveEvent);
10441044
final _ButtonSanitizer sanitizer = _ensureSanitizer(device);
10451045
final List<ui.PointerData> pointerData = <ui.PointerData>[];
1046-
final List<DomPointerEvent> expandedEvents = _expandEvents(event);
1046+
final List<DomPointerEvent> expandedEvents = _expandEvents(moveEvent);
10471047
for (final DomPointerEvent event in expandedEvents) {
10481048
final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!.toInt());
10491049
if (up != null) {
1050-
_convertEventsToPointerData(data: pointerData, event: event, details: up);
1050+
_convertEventsToPointerData(
1051+
data: pointerData,
1052+
event: event,
1053+
details: up,
1054+
pointerId: device,
1055+
eventTarget: moveEvent.target,
1056+
);
10511057
}
10521058
final _SanitizedDetails move = sanitizer.sanitizeMoveEvent(buttons: event.buttons!.toInt());
1053-
_convertEventsToPointerData(data: pointerData, event: event, details: move);
1059+
_convertEventsToPointerData(
1060+
data: pointerData,
1061+
event: event,
1062+
details: move,
1063+
pointerId: device,
1064+
eventTarget: moveEvent.target,
1065+
);
10541066
}
1055-
_callback(event, pointerData);
1067+
_callback(moveEvent, pointerData);
10561068
});
10571069

10581070
_addPointerEventListener(_viewTarget, 'pointerleave', (DomPointerEvent event) {
@@ -1106,20 +1118,25 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
11061118
required List<ui.PointerData> data,
11071119
required DomPointerEvent event,
11081120
required _SanitizedDetails details,
1121+
// `pointerId` and `eventTarget` are optional but useful when it's not
1122+
// desired to get those values from the event object. For example, when the
1123+
// event is a coalesced event.
1124+
int? pointerId,
1125+
DomEventTarget? eventTarget,
11091126
}) {
11101127
final ui.PointerDeviceKind kind = _pointerTypeToDeviceKind(event.pointerType!);
11111128
final double tilt = _computeHighestTilt(event);
11121129
final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!);
11131130
final num? pressure = event.pressure;
1114-
final ui.Offset offset = computeEventOffsetToTarget(event, _view);
1131+
final ui.Offset offset = computeEventOffsetToTarget(event, _view, eventTarget: eventTarget);
11151132
_pointerDataConverter.convert(
11161133
data,
11171134
viewId: _view.viewId,
11181135
change: details.change,
11191136
timeStamp: timeStamp,
11201137
kind: kind,
11211138
signalKind: ui.PointerSignalKind.none,
1122-
device: _getPointerId(event),
1139+
device: pointerId ?? _getPointerId(event),
11231140
physicalX: offset.dx * _view.devicePixelRatio,
11241141
physicalY: offset.dy * _view.devicePixelRatio,
11251142
buttons: details.buttons,

engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,32 @@ import '../text_editing/text_editing.dart';
1212
import '../vector_math.dart';
1313
import '../window.dart';
1414

15-
/// Returns an [ui.Offset] of the position of [event], relative to the position of [actualTarget].
15+
/// Returns an [ui.Offset] of the position of [event], relative to the position
16+
/// of the Flutter [view].
1617
///
1718
/// The offset is *not* multiplied by DPR or anything else, it's the closest
1819
/// to what the DOM would return if we had currentTarget readily available.
1920
///
20-
/// This needs an `actualTarget`, because the `event.currentTarget` (which is what
21-
/// this would really need to use) gets lost when the `event` comes from a "coalesced"
22-
/// event.
21+
/// This needs an `eventTarget`, because the `event.target` (which is what
22+
/// this would really need to use) gets lost when the `event` comes from a
23+
/// "coalesced" event (see https://github.com/flutter/flutter/issues/155987).
2324
///
2425
/// It also takes into account semantics being enabled to fix the case where
2526
/// offsetX, offsetY == 0 (TalkBack events).
26-
ui.Offset computeEventOffsetToTarget(DomMouseEvent event, EngineFlutterView view) {
27+
ui.Offset computeEventOffsetToTarget(
28+
DomMouseEvent event,
29+
EngineFlutterView view, {
30+
DomEventTarget? eventTarget,
31+
}) {
2732
final DomElement actualTarget = view.dom.rootElement;
2833
// On a TalkBack event
2934
if (EngineSemantics.instance.semanticsEnabled && event.offsetX == 0 && event.offsetY == 0) {
3035
return _computeOffsetForTalkbackEvent(event, actualTarget);
3136
}
3237

3338
// On one of our text-editing nodes
34-
final bool isInput = view.dom.textEditingHost.contains(event.target! as DomNode);
39+
eventTarget ??= event.target!;
40+
final bool isInput = view.dom.textEditingHost.contains(eventTarget as DomNode);
3541
if (isInput) {
3642
final EditableTextGeometry? inputGeometry = textEditing.strategy.geometry;
3743
if (inputGeometry != null) {

engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,6 +2609,88 @@ void testMain() {
26092609
},
26102610
);
26112611

2612+
test('ignores pointerId on coalesced events', () {
2613+
final _MultiPointerEventMixin context = _PointerEventContext();
2614+
final List<ui.PointerDataPacket> packets = <ui.PointerDataPacket>[];
2615+
List<ui.PointerData> data;
2616+
ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) {
2617+
packets.add(packet);
2618+
};
2619+
2620+
context.multiTouchDown(const <_TouchDetails>[
2621+
_TouchDetails(pointer: 52, clientX: 100, clientY: 101),
2622+
]).forEach(rootElement.dispatchEvent);
2623+
expect(packets.length, 1);
2624+
2625+
data = packets.single.data;
2626+
expect(data, hasLength(2));
2627+
expect(data[0].change, equals(ui.PointerChange.add));
2628+
expect(data[0].synthesized, isTrue);
2629+
expect(data[0].device, equals(52));
2630+
expect(data[0].physicalX, equals(100 * dpi));
2631+
expect(data[0].physicalY, equals(101 * dpi));
2632+
2633+
expect(data[1].change, equals(ui.PointerChange.down));
2634+
expect(data[1].device, equals(52));
2635+
expect(data[1].buttons, equals(1));
2636+
expect(data[1].physicalX, equals(100 * dpi));
2637+
expect(data[1].physicalY, equals(101 * dpi));
2638+
expect(data[1].physicalDeltaX, equals(0));
2639+
expect(data[1].physicalDeltaY, equals(0));
2640+
packets.clear();
2641+
2642+
// Pointer move with coaleasced events
2643+
context.multiTouchMove(const <_TouchDetails>[
2644+
_TouchDetails(pointer: 52, coalescedEvents: <_CoalescedTouchDetails>[
2645+
_CoalescedTouchDetails(pointer: 0, clientX: 301, clientY: 302),
2646+
_CoalescedTouchDetails(pointer: 0, clientX: 401, clientY: 402),
2647+
]),
2648+
]).forEach(rootElement.dispatchEvent);
2649+
expect(packets.length, 1);
2650+
2651+
data = packets.single.data;
2652+
expect(data, hasLength(2));
2653+
expect(data[0].change, equals(ui.PointerChange.move));
2654+
expect(data[0].device, equals(52));
2655+
expect(data[0].buttons, equals(1));
2656+
expect(data[0].physicalX, equals(301 * dpi));
2657+
expect(data[0].physicalY, equals(302 * dpi));
2658+
expect(data[0].physicalDeltaX, equals(201 * dpi));
2659+
expect(data[0].physicalDeltaY, equals(201 * dpi));
2660+
2661+
expect(data[1].change, equals(ui.PointerChange.move));
2662+
expect(data[1].device, equals(52));
2663+
expect(data[1].buttons, equals(1));
2664+
expect(data[1].physicalX, equals(401 * dpi));
2665+
expect(data[1].physicalY, equals(402 * dpi));
2666+
expect(data[1].physicalDeltaX, equals(100 * dpi));
2667+
expect(data[1].physicalDeltaY, equals(100 * dpi));
2668+
packets.clear();
2669+
2670+
// Pointer up
2671+
context.multiTouchUp(const <_TouchDetails>[
2672+
_TouchDetails(pointer: 52, clientX: 401, clientY: 402),
2673+
]).forEach(rootElement.dispatchEvent);
2674+
expect(packets, hasLength(1));
2675+
expect(packets[0].data, hasLength(2));
2676+
expect(packets[0].data[0].change, equals(ui.PointerChange.up));
2677+
expect(packets[0].data[0].device, equals(52));
2678+
expect(packets[0].data[0].buttons, equals(0));
2679+
expect(packets[0].data[0].physicalX, equals(401 * dpi));
2680+
expect(packets[0].data[0].physicalY, equals(402 * dpi));
2681+
expect(packets[0].data[0].physicalDeltaX, equals(0));
2682+
expect(packets[0].data[0].physicalDeltaY, equals(0));
2683+
2684+
expect(packets[0].data[1].change, equals(ui.PointerChange.remove));
2685+
expect(packets[0].data[1].device, equals(52));
2686+
expect(packets[0].data[1].buttons, equals(0));
2687+
expect(packets[0].data[1].physicalX, equals(401 * dpi));
2688+
expect(packets[0].data[1].physicalY, equals(402 * dpi));
2689+
expect(packets[0].data[1].physicalDeltaX, equals(0));
2690+
expect(packets[0].data[1].physicalDeltaY, equals(0));
2691+
packets.clear();
2692+
});
2693+
26122694
test(
26132695
'correctly parses cancel event',
26142696
() {
@@ -3419,7 +3501,26 @@ mixin _ButtonedEventMixin on _BasicEventContext {
34193501
}
34203502

34213503
class _TouchDetails {
3422-
const _TouchDetails({this.pointer, this.clientX, this.clientY});
3504+
const _TouchDetails({
3505+
this.pointer,
3506+
this.clientX,
3507+
this.clientY,
3508+
this.coalescedEvents,
3509+
});
3510+
3511+
final int? pointer;
3512+
final double? clientX;
3513+
final double? clientY;
3514+
3515+
final List<_CoalescedTouchDetails>? coalescedEvents;
3516+
}
3517+
3518+
class _CoalescedTouchDetails {
3519+
const _CoalescedTouchDetails({
3520+
this.pointer,
3521+
this.clientX,
3522+
this.clientY,
3523+
});
34233524

34243525
final int? pointer;
34253526
final double? clientX;
@@ -3478,6 +3579,10 @@ class _PointerEventContext extends _BasicEventContext
34783579

34793580
@override
34803581
List<DomEvent> multiTouchDown(List<_TouchDetails> touches) {
3582+
assert(
3583+
touches.every((_TouchDetails details) => details.coalescedEvents == null),
3584+
'Coalesced events are not allowed for pointerdown events.',
3585+
);
34813586
return touches
34823587
.map((_TouchDetails details) => _downWithFullDetails(
34833588
pointer: details.pointer,
@@ -3541,6 +3646,7 @@ class _PointerEventContext extends _BasicEventContext
35413646
clientX: details.clientX,
35423647
clientY: details.clientY,
35433648
pointerType: 'touch',
3649+
coalescedEvents: details.coalescedEvents,
35443650
))
35453651
.toList();
35463652
}
@@ -3570,8 +3676,9 @@ class _PointerEventContext extends _BasicEventContext
35703676
int? buttons,
35713677
int? pointer,
35723678
String? pointerType,
3679+
List<_CoalescedTouchDetails>? coalescedEvents,
35733680
}) {
3574-
return createDomPointerEvent('pointermove', <String, dynamic>{
3681+
final event = createDomPointerEvent('pointermove', <String, dynamic>{
35753682
'bubbles': true,
35763683
'pointerId': pointer,
35773684
'button': button,
@@ -3580,6 +3687,26 @@ class _PointerEventContext extends _BasicEventContext
35803687
'clientY': clientY,
35813688
'pointerType': pointerType,
35823689
});
3690+
3691+
if (coalescedEvents != null) {
3692+
// There's no JS API for setting coalesced events, so we need to
3693+
// monkey-patch the `getCoalescedEvents` method to return what we want.
3694+
final coalescedEventJs = coalescedEvents
3695+
.map((_CoalescedTouchDetails details) => _moveWithFullDetails(
3696+
pointer: details.pointer,
3697+
button: button,
3698+
buttons: buttons,
3699+
clientX: details.clientX,
3700+
clientY: details.clientY,
3701+
pointerType: 'touch',
3702+
)).toJSAnyDeep;
3703+
3704+
js_util.setProperty(event, 'getCoalescedEvents', js_util.allowInterop(() {
3705+
return coalescedEventJs;
3706+
}));
3707+
}
3708+
3709+
return event;
35833710
}
35843711

35853712
@override
@@ -3620,6 +3747,10 @@ class _PointerEventContext extends _BasicEventContext
36203747

36213748
@override
36223749
List<DomEvent> multiTouchUp(List<_TouchDetails> touches) {
3750+
assert(
3751+
touches.every((_TouchDetails details) => details.coalescedEvents == null),
3752+
'Coalesced events are not allowed for pointerup events.',
3753+
);
36233754
return touches
36243755
.map((_TouchDetails details) => _upWithFullDetails(
36253756
pointer: details.pointer,
@@ -3670,6 +3801,10 @@ class _PointerEventContext extends _BasicEventContext
36703801

36713802
@override
36723803
List<DomEvent> multiTouchCancel(List<_TouchDetails> touches) {
3804+
assert(
3805+
touches.every((_TouchDetails details) => details.coalescedEvents == null),
3806+
'Coalesced events are not allowed for pointercancel events.',
3807+
);
36733808
return touches
36743809
.map((_TouchDetails details) =>
36753810
createDomPointerEvent('pointercancel', <String, dynamic>{

0 commit comments

Comments
 (0)