Skip to content

Commit

Permalink
Web trackpad pan (flutter#36346)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
moffatman authored and loic-sharma committed Dec 16, 2022
1 parent 0751f0b commit 78713c5
Show file tree
Hide file tree
Showing 3 changed files with 322 additions and 2 deletions.
2 changes: 2 additions & 0 deletions lib/web_ui/lib/src/engine/dom.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,8 @@ class DomWheelEvent extends DomMouseEvent {}
extension DomWheelEventExtension on DomWheelEvent {
external double get deltaX;
external double get deltaY;
external double? get wheelDeltaX;
external double? get wheelDeltaY;
external double get deltaMode;
}

Expand Down
76 changes: 75 additions & 1 deletion lib/web_ui/lib/src/engine/pointer_binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ abstract class _BaseAdapter {
final _PointerDataCallback _callback;
final PointerDataConverter _pointerDataConverter;
final KeyboardConverter _keyboardConverter;
DomWheelEvent? _lastWheelEvent;
bool _lastWheelEventWasTrackpad = false;

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

bool _isAcceleratedMouseWheelDelta(num delta, num? wheelDelta) {
// On macOS, scrolling using a mouse wheel by default uses an acceleration
// curve, so delta values ramp up and are not at fixed multiples of 120.
// But in this case, the wheelDelta properties of the event still keep
// their original values.
// For all events without this acceleration curve applied, the wheelDelta
// values are by convention three times greater than the delta values and with
// the opposite sign.
if (wheelDelta == null) {
return false;
}
// Account for observed issues with integer truncation by allowing +-1px error.
return (wheelDelta - (-3 * delta)).abs() > 1;
}

bool _isTrackpadEvent(DomWheelEvent event) {
// This function relies on deprecated and non-standard implementation
// details. Useful reference material can be found below.
//
// https://source.chromium.org/chromium/chromium/src/+/main:ui/events/event.cc
// https://source.chromium.org/chromium/chromium/src/+/main:ui/events/cocoa/events_mac.mm
// /~https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/mac/PlatformEventFactoryMac.mm
// https://searchfox.org/mozilla-central/source/dom/events/WheelEvent.h
// https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-mousewheel
if (browserEngine == BrowserEngine.firefox) {
// Firefox has restricted the wheelDelta properties, they do not provide
// enough information to accurately disambiguate trackpad events from mouse
// wheel events.
return false;
}
if (_isAcceleratedMouseWheelDelta(event.deltaX, event.wheelDeltaX) ||
_isAcceleratedMouseWheelDelta(event.deltaY, event.wheelDeltaY)) {
return false;
}
if (((event.deltaX % 120 == 0) && (event.deltaY % 120 == 0)) ||
(((event.wheelDeltaX ?? 1) % 120 == 0) && ((event.wheelDeltaY ?? 1) % 120) == 0)) {
// While not in any formal web standard, `blink` and `webkit` browsers use
// a delta of 120 to represent one mouse wheel turn. If both dimensions of
// the delta are divisible by 120, this event is probably from a mouse.
// Checking if wheelDeltaX and wheelDeltaY are both divisible by 120
// catches any macOS accelerated mouse wheel deltas which by random chance
// are not caught by _isAcceleratedMouseWheelDelta.
final num deltaXChange = (event.deltaX - (_lastWheelEvent?.deltaX ?? 0)).abs();
final num deltaYChange = (event.deltaY - (_lastWheelEvent?.deltaY ?? 0)).abs();
if ((_lastWheelEvent == null) ||
(deltaXChange == 0 && deltaYChange == 0) ||
!(deltaXChange < 20 && deltaYChange < 20)) {
// A trackpad event might by chance have a delta of exactly 120, so
// make sure this event does not have a similar delta to the previous
// one before calling it a mouse event.
if (event.timeStamp != null && _lastWheelEvent?.timeStamp != null) {
// If the event has a large delta to the previous event, check if
// it was preceded within 50 milliseconds by a trackpad event. This
// handles unlucky 120-delta trackpad events during rapid movement.
final num diffMs = event.timeStamp! - _lastWheelEvent!.timeStamp!;
if (diffMs < 50 && _lastWheelEventWasTrackpad) {
return true;
}
}
return false;
}
}
return true;
}

List<ui.PointerData> _convertWheelEventToPointerData(
DomWheelEvent event
) {
const int domDeltaPixel = 0x00;
const int domDeltaLine = 0x01;
const int domDeltaPage = 0x02;

ui.PointerDeviceKind kind = ui.PointerDeviceKind.mouse;
if (_isTrackpadEvent(event)) {
kind = ui.PointerDeviceKind.trackpad;
}

// Flutter only supports pixel scroll delta. Convert deltaMode values
// to pixels.
double deltaX = event.deltaX;
Expand Down Expand Up @@ -371,7 +443,7 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
data,
change: ui.PointerChange.hover,
timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!),
kind: ui.PointerDeviceKind.mouse,
kind: kind,
signalKind: ui.PointerSignalKind.scroll,
device: _mouseDeviceId,
physicalX: event.clientX * ui.window.devicePixelRatio,
Expand All @@ -382,6 +454,8 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
scrollDeltaX: deltaX,
scrollDeltaY: deltaY,
);
_lastWheelEvent = event;
_lastWheelEventWasTrackpad = kind == ui.PointerDeviceKind.trackpad;
return data;
}

