Experimental Flutter Web package that lets the browser own the outermost scroll.
Built on top of Mouad Debbar's flutter-browser-scroll proof of concept. Thank you.
This package is web-only. BrowserScroller uses web DOM APIs through its default JsViewScroller, so non-web platforms are not supported.
Letting the browser scroll the page exercises parts of the Flutter web engine that a regular Flutter app never reaches, so this package evolves with Flutter releases. Two engine fixes it relies on have not reached every channel yet:
| Engine fix | Stable 3.47 | Beta 3.50 | Master |
|---|---|---|---|
| #193744: on iOS, the Flutter view keeps its old height when the browser toolbar collapses, leaving a white strip at the bottom | Not fixed | Fixed | Fixed |
| #194109: on Android, a drag can lose its pointer up, which leaves the page unable to scroll until reload | Not fixed | Not fixed | Fixed |
- CI runs the tests on stable and beta.
- Each release notes in the changelog the Flutter version it was tested on.
- Until 1.0, a minor release can include breaking changes.
- When a fix reaches stable, a release raises the minimum Flutter version and removes the matching workaround.
When you report a bug, include your Flutter version and channel.
Comprehensive A/B demos are deployed, all built with Flutter 3.47.7 stable:
- After (package applied): https://flutter-demo-26-after.web.app
- Before (no package, same UI): https://flutter-demo-26-before.web.app
- After, semantics on: https://flutter-demo-26-semantic-after.web.app
- Before, semantics on: https://flutter-demo-26-semantic-bef.web.app
Compare inner-list overscroll chaining, the RefreshIndicator flow, iframes and platform views, keyboard scroll, programmatic scroll, and a horizontal carousel between each After and Before pair. Source at example/lib/comprehensive.dart.
The semantics demos enable semantics at startup, so Flutter builds the accessibility tree a screen reader would use, without needing one. Source at example/lib/comprehensive_semantics.dart and example/lib/comprehensive_semantics_before.dart.
To see the package with both engine fixes from Flutter version support, try the same demo built with Flutter master: https://flutter-demo-82-after.web.app
Until this package is published to pub.dev, add it as a Git dependency:
dependencies:
flutter_browser_scroll:
git:
url: https://github.com/flutter-zl/flutter-browser-scroll.git
ref: v0.3.0The target mental model:
Browser owns outer scroll
|
v
Package listens to the browser scroll event
|
v
Flutter ScrollController syncs to the reported position
No platform-view scroll reimplementation. No wheel interception. The package only adds the small Flutter-side bridges the browser cannot infer from canvas-painted scrollables.
Because the browser owns the page scroll, a few things differ from a normal ScrollController:
animateTouses the browser's native smooth scroll. Thedurationandcurveyou pass are ignored.- Browser scrolls do not emit
ScrollNotifications. Widgets that depend on them, such as an auto-hidingScrollbaror a FAB that hides on scroll, will not react to page scrolls. Usecontroller.addListenerinstead. - Inner scrollables default to
ClampingScrollPhysicson every platform, so their edge overscroll can hand off to the page. Setphysicsexplicitly to override.
- On Android with Flutter beta 3.50, if the first gesture after a page load is a long drag on an inner Flutter list, the page can stop scrolling until it is reloaded. Flutter removes its accessibility placeholder from under the finger mid-drag, and the browser then never reports the finger lifting. Flutter stable 3.47 can hit this too, but rarely. #194109 fixes it in Flutter and is on master.
- On iOS Safari and Chrome, an embedded HTML scroller, such as an iframe or an element with
overflow: auto, hands off to the page only after it stops moving. A swipe that starts while it is still coasting or bouncing at its edge scrolls it again instead of the page. This is how WebKit scrolls nested HTML scrollers, also on pages without Flutter, and the package does not change how touches inside platform views scroll.
Wrap your page content in BrowserScroller, using a Column rather than a ListView. The browser does the scrolling, so the whole page is built at once, and a ListView here throws an error.
import 'package:flutter/material.dart';
import 'package:flutter_browser_scroll/flutter_browser_scroll.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: BrowserScroller(
child: Column(
children: <Widget>[
for (int i = 0; i < 100; i++) ListTile(title: Text('Item $i')),
],
),
),
),
);
}
}When a button or other widget needs to scroll the page, pass a BrowserScrollController to BrowserScroller. It works like a normal ScrollController, with the differences listed under Behavior differences.
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final BrowserScrollController _controller = BrowserScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: BrowserScroller(
controller: _controller,
child: Column(
children: <Widget>[
for (int i = 0; i < 100; i++) ListTile(title: Text('Item $i')),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
},
child: const Icon(Icons.arrow_upward),
),
),
);
}
}The browser keeps scrolling the page under a Flutter modal unless you tell it not to. Add BrowserScrollRouteObserver to freeze the page while any showDialog, showModalBottomSheet, PopupMenuButton, or DropdownButton is open:
MaterialApp(
navigatorObservers: <NavigatorObserver>[
BrowserScrollRouteObserver(_controller),
],
home: ...,
)MenuAnchor and DropdownMenu do not open a route, so the observer does not see them. For those and other custom cases, call _controller.lockPageScroll() and unlockPageScroll() directly. Locks nest, so the page stays frozen until the last one is released.
A plain inner vertical Flutter scrollable inside BrowserScroller works without any wrapper. Give it a bounded height, such as a SizedBox. Top-edge overscroll chains to the page only during an active drag, so a fling that reaches the top does not move the page. Bottom-edge overscroll chains during both drag and the fling that follows it.
For a RefreshIndicator, wrap the inner scrollable in BrowserScrollChild(preserveTopOverscroll: true, ...) so a pull-down at the top arms refresh instead of scrolling the page:
SizedBox(
height: 400,
child: RefreshIndicator(
onRefresh: _onRefresh,
child: BrowserScrollChild(
preserveTopOverscroll: true,
child: ListView.builder(
primary: false,
itemCount: 50,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text('Refresh item $index'));
},
),
),
),
)- The page's scroll height grows as the user reaches more content, with up to one screen of lookahead.
animateToandjumpTodelegation to browser scroll.- Edge overscroll from inner vertical Flutter scrollables continues into the page.
- Touches that start on an inner vertical Flutter scrollable do not also pan the page on mobile browsers.
- Page scroll lock while a popup route is open, via
BrowserScrollRouteObserver, or on demand withlockPageScroll. - Demo covering inner lists, pull-to-refresh, iframes, same-origin HTML, keyboard scroll, overlays, programmatic scroll, and a horizontal carousel.