[web] Add browser scroll controller and platform channel integration
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index d56b9ec..d608f09 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
@@ -19,6 +19,7 @@ export 'engine/app_bootstrap.dart'; export 'engine/arena.dart'; export 'engine/browser_detection.dart'; +export 'engine/browser_scroll_controller.dart'; export 'engine/canvaskit/canvas.dart'; export 'engine/canvaskit/canvaskit_api.dart'; export 'engine/canvaskit/color_filter.dart'; @@ -83,6 +84,7 @@ export 'engine/noto_font_encoding.dart'; export 'engine/occlusion_map.dart'; export 'engine/onscreen_logging.dart'; +export 'engine/platform_channels/browser_scroll_channel.dart'; export 'engine/platform_dispatcher.dart'; export 'engine/platform_dispatcher/app_lifecycle_state.dart'; export 'engine/platform_dispatcher/view_focus_binding.dart';
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/browser_scroll_controller.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/browser_scroll_controller.dart new file mode 100644 index 0000000..ca088bd --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/browser_scroll_controller.dart
@@ -0,0 +1,341 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; + +/// Enables browser-native scrolling for a Flutter view. +/// +/// When enabled: +/// - The Flutter view becomes position:fixed +/// - A placeholder element defines the scrollable extent +/// - Browser handles all scroll events naturally +/// - Flutter compensates rendering based on scroll position +/// +/// This solves the nested scrolling problem where iframes or HTML content +/// would otherwise block Flutter's scroll detection. +class BrowserScrollController { + BrowserScrollController(this._view); + + final EngineFlutterView _view; + DomElement? _placeholder; + DomEventListener? _scrollListener; + bool _enabled = false; + double _currentScrollY = 0.0; + + /// Whether browser-driven scrolling is currently enabled. + bool get isEnabled => _enabled; + + /// Current browser scroll position. + double get scrollY => _currentScrollY; + + /// Enable browser-driven scrolling mode. + void enable() { + print('[DEBUG BrowserScrollController] enable() called'); + + if (_enabled) { + print('[DEBUG BrowserScrollController] Already enabled, returning'); + return; + } + + print('[DEBUG BrowserScrollController] Starting enable process...'); + final DomElement rootElement = _view.dom.rootElement; + + assert(() { + print('[DEBUG] rootElement tag: ${rootElement.tagName}'); + print('[DEBUG] rootElement id: ${rootElement.id}'); + print('[DEBUG] rootElement parent: ${rootElement.parent?.tagName}'); + print('[DEBUG] rootElement current position: ${rootElement.style.position}'); + return true; + }()); + + // Create placeholder that will define scroll extent + // Create a simple div instead of cloning to avoid inherited styles + _placeholder = domDocument.createElement('div'); + _placeholder!.id = 'flt-browser-scroll-placeholder'; + _placeholder!.style + ..position = + 'relative' // Must be in document flow to create scrollable space + ..display = + 'block' // Block element respects height + ..height = '0px' + ..width = '100%' + ..pointerEvents = + 'none' // Don't intercept pointer events + ..visibility = + 'hidden' // Invisible but takes up space + ..margin = '0' + ..padding = '0'; + + assert(() { + print('[DEBUG] Placeholder created with id: ${_placeholder!.id}'); + return true; + }()); + + // Insert placeholder before Flutter view in DOM + rootElement.parent!.insertBefore(_placeholder!, rootElement); + + assert(() { + print('[DEBUG] Placeholder inserted into DOM'); + return true; + }()); + + // Make Flutter view fixed to viewport + // Remove 'inset' and 'bottom' to allow explicit height control + rootElement.style.removeProperty('inset'); + rootElement.style.removeProperty('bottom'); + + rootElement.style + ..position = 'fixed' + ..top = '0' + ..left = '0' + ..right = '0' + ..overflow = 'hidden'; // Flutter handles internal scrolling + // Height will be set by updateScrollExtent + + assert(() { + print('[DEBUG] Flutter view style updated to fixed'); + print('[DEBUG] Position: ${rootElement.style.position}'); + print('[DEBUG] Top: ${rootElement.style.top}'); + print('[DEBUG] Left: ${rootElement.style.left}'); + print('[DEBUG] Right: ${rootElement.style.right}'); + print('[DEBUG] Bottom: ${rootElement.style.bottom}'); + print('[DEBUG] Height: ${rootElement.style.height}'); + print('[DEBUG] Inset: ${rootElement.style.getPropertyValue("inset")}'); + return true; + }()); + + // Enable scrolling on body to allow browser-driven scrolling + // Inject CSS to override Flutter's default fixed positioning + final DomHTMLStyleElement styleElement = + domDocument.createElement('style') as DomHTMLStyleElement; + styleElement.id = 'flt-browser-scroll-style'; + styleElement.text = ''' + html, body { + height: auto !important; + min-height: 100% !important; + position: static !important; + overflow: auto !important; + } + '''; + domDocument.head!.append(styleElement); + + assert(() { + print('[DEBUG] Browser scroll CSS injected'); + return true; + }()); + + // Listen to scroll events on window + _scrollListener = createDomEventListener(_onScroll); + domWindow.addEventListener('scroll', _scrollListener); + + // Also listen for resize events to maintain the height + _resizeListener = createDomEventListener((DomEvent event) { + assert(() { + print('[DEBUG] Window resize detected, re-enforcing height'); + return true; + }()); + _enforceHeight(); + }); + domWindow.addEventListener('resize', _resizeListener); + + // Mark as enabled + _enabled = true; + + assert(() { + print('[BrowserScrollController] Enabled for view ${_view.viewId}'); + print('[BrowserScrollController] Waiting for framework to send content height...'); + return true; + }()); + } + + void _onScroll(DomEvent event) { + if (!_enabled || _placeholder == null) { + return; + } + + // Get current scroll position + final double scrollY = + domDocument.documentElement?.scrollTop ?? domDocument.body?.scrollTop ?? 0.0; + + // Move the Flutter view up to compensate for scroll + // This creates the illusion that the Flutter content is scrolling + _view.dom.rootElement.style.transform = 'translateY(-${scrollY}px)'; + + // Re-enforce height on every scroll to fight Flutter's resize logic + _enforceHeight(); + + _currentScrollY = scrollY; + + // Send scroll position to framework + _sendScrollPositionToFramework(_currentScrollY); + } + + /// Update the scrollable extent (height). + /// + /// Called when Flutter's content height changes to update the browser's + /// scrollable area. + void updateScrollExtent(double height) { + print('[DEBUG] updateScrollExtent called with height: $height'); + print('[DEBUG] _placeholder: ${_placeholder != null ? "exists" : "null"}'); + print('[DEBUG] _enabled: $_enabled'); + + if (_placeholder != null && _enabled) { + _placeholder!.style.height = '${height}px'; + + // Store the desired height for continuous enforcement + _desiredHeight = height; + + // Set Flutter view's height to match content so it renders everything + _enforceHeight(); + + assert(() { + print('[BrowserScrollController] Updated scroll extent to ${height}px'); + print('[DEBUG] Placeholder actual height style: ${_placeholder!.style.height}'); + print('[DEBUG] Flutter view height: ${_view.dom.rootElement.style.height}'); + return true; + }()); + } else { + print('[DEBUG] Skipping update - placeholder null or not enabled'); + } + } + + double? _desiredHeight; + DomEventListener? _resizeListener; + DomMutationObserver? _heightObserver; + + /// Enforce the height setting, preventing Flutter's resize logic from overriding it. + void _enforceHeight() { + if (_desiredHeight == null) return; + + final DomElement rootElement = _view.dom.rootElement; + + // Use setProperty with priority 'important' to prevent Flutter's resize logic from overriding + rootElement.style.setProperty('height', '${_desiredHeight}px', 'important'); + + // Also force min-height to prevent collapsing + rootElement.style.setProperty('min-height', '${_desiredHeight}px', 'important'); + + // Set up a MutationObserver to aggressively prevent any height changes + if (_heightObserver == null) { + _heightObserver = createDomMutationObserver(( + JSArray<JSAny?> mutations, + DomMutationObserver observer, + ) { + // Check if height was changed by Flutter's resize logic + final String? currentHeight = rootElement.style.height; + if (currentHeight != null && !currentHeight.contains('${_desiredHeight}px')) { + // Flutter tried to change the height - revert it immediately! + assert(() { + print( + '[DEBUG] 🛡️ Height override detected ($currentHeight), reverting to ${_desiredHeight}px', + ); + return true; + }()); + + // Temporarily disconnect to avoid triggering ourselves + observer.disconnect(); + + rootElement.style.setProperty('height', '${_desiredHeight}px', 'important'); + rootElement.style.setProperty('min-height', '${_desiredHeight}px', 'important'); + + // Reconnect to continue monitoring + observer.observe(rootElement, attributes: true, attributeFilter: <String>['style']); + } + }); + + // Observe style attribute changes + _heightObserver!.observe(rootElement, attributes: true, attributeFilter: <String>['style']); + + assert(() { + print('[DEBUG] 🛡️ MutationObserver installed to protect height'); + return true; + }()); + } + + assert(() { + print('[DEBUG] _enforceHeight() set height to ${_desiredHeight}px'); + return true; + }()); + } + + void _sendScrollPositionToFramework(double scrollY) { + // Send scroll position update as a method call to the framework + final ByteData? message = const StandardMethodCodec().encodeMethodCall( + MethodCall('updateScrollPosition', <String, dynamic>{ + 'viewId': _view.viewId, + 'scrollY': scrollY, + }), + ); + + EnginePlatformDispatcher.instance.invokeOnPlatformMessage( + 'flutter/browserscroll', + message, + (ByteData? _) {}, // Empty callback - no response needed + ); + } + + /// Disable browser-driven scrolling and restore normal Flutter mode. + /// Automatically sets a large default scroll extent for browser scrolling + /// This provides a reasonable default that works for most content + + void disable() { + if (!_enabled) { + return; + } + + // Restore normal positioning + _view.dom.rootElement.style + ..removeProperty('position') + ..removeProperty('top') + ..removeProperty('left') + ..removeProperty('right') + ..removeProperty('bottom') + ..removeProperty('height') + ..removeProperty('min-height') + ..removeProperty('overflow') + ..removeProperty('transform'); + + // Remove injected CSS + domDocument.getElementById('flt-browser-scroll-style')?.remove(); + + // Remove placeholder + _placeholder?.remove(); + _placeholder = null; + + // Remove scroll listener + if (_scrollListener != null) { + domWindow.removeEventListener('scroll', _scrollListener); + _scrollListener = null; + } + + // Remove resize listener + if (_resizeListener != null) { + domWindow.removeEventListener('resize', _resizeListener); + _resizeListener = null; + } + + // Disconnect MutationObserver + if (_heightObserver != null) { + _heightObserver!.disconnect(); + _heightObserver = null; + } + + _enabled = false; + _currentScrollY = 0.0; + _desiredHeight = null; + + assert(() { + print('[BrowserScrollController] Disabled for view ${_view.viewId}'); + return true; + }()); + } + + /// Clean up resources. + void dispose() { + disable(); + } +}
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_channels/browser_scroll_channel.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_channels/browser_scroll_channel.dart new file mode 100644 index 0000000..eae2362 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_channels/browser_scroll_channel.dart
@@ -0,0 +1,182 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; + +/// Platform channel for browser scroll coordination between engine and framework. +/// +/// This channel allows the framework to: +/// - Enable/disable browser scrolling mode +/// - Update scroll extent when content changes +/// - Receive scroll position updates from the browser +class BrowserScrollChannel { + /// The channel name used for browser scroll communication. + static const String channelName = 'flutter/browserscroll'; + + static const MethodCodec _codec = StandardMethodCodec(); + + /// Handle incoming messages from the framework. + static void handleMessage(ByteData? message, ui.PlatformMessageResponseCallback? callback) { + print('[DEBUG BrowserScrollChannel] handleMessage called'); + + if (message == null) { + print('[DEBUG BrowserScrollChannel] Message is null!'); + _sendErrorResponse(callback, 'invalid_message', 'Message is null', null); + return; + } + + try { + print('[DEBUG BrowserScrollChannel] Decoding method call...'); + final MethodCall methodCall = _codec.decodeMethodCall(message); + print('[DEBUG BrowserScrollChannel] Method: ${methodCall.method}'); + print('[DEBUG BrowserScrollChannel] Arguments: ${methodCall.arguments}'); + + // Convert arguments to Map<String, dynamic> + final Map<String, dynamic> args; + if (methodCall.arguments == null) { + args = <String, dynamic>{}; + } else if (methodCall.arguments is Map) { + args = Map<String, dynamic>.from(methodCall.arguments as Map); + } else { + _sendErrorResponse( + callback, + 'invalid_args', + 'Arguments must be a Map, got: ${methodCall.arguments.runtimeType}', + null, + ); + return; + } + + switch (methodCall.method) { + case 'enable': + _handleEnable(args, callback); + break; + + case 'disable': + _handleDisable(args, callback); + break; + + case 'updateExtent': + _handleUpdateExtent(args, callback); + break; + + default: + _sendErrorResponse( + callback, + 'unknown_method', + 'Unknown method: ${methodCall.method}', + null, + ); + } + } catch (e, stackTrace) { + print('[DEBUG BrowserScrollChannel] Error: $e'); + print('[DEBUG BrowserScrollChannel] Stack: $stackTrace'); + _sendErrorResponse(callback, 'error', 'Error handling message: $e', null); + } + } + + static void _handleEnable( + Map<String, dynamic> args, + ui.PlatformMessageResponseCallback? callback, + ) { + print('[DEBUG BrowserScrollChannel] _handleEnable called with args: $args'); + + final int? viewId = args['viewId'] as int?; + + if (viewId == null) { + print('[DEBUG BrowserScrollChannel] viewId is null!'); + _sendErrorResponse(callback, 'invalid_args', 'viewId is required', null); + return; + } + + print('[DEBUG BrowserScrollChannel] Looking for view $viewId...'); + final EngineFlutterView? view = EnginePlatformDispatcher.instance.viewManager[viewId]; + + if (view == null) { + print('[DEBUG BrowserScrollChannel] View $viewId not found!'); + _sendErrorResponse(callback, 'view_not_found', 'View not found: $viewId', null); + return; + } + + print('[DEBUG BrowserScrollChannel] Calling view.enableBrowserScrolling()...'); + view.enableBrowserScrolling(); + print('[DEBUG BrowserScrollChannel] Browser scrolling enabled successfully!'); + _sendSuccessResponse(callback, true); + } + + static void _handleDisable( + Map<String, dynamic> args, + ui.PlatformMessageResponseCallback? callback, + ) { + final int? viewId = args['viewId'] as int?; + + if (viewId == null) { + _sendErrorResponse(callback, 'invalid_args', 'viewId is required', null); + return; + } + + final EngineFlutterView? view = EnginePlatformDispatcher.instance.viewManager[viewId]; + + if (view == null) { + _sendErrorResponse(callback, 'view_not_found', 'View not found: $viewId', null); + return; + } + + view.disableBrowserScrolling(); + _sendSuccessResponse(callback, true); + } + + static void _handleUpdateExtent( + Map<String, dynamic> args, + ui.PlatformMessageResponseCallback? callback, + ) { + final int? viewId = args['viewId'] as int?; + final double? height = (args['height'] as num?)?.toDouble(); + + if (viewId == null || height == null) { + _sendErrorResponse(callback, 'invalid_args', 'viewId and height are required', null); + return; + } + + final EngineFlutterView? view = EnginePlatformDispatcher.instance.viewManager[viewId]; + + if (view == null) { + _sendErrorResponse(callback, 'view_not_found', 'View not found: $viewId', null); + return; + } + + view.updateBrowserScrollExtent(height); + _sendSuccessResponse(callback, true); + } + + static void _sendSuccessResponse(ui.PlatformMessageResponseCallback? callback, dynamic result) { + if (callback == null) { + return; + } + + final ByteData? responseData = _codec.encodeSuccessEnvelope(result); + callback(responseData); + } + + static void _sendErrorResponse( + ui.PlatformMessageResponseCallback? callback, + String code, + String? message, + dynamic details, + ) { + if (callback == null) { + return; + } + + final ByteData? responseData = _codec.encodeErrorEnvelope( + code: code, + message: message, + details: details, + ); + callback(responseData); + } +}
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 09c1ec4..e1a4f20 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart
@@ -609,6 +609,10 @@ PlatformViewMessageHandler.instance.handlePlatformViewCall(method, arguments, callback!); return; + case BrowserScrollChannel.channelName: + BrowserScrollChannel.handleMessage(data, callback); + return; + case 'flutter/accessibility': // In widget tests we want to bypass processing of platform messages. const StandardMessageCodec codec = StandardMessageCodec();
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..4b5118b 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
@@ -64,6 +64,9 @@ return _contents.containsKey(viewId); } + /// Track if we've shown the browser scroll hint + bool _hasShownBrowserScrollHint = false; + /// Returns the cached contents of [viewId], to be injected into the DOM. /// /// This is only used by the active `Renderer` object when a platform view needs @@ -166,10 +169,55 @@ wrapper.setAttribute(_ariaHiddenAttribute, 'true'); + // Auto-detection: Suggest browser scrolling when first platform view is created + _maybeSuggestBrowserScrolling(); + return wrapper; }); } + /// Suggest enabling browser scrolling when platform views are detected. + /// + /// This helps users discover the solution to nested scrolling problems + /// with iframes and HTML content. + void _maybeSuggestBrowserScrolling() { + if (_hasShownBrowserScrollHint || _contents.length != 1) { + return; + } + + _hasShownBrowserScrollHint = true; + + assert(() { + print( + '\n' + '╔═══════════════════════════════════════════════════════════════════════════╗\n' + '║ Platform View Detected - Browser Scrolling Available ║\n' + '╠═══════════════════════════════════════════════════════════════════════════╣\n' + '║ ║\n' + '║ If you experience scrolling issues with iframes or HTML content, ║\n' + '║ consider enabling browser-driven scrolling mode: ║\n' + '║ ║\n' + '║ // In your Flutter app (requires framework support): ║\n' + '║ BrowserScrollView( ║\n' + '║ child: YourContent(), ║\n' + '║ ) ║\n' + '║ ║\n' + '║ // Or enable via JavaScript console for testing: ║\n' + '║ const channel = new MessageChannel(); ║\n' + '║ window.flutter.postMessage({ ║\n' + '║ method: "enable", ║\n' + '║ viewId: 0 ║\n' + '║ }, "flutter/browserscroll", [channel.port2]); ║\n' + '║ ║\n' + '║ This solves the nested scrolling problem where scrolling stops ║\n' + '║ working when the pointer is over platform views (iframes, HTML). ║\n' + '║ ║\n' + '╚═══════════════════════════════════════════════════════════════════════════╝\n', + ); + return true; + }()); + } + /// 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 ae33a76..269a896 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
@@ -764,6 +764,20 @@ } void _handleWheelEvent(DomEvent event) { + // DEBUG: Log wheel event handling + print('[DEBUG] _handleWheelEvent called, browserScrollEnabled=${_view.isBrowserScrollEnabled}'); + + // If browser scroll mode is enabled, let the browser handle wheel events + // naturally instead of Flutter intercepting them. This solves the nested + // scrolling problem with iframes and HTML content. + if (_view.isBrowserScrollEnabled) { + // Don't call preventDefault() - let browser scroll + print('[DEBUG] Browser scroll enabled - NOT calling preventDefault()'); + return; + } + + print('[DEBUG] Browser scroll NOT enabled - calling preventDefault()'); + // Wheel events should switch semantics to pointer event mode, because wheel // events should always be handled by the framework. // See: https://github.com/flutter/flutter/issues/159358 @@ -931,11 +945,11 @@ double? _currentY; num? _startTime; bool _isScrollGesture = false; - + // Thresholds for detecting scroll gestures static const double _scrollThreshold = 10.0; // pixels static const double _scrollAngleThreshold = 0.5; // radians (~30 degrees) - + void onPointerDown(DomPointerEvent event) { _startX = event.clientX.toDouble(); _startY = event.clientY.toDouble(); @@ -943,39 +957,43 @@ _currentY = _startY; _startTime = event.timeStamp; _isScrollGesture = false; - + if (_debugLogPointerEvents) { print('[TOUCH_GESTURE] Down at ($_startX, $_startY)'); } } - + void onPointerMove(DomPointerEvent event) { _currentX = event.clientX.toDouble(); _currentY = event.clientY.toDouble(); - + if (_startX != null && _startY != null) { final double deltaX = (_currentX! - _startX!).abs(); final double deltaY = (_currentY! - _startY!).abs(); - + // Check if movement exceeds threshold if (deltaY > _scrollThreshold || deltaX > _scrollThreshold) { // Check if it's primarily vertical movement if (deltaY > deltaX * 1.5) { _isScrollGesture = true; if (_debugLogPointerEvents) { - print('[TOUCH_GESTURE] Detected vertical scroll gesture: deltaX=$deltaX, deltaY=$deltaY'); + print( + '[TOUCH_GESTURE] Detected vertical scroll gesture: deltaX=$deltaX, deltaY=$deltaY', + ); } } else if (deltaX > deltaY * 1.5) { // Horizontal scroll - also consider it a scroll gesture _isScrollGesture = true; if (_debugLogPointerEvents) { - print('[TOUCH_GESTURE] Detected horizontal scroll gesture: deltaX=$deltaX, deltaY=$deltaY'); + print( + '[TOUCH_GESTURE] Detected horizontal scroll gesture: deltaX=$deltaX, deltaY=$deltaY', + ); } } } } } - + void reset() { _startX = null; _startY = null; @@ -984,9 +1002,9 @@ _startTime = null; _isScrollGesture = false; } - + bool get isScrollGesture => _isScrollGesture; - + bool get hasMovedSignificantly { if (_startX == null || _startY == null || _currentX == null || _currentY == null) { return false; @@ -1080,9 +1098,11 @@ () => _TouchGestureTracker(), ); tracker.onPointerDown(event); - + if (_debugLogPointerEvents) { - print('[TOUCH_PASSTHROUGH] Touch pointerdown - NOT calling preventDefault() to allow browser scroll'); + print( + '[TOUCH_PASSTHROUGH] Touch pointerdown - NOT calling preventDefault() to allow browser scroll', + ); } } @@ -1107,7 +1127,9 @@ ); }); } else if (_debugLogPointerEvents) { - print('[TOUCH_PASSTHROUGH] Skipping preventDefault() for touch event to enable browser scroll'); + print( + '[TOUCH_PASSTHROUGH] Skipping preventDefault() for touch event to enable browser scroll', + ); } } }); @@ -1130,7 +1152,7 @@ final _ButtonSanitizer sanitizer = _ensureSanitizer(device); final List<ui.PointerData> pointerData = <ui.PointerData>[]; final List<DomPointerEvent> expandedEvents = _expandEvents(moveEvent); - + // Track touch gesture movement if (moveEvent.pointerType == 'touch') { final _TouchGestureTracker? tracker = _touchTrackers[device]; @@ -1141,7 +1163,7 @@ } } } - + for (final DomPointerEvent event in expandedEvents) { final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp( buttons: event.buttons!.toInt(), @@ -1194,7 +1216,7 @@ _callback(event, pointerData); } } - + // Clean up touch tracker if (event.pointerType == 'touch') { _touchTrackers.remove(device); @@ -1217,7 +1239,7 @@ _convertEventsToPointerData(data: pointerData, event: event, details: details); _callback(event, pointerData); } - + // Clean up touch tracker if (event.pointerType == 'touch') { _touchTrackers.remove(device);
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart index 40a4935..717933b 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart
@@ -110,6 +110,7 @@ _resizeSubscription.cancel(); dimensionsProvider.close(); pointerBinding.dispose(); + _browserScrollController?.dispose(); dom.rootElement.remove(); // TODO(harryterkelsen): What should we do about this in multi-view? renderer.clearFragmentProgramCache(); @@ -165,6 +166,34 @@ late final EngineSemanticsOwner semantics = EngineSemanticsOwner(viewId, dom.semanticsHost); + /// Browser scroll controller (null if browser scrolling not enabled). + BrowserScrollController? _browserScrollController; + + /// Whether browser-driven scrolling is enabled for this view. + bool get isBrowserScrollEnabled => _browserScrollController?.isEnabled ?? false; + + /// Enable browser-driven scrolling for this view. + /// + /// This makes the browser handle all scrolling instead of Flutter, + /// which solves the nested scrolling problem with iframes and HTML content. + void enableBrowserScrolling() { + _browserScrollController ??= BrowserScrollController(this); + _browserScrollController!.enable(); + } + + /// Disable browser-driven scrolling and restore normal Flutter mode. + void disableBrowserScrolling() { + _browserScrollController?.disable(); + } + + /// Update the browser scroll extent (total content height). + /// + /// Called when Flutter's content height changes to update the browser's + /// scrollable area. + void updateBrowserScrollExtent(double height) { + _browserScrollController?.updateScrollExtent(height); + } + @override ui.Size get physicalSize { return _physicalSize ??= _computePhysicalSize();