draft
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 0b3d784..ae33a76 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
@@ -923,6 +923,80 @@ typedef _PointerEventListener = dynamic Function(DomPointerEvent event); +/// Tracks touch gesture state to determine if it's a scroll gesture. +class _TouchGestureTracker { + double? _startX; + double? _startY; + double? _currentX; + 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(); + _currentX = _startX; + _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'); + } + } 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'); + } + } + } + } + } + + void reset() { + _startX = null; + _startY = null; + _currentX = null; + _currentY = null; + _startTime = null; + _isScrollGesture = false; + } + + bool get isScrollGesture => _isScrollGesture; + + bool get hasMovedSignificantly { + if (_startX == null || _startY == null || _currentX == null || _currentY == null) { + return false; + } + final double deltaX = (_currentX! - _startX!).abs(); + final double deltaY = (_currentY! - _startY!).abs(); + return deltaX > _scrollThreshold || deltaY > _scrollThreshold; + } +} + /// Adapter class to be used with browsers that support native pointer events. /// /// For the difference between MouseEvent and PointerEvent, see _MouseAdapter. @@ -930,6 +1004,7 @@ _PointerAdapter(super.owner); final Map<int, _ButtonSanitizer> _sanitizers = <int, _ButtonSanitizer>{}; + final Map<int, _TouchGestureTracker> _touchTrackers = <int, _TouchGestureTracker>{}; @visibleForTesting Iterable<int> debugTrackedDevices() => _sanitizers.keys; @@ -997,20 +1072,43 @@ _convertEventsToPointerData(data: pointerData, event: event, details: down); _callback(event, pointerData); + // Track touch gestures for scroll detection + final bool isTouchEvent = event.pointerType == 'touch'; + if (isTouchEvent) { + final _TouchGestureTracker tracker = _touchTrackers.putIfAbsent( + device, + () => _TouchGestureTracker(), + ); + tracker.onPointerDown(event); + + if (_debugLogPointerEvents) { + print('[TOUCH_PASSTHROUGH] Touch pointerdown - NOT calling preventDefault() to allow browser scroll'); + } + } + if (event.target == _viewTarget) { // Ensure smooth focus transitions between text fields within the Flutter view. // Without preventing the default and this delay, the engine may not have fully // rendered the next input element, leading to the focus incorrectly returning to // the main Flutter view instead. // A zero-length timer is sufficient in all tested browsers to achieve this. - event.preventDefault(); - Timer(Duration.zero, () { - EnginePlatformDispatcher.instance.requestViewFocusChange( - viewId: _view.viewId, - state: ui.ViewFocusState.focused, - direction: ui.ViewFocusDirection.undefined, - ); - }); + // + // IMPORTANT: For touch events, we DON'T call preventDefault() to allow browser + // scrolling to work. This is part of the browser-driven scrolling solution. + // The trade-off is that focus transitions on touch might be slightly less smooth, + // but browser scrolling is more important for mobile UX. + if (!isTouchEvent) { + event.preventDefault(); + Timer(Duration.zero, () { + EnginePlatformDispatcher.instance.requestViewFocusChange( + viewId: _view.viewId, + state: ui.ViewFocusState.focused, + direction: ui.ViewFocusDirection.undefined, + ); + }); + } else if (_debugLogPointerEvents) { + print('[TOUCH_PASSTHROUGH] Skipping preventDefault() for touch event to enable browser scroll'); + } } }); @@ -1032,6 +1130,18 @@ 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]; + if (tracker != null) { + tracker.onPointerMove(moveEvent); + if (_debugLogPointerEvents && tracker.isScrollGesture) { + print('[TOUCH_PASSTHROUGH] Touch scroll gesture detected in pointermove'); + } + } + } + for (final DomPointerEvent event in expandedEvents) { final _SanitizedDetails? up = sanitizer.sanitizeMissingRightClickUp( buttons: event.buttons!.toInt(), @@ -1084,6 +1194,14 @@ _callback(event, pointerData); } } + + // Clean up touch tracker + if (event.pointerType == 'touch') { + _touchTrackers.remove(device); + if (_debugLogPointerEvents) { + print('[TOUCH_PASSTHROUGH] Touch pointerup - cleaned up tracker for device $device'); + } + } }); // TODO(dit): Synthesize a "cancel" event when 'pointerup' happens outside of the flutterViewElement, https://github.com/flutter/flutter/issues/116561 @@ -1099,6 +1217,14 @@ _convertEventsToPointerData(data: pointerData, event: event, details: details); _callback(event, pointerData); } + + // Clean up touch tracker + if (event.pointerType == 'touch') { + _touchTrackers.remove(device); + if (_debugLogPointerEvents) { + print('[TOUCH_PASSTHROUGH] Touch pointercancel - cleaned up tracker for device $device'); + } + } }, checkModifiers: false); _addWheelEventListener((DomEvent event) {
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart index 6733dec..3ec9098 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart
@@ -60,10 +60,11 @@ setElementStyle(hostElement, 'user-select', 'none'); setElementStyle(hostElement, '-webkit-user-select', 'none'); - // This is required to prevent the browser from doing any native touch - // handling. If this is not done, the browser doesn't report 'pointermove' - // events properly. - setElementStyle(hostElement, 'touch-action', 'none'); + // Allow touch scrolling (pan gestures) while preventing other touch actions + // like double-tap zoom. This enables browser-driven scrolling for better + // mobile UX while still allowing Flutter to handle other gestures. + // Changed from 'none' to 'pan-x pan-y' to fix Issue #157435. + setElementStyle(hostElement, 'touch-action', 'pan-x pan-y'); } // Sets a meta viewport tag appropriate for Flutter Web in full screen.
diff --git a/packages/flutter/lib/src/widgets/browser_scroll_zone.dart b/packages/flutter/lib/src/widgets/browser_scroll_zone.dart new file mode 100644 index 0000000..cf3e2ce --- /dev/null +++ b/packages/flutter/lib/src/widgets/browser_scroll_zone.dart
@@ -0,0 +1,281 @@ +// Copyright 2014 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. + +/// Web-specific widget for nested scrolling with boundary detection. +/// +/// This file provides [BrowserScrollZone], which enables smooth nested scrolling +/// on Flutter web by detecting scroll boundaries and allowing touch events to +/// pass through to parent scrollables. +library; + +import 'dart:js_interop'; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/foundation.dart'; +import 'package:web/web.dart' as web; + +import 'basic.dart'; +import 'framework.dart'; +import 'platform_view.dart'; +import 'scroll_controller.dart'; + +/// A widget that enables boundary detection for nested scrollables on web. +/// +/// When a scrollable wrapped in [BrowserScrollZone] reaches its scroll +/// boundaries (top or bottom), touch and wheel events will pass through to +/// parent scrollables, enabling natural nested scrolling behavior. +/// +/// This widget is only effective on web platforms. On other platforms, it +/// simply returns its child without any modifications. +/// +/// ## Use Cases +/// +/// [BrowserScrollZone] is useful when you have a scrollable (like [ListView]) +/// nested inside another scrollable area and want smooth scrolling transitions +/// at boundaries: +/// +/// - Nested lists in a scrollable page +/// - Modal dialogs with scrollable content +/// - Embedded scrollable regions +/// - Any nested scrolling scenario on web +/// +/// ## How It Works +/// +/// [BrowserScrollZone] creates an HTML platform view that sits above Flutter's +/// canvas. This platform view intercepts touch and wheel events before they +/// reach Flutter's rendering system, allowing conditional event handling based +/// on the scroll position: +/// +/// - **Not at boundary**: Events are prevented, Flutter handles scrolling +/// - **At top + scrolling up**: Events pass through to parent +/// - **At bottom + scrolling down**: Events pass through to parent +/// +/// ## Requirements +/// +/// - Requires a [ScrollController] to detect scroll position +/// - Only works on web platform (no-op on other platforms) +/// - Child should be a scrollable widget (ListView, GridView, etc.) +/// +/// ## Example +/// +/// ```dart +/// final controller = ScrollController(); +/// +/// SingleChildScrollView( +/// browserScrolling: true, // Enable browser scrolling for outer +/// child: Column( +/// children: [ +/// Text('Header'), +/// SizedBox( +/// height: 400, +/// child: BrowserScrollZone( +/// scrollController: controller, +/// child: ListView.builder( +/// controller: controller, +/// itemCount: 30, +/// itemBuilder: (context, index) => ListTile( +/// title: Text('Item $index'), +/// ), +/// ), +/// ), +/// ), +/// Text('Footer'), +/// ], +/// ), +/// ) +/// ``` +/// +/// ## Important Notes +/// +/// - The [ScrollController] must be attached to the child scrollable +/// - Boundary detection only works when the controller has clients +/// - The widget creates a platform view, which has some performance overhead +/// - Works with both touch and mouse wheel events +/// +/// See also: +/// +/// * [Scrollable.browserScrolling], which enables browser-driven scrolling +/// * [ScrollController], which is required for boundary detection +/// * [HtmlElementView], which is used internally to create the platform view +class BrowserScrollZone extends StatefulWidget { + /// Creates a [BrowserScrollZone] that enables boundary detection for nested scrolling. + /// + /// The [child] parameter must not be null and should be a scrollable widget. + /// + /// The [scrollController] parameter is required and must be the same controller + /// attached to the child scrollable widget. It is used to detect scroll position + /// and determine when to allow events to pass through to parent scrollables. + const BrowserScrollZone({ + super.key, + required this.child, + required this.scrollController, + }); + + /// The scrollable widget to wrap. + /// + /// This should typically be a [ListView], [GridView], [SingleChildScrollView], + /// or any other scrollable widget. + final Widget child; + + /// The scroll controller attached to the child scrollable. + /// + /// This controller is used to detect the current scroll position and determine + /// when the scrollable is at its boundaries (top or bottom). + /// + /// The same controller must be passed to both [BrowserScrollZone] and the + /// child scrollable widget for boundary detection to work correctly. + final ScrollController scrollController; + + @override + State<BrowserScrollZone> createState() => _BrowserScrollZoneState(); +} + +class _BrowserScrollZoneState extends State<BrowserScrollZone> { + static int _nextViewId = 0; + late final String _viewId; + + @override + void initState() { + super.initState(); + _viewId = 'flutter-browser-scroll-zone-${_nextViewId++}'; + + // Only register platform view on web + if (kIsWeb) { + _registerPlatformView(); + } + } + + void _registerPlatformView() { + // Register a factory that creates a div with boundary detection + ui_web.platformViewRegistry.registerViewFactory( + _viewId, + (int viewId) { + final container = web.document.createElement('div') as web.HTMLDivElement; + container.style + ..width = '100%' + ..height = '100%' + ..touchAction = 'pan-y' // Allow vertical scrolling (controlled by JS) + ..overflow = 'hidden' // Prevent browser scrolling + ..pointerEvents = 'auto'; // Allow event interception + + // Track touch start position for direction detection + double? touchStartY; + + // Handle touch events for boundary detection + container.addEventListener('touchstart', (web.TouchEvent event) { + if (event.touches.length > 0) { + touchStartY = event.touches.item(0)!.clientY.toDouble(); + } + }.toJS, web.AddEventListenerOptions(passive: true)); + + container.addEventListener('touchmove', (web.TouchEvent event) { + final scrollController = widget.scrollController; + + // If no scroll controller or no clients, block all events + if (!scrollController.hasClients || event.touches.length == 0) { + event.preventDefault(); + return; + } + + final currentY = event.touches.item(0)!.clientY.toDouble(); + final deltaY = touchStartY != null ? currentY - touchStartY! : 0.0; + final position = scrollController.position; + + // Check if we're at boundaries and trying to scroll beyond + final isAtTop = position.pixels <= position.minScrollExtent; + final isAtBottom = position.pixels >= position.maxScrollExtent; + final isScrollingUp = deltaY > 0; // Positive deltaY = finger down = scroll up + final isScrollingDown = deltaY < 0; // Negative deltaY = finger up = scroll down + + // Allow event to bubble to parent if: + // - At top and scrolling up, OR + // - At bottom and scrolling down + final shouldBubble = (isAtTop && isScrollingUp) || (isAtBottom && isScrollingDown); + + if (!shouldBubble) { + event.preventDefault(); // Block event, Flutter handles it + if (kDebugMode) { + print('[BrowserScrollZone] Blocked touch event (scrollable can scroll)'); + } + } else { + // Allow event to bubble to parent + if (kDebugMode) { + print('[BrowserScrollZone] Allowing touch event to bubble (at boundary)'); + } + } + }.toJS, web.AddEventListenerOptions(passive: false)); // Must be non-passive to call preventDefault + + container.addEventListener('touchend', (web.TouchEvent event) { + touchStartY = null; + }.toJS, web.AddEventListenerOptions(passive: true)); + + container.addEventListener('touchcancel', (web.TouchEvent event) { + touchStartY = null; + }.toJS, web.AddEventListenerOptions(passive: true)); + + // Handle wheel events for boundary detection (mouse scroll) + container.addEventListener('wheel', (web.WheelEvent event) { + final scrollController = widget.scrollController; + + // If no clients, block all events + if (!scrollController.hasClients) { + event.preventDefault(); + return; + } + + final position = scrollController.position; + final deltaY = event.deltaY.toDouble(); + + // Check if we're at boundaries and trying to scroll beyond + final isAtTop = position.pixels <= position.minScrollExtent; + final isAtBottom = position.pixels >= position.maxScrollExtent; + final isScrollingUp = deltaY < 0; // Negative deltaY = scroll up + final isScrollingDown = deltaY > 0; // Positive deltaY = scroll down + + // Allow event to bubble to parent if: + // - At top and scrolling up, OR + // - At bottom and scrolling down + final shouldBubble = (isAtTop && isScrollingUp) || (isAtBottom && isScrollingDown); + + if (!shouldBubble) { + event.preventDefault(); // Block event, Flutter handles it + if (kDebugMode) { + print('[BrowserScrollZone] Blocked wheel event (scrollable can scroll)'); + } + } else { + // Allow event to bubble to parent + if (kDebugMode) { + print('[BrowserScrollZone] Allowing wheel event to bubble (at boundary)'); + } + } + }.toJS); + + if (kDebugMode) { + print('[BrowserScrollZone] Created platform view with boundary detection'); + } + + return container; + }, + ); + } + + @override + Widget build(BuildContext context) { + // On non-web platforms, just return the child + if (!kIsWeb) { + return widget.child; + } + + // On web, stack the platform view behind the child + return Stack( + children: <Widget>[ + Positioned.fill( + child: HtmlElementView(viewType: _viewId), + ), + widget.child, + ], + ); + } +} +
diff --git a/packages/flutter/lib/src/widgets/scroll_configuration_web.dart b/packages/flutter/lib/src/widgets/scroll_configuration_web.dart new file mode 100644 index 0000000..3eee2b8 --- /dev/null +++ b/packages/flutter/lib/src/widgets/scroll_configuration_web.dart
@@ -0,0 +1,504 @@ +// Copyright 2014 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. + +/// Web-specific scroll configuration and browser scrolling integration. +/// +/// This file provides automatic browser-driven scrolling for Flutter web apps, +/// solving nested scrolling issues and providing native browser scrolling behavior. +library; + +import 'dart:js_interop'; +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:web/web.dart' as web; + +import 'framework.dart'; +import 'scroll_controller.dart'; +import 'scroll_physics.dart'; +import 'scroll_position.dart'; +import 'scrollable.dart'; +import 'view.dart'; + +/// Whether browser scrolling is enabled by default for Flutter web apps. +/// +/// Defaults to false (opt-in). Set to true via [setBrowserScrollingDefault] +/// to enable browser scrolling by default for all scrollables. +bool _kDefaultBrowserScrollingEnabled = false; + +/// Gets the current default browser scrolling setting. +/// +/// Returns true if browser scrolling is enabled by default. +bool get kDefaultBrowserScrollingEnabled => _kDefaultBrowserScrollingEnabled; + +/// Sets the default browser scrolling behavior for all scrollables. +/// +/// When enabled, Flutter will use native browser scrolling on web by default +/// (unless explicitly disabled with browserScrolling: false), which provides: +/// - Native touch and wheel scrolling behavior +/// - Proper nested scrolling disambiguation +/// - No iframe scroll blocking +/// +/// Defaults to false. Set to true to opt-in all scrollables to browser scrolling. +void setBrowserScrollingDefault(bool enabled) { + _kDefaultBrowserScrollingEnabled = enabled; +} + +/// Interface for external scrollers that control scrolling via the browser DOM. +/// +/// This is used internally by [BrowserScrollStrategy] to create and manage +/// a placeholder DOM element that the browser scrolls, while Flutter renders +/// at the appropriate scroll offset. +abstract class ExternalScroller { + /// Sets up the external scroller (creates placeholder DOM, sets styles). + void setup(); + + /// Computes the currently visible rectangle based on browser scroll position. + ui.Rect computeVisibleRect(); + + /// Adds a listener for when the visible rect changes (browser scrolls). + void addVisibleRectListener(VoidCallback listener); + + /// Removes a previously added visible rect listener. + void removeVisibleRectListener(VoidCallback listener); + + /// Adds a listener for when the browser scroll event fires. + void addScrollListener(VoidCallback listener); + + /// Removes a previously added scroll listener. + void removeScrollListener(VoidCallback listener); + + /// Updates the height of the scrollable content. + void updateHeight(double height); + + /// Enables boundary detection for nested scrolling. + /// + /// When enabled, touch events are conditionally prevented based on scroll position. + /// This allows parent scrollables to receive events when this scrollable is at its boundaries. + void enableBoundaryDetection(ScrollController controller); + + /// Disables boundary detection. + void disableBoundaryDetection(); + + /// Cleans up resources. + void dispose(); +} + +/// Implementation of [ExternalScroller] that uses JavaScript/DOM APIs. +/// +/// This creates a placeholder `<body>` element with the correct height, +/// while setting the actual Flutter view to `position: fixed`. The browser +/// scrolls the placeholder, and Flutter renders based on `window.scrollY`. +class JsViewScroller implements ExternalScroller { + JsViewScroller(this.viewId); + + final int viewId; + late final web.HTMLElement _hostElement; + late final web.HTMLElement _placeholderElement; + final List<VoidCallback> _visibleRectListeners = <VoidCallback>[]; + final List<VoidCallback> _scrollListeners = <VoidCallback>[]; + late final web.EventListener _jsScrollListener; + web.ResizeObserver? _observer; + + // Boundary detection state + ScrollController? _boundaryDetectionController; + web.EventListener? _touchStartListener; + web.EventListener? _touchMoveListener; + web.EventListener? _touchEndListener; + web.EventListener? _touchCancelListener; + double? _touchStartY; + + @override + void setup() { + _hostElement = ui_web.views.getHostElement(viewId) as web.HTMLElement; + + // Create placeholder element (a clone of the host that will be scrollable) + _placeholderElement = _hostElement.cloneNode() as web.HTMLElement; + _hostElement.parentElement!.insertBefore(_placeholderElement, _hostElement); + + // Set host element to fixed position (doesn't scroll with browser) + _hostElement.style + ..position = 'fixed' + ..top = '0' + ..left = '0' + ..right = '0' + ..bottom = '0'; + + // Ensure HTML element allows scrolling + (web.document.documentElement as web.HTMLElement).style + ..overflow = 'auto' // Allow scrolling on <html> + ..height = 'auto' + ..margin = '0' + ..padding = '0'; + + // Set placeholder to static positioning and visible overflow + (_hostElement.parentElement! as web.HTMLElement).style + ..position = 'static' + ..overflow = 'visible'; + + if (kDebugMode) { + print('[BrowserScroller] Setup complete: placeholder created, host set to fixed'); + } + } + + @override + ui.Rect computeVisibleRect() { + final double scrollTop = web.window.scrollY; + final double windowHeight = web.window.innerHeight.toDouble(); + return ui.Rect.fromLTWH(0, scrollTop, web.window.innerWidth.toDouble(), windowHeight); + } + + @override + void addVisibleRectListener(VoidCallback listener) { + if (_visibleRectListeners.isEmpty) { + final int viewId = this.viewId; + _observer = web.ResizeObserver((JSArray<JSObject> entries, JSObject observer) { + for (final VoidCallback listener in _visibleRectListeners) { + listener(); + } + }.toJS); + final web.Element? hostElement = ui_web.views.getHostElement(viewId) as web.Element?; + if (hostElement != null) { + _observer!.observe(hostElement); + } + } + _visibleRectListeners.add(listener); + } + + @override + void removeVisibleRectListener(VoidCallback listener) { + _visibleRectListeners.remove(listener); + if (_visibleRectListeners.isEmpty) { + _observer?.disconnect(); + _observer = null; + } + } + + web.EventTarget get _scrollTarget => web.window; + + @override + void addScrollListener(VoidCallback listener) { + if (_scrollListeners.isEmpty) { + _jsScrollListener = (web.Event event) { + for (final VoidCallback listener in _scrollListeners) { + listener(); + } + }.toJS; + _scrollTarget.addEventListener('scroll', _jsScrollListener); + } + _scrollListeners.add(listener); + } + + @override + void removeScrollListener(VoidCallback listener) { + _scrollListeners.remove(listener); + if (_scrollListeners.isEmpty) { + _scrollTarget.removeEventListener('scroll', _jsScrollListener); + } + } + + @override + void updateHeight(double height) { + _placeholderElement.style.height = '${math.max(height, web.window.innerHeight.toDouble())}px'; + if (kDebugMode) { + print('[BrowserScroller] Updated height: ${height}px'); + } + } + + @override + void enableBoundaryDetection(ScrollController controller) { + _boundaryDetectionController = controller; + + // Add touch event listeners to the host element for boundary detection + _touchStartListener = (web.TouchEvent event) { + if (event.touches.length > 0) { + _touchStartY = event.touches.item(0)!.clientY.toDouble(); + } + }.toJS; + _hostElement.addEventListener('touchstart', _touchStartListener!, web.AddEventListenerOptions(passive: true)); + + _touchMoveListener = (web.TouchEvent event) { + final scrollController = _boundaryDetectionController; + + // If no scroll controller, always block (defensive) + if (scrollController == null || !scrollController.hasClients || event.touches.length == 0) { + event.preventDefault(); + return; + } + + final currentY = event.touches.item(0)!.clientY.toDouble(); + final deltaY = _touchStartY != null ? currentY - _touchStartY! : 0.0; + final position = scrollController.position; + + // Check if we're at boundaries and trying to scroll beyond + final isAtTop = position.pixels <= position.minScrollExtent; + final isAtBottom = position.pixels >= position.maxScrollExtent; + final isScrollingUp = deltaY > 0; // Positive deltaY means finger moving down = scrolling up + final isScrollingDown = deltaY < 0; // Negative deltaY means finger moving up = scrolling down + + // Allow event to bubble to parent if: + // - At top and scrolling up, OR + // - At bottom and scrolling down + final shouldBubble = (isAtTop && isScrollingUp) || (isAtBottom && isScrollingDown); + + if (!shouldBubble) { + event.preventDefault(); // Block browser scrolling + if (kDebugMode) { + print('[BrowserScroller] Blocked touch event (scrollable can scroll)'); + } + } else { + // Allow event to bubble to parent + if (kDebugMode) { + print('[BrowserScroller] Allowing touch event to bubble (at boundary)'); + } + } + }.toJS; + _hostElement.addEventListener('touchmove', _touchMoveListener!, web.AddEventListenerOptions(passive: false)); // Must be non-passive to call preventDefault + + _touchEndListener = (web.TouchEvent event) { + _touchStartY = null; + }.toJS; + _hostElement.addEventListener('touchend', _touchEndListener!, web.AddEventListenerOptions(passive: true)); + + _touchCancelListener = (web.TouchEvent event) { + _touchStartY = null; + }.toJS; + _hostElement.addEventListener('touchcancel', _touchCancelListener!, web.AddEventListenerOptions(passive: true)); + + if (kDebugMode) { + print('[BrowserScroller] Boundary detection enabled'); + } + } + + @override + void disableBoundaryDetection() { + if (_touchStartListener != null) { + _hostElement.removeEventListener('touchstart', _touchStartListener!); + _touchStartListener = null; + } + if (_touchMoveListener != null) { + _hostElement.removeEventListener('touchmove', _touchMoveListener!); + _touchMoveListener = null; + } + if (_touchEndListener != null) { + _hostElement.removeEventListener('touchend', _touchEndListener!); + _touchEndListener = null; + } + if (_touchCancelListener != null) { + _hostElement.removeEventListener('touchcancel', _touchCancelListener!); + _touchCancelListener = null; + } + _boundaryDetectionController = null; + _touchStartY = null; + + if (kDebugMode) { + print('[BrowserScroller] Boundary detection disabled'); + } + } + + @override + void dispose() { + disableBoundaryDetection(); + _observer?.disconnect(); + if (_scrollListeners.isNotEmpty) { + _scrollTarget.removeEventListener('scroll', _jsScrollListener); + } + } +} + +/// A scroll strategy that uses native browser scrolling instead of canvas-based scrolling. +/// +/// This strategy creates a placeholder DOM element that the browser scrolls, +/// while Flutter renders at the current scroll offset. This provides: +/// - Native touch and wheel scrolling behavior +/// - No iframe scroll blocking +/// - Proper momentum scrolling +/// - Better accessibility +/// +/// This is automatically used on web when browser scrolling is enabled. +class BrowserScrollStrategy { + BrowserScrollStrategy({ + required this.scrollController, + required this.externalScroller, + }); + + final ScrollController scrollController; + final ExternalScroller externalScroller; + + late ui.Rect _visibleRect; + bool _isInitialized = false; + + /// Initializes the browser scroll strategy. + void initialize() { + if (_isInitialized) { + return; + } + + externalScroller.setup(); + + _syncVisibleRect(); + SchedulerBinding.instance.addPostFrameCallback((_) { + _syncScrollPosition(); + _syncContentHeight(); + }); + + _isInitialized = true; + } + + void _syncVisibleRect() { + _visibleRect = externalScroller.computeVisibleRect(); + externalScroller.addVisibleRectListener(_updateVisibleRect); + } + + void _updateVisibleRect() { + final ui.Rect newVisibleRect = externalScroller.computeVisibleRect(); + if (_visibleRect != newVisibleRect) { + _visibleRect = newVisibleRect; + } + } + + void _syncScrollPosition() { + externalScroller.addScrollListener(() { + final ui.Rect visibleRect = externalScroller.computeVisibleRect(); + if (scrollController.hasClients) { + final ScrollPosition position = scrollController.position; + if ((position.pixels - visibleRect.top).abs() > 1.0) { + position.jumpTo(visibleRect.top); + } + } + }); + } + + void _syncContentHeight() { + if (scrollController.hasClients) { + scrollController.position.addListener(_handleScrollPositionChange); + _handleScrollPositionChange(); + } + } + + void _handleScrollPositionChange() { + if (scrollController.hasClients) { + final ScrollPosition position = scrollController.position; + final double maxScrollExtent = position.maxScrollExtent; + final double totalHeight = maxScrollExtent + _visibleRect.height; + externalScroller.updateHeight(totalHeight); + } + } + + /// Disposes of the browser scroll strategy. + void dispose() { + if (scrollController.hasClients) { + scrollController.position.removeListener(_handleScrollPositionChange); + } + externalScroller.removeVisibleRectListener(_updateVisibleRect); + externalScroller.dispose(); + } +} + +/// Widget that enables browser scrolling for its child. +/// +/// This is automatically applied to scrollables on web when browser scrolling +/// is enabled. It can also be used manually to wrap content that should use +/// browser scrolling. +/// +/// Example: +/// ```dart +/// BrowserScrollView( +/// child: ListView.builder(...), +/// ) +/// ``` +class BrowserScrollView extends StatefulWidget { + const BrowserScrollView({ + super.key, + required this.child, + this.scrollController, + }); + + final Widget child; + final ScrollController? scrollController; + + @override + State<BrowserScrollView> createState() => _BrowserScrollViewState(); +} + +class _BrowserScrollViewState extends State<BrowserScrollView> { + late final ScrollController _scrollController; + late final BrowserScrollStrategy _strategy; + + @override + void initState() { + super.initState(); + _scrollController = widget.scrollController ?? ScrollController(); + + // Get the view ID for this widget's context + final int viewId = View.of(context).viewId; + + _strategy = BrowserScrollStrategy( + scrollController: _scrollController, + externalScroller: JsViewScroller(viewId), + ); + + _strategy.initialize(); + } + + @override + void dispose() { + _strategy.dispose(); + if (widget.scrollController == null) { + _scrollController.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Determines whether browser scrolling should be used for a given scrollable. +/// +/// Returns true if: +/// - Running on web platform +/// - Browser scrolling is enabled globally +/// - Not explicitly disabled for this scrollable +/// - No custom scroll physics that conflict with browser scrolling +bool shouldUseBrowserScrolling({ + bool? explicitSetting, + ScrollPhysics? physics, +}) { + if (!kIsWeb) { + return false; + } + + if (explicitSetting != null) { + return explicitSetting; + } + + if (!_kDefaultBrowserScrollingEnabled) { + return false; + } + + // Check if physics would conflict with browser scrolling + // NeverScrollableScrollPhysics means the content shouldn't scroll at all + if (physics is NeverScrollableScrollPhysics) { + return false; + } + + return true; +} + +/// Creates a browser scroll strategy for the given view ID. +/// +/// This is called by [ScrollableState] when browser scrolling is enabled. +/// Returns an [ExternalScroller] that manages the DOM placeholder and +/// syncs with browser scroll events. +ExternalScroller createBrowserScrollStrategy(int viewId) { + return JsViewScroller(viewId); +} +
diff --git a/packages/flutter/lib/src/widgets/scroll_configuration_web_stub.dart b/packages/flutter/lib/src/widgets/scroll_configuration_web_stub.dart new file mode 100644 index 0000000..7685b0e --- /dev/null +++ b/packages/flutter/lib/src/widgets/scroll_configuration_web_stub.dart
@@ -0,0 +1,71 @@ +// Copyright 2014 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. + +/// Stub implementation for non-web platforms. +/// +/// This file provides empty implementations for web-specific browser scrolling +/// features on non-web platforms. +library; + +import 'package:flutter/foundation.dart'; + +import 'framework.dart'; +import 'scroll_controller.dart'; +import 'scroll_physics.dart'; + +/// Always returns false on non-web platforms. +bool get kDefaultBrowserScrollingEnabled => false; + +/// No-op on non-web platforms. +void setBrowserScrollingDefault(bool enabled) { + // No-op on non-web platforms +} + +/// Always returns false on non-web platforms. +bool shouldUseBrowserScrolling({ + bool? explicitSetting, + ScrollPhysics? physics, +}) { + return false; +} + +/// Stub interface for external scrollers on non-web platforms. +abstract class ExternalScroller { + void setup(); + Object computeVisibleRect(); // Returns ui.Rect on web + void addScrollListener(VoidCallback listener); + void removeScrollListener(VoidCallback listener); + void updateHeight(double height); + void enableBoundaryDetection(ScrollController controller); + void disableBoundaryDetection(); + void dispose(); +} + +/// Stub widget for non-web platforms. +class BrowserScrollView extends StatelessWidget { + const BrowserScrollView({ + super.key, + required this.child, + this.scrollController, + }); + + final Widget child; + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + return child; + } +} + +/// Throws an error on non-web platforms. +/// +/// Browser scrolling is only available on web. +ExternalScroller createBrowserScrollStrategy(int viewId) { + throw UnsupportedError( + 'Browser scrolling is only supported on web platforms. ' + 'This method should never be called on non-web platforms.' + ); +} +
diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart index d37cb17..a26a001 100644 --- a/packages/flutter/lib/src/widgets/scroll_view.dart +++ b/packages/flutter/lib/src/widgets/scroll_view.dart
@@ -123,6 +123,7 @@ this.restorationId, this.clipBehavior = Clip.hardEdge, this.hitTestBehavior = HitTestBehavior.opaque, + this.browserScrolling, }) : assert( !(controller != null && (primary ?? false)), 'Primary ScrollViews obtain their ScrollController via inheritance ' @@ -402,6 +403,9 @@ /// Defaults to [HitTestBehavior.opaque]. final HitTestBehavior hitTestBehavior; + /// {@macro flutter.widgets.scrollable.browserScrolling} + final bool? browserScrolling; + /// Returns the [AxisDirection] in which the scroll view scrolls. /// /// Combines the [scrollDirection] with the [reverse] boolean to obtain the @@ -506,6 +510,7 @@ semanticChildCount: semanticChildCount, restorationId: restorationId, hitTestBehavior: hitTestBehavior, + browserScrolling: browserScrolling, viewportBuilder: (BuildContext context, ViewportOffset offset) { return buildViewport(context, offset, axisDirection, slivers); }, @@ -857,6 +862,7 @@ super.restorationId, super.clipBehavior, super.hitTestBehavior, + super.browserScrolling, }); /// The amount of space by which to inset the children. @@ -1298,6 +1304,7 @@ super.restorationId, super.clipBehavior, super.hitTestBehavior, + super.browserScrolling, }) : assert( (itemExtent == null && prototypeItem == null) || (itemExtent == null && itemExtentBuilder == null) || @@ -1375,6 +1382,7 @@ super.restorationId, super.clipBehavior, super.hitTestBehavior, + super.browserScrolling, }) : assert(itemCount == null || itemCount >= 0), assert(semanticChildCount == null || semanticChildCount <= itemCount!), assert( @@ -1489,6 +1497,7 @@ super.restorationId, super.clipBehavior, super.hitTestBehavior, + super.browserScrolling, }) : assert(itemCount >= 0), assert( findItemIndexCallback == null || findChildIndexCallback == null, @@ -1553,6 +1562,7 @@ super.restorationId, super.clipBehavior, super.hitTestBehavior, + super.browserScrolling, }) : assert( (itemExtent == null && prototypeItem == null) || (itemExtent == null && itemExtentBuilder == null) ||
diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index cf71c2f..2d1b1f5 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart
@@ -16,6 +16,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -32,6 +33,8 @@ import 'restoration_properties.dart'; import 'scroll_activity.dart'; import 'scroll_configuration.dart'; +import 'scroll_configuration_web_stub.dart' + if (dart.library.js_interop) 'scroll_configuration_web.dart'; import 'scroll_context.dart'; import 'scroll_controller.dart'; import 'scroll_physics.dart'; @@ -134,6 +137,7 @@ this.scrollBehavior, this.clipBehavior = Clip.hardEdge, this.hitTestBehavior = HitTestBehavior.opaque, + this.browserScrolling, }) : assert(semanticChildCount == null || semanticChildCount >= 0); /// {@template flutter.widgets.Scrollable.axisDirection} @@ -333,6 +337,36 @@ /// to [ScrollView.clipBehavior] and is supplied to the [Viewport]. final Clip clipBehavior; + /// Whether to use native browser scrolling on web. + /// + /// When `true`, Flutter uses the browser's native scrolling mechanism, which provides: + /// - Native touch and wheel scrolling behavior + /// - Proper nested scrolling disambiguation + /// - No iframe scroll blocking + /// - Better accessibility and integration with browser features + /// + /// When `false`, Flutter uses canvas-based scrolling (the traditional behavior). + /// + /// When `null` (default), uses canvas-based scrolling for backward compatibility. + /// The default may change to `true` on web in a future release. + /// + /// This parameter only affects web platforms. On other platforms, it is ignored. + /// + /// Example of enabling browser scrolling for nested scrolling scenarios: + /// ```dart + /// ListView.builder( + /// browserScrolling: true, // Opt-in to browser scrolling + /// itemBuilder: (context, index) => ListTile(...), + /// ) + /// ``` + /// + /// See also: + /// + /// * https://github.com/flutter/flutter/issues/156985 (nested scrolling) + /// * https://github.com/flutter/flutter/issues/157435 (touch scrolling) + /// * https://github.com/flutter/flutter/issues/113196 (iframe blocking) + final bool? browserScrolling; + /// The axis along which the scroll view scrolls. /// /// Determined by the [axisDirection]. @@ -612,6 +646,7 @@ late ScrollBehavior _configuration; ScrollController? _fallbackScrollController; DeviceGestureSettings? _mediaQueryGestureSettings; + ExternalScroller? _browserScrollStrategy; // Only call this from places that will definitely trigger a rebuild. void _updatePosition() { @@ -655,6 +690,92 @@ ServicesBinding.instance.restorationManager.flushData(); } + bool get _shouldUseBrowserScrolling { + // Only on web platform + if (!kIsWeb) { + return false; + } + // Check widget parameter + if (widget.browserScrolling != null) { + return widget.browserScrolling!; + } + // Fall back to global default (currently false for opt-in) + return kDefaultBrowserScrollingEnabled; + } + + void _initBrowserScrolling() { + if (!_shouldUseBrowserScrolling) { + return; + } + + // Get the view ID from the current context + final int viewId = View.of(context).viewId; + _browserScrollStrategy = createBrowserScrollStrategy(viewId); + _browserScrollStrategy?.setup(); + + // Sync scroll position from browser to Flutter + _browserScrollStrategy?.addScrollListener(_syncScrollFromBrowser); + + // Sync content height from Flutter to browser (in next frame after position is available) + SchedulerBinding.instance.addPostFrameCallback((_) { + if (_position != null) { + _position!.addListener(_syncHeightToBrowser); + _syncHeightToBrowser(); + + // Enable boundary detection for nested scrolling + // This allows touch events to pass through when scrollable is at boundaries + _browserScrollStrategy?.enableBoundaryDetection(_effectiveScrollController); + } + }); + } + + void _syncScrollFromBrowser() { + if (_browserScrollStrategy == null || _position == null) { + return; + } + + // Get the scroll position from browser (only on web) + final ui.Rect visibleRect = _browserScrollStrategy!.computeVisibleRect() as ui.Rect; + final double browserScrollTop = visibleRect.top; + + // Update Flutter's scroll position to match browser + if ((_position!.pixels - browserScrollTop).abs() > 1.0) { + _position!.jumpTo( + clampDouble( + browserScrollTop, + _position!.minScrollExtent, + _position!.maxScrollExtent, + ), + ); + } + } + + void _syncHeightToBrowser() { + if (_browserScrollStrategy == null || _position == null) { + return; + } + + // Calculate total content height + final double maxScrollExtent = _position!.maxScrollExtent; + final double viewportHeight = _position!.viewportDimension; + final double totalHeight = maxScrollExtent + viewportHeight; + + // Update browser placeholder height + _browserScrollStrategy!.updateHeight(totalHeight); + } + + void _disposeBrowserScrolling() { + if (_browserScrollStrategy != null) { + _browserScrollStrategy!.removeScrollListener(_syncScrollFromBrowser); + if (_position != null) { + _position!.removeListener(_syncHeightToBrowser); + } + _browserScrollStrategy!.disableBoundaryDetection(); + _browserScrollStrategy!.dispose(); + _browserScrollStrategy = null; + } + } + @protected @override void initState() { @@ -671,6 +792,8 @@ _devicePixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? View.of(context).devicePixelRatio; _updatePosition(); + // Initialize browser scrolling after position is created + _initBrowserScrolling(); super.didChangeDependencies(); } @@ -727,6 +850,12 @@ if (_shouldUpdatePosition(oldWidget)) { _updatePosition(); } + + // Handle changes to browserScrolling parameter + if (widget.browserScrolling != oldWidget.browserScrolling) { + _disposeBrowserScrolling(); + _initBrowserScrolling(); + } } @protected @@ -741,6 +870,7 @@ position.dispose(); _persistedScrollOffset.dispose(); + _disposeBrowserScrolling(); super.dispose(); }
diff --git a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart index 8b19273..8e8231a 100644 --- a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart
@@ -160,6 +160,7 @@ this.hitTestBehavior = HitTestBehavior.opaque, this.restorationId, this.keyboardDismissBehavior, + this.browserScrolling, }) : assert( !(controller != null && (primary ?? false)), 'Primary ScrollViews obtain their ScrollController via inheritance ' @@ -239,6 +240,9 @@ /// [ScrollBehavior.getKeyboardDismissBehavior]. final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + /// {@macro flutter.widgets.scrollable.browserScrolling} + final bool? browserScrolling; + AxisDirection _getDirection(BuildContext context) { return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse); } @@ -266,6 +270,7 @@ restorationId: restorationId, clipBehavior: clipBehavior, hitTestBehavior: hitTestBehavior, + browserScrolling: browserScrolling, viewportBuilder: (BuildContext context, ViewportOffset offset) { return _SingleChildViewport( axisDirection: axisDirection,
diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 4db5774..fc84757 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart
@@ -116,6 +116,7 @@ export 'src/widgets/router.dart'; export 'src/widgets/routes.dart'; export 'src/widgets/safe_area.dart'; +export 'src/widgets/browser_scroll_zone.dart'; export 'src/widgets/scroll_activity.dart'; export 'src/widgets/scroll_aware_image_provider.dart'; export 'src/widgets/scroll_configuration.dart';
diff --git a/packages/flutter/pubspec.yaml b/packages/flutter/pubspec.yaml index c8fdce5..ae2cf75 100644 --- a/packages/flutter/pubspec.yaml +++ b/packages/flutter/pubspec.yaml
@@ -17,6 +17,7 @@ material_color_utilities: 0.11.1 meta: 1.17.0 vector_math: 2.2.0 + web: any sky_engine: sdk: flutter