Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 99f3d19

Browse files
authored
Web trackpad pan (#36346)
* Guess at trackpad pans on web * Add test * Update comment * Handle macOS accelerated scroll wheel * Fix test after last commit * Disable on firefox * Pull out _isTrackpadEvent and add doc links * Fix issue with floating point / integer conversion error. * Workaround for magic mouse events which happen to be divisible by 120. * Refactor to handle bad luck in accelerated mouse deltas. Basically, bias towards choosing mouse, but if timestamps are available, we can check the previous event and ensure that false-mouses are avoided. * Use 120 wheelDelta to identify mouse-accelerated events instead of 240 Apparently some high-precision mice use 120 instead of 240 as the wheelDelta per tick. * Handle multiple bad-luck events in a row. Also fix setting of timeStamp in test. * Cleanup parameters
1 parent b97d354 commit 99f3d19

File tree

3 files changed

+322
-2
lines changed

3 files changed

+322
-2
lines changed

lib/web_ui/lib/src/engine/dom.dart

+2
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,8 @@ class DomWheelEvent extends DomMouseEvent {}
11271127
extension DomWheelEventExtension on DomWheelEvent {
11281128
external double get deltaX;
11291129
external double get deltaY;
1130+
external double? get wheelDeltaX;
1131+
external double? get wheelDeltaY;
11301132
external double get deltaMode;
11311133
}
11321134

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

+75-1
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ abstract class _BaseAdapter {
263263
final _PointerDataCallback _callback;
264264
final PointerDataConverter _pointerDataConverter;
265265
final KeyboardConverter _keyboardConverter;
266+
DomWheelEvent? _lastWheelEvent;
267+
bool _lastWheelEventWasTrackpad = false;
266268

267269
/// Each subclass is expected to override this method to attach its own event
268270
/// listeners and convert events into pointer events.
@@ -333,13 +335,83 @@ abstract class _BaseAdapter {
333335
mixin _WheelEventListenerMixin on _BaseAdapter {
334336
static double? _defaultScrollLineHeight;
335337

338+
bool _isAcceleratedMouseWheelDelta(num delta, num? wheelDelta) {
339+
// On macOS, scrolling using a mouse wheel by default uses an acceleration
340+
// curve, so delta values ramp up and are not at fixed multiples of 120.
341+
// But in this case, the wheelDelta properties of the event still keep
342+
// their original values.
343+
// For all events without this acceleration curve applied, the wheelDelta
344+
// values are by convention three times greater than the delta values and with
345+
// the opposite sign.
346+
if (wheelDelta == null) {
347+
return false;
348+
}
349+
// Account for observed issues with integer truncation by allowing +-1px error.
350+
return (wheelDelta - (-3 * delta)).abs() > 1;
351+
}
352+
353+
bool _isTrackpadEvent(DomWheelEvent event) {
354+
// This function relies on deprecated and non-standard implementation
355+
// details. Useful reference material can be found below.
356+
//
357+
// https://source.chromium.org/chromium/chromium/src/+/main:ui/events/event.cc
358+
// https://source.chromium.org/chromium/chromium/src/+/main:ui/events/cocoa/events_mac.mm
359+
// https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/mac/PlatformEventFactoryMac.mm
360+
// https://searchfox.org/mozilla-central/source/dom/events/WheelEvent.h
361+
// https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-mousewheel
362+
if (browserEngine == BrowserEngine.firefox) {
363+
// Firefox has restricted the wheelDelta properties, they do not provide
364+
// enough information to accurately disambiguate trackpad events from mouse
365+
// wheel events.
366+
return false;
367+
}
368+
if (_isAcceleratedMouseWheelDelta(event.deltaX, event.wheelDeltaX) ||
369+
_isAcceleratedMouseWheelDelta(event.deltaY, event.wheelDeltaY)) {
370+
return false;
371+
}
372+
if (((event.deltaX % 120 == 0) && (event.deltaY % 120 == 0)) ||
373+
(((event.wheelDeltaX ?? 1) % 120 == 0) && ((event.wheelDeltaY ?? 1) % 120) == 0)) {
374+
// While not in any formal web standard, `blink` and `webkit` browsers use
375+
// a delta of 120 to represent one mouse wheel turn. If both dimensions of
376+
// the delta are divisible by 120, this event is probably from a mouse.
377+
// Checking if wheelDeltaX and wheelDeltaY are both divisible by 120
378+
// catches any macOS accelerated mouse wheel deltas which by random chance
379+
// are not caught by _isAcceleratedMouseWheelDelta.
380+
final num deltaXChange = (event.deltaX - (_lastWheelEvent?.deltaX ?? 0)).abs();
381+
final num deltaYChange = (event.deltaY - (_lastWheelEvent?.deltaY ?? 0)).abs();
382+
if ((_lastWheelEvent == null) ||
383+
(deltaXChange == 0 && deltaYChange == 0) ||
384+
!(deltaXChange < 20 && deltaYChange < 20)) {
385+
// A trackpad event might by chance have a delta of exactly 120, so
386+
// make sure this event does not have a similar delta to the previous
387+
// one before calling it a mouse event.
388+
if (event.timeStamp != null && _lastWheelEvent?.timeStamp != null) {
389+
// If the event has a large delta to the previous event, check if
390+
// it was preceded within 50 milliseconds by a trackpad event. This
391+
// handles unlucky 120-delta trackpad events during rapid movement.
392+
final num diffMs = event.timeStamp! - _lastWheelEvent!.timeStamp!;
393+
if (diffMs < 50 && _lastWheelEventWasTrackpad) {
394+
return true;
395+
}
396+
}
397+
return false;
398+
}
399+
}
400+
return true;
401+
}
402+
336403
List<ui.PointerData> _convertWheelEventToPointerData(
337404
DomWheelEvent event
338405
) {
339406
const int domDeltaPixel = 0x00;
340407
const int domDeltaLine = 0x01;
341408
const int domDeltaPage = 0x02;
342409

410+
ui.PointerDeviceKind kind = ui.PointerDeviceKind.mouse;
411+
if (_isTrackpadEvent(event)) {
412+
kind = ui.PointerDeviceKind.trackpad;
413+
}
414+
343415
// Flutter only supports pixel scroll delta. Convert deltaMode values
344416
// to pixels.
345417
double deltaX = event.deltaX;
@@ -371,7 +443,7 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
371443
data,
372444
change: ui.PointerChange.hover,
373445
timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!),
374-
kind: ui.PointerDeviceKind.mouse,
446+
kind: kind,
375447
signalKind: ui.PointerSignalKind.scroll,
376448
device: _mouseDeviceId,
377449
physicalX: event.clientX * ui.window.devicePixelRatio,
@@ -382,6 +454,8 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
382454
scrollDeltaX: deltaX,
383455
scrollDeltaY: deltaY,
384456
);
457+
_lastWheelEvent = event;
458+
_lastWheelEventWasTrackpad = kind == ui.PointerDeviceKind.trackpad;
385459
return data;
386460
}
387461

lib/web_ui/test/engine/pointer_binding_test.dart

+245-1
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,229 @@ void testMain() {
11471147
},
11481148
);
11491149

1150+
_testEach<_ButtonedEventMixin>(
1151+
<_ButtonedEventMixin>[
1152+
if (!isIosSafari) _PointerEventContext(),
1153+
if (!isIosSafari) _MouseEventContext(),
1154+
],
1155+
'does set pointer device kind based on delta precision and wheelDelta',
1156+
(_ButtonedEventMixin context) {
1157+
if (isFirefox) {
1158+
// Firefox does not support trackpad events, as they cannot be
1159+
// disambiguated from smoothed mouse wheel events.
1160+
return;
1161+
}
1162+
PointerBinding.instance!.debugOverrideDetector(context);
1163+
final List<ui.PointerDataPacket> packets = <ui.PointerDataPacket>[];
1164+
ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) {
1165+
packets.add(packet);
1166+
};
1167+
1168+
glassPane.dispatchEvent(context.wheel(
1169+
buttons: 0,
1170+
clientX: 10,
1171+
clientY: 10,
1172+
deltaX: 119,
1173+
deltaY: 119,
1174+
wheelDeltaX: -357,
1175+
wheelDeltaY: -357,
1176+
timeStamp: 0,
1177+
));
1178+
1179+
glassPane.dispatchEvent(context.wheel(
1180+
buttons: 0,
1181+
clientX: 10,
1182+
clientY: 10,
1183+
deltaX: 120,
1184+
deltaY: 120,
1185+
wheelDeltaX: -360,
1186+
wheelDeltaY: -360,
1187+
timeStamp: 10,
1188+
));
1189+
1190+
glassPane.dispatchEvent(context.wheel(
1191+
buttons: 0,
1192+
clientX: 10,
1193+
clientY: 10,
1194+
deltaX: 120,
1195+
deltaY: 120,
1196+
wheelDeltaX: -360,
1197+
wheelDeltaY: -360,
1198+
timeStamp: 20,
1199+
));
1200+
1201+
glassPane.dispatchEvent(context.wheel(
1202+
buttons: 0,
1203+
clientX: 10,
1204+
clientY: 10,
1205+
deltaX: 119,
1206+
deltaY: 119,
1207+
wheelDeltaX: -357,
1208+
wheelDeltaY: -357,
1209+
timeStamp: 1000,
1210+
));
1211+
1212+
glassPane.dispatchEvent(context.wheel(
1213+
buttons: 0,
1214+
clientX: 10,
1215+
clientY: 10,
1216+
deltaX: -120,
1217+
deltaY: -120,
1218+
wheelDeltaX: 360,
1219+
wheelDeltaY: 360,
1220+
timeStamp: 1010,
1221+
));
1222+
1223+
glassPane.dispatchEvent(context.wheel(
1224+
buttons: 0,
1225+
clientX: 10,
1226+
clientY: 10,
1227+
deltaX: 0,
1228+
deltaY: -120,
1229+
wheelDeltaX: 0,
1230+
wheelDeltaY: 360,
1231+
timeStamp: 2000,
1232+
));
1233+
1234+
glassPane.dispatchEvent(context.wheel(
1235+
buttons: 0,
1236+
clientX: 10,
1237+
clientY: 10,
1238+
deltaX: 0,
1239+
deltaY: 40,
1240+
wheelDeltaX: 0,
1241+
wheelDeltaY: -360,
1242+
timeStamp: 3000,
1243+
));
1244+
1245+
expect(packets, hasLength(7));
1246+
1247+
// An add will be synthesized.
1248+
expect(packets[0].data, hasLength(2));
1249+
expect(packets[0].data[0].change, equals(ui.PointerChange.add));
1250+
expect(packets[0].data[0].pointerIdentifier, equals(0));
1251+
expect(packets[0].data[0].synthesized, isTrue);
1252+
expect(packets[0].data[0].physicalX, equals(10.0 * dpi));
1253+
expect(packets[0].data[0].physicalY, equals(10.0 * dpi));
1254+
expect(packets[0].data[0].physicalDeltaX, equals(0.0));
1255+
expect(packets[0].data[0].physicalDeltaY, equals(0.0));
1256+
// Because the delta is not in increments of 120 and has matching wheelDelta,
1257+
// it will be a trackpad event.
1258+
expect(packets[0].data[1].change, equals(ui.PointerChange.hover));
1259+
expect(
1260+
packets[0].data[1].signalKind, equals(ui.PointerSignalKind.scroll));
1261+
expect(
1262+
packets[0].data[1].kind, equals(ui.PointerDeviceKind.trackpad));
1263+
expect(packets[0].data[1].pointerIdentifier, equals(0));
1264+
expect(packets[0].data[1].synthesized, isFalse);
1265+
expect(packets[0].data[1].physicalX, equals(10.0 * dpi));
1266+
expect(packets[0].data[1].physicalY, equals(10.0 * dpi));
1267+
expect(packets[0].data[1].physicalDeltaX, equals(0.0));
1268+
expect(packets[0].data[1].physicalDeltaY, equals(0.0));
1269+
expect(packets[0].data[1].scrollDeltaX, equals(119.0));
1270+
expect(packets[0].data[1].scrollDeltaY, equals(119.0));
1271+
1272+
// Because the delta is in increments of 120, but is similar to the
1273+
// previous event, it will be a trackpad event.
1274+
expect(packets[1].data[0].change, equals(ui.PointerChange.hover));
1275+
expect(
1276+
packets[1].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
1277+
expect(
1278+
packets[1].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
1279+
expect(packets[1].data[0].pointerIdentifier, equals(0));
1280+
expect(packets[1].data[0].synthesized, isFalse);
1281+
expect(packets[1].data[0].physicalX, equals(10.0 * dpi));
1282+
expect(packets[1].data[0].physicalY, equals(10.0 * dpi));
1283+
expect(packets[1].data[0].physicalDeltaX, equals(0.0));
1284+
expect(packets[1].data[0].physicalDeltaY, equals(0.0));
1285+
expect(packets[1].data[0].scrollDeltaX, equals(120.0));
1286+
expect(packets[1].data[0].scrollDeltaY, equals(120.0));
1287+
1288+
// Because the delta is in increments of 120, but is again similar to the
1289+
// previous event, it will be a trackpad event.
1290+
expect(packets[2].data[0].change, equals(ui.PointerChange.hover));
1291+
expect(
1292+
packets[2].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
1293+
expect(
1294+
packets[2].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
1295+
expect(packets[2].data[0].pointerIdentifier, equals(0));
1296+
expect(packets[2].data[0].synthesized, isFalse);
1297+
expect(packets[2].data[0].physicalX, equals(10.0 * dpi));
1298+
expect(packets[2].data[0].physicalY, equals(10.0 * dpi));
1299+
expect(packets[2].data[0].physicalDeltaX, equals(0.0));
1300+
expect(packets[2].data[0].physicalDeltaY, equals(0.0));
1301+
expect(packets[2].data[0].scrollDeltaX, equals(120.0));
1302+
expect(packets[2].data[0].scrollDeltaY, equals(120.0));
1303+
1304+
// Because the delta is not in increments of 120 and has matching wheelDelta,
1305+
// it will be a trackpad event.
1306+
expect(packets[3].data[0].change, equals(ui.PointerChange.hover));
1307+
expect(
1308+
packets[3].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
1309+
expect(
1310+
packets[3].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
1311+
expect(packets[3].data[0].pointerIdentifier, equals(0));
1312+
expect(packets[3].data[0].synthesized, isFalse);
1313+
expect(packets[3].data[0].physicalX, equals(10.0 * dpi));
1314+
expect(packets[3].data[0].physicalY, equals(10.0 * dpi));
1315+
expect(packets[3].data[0].physicalDeltaX, equals(0.0));
1316+
expect(packets[3].data[0].physicalDeltaY, equals(0.0));
1317+
expect(packets[3].data[0].scrollDeltaX, equals(119.0));
1318+
expect(packets[3].data[0].scrollDeltaY, equals(119.0));
1319+
1320+
// Because the delta is in increments of 120, and is not similar to the
1321+
// previous event, but occured soon after the previous event, it will be
1322+
// a trackpad event.
1323+
expect(packets[4].data[0].change, equals(ui.PointerChange.hover));
1324+
expect(
1325+
packets[4].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
1326+
expect(
1327+
packets[4].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
1328+
expect(packets[4].data[0].pointerIdentifier, equals(0));
1329+
expect(packets[4].data[0].synthesized, isFalse);
1330+
expect(packets[4].data[0].physicalX, equals(10.0 * dpi));
1331+
expect(packets[4].data[0].physicalY, equals(10.0 * dpi));
1332+
expect(packets[4].data[0].physicalDeltaX, equals(0.0));
1333+
expect(packets[4].data[0].physicalDeltaY, equals(0.0));
1334+
expect(packets[4].data[0].scrollDeltaX, equals(-120.0));
1335+
expect(packets[4].data[0].scrollDeltaY, equals(-120.0));
1336+
1337+
// Because the delta is in increments of 120, and is not similar to
1338+
// the previous event, and occured long after the previous event, it will be a mouse event.
1339+
expect(packets[5].data, hasLength(1));
1340+
expect(packets[5].data[0].change, equals(ui.PointerChange.hover));
1341+
expect(
1342+
packets[5].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
1343+
expect(
1344+
packets[5].data[0].kind, equals(ui.PointerDeviceKind.mouse));
1345+
expect(packets[5].data[0].pointerIdentifier, equals(0));
1346+
expect(packets[5].data[0].synthesized, isFalse);
1347+
expect(packets[5].data[0].physicalX, equals(10.0 * dpi));
1348+
expect(packets[5].data[0].physicalY, equals(10.0 * dpi));
1349+
expect(packets[5].data[0].physicalDeltaX, equals(0.0));
1350+
expect(packets[5].data[0].physicalDeltaY, equals(0.0));
1351+
expect(packets[5].data[0].scrollDeltaX, equals(0.0));
1352+
expect(packets[5].data[0].scrollDeltaY, equals(-120.0));
1353+
1354+
// Because the delta is not in increments of 120 and has non-matching
1355+
// wheelDelta, it will be a mouse event.
1356+
expect(packets[6].data, hasLength(1));
1357+
expect(packets[6].data[0].change, equals(ui.PointerChange.hover));
1358+
expect(
1359+
packets[6].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
1360+
expect(
1361+
packets[6].data[0].kind, equals(ui.PointerDeviceKind.mouse));
1362+
expect(packets[6].data[0].pointerIdentifier, equals(0));
1363+
expect(packets[6].data[0].synthesized, isFalse);
1364+
expect(packets[6].data[0].physicalX, equals(10.0 * dpi));
1365+
expect(packets[6].data[0].physicalY, equals(10.0 * dpi));
1366+
expect(packets[6].data[0].physicalDeltaX, equals(0.0));
1367+
expect(packets[6].data[0].physicalDeltaY, equals(0.0));
1368+
expect(packets[6].data[0].scrollDeltaX, equals(0.0));
1369+
expect(packets[6].data[0].scrollDeltaY, equals(40.0));
1370+
},
1371+
);
1372+
11501373
_testEach<_ButtonedEventMixin>(
11511374
<_ButtonedEventMixin>[
11521375
if (!isIosSafari) _PointerEventContext(),
@@ -2854,6 +3077,9 @@ mixin _ButtonedEventMixin on _BasicEventContext {
28543077
required double? clientY,
28553078
required double? deltaX,
28563079
required double? deltaY,
3080+
double? wheelDeltaX,
3081+
double? wheelDeltaY,
3082+
int? timeStamp,
28573083
}) {
28583084
final Function jsWheelEvent = js_util.getProperty<Function>(domWindow, 'WheelEvent');
28593085
final List<dynamic> eventArgs = <dynamic>[
@@ -2864,12 +3090,30 @@ mixin _ButtonedEventMixin on _BasicEventContext {
28643090
'clientY': clientY,
28653091
'deltaX': deltaX,
28663092
'deltaY': deltaY,
3093+
'wheelDeltaX': wheelDeltaX,
3094+
'wheelDeltaY': wheelDeltaY,
28673095
}
28683096
];
2869-
return js_util.callConstructor<DomEvent>(
3097+
final DomEvent event = js_util.callConstructor<DomEvent>(
28703098
jsWheelEvent,
28713099
js_util.jsify(eventArgs) as List<Object?>,
28723100
);
3101+
// timeStamp can't be set in the constructor, need to override the getter.
3102+
if (timeStamp != null) {
3103+
js_util.callMethod(
3104+
objectConstructor,
3105+
'defineProperty',
3106+
<dynamic>[
3107+
event,
3108+
'timeStamp',
3109+
js_util.jsify(<String, dynamic>{
3110+
'value': timeStamp,
3111+
'configurable': true
3112+
})
3113+
]
3114+
);
3115+
}
3116+
return event;
28733117
}
28743118
}
28753119

0 commit comments

Comments
 (0)