[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:
parent
d833c35591
commit
1b37c5441c
@ -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()
|
||||||
|
@ -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>[];
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user