skip to content

Why does a Flutter list bounce past its edge on iOS but stop at the edge on Android, and how do you change that?

level: middleimportance: should knowfreq 45%

answer

  1. ScrollBehavior picks physics per platform
  2. Bouncing on iOS and macOS
  3. Clamping plus stretch indicator elsewhere
  4. physics: wraps the default via applyTo
  5. AlwaysScrollable for short lists

basics

~10 s

The inherited ScrollBehavior chooses physics by platform: BouncingScrollPhysics on iOS and macOS, ClampingScrollPhysics on Android, Windows and Linux, where Material 3 adds a stretch indicator. Override per list with physics: or app-wide through MaterialApp.scrollBehavior.

solid answer

~40 s

A `Scrollable` asks the nearest `ScrollBehavior` (from `ScrollConfiguration`, which `MaterialApp` provides as `MaterialScrollBehavior`) for its physics. `getScrollPhysics` returns `BouncingScrollPhysics` on iOS and macOS and `ClampingScrollPhysics` on Android, Fuchsia, Linux and Windows, each over `RangeMaintainingScrollPhysics`. Bouncing physics let the position go past the edge and spring back; clamping physics stop it and report an overscroll, which Material 3 shows with a `StretchingOverscrollIndicator` (Material 2 used a glow). To change one list, pass `physics:`; it is applied on top of the platform default, so chain with `parent:` where needed. To change the whole app, set `scrollBehavior` with `copyWith(physics: ...)`. One gotcha: a vertical list without a controller defaults to `AlwaysScrollableScrollPhysics`, but once you give it a `ScrollController` a short list stops accepting drags unless you add that physics back.

code

dart · 17 lines
dart
import 'package:flutter/material.dart';

// One list: bounce everywhere, even when the thread is short.
final Widget thread = ListView.builder(
  controller: threadController,
  physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
  itemCount: posts.length,
  itemBuilder: (context, index) => ListTile(title: Text(posts[index])),
);

// Whole app: override only the physics, keep Material's other defaults.
final Widget app = MaterialApp(
  scrollBehavior: const MaterialScrollBehavior().copyWith(
    physics: const ClampingScrollPhysics(),
  ),
  home: const ForumHomePage(),
);

go deeper

for a junior

Know that iOS and macOS bounce and Android clamps, and that the physics parameter changes it.

for a middle

Explain ScrollBehavior, per-platform physics, parent chaining, and that the stretch is an overscroll indicator.

for a senior

Diagnose the short-list drag problem after adding a controller, and choose app-wide versus per-list overrides deliberately.

for a principal

Decide how far the app follows each platform's scroll conventions versus one shared feel, and document it.

## Where physics come from **`ScrollPhysics`** decides how a scroll position responds to input: whether a drag past the edge is accepted, what happens at the boundary, and which simulation runs after a fling. A scroll view rarely picks physics itself. The `Scrollable` inside it asks the nearest **`ScrollBehavior`**, provided by a `ScrollConfiguration` widget. `MaterialApp` installs `MaterialScrollBehavior`, `CupertinoApp` its Cupertino equivalent. The base `ScrollBehavior.getScrollPhysics` switches on the target platform: | Platform | Default physics | |---|---| | iOS | `BouncingScrollPhysics(parent: RangeMaintainingScrollPhysics())` | | macOS | `BouncingScrollPhysics` with `ScrollDecelerationRate.fast`, over `RangeMaintainingScrollPhysics` | | Android, Fuchsia, Linux, Windows | `ClampingScrollPhysics(parent: RangeMaintainingScrollPhysics())` | `RangeMaintainingScrollPhysics` keeps the position stable when content changes size under it. ## Bouncing versus clamping - **`BouncingScrollPhysics`** accepts movement past the edge with increasing resistance, then runs a spring back. Its `applyBoundaryConditions` returns 0, so no `OverscrollNotification` is sent; the position is simply out of range for a moment. - **`ClampingScrollPhysics`** refuses movement past the edge. The rejected amount is reported as an `OverscrollNotification`, and the overscroll indicator turns it into visual feedback. - On Android, `MaterialScrollBehavior` wraps scrollables in a **`StretchingOverscrollIndicator`** when `ThemeData.useMaterial3` is true (the default) and a `GlowingOverscrollIndicator` otherwise. The stretch is not physics; it is a paint effect driven by the overscroll notifications. ## Changing it 1. **One list:** `physics: const BouncingScrollPhysics()`. The `Scrollable` applies the widget's physics *on top of* the behavior's physics with `applyTo`, so the platform default becomes the ancestor of yours. 2. **Combining behaviours:** chain with `parent:`, for example `BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics())` to bounce even when the content is shorter than the screen. 3. **The whole app:** `MaterialApp(scrollBehavior: const MaterialScrollBehavior().copyWith(physics: const BouncingScrollPhysics()))`. `copyWith` also takes `scrollbars` and `overscroll` flags. 4. **A subtree:** wrap it in `ScrollConfiguration(behavior: ..., child: ...)`. Do not force one platform's feel on the other without a design reason; users notice a list that bounces like iOS on Android. ## The short-list gotcha Whether a drag is accepted at all is `shouldAcceptUserOffset`. The base rule accepts it only if there is somewhere to scroll or the position is already off zero, so a list shorter than the viewport ignores drags. `AlwaysScrollableScrollPhysics` overrides that to always accept. A `ScrollView` picks `AlwaysScrollableScrollPhysics` for you when it is the **primary** scroll view: vertical and with no `controller`. The moment you pass your own `ScrollController`, that implicit default disappears. A short thread that could be pulled before — to trigger pull-to-refresh, or just to bounce on iOS — now does not move. The fix is `physics: const AlwaysScrollableScrollPhysics()` (or a bouncing parent chain) on that list. ## Summary of the moving parts - `ScrollConfiguration` supplies a `ScrollBehavior`. - `ScrollBehavior.getScrollPhysics` picks physics per platform. - `physics:` on the scroll view is layered over it. - `buildOverscrollIndicator` adds the stretch or glow; `buildScrollbar` adds a desktop scrollbar. - `AlwaysScrollableScrollPhysics` restores drag acceptance for short content.

  • Why does an iOS-style list not send OverscrollNotification when pulled past the top?
    `BouncingScrollPhysics.applyBoundaryConditions` returns 0, so the physics never reject movement; the position just goes out of range and springs back. Listeners see `ScrollUpdateNotification`s with `metrics.outOfRange` true instead. Clamping physics reject the excess and report it as `OverscrollNotification`.
  • After adding a ScrollController, a short list no longer responds to drags. Why?
    Without a controller a vertical `ScrollView` is primary and defaults to `AlwaysScrollableScrollPhysics`. With a controller that default goes away, and the base physics accept a drag only when there is extent to scroll. Add `physics: const AlwaysScrollableScrollPhysics()` to restore it.

saying these in an interview costs you the question

  • Flutter reads the scroll feel from the native OS list widget.
  • The Android stretch effect is a kind of ScrollPhysics.
  • Setting physics: replaces the platform default entirely.
  • Every list scrolls even when its content is shorter than the screen.
  • Bouncing physics report overscroll with OverscrollNotification.