Expand Down
246 changes: 245 additions & 1 deletion lib/web_ui/test/engine/pointer_binding_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,229 @@ void testMain() {
},
);

_testEach<_ButtonedEventMixin>(
<_ButtonedEventMixin>[
if (!isIosSafari) _PointerEventContext(),
if (!isIosSafari) _MouseEventContext(),
],
'does set pointer device kind based on delta precision and wheelDelta',
(_ButtonedEventMixin context) {
if (isFirefox) {
// Firefox does not support trackpad events, as they cannot be
// disambiguated from smoothed mouse wheel events.
return;
}
PointerBinding.instance!.debugOverrideDetector(context);
final List<ui.PointerDataPacket> packets = <ui.PointerDataPacket>[];
ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) {
packets.add(packet);
};

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: 119,
deltaY: 119,
wheelDeltaX: -357,
wheelDeltaY: -357,
timeStamp: 0,
));

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: 120,
deltaY: 120,
wheelDeltaX: -360,
wheelDeltaY: -360,
timeStamp: 10,
));

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: 120,
deltaY: 120,
wheelDeltaX: -360,
wheelDeltaY: -360,
timeStamp: 20,
));

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: 119,
deltaY: 119,
wheelDeltaX: -357,
wheelDeltaY: -357,
timeStamp: 1000,
));

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: -120,
deltaY: -120,
wheelDeltaX: 360,
wheelDeltaY: 360,
timeStamp: 1010,
));

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: 0,
deltaY: -120,
wheelDeltaX: 0,
wheelDeltaY: 360,
timeStamp: 2000,
));

glassPane.dispatchEvent(context.wheel(
buttons: 0,
clientX: 10,
clientY: 10,
deltaX: 0,
deltaY: 40,
wheelDeltaX: 0,
wheelDeltaY: -360,
timeStamp: 3000,
));

expect(packets, hasLength(7));

// An add will be synthesized.
expect(packets[0].data, hasLength(2));
expect(packets[0].data[0].change, equals(ui.PointerChange.add));
expect(packets[0].data[0].pointerIdentifier, equals(0));
expect(packets[0].data[0].synthesized, isTrue);
expect(packets[0].data[0].physicalX, equals(10.0 * dpi));
expect(packets[0].data[0].physicalY, equals(10.0 * dpi));
expect(packets[0].data[0].physicalDeltaX, equals(0.0));
expect(packets[0].data[0].physicalDeltaY, equals(0.0));
// Because the delta is not in increments of 120 and has matching wheelDelta,
// it will be a trackpad event.
expect(packets[0].data[1].change, equals(ui.PointerChange.hover));
expect(
packets[0].data[1].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[0].data[1].kind, equals(ui.PointerDeviceKind.trackpad));
expect(packets[0].data[1].pointerIdentifier, equals(0));
expect(packets[0].data[1].synthesized, isFalse);
expect(packets[0].data[1].physicalX, equals(10.0 * dpi));
expect(packets[0].data[1].physicalY, equals(10.0 * dpi));
expect(packets[0].data[1].physicalDeltaX, equals(0.0));
expect(packets[0].data[1].physicalDeltaY, equals(0.0));
expect(packets[0].data[1].scrollDeltaX, equals(119.0));
expect(packets[0].data[1].scrollDeltaY, equals(119.0));

