[web] Handle scroll in HTML platform views
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
index 72224d4..6285f304 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
@@ -478,6 +478,8 @@
external double scrollTop;
external double scrollLeft;
+ external double get scrollHeight;
+ external double get scrollWidth;
external DomTokenList get classList;
external String className;
@@ -1920,6 +1922,10 @@
@JS('changedTouches')
external _DomList get _changedTouches;
Iterable<DomTouch> get changedTouches => _createDomListWrapper<DomTouch>(_changedTouches);
+
+ @JS('touches')
+ external _DomList get _touches;
+ Iterable<DomTouch> get touches => _createDomListWrapper<DomTouch>(_touches);
}
@JS('Touch')
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
index d27c70c..7d92238 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
@@ -151,6 +151,13 @@
..id = getPlatformViewDomId(viewId)
..setAttribute('slot', slotName);
+ // Enable touch scrolling inside platform views.
+ // Flutter sets touch-action: none on the body to capture all touch events,
+ // but this prevents native touch scrolling inside platform views.
+ // We override it here to allow touch scrolling for HTML content.
+ // This fixes GitHub issue #179360.
+ setElementStyle(wrapper, 'touch-action', 'pan-y pan-x');
+
final Function factoryFunction = _factories[viewType]!;
final DomElement content;
@@ -164,12 +171,113 @@
_ensureContentCorrectlySized(content, viewType);
wrapper.append(content);
+ // For regular HTML elements, set up touch boundary detection.
+ // When the user scrolls to the boundary of the HTML element,
+ // we need to propagate the scroll to Flutter.
+ _setupTouchBoundaryDetection(content, wrapper);
+
wrapper.setAttribute(_ariaHiddenAttribute, 'true');
return wrapper;
});
}
+ /// Sets up touch boundary detection for regular HTML elements.
+ /// When the user scrolls to the boundary of the HTML element during touch scrolling,
+ /// we prevent the default to stop the HTML element from rubber-banding and let
+ /// Flutter handle the scroll.
+ void _setupTouchBoundaryDetection(DomElement content, DomElement wrapper) {
+ double? lastTouchY;
+ bool? browserOwnsScroll; // Once browser takes over (cancelable=false), it owns the gesture
+
+ content.addEventListener(
+ 'touchstart',
+ createDomEventListener((DomEvent event) {
+ final touchEvent = event as DomTouchEvent;
+ final Iterable<DomTouch> touches = touchEvent.touches;
+ if (touches.isNotEmpty) {
+ lastTouchY = touches.first.clientY;
+ browserOwnsScroll = null; // Reset on new touch
+ }
+ }),
+ <String, bool>{'passive': true}.toJSAnyDeep,
+ );
+
+ content.addEventListener(
+ 'touchmove',
+ createDomEventListener((DomEvent event) {
+ final touchEvent = event as DomTouchEvent;
+ final Iterable<DomTouch> touches = touchEvent.touches;
+ if (touches.isEmpty || lastTouchY == null) {
+ return;
+ }
+
+ final double currentY = touches.first.clientY;
+ final double deltaY = lastTouchY! - currentY; // Positive = scrolling down
+ lastTouchY = currentY;
+
+ // Find the scrollable element (could be content itself or a child)
+ final DomElement scrollable = _findScrollableElement(content);
+
+ final double scrollTop = scrollable.scrollTop;
+ final double scrollHeight = scrollable.scrollHeight;
+ final double clientHeight = scrollable.clientHeight;
+ final double maxScroll = scrollHeight - clientHeight;
+
+ // Check if at boundary in the direction of scroll
+ final bool atTop = scrollTop <= 1 && deltaY < 0; // Trying to scroll up at top
+ final bool atBottom =
+ scrollTop >= maxScroll - 1 && deltaY > 0; // Trying to scroll down at bottom
+ final bool atBoundary = atTop || atBottom;
+
+ // Once browser takes over (first non-cancelable event), it owns this gesture
+ if (!event.cancelable) {
+ browserOwnsScroll = true;
+ }
+
+ // Only prevent if:
+ // 1. Event is cancelable (we can still prevent it)
+ // 2. We're at a boundary in the scroll direction
+ // 3. Browser hasn't already taken over this gesture
+ if (event.cancelable && atBoundary && browserOwnsScroll != true) {
+ // At boundary - prevent default to stop rubber-banding
+ // and let Flutter handle the scroll
+ event.preventDefault();
+ }
+ }),
+ <String, bool>{'passive': false}.toJSAnyDeep,
+ );
+ }
+
+ /// Finds the scrollable element within a platform view content.
+ /// Returns the content itself if it's scrollable, or searches for a scrollable child.
+ DomElement _findScrollableElement(DomElement content) {
+ // Check if content itself is scrollable
+ if (_isScrollable(content)) {
+ return content;
+ }
+
+ // Search for a scrollable child
+ final Iterable<DomElement> children = content.querySelectorAll('*');
+ for (final child in children) {
+ if (_isScrollable(child)) {
+ return child;
+ }
+ }
+
+ // Default to content
+ return content;
+ }
+
+ bool _isScrollable(DomElement element) {
+ final String overflowY = element.style.overflowY;
+ final String overflow = element.style.overflow;
+ final bool hasOverflow =
+ overflowY == 'auto' || overflowY == 'scroll' || overflow == 'auto' || overflow == 'scroll';
+ final bool hasContent = element.scrollHeight > element.clientHeight;
+ return hasOverflow && hasContent;
+ }
+
/// Removes a PlatformView by its `viewId` from the manager, and from the DOM.
///
/// Once a view has been cleared, calls to [knowsViewId] will fail, as if it had
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart
index b3266f4..c429922 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart
@@ -772,14 +772,47 @@
}
assert(event.isA<DomWheelEvent>());
+ final wheelEvent = event as DomWheelEvent;
if (_debugLogPointerEvents) {
print(event.type);
}
+
+ // Check if the event target is inside a platform view with regular HTML content
+ // (not a cross-origin iframe). If so, check if the HTML element can scroll.
+ // This fixes GitHub issue #179360.
+ final _HtmlScrollableInfo? htmlScrollInfo = _getHtmlScrollableInfo(event);
+ if (htmlScrollInfo != null) {
+ // We're over a regular HTML element in a platform view.
+ // Check if it can scroll in the direction of the wheel event.
+ final bool canScrollInDirection = _canHtmlElementScroll(
+ htmlScrollInfo.scrollableElement,
+ wheelEvent.deltaX,
+ wheelEvent.deltaY,
+ );
+
+ if (canScrollInDirection) {
+ // The HTML element can scroll in this direction.
+ // We need to programmatically scroll the HTML element because Flutter's
+ // event listener captures wheel events at the glass pane level, preventing
+ // them from naturally propagating to the HTML element.
+ final DomElement scrollable = htmlScrollInfo.scrollableElement;
+ scrollable.scrollTop = scrollable.scrollTop + wheelEvent.deltaY;
+ if (wheelEvent.deltaX != 0) {
+ scrollable.scrollLeft = scrollable.scrollLeft + wheelEvent.deltaX;
+ }
+
+ // Prevent default and stop - we handled the scroll
+ event.preventDefault();
+ return;
+ }
+ // At boundary - fall through to let Flutter handle it
+ }
+
_lastWheelEventAllowedDefault = false;
// [ui.PointerData] can set the `_lastWheelEventAllowedDefault` variable
// to true, when the framework says so. See the implementation of `respond`
// when creating the PointerData object above.
- _callback(event, _convertWheelEventToPointerData(event as DomWheelEvent));
+ _callback(event, _convertWheelEventToPointerData(wheelEvent));
// This works because the `_callback` is handled synchronously in the
// framework, so it's able to modify `_lastWheelEventAllowedDefault`.
if (!_lastWheelEventAllowedDefault) {
@@ -787,6 +820,111 @@
}
}
+ /// Information about a scrollable HTML element inside a platform view.
+ _HtmlScrollableInfo? _getHtmlScrollableInfo(DomEvent event) {
+ final target = event.target as DomElement?;
+ if (target == null) {
+ return null;
+ }
+
+ // Walk up the DOM tree to find the scrollable element and platform view
+ DomElement? scrollableElement;
+ DomElement? current = target;
+
+ while (current != null) {
+ final String tagName = current.tagName.toLowerCase();
+
+ // Check if this element is scrollable
+ if (scrollableElement == null) {
+ final String overflow = current.style.overflow;
+ final String overflowY = current.style.overflowY;
+ if (overflow == 'auto' ||
+ overflow == 'scroll' ||
+ overflowY == 'auto' ||
+ overflowY == 'scroll') {
+ scrollableElement = current;
+ }
+ }
+
+ // Check if we've reached the flt-platform-view wrapper
+ if (tagName == 'flt-platform-view') {
+ // Found a platform view - check if its first child is a cross-origin iframe
+ final DomElement? content = current.firstElementChild;
+ if (content == null) {
+ return null;
+ }
+
+ // If the content is an iframe, we have the wheel overlay handling it
+ if (content.tagName.toLowerCase() == 'iframe') {
+ return null;
+ }
+
+ // It's a regular HTML element - return the scrollable info
+ // Use the scrollable element we found, or the content itself
+ return _HtmlScrollableInfo(scrollableElement: scrollableElement ?? content);
+ }
+
+ // Stop searching if we've exited the flutter-view
+ if (tagName == 'flutter-view') {
+ break;
+ }
+
+ current = current.parentElement;
+ }
+
+ return null;
+ }
+
+ /// Checks if an HTML element can scroll in the given direction.
+ /// Returns true if the element can handle the scroll, false if it's at boundary.
+ bool _canHtmlElementScroll(DomElement element, num deltaX, num deltaY) {
+ final double scrollTop = element.scrollTop;
+ final double scrollLeft = element.scrollLeft;
+ final double scrollHeight = element.scrollHeight;
+ final double scrollWidth = element.scrollWidth;
+ final double clientHeight = element.clientHeight;
+ final double clientWidth = element.clientWidth;
+
+ // Check if the element is actually scrollable (has overflow content)
+ final bool hasVerticalOverflow = scrollHeight > clientHeight + 1;
+ final bool hasHorizontalOverflow = scrollWidth > clientWidth + 1;
+
+ // Check vertical scrolling
+ if (deltaY != 0 && hasVerticalOverflow) {
+ if (deltaY > 0) {
+ // Scrolling down - can scroll if not at bottom
+ final bool canScrollDown = scrollTop + clientHeight < scrollHeight - 1;
+ if (canScrollDown) {
+ return true;
+ }
+ } else {
+ // Scrolling up - can scroll if not at top
+ final bool canScrollUp = scrollTop > 1;
+ if (canScrollUp) {
+ return true;
+ }
+ }
+ }
+
+ // Check horizontal scrolling
+ if (deltaX != 0 && hasHorizontalOverflow) {
+ if (deltaX > 0) {
+ // Scrolling right - can scroll if not at right edge
+ if (scrollLeft + clientWidth < scrollWidth - 1) {
+ return true;
+ }
+ } else {
+ // Scrolling left - can scroll if not at left edge
+ if (scrollLeft > 1) {
+ return true;
+ }
+ }
+ }
+
+ // Element is at boundary in the scroll direction
+ return false;
+ }
+
/// For browsers that report delta line instead of pixels such as FireFox
/// compute line height using the default font size.
///
@@ -820,6 +958,17 @@
String toString() => '$runtimeType(change: $change, buttons: $buttons)';
}
+/// Information about a scrollable HTML element inside a platform view.
+/// Used to determine if wheel events should be handled by the HTML element
+/// or passed to Flutter.
+@immutable
+class _HtmlScrollableInfo {
+ const _HtmlScrollableInfo({required this.scrollableElement});
+
+ /// The scrollable HTML element (has overflow: auto/scroll).
+ final DomElement scrollableElement;
+}
+
class _ButtonSanitizer {
int _pressedButtons = 0;
diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart
index e9e9b31..7f623f4 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart
@@ -309,5 +309,32 @@
expect(() => contentManager.updatePlatformViewAccessibility(999, false), returnsNormally);
});
});
+
+ group('touch scrolling support', () {
+ setUp(() {
+ contentManager.registerFactory(viewType, (int id) => createDomHTMLDivElement());
+ });
+
+ test('sets touch-action style to enable native touch scrolling', () {
+ final DomElement wrapper = contentManager.renderContent(viewType, viewId, null);
+
+ // Browser may return 'pan-x pan-y' or 'pan-y pan-x' depending on implementation
+ final String touchAction = wrapper.style.touchAction;
+ expect(
+ touchAction.contains('pan-x') && touchAction.contains('pan-y'),
+ isTrue,
+ reason: 'Platform views should have touch-action set to enable native touch scrolling',
+ );
+ });
+
+ test('sets up touch event listeners on content element', () {
+ final DomElement wrapper = contentManager.renderContent(viewType, viewId, null);
+ final DomElement content = wrapper.querySelector('div')!;
+
+ // Verify content element exists and is properly nested
+ expect(content, isNotNull);
+ expect(content.parentElement, equals(wrapper));
+ });
+ });
});
}
diff --git a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart
index 72b6583..b03449e 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart
@@ -1193,6 +1193,54 @@
ui_web.browser.debugOperatingSystemOverride = null;
});
+ group('wheel event over HTML platform view', () {
+ test('wheel event dispatched to framework when not over platform view', () {
+ // When wheel event target is the root element (not inside platform view),
+ // _getHtmlScrollableInfo returns null and event is dispatched to framework.
+ final _ButtonedEventMixin context = _PointerEventContext();
+ final packets = <ui.PointerDataPacket>[];
+ ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) {
+ packets.add(packet);
+ };
+
+ final DomEvent event = context.wheel(
+ buttons: 0,
+ clientX: 10,
+ clientY: 10,
+ deltaX: 0,
+ deltaY: 100,
+ );
+ rootElement.dispatchEvent(event);
+
+ // Event should be handled by Flutter (dispatched to framework)
+ // First wheel event generates 2 data items: synthesized add + scroll
+ expect(packets, hasLength(1));
+ expect(packets[0].data, hasLength(2));
+ expect(packets[0].data[0].change, equals(ui.PointerChange.add));
+ expect(packets[0].data[1].signalKind, equals(ui.PointerSignalKind.scroll));
+ expect(packets[0].data[1].scrollDeltaY, equals(100.0));
+ // And preventDefault should be called (since no widget allowed platform default)
+ expect(event.defaultPrevented, isTrue);
+ });
+
+ test('wheel event preventDefault is called when framework does not allow platform default', () {
+ final _ButtonedEventMixin context = _PointerEventContext();
+
+ // Don't set onPointerDataPacket - framework doesn't respond
+ final DomEvent event = context.wheel(
+ buttons: 0,
+ clientX: 10,
+ clientY: 10,
+ deltaX: 0,
+ deltaY: 100,
+ );
+ rootElement.dispatchEvent(event);
+
+ // preventDefault should be called since no widget allowed platform default
+ expect(event.defaultPrevented, isTrue);
+ });
+ });
+
test('does calculate delta and pointer identifier correctly', () {
final _ButtonedEventMixin context = _PointerEventContext();
final packets = <ui.PointerDataPacket>[];