[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>[];