// Because the delta is in increments of 120, but is similar to the
// previous event, it will be a trackpad event.
expect(packets[1].data[0].change, equals(ui.PointerChange.hover));
expect(
packets[1].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[1].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
expect(packets[1].data[0].pointerIdentifier, equals(0));
expect(packets[1].data[0].synthesized, isFalse);
expect(packets[1].data[0].physicalX, equals(10.0 * dpi));
expect(packets[1].data[0].physicalY, equals(10.0 * dpi));
expect(packets[1].data[0].physicalDeltaX, equals(0.0));
expect(packets[1].data[0].physicalDeltaY, equals(0.0));
expect(packets[1].data[0].scrollDeltaX, equals(120.0));
expect(packets[1].data[0].scrollDeltaY, equals(120.0));

// Because the delta is in increments of 120, but is again similar to the
// previous event, it will be a trackpad event.
expect(packets[2].data[0].change, equals(ui.PointerChange.hover));
expect(
packets[2].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[2].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
expect(packets[2].data[0].pointerIdentifier, equals(0));
expect(packets[2].data[0].synthesized, isFalse);
expect(packets[2].data[0].physicalX, equals(10.0 * dpi));
expect(packets[2].data[0].physicalY, equals(10.0 * dpi));
expect(packets[2].data[0].physicalDeltaX, equals(0.0));
expect(packets[2].data[0].physicalDeltaY, equals(0.0));
expect(packets[2].data[0].scrollDeltaX, equals(120.0));
expect(packets[2].data[0].scrollDeltaY, equals(120.0));

// Because the delta is not in increments of 120 and has matching wheelDelta,
// it will be a trackpad event.
expect(packets[3].data[0].change, equals(ui.PointerChange.hover));
expect(
packets[3].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[3].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
expect(packets[3].data[0].pointerIdentifier, equals(0));
expect(packets[3].data[0].synthesized, isFalse);
expect(packets[3].data[0].physicalX, equals(10.0 * dpi));
expect(packets[3].data[0].physicalY, equals(10.0 * dpi));
expect(packets[3].data[0].physicalDeltaX, equals(0.0));
expect(packets[3].data[0].physicalDeltaY, equals(0.0));
expect(packets[3].data[0].scrollDeltaX, equals(119.0));
expect(packets[3].data[0].scrollDeltaY, equals(119.0));

// Because the delta is in increments of 120, and is not similar to the
// previous event, but occured soon after the previous event, it will be
// a trackpad event.
expect(packets[4].data[0].change, equals(ui.PointerChange.hover));
expect(
packets[4].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[4].data[0].kind, equals(ui.PointerDeviceKind.trackpad));
expect(packets[4].data[0].pointerIdentifier, equals(0));
expect(packets[4].data[0].synthesized, isFalse);
expect(packets[4].data[0].physicalX, equals(10.0 * dpi));
expect(packets[4].data[0].physicalY, equals(10.0 * dpi));
expect(packets[4].data[0].physicalDeltaX, equals(0.0));
expect(packets[4].data[0].physicalDeltaY, equals(0.0));
expect(packets[4].data[0].scrollDeltaX, equals(-120.0));
expect(packets[4].data[0].scrollDeltaY, equals(-120.0));

// Because the delta is in increments of 120, and is not similar to
// the previous event, and occured long after the previous event, it will be a mouse event.
expect(packets[5].data, hasLength(1));
expect(packets[5].data[0].change, equals(ui.PointerChange.hover));
expect(
packets[5].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[5].data[0].kind, equals(ui.PointerDeviceKind.mouse));
expect(packets[5].data[0].pointerIdentifier, equals(0));
expect(packets[5].data[0].synthesized, isFalse);
expect(packets[5].data[0].physicalX, equals(10.0 * dpi));
expect(packets[5].data[0].physicalY, equals(10.0 * dpi));
expect(packets[5].data[0].physicalDeltaX, equals(0.0));
expect(packets[5].data[0].physicalDeltaY, equals(0.0));
expect(packets[5].data[0].scrollDeltaX, equals(0.0));
expect(packets[5].data[0].scrollDeltaY, equals(-120.0));

// Because the delta is not in increments of 120 and has non-matching
// wheelDelta, it will be a mouse event.
expect(packets[6].data, hasLength(1));
expect(packets[6].data[0].change, equals(ui.PointerChange.hover));
expect(
packets[6].data[0].signalKind, equals(ui.PointerSignalKind.scroll));
expect(
packets[6].data[0].kind, equals(ui.PointerDeviceKind.mouse));
expect(packets[6].data[0].pointerIdentifier, equals(0));
expect(packets[6].data[0].synthesized, isFalse);
expect(packets[6].data[0].physicalX, equals(10.0 * dpi));
expect(packets[6].data[0].physicalY, equals(10.0 * dpi));
expect(packets[6].data[0].physicalDeltaX, equals(0.0));
expect(packets[6].data[0].physicalDeltaY, equals(0.0));
expect(packets[6].data[0].scrollDeltaX, equals(0.0));
expect(packets[6].data[0].scrollDeltaY, equals(40.0));
},
);

_testEach<_ButtonedEventMixin>(
<_ButtonedEventMixin>[
if (!isIosSafari) _PointerEventContext(),
Expand Down Expand Up @@ -2854,6 +3077,9 @@ mixin _ButtonedEventMixin on _BasicEventContext {
required double? clientY,
required double? deltaX,
required double? deltaY,
double? wheelDeltaX,
double? wheelDeltaY,
int? timeStamp,
}) {
final Function jsWheelEvent = js_util.getProperty<Function>(domWindow, 'WheelEvent');
final List<dynamic> eventArgs = <dynamic>[
Expand All @@ -2864,12 +3090,30 @@ mixin _ButtonedEventMixin on _BasicEventContext {
'clientY': clientY,
'deltaX': deltaX,
'deltaY': deltaY,
'wheelDeltaX': wheelDeltaX,
'wheelDeltaY': wheelDeltaY,
}
];
return js_util.callConstructor<DomEvent>(
final DomEvent event = js_util.callConstructor<DomEvent>(
jsWheelEvent,
js_util.jsify(eventArgs) as List<Object?>,
);
// timeStamp can't be set in the constructor, need to override the getter.
if (timeStamp != null) {
js_util.callMethod(
objectConstructor,
'defineProperty',
<dynamic>[
event,
'timeStamp',
js_util.jsify(<String, dynamic>{
'value': timeStamp,
'configurable': true
})
]
);
}
return event;
}
}

Expand Down

0 comments on commit 78713c5

Please sign in to comment.