skip to content

A Flutter page nests shrinkWrap ListViews with NeverScrollableScrollPhysics inside a SingleChildScrollView and janks with 2,000 rows; why, and how do you fix it?

level: seniorimportance: must knowfreq 55%

answer

  1. shrinkWrap sizes the list to its content
  2. unbounded parent means infinite paint extent
  3. every row built and laid out
  4. Never-scrollable just hands drags outward
  5. one scroll view, many slivers

basics

~20 s

Inside a SingleChildScrollView a shrinkWrap list gets unbounded height, so its viewport treats every row as visible and builds and lays out all 2,000. NeverScrollableScrollPhysics only passes drags to the outer view. Fix it with one scroll view: a CustomScrollView of sliver lists.

solid answer

~50 s

`shrinkWrap: true` makes a scroll view size itself to its content, using a shrink-wrapping viewport whose layout extent is the parent's max height. Inside a `SingleChildScrollView` or `Column` in a scrollable, that max height is **infinite**, so the viewport treats the whole list as on screen: every row is built, laid out and kept, and the size is recomputed as content changes. `NeverScrollableScrollPhysics` is added so the inner list stops taking drags and the outer view scrolls, but it changes nothing about the cost. With 2,000 rows that is 2,000 widgets built on the first frame. The fix is to make the page **one** scroll view: a `CustomScrollView` with a `SliverList` per section and `SliverToBoxAdapter` headers, or one `ListView.builder` over a flattened list of typed rows. `shrinkWrap` is fine for a handful of items under a bounded parent.

code

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

class StageLineup extends StatelessWidget {
  const StageLineup({super.key, required this.stages});

  final Map<String, List<String>> stages;

  @override
  Widget build(BuildContext context) {
    // Before: SingleChildScrollView > Column > ListView(shrinkWrap: true,
    //   physics: NeverScrollableScrollPhysics()) per stage: every act built.
    return CustomScrollView(
      slivers: [
        for (final MapEntry(key: stage, value: acts) in stages.entries) ...[
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
              child: Text(stage, style: Theme.of(context).textTheme.titleLarge),
            ),
          ),
          SliverList.builder(
            itemCount: acts.length,
            itemBuilder: (context, i) => ListTile(title: Text(acts[i])),
          ),
        ],
      ],
    );
  }
}

go deeper

for a junior

Know that shrinkWrap exists to size a list to its content and that it is not free.

for a middle

Explain why an unbounded parent makes every row visible to a shrink-wrapped list, and what NeverScrollableScrollPhysics does and does not change.

for a senior

Diagnose the pattern from DevTools and itemBuilder logs, and restructure into slivers or a flattened typed list.

for a principal

Add a review rule or lint for shrink-wrapped lists over user data so the pattern cannot return with growth.

## The anti-pattern A festival page shows three stages. A common first version: - `SingleChildScrollView` → - `Column` → - for each stage: a heading, then `ListView.builder(shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), ...)`. It renders correctly with 20 acts. With 2,000 it takes seconds to open and scrolls in bursts. ## What shrinkWrap actually does Normally a scroll view's viewport **expands** to the maximum space its parent allows and lazily builds only the children that intersect that space. With `shrinkWrap: true` the framework uses a **shrink-wrapping viewport** instead, which sizes itself to its content. The docs call this *significantly more expensive* because the size must be recomputed as the content changes. The decisive detail is what "space" means. The shrink-wrapping viewport lays its slivers out against the parent's **maximum** main-axis extent. Inside a `Column` inside a `SingleChildScrollView`, that maximum is **infinite**. Every row therefore falls inside the paint area, so the list builds and lays out **all** of its children on the first layout. The laziness you chose `ListView.builder` for is gone. ## What NeverScrollableScrollPhysics adds It only removes the inner list's own scrolling so the drag reaches the outer `SingleChildScrollView`. It does not change sizing or building. The pairing exists because without it the inner list would try to scroll inside a box exactly its own size. | Setting | Effect | Cost | |---|---|---| | `shrinkWrap: true` under an unbounded parent | list is as tall as all its rows | builds and lays out every row | | `shrinkWrap: true` under a bounded parent | list is at most the parent's height | still recomputes size; lazy beyond that height | | `NeverScrollableScrollPhysics` | inner list ignores drags | none by itself | | one `CustomScrollView` with `SliverList`s | one scroll, one viewport | only visible rows built | ## Diagnosing it 1. In DevTools, the first frame after navigation is very long and the widget count jumps by thousands. 2. Put a `debugPrint` in `itemBuilder`: it fires for every index at once, not as you scroll. 3. Search the codebase for `shrinkWrap: true` next to `NeverScrollableScrollPhysics` — the pair is the tell. ## Fixing it **Option A — slivers.** Replace the outer scroll view and `Column` with a `CustomScrollView`. Each stage becomes a `SliverToBoxAdapter` heading plus a `SliverList.builder`. All lists share one viewport and each builds only what is visible. `SliverMainAxisGroup` can group a heading with its list if a section needs a sticky header. **Option B — flatten.** Build one list of typed rows (`StageHeader`, `ActRow`) and render it with a single `ListView.builder` whose `itemBuilder` switches on the type. This keeps one lazy list and is easy to test. **Option C — bound it.** If the inner list genuinely should scroll on its own inside a fixed area, give it a bounded height (a `SizedBox`, or `Expanded` inside a `Column` that has a bounded height) and drop `shrinkWrap`. ## When shrinkWrap is acceptable - A short, bounded list of a few items, such as five filter options in a dialog. - A list inside a bottom sheet that should be only as tall as its content. - Never for a list whose length grows with user data.

  • Is shrinkWrap still expensive when the parent has a bounded height?
    Less so. Under a bounded parent the shrink-wrapping viewport lays out against that height, so rows beyond it are not built. It still has to recompute its own size as content changes, which the docs call significantly more expensive than expanding, so prefer a bounded, non-shrink-wrapped list or slivers.
  • Why is NeverScrollableScrollPhysics usually paired with shrinkWrap?
    A shrink-wrapped list is exactly as tall as its content, so it has nothing to scroll; left scrollable it would still claim vertical drags. Making it never-scrollable lets the drag fall through to the outer scroll view. The physics change is about input only and does not reduce the build cost.

saying these in an interview costs you the question

  • NeverScrollableScrollPhysics makes the inner list lazy again.
  • shrinkWrap only affects how the scrollbar is drawn.
  • ListView.builder stays lazy whatever its parent's constraints.
  • shrinkWrap is the standard fix for any list inside a Column.
  • The cost only shows in debug mode, so release is fine.