[web] Fixes drag scrolling in embedded mode. (flutter/engine#53647)

When Flutter web runs embedded in a page, scrolling by dragging is interrupted very early by the browser.

It turns out that our `pointer` events receive a `pointercancel` + `pointerleave` by the browser because they happen in an area (the flutter glasspane) that is not really scrollable. [See documentation](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointercancel_event).

  > [!NOTE]
  > After the pointercancel event is fired, the browser will also send [pointerout](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerout_event) followed by [pointerleave](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerleave_event).

(Firefox is a good browser to test this, because the browser will cancel our events **only if there's scrollable areas around the embedded flutter app**.)

There's several solutions, but one of them (used by PixiJS as well) is to cancel the `touchstart` event that fires with the `pointerdown` event.

(This PR also removes an unnecessary call to `addEventListener` in the `Listener` helper class, and adds some testing to it).

## Testing

* Added a happy case test for the fix (preventDefault on 'touchstart' events)
* Deployed demo app here: https://dit-multiview-scroll.web.app

## Issues

* Fixes https://github.com/flutter/flutter/issues/138985
* Fixes https://github.com/flutter/flutter/issues/146732
* Related to https://github.com/flutter/flutter/issues/139263

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
This commit is contained in:
David Iglesias 2024-06-28 20:47:25 -07:00 committed by GitHub
parent d833c35591
commit 1b37c5441c
3 changed files with 97 additions and 10 deletions

View File

@ -431,6 +431,9 @@ extension DomEventExtension on DomEvent {
external JSString get _type; external JSString get _type;
String get type => _type.toDart; String get type => _type.toDart;
external JSBoolean? get _cancelable;
bool get cancelable => _cancelable?.toDart ?? true;
external JSVoid preventDefault(); external JSVoid preventDefault();
external JSVoid stopPropagation(); external JSVoid stopPropagation();
@ -729,6 +732,8 @@ extension DomElementExtension on DomElement {
removeChild(firstChild!); removeChild(firstChild!);
} }
} }
external void setPointerCapture(num? pointerId);
} }
@JS() @JS()

View File

@ -432,8 +432,10 @@ class PointerSupportDetector {
String toString() => 'pointers:$hasPointerEvents'; String toString() => 'pointers:$hasPointerEvents';
} }
class _Listener { /// Encapsulates a DomEvent registration so it can be easily unregistered later.
_Listener._({ @visibleForTesting
class Listener {
Listener._({
required this.event, required this.event,
required this.target, required this.target,
required this.handler, required this.handler,
@ -448,7 +450,7 @@ class _Listener {
/// associated with this event. If `passive` is false, the browser will wait /// associated with this event. If `passive` is false, the browser will wait
/// for the handler to finish execution before performing the respective /// for the handler to finish execution before performing the respective
/// action. /// action.
factory _Listener.register({ factory Listener.register({
required String event, required String event,
required DomEventTarget target, required DomEventTarget target,
required DartDomEventListener handler, required DartDomEventListener handler,
@ -465,12 +467,12 @@ class _Listener {
target.addEventListenerWithOptions(event, jsHandler, eventOptions); target.addEventListenerWithOptions(event, jsHandler, eventOptions);
} }
final _Listener listener = _Listener._( final Listener listener = Listener._(
event: event, event: event,
target: target, target: target,
handler: jsHandler, handler: jsHandler,
); );
target.addEventListener(event, jsHandler);
return listener; return listener;
} }
@ -496,12 +498,12 @@ abstract class _BaseAdapter {
PointerDataConverter get _pointerDataConverter => _owner._pointerDataConverter; PointerDataConverter get _pointerDataConverter => _owner._pointerDataConverter;
KeyboardConverter? get _keyboardConverter => _owner._keyboardConverter; KeyboardConverter? get _keyboardConverter => _owner._keyboardConverter;
final List<_Listener> _listeners = <_Listener>[]; final List<Listener> _listeners = <Listener>[];
DomWheelEvent? _lastWheelEvent; DomWheelEvent? _lastWheelEvent;
bool _lastWheelEventWasTrackpad = false; bool _lastWheelEventWasTrackpad = false;
bool _lastWheelEventAllowedDefault = false; bool _lastWheelEventAllowedDefault = false;
DomEventTarget get _viewTarget => _view.dom.rootElement; DomElement get _viewTarget => _view.dom.rootElement;
DomEventTarget get _globalTarget => _view.embeddingStrategy.globalEventTarget; DomEventTarget get _globalTarget => _view.embeddingStrategy.globalEventTarget;
/// Each subclass is expected to override this method to attach its own event /// Each subclass is expected to override this method to attach its own event
@ -510,7 +512,7 @@ abstract class _BaseAdapter {
/// Cleans up all event listeners attached by this adapter. /// Cleans up all event listeners attached by this adapter.
void dispose() { void dispose() {
for (final _Listener listener in _listeners) { for (final Listener listener in _listeners) {
listener.unregister(); listener.unregister();
} }
_listeners.clear(); _listeners.clear();
@ -546,7 +548,7 @@ abstract class _BaseAdapter {
handler(event); handler(event);
} }
} }
_listeners.add(_Listener.register( _listeners.add(Listener.register(
event: eventName, event: eventName,
target: target, target: target,
handler: loggedHandler, handler: loggedHandler,
@ -719,7 +721,7 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
} }
void _addWheelEventListener(DartDomEventListener handler) { void _addWheelEventListener(DartDomEventListener handler) {
_listeners.add(_Listener.register( _listeners.add(Listener.register(
event: 'wheel', event: 'wheel',
target: _viewTarget, target: _viewTarget,
handler: handler, handler: handler,
@ -966,6 +968,20 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
@override @override
void setup() { void setup() {
// Prevents the browser auto-canceling pointer events.
// Register into `_listener` directly so the event isn't forwarded to semantics.
_listeners.add(Listener.register(
event: 'touchstart',
target: _viewTarget,
handler: (DomEvent event) {
// This is one of the ways I've seen this done. PixiJS does a similar thing.
// ThreeJS seems to subscribe move/leave in the pointerdown handler instead?
if (event.cancelable) {
event.preventDefault();
}
},
));
_addPointerEventListener(_viewTarget, 'pointerdown', (DomPointerEvent event) { _addPointerEventListener(_viewTarget, 'pointerdown', (DomPointerEvent event) {
final int device = _getPointerId(event); final int device = _getPointerId(event);
final List<ui.PointerData> pointerData = <ui.PointerData>[]; final List<ui.PointerData> pointerData = <ui.PointerData>[];

View File

@ -305,6 +305,18 @@ void testMain() {
}, },
); );
test('prevents default on touchstart events', () async {
final event = createDomEvent('Event', 'touchstart');
rootElement.dispatchEvent(event);
expect(
event.defaultPrevented,
isTrue,
reason: 'touchstart events should be prevented so pointer events are not cancelled later.',
);
});
test( test(
'can receive pointer events on the app root', 'can receive pointer events on the app root',
() { () {
@ -2670,6 +2682,60 @@ void testMain() {
); );
}); });
group('Listener', () {
late DomElement eventTarget;
late DomEvent expected;
late bool handled;
setUp(() {
eventTarget = createDomElement('div');
expected = createDomEvent('Event', 'custom-event');
handled = false;
});
test('listeners can be registered', () {
Listener.register(
event: 'custom-event',
target: eventTarget,
handler: (event) {
expect(event, expected);
handled = true;
},
);
// Trigger the event...
eventTarget.dispatchEvent(expected);
expect(handled, isTrue);
});
test('listeners can be unregistered', () {
final Listener listener = Listener.register(
event: 'custom-event',
target: eventTarget,
handler: (event) {
handled = true;
},
);
listener.unregister();
eventTarget.dispatchEvent(expected);
expect(handled, isFalse);
});
test('listeners are registered only once', () {
int timesHandled = 0;
Listener.register(
event: 'custom-event',
target: eventTarget,
handler: (event) {
timesHandled++;
},
);
eventTarget.dispatchEvent(expected);
expect(timesHandled, 1, reason: 'The handler ran multiple times for a single event.');
});
});
group('ClickDebouncer', () { group('ClickDebouncer', () {
_testClickDebouncer(getBinding: () => instance); _testClickDebouncer(getBinding: () => instance);
}); });