flutter staggered grid view does 1.18M downloads a month and jumps you backwards with two grids in one scroll view. Here is the cause, measured, and the fix.
Put two SliverMasonryGrids in one CustomScrollView, scroll down into the
second, and the view throws you backwards into the first.
It has been reported four times —
#265,
#244,
#286,
#338 — the
earliest in 2022. flutter_staggered_grid_view does about 1.18 million
downloads a month and has not had a commit since July 2023.
So this is not a bug that is about to be fixed, and it is worth understanding
rather than working around, because the cause explains a whole class of sliver
problems.
I ran both packages through the same harness: identical widget tree, identical
drag gestures, dragging forward 45 times, counting how often the scroll offset
moved backwards when the finger only ever moved forwards.
| Grids in one CustomScrollView | flutter_staggered_grid_view 0.7.0 | masonry_kit |
| --- | --- | --- |
| one | no backward jumps | no backward jumps |
| two | 2 jumps, worst 3,400px | none |
| four | 3 jumps, worst 2,200px | none |
One grid is fine in both. The failure needs a second grid below the first, which
is exactly why it survived three years of bug reports without a fix — the
simplest reproduction anyone writes has a single grid in it.
A masonry grid has no rows. Each column is a separate stack of tiles at
independent heights, so where any given tile lands depends on the heights of
everything that came before it in that column.
A lazy sliver only builds what is visible. So while you scroll, the grid is
constantly discovering tile heights it did not know about, and the column
heights it is balancing against keep changing. Each time it learns something
new, it revises its layout.
With one grid, revising is harmless. Everything after it in the scroll view is
below the fold, so nudging the total height changes nothing you can see.
With two, it is not harmless at all. The first grid's total height is the second
grid's starting offset. When the first grid revises its height mid-scroll —
because it just learned a real height for a tile it had estimated — every offset
below it shifts. The viewport is holding a scroll position expressed in those
offsets. The position it was holding now points somewhere else, and the content
jumps.
Worst case in my harness: 3,400 pixels backwards, from a forward drag.
The obvious mitigation is to estimate better. Give the sliver a
prototypeItem, or an average extent, so its guesses are closer and the
revisions are smaller.
It helps and it does not fix it, for a reason worth internalising: **the size of
the jump is not what makes it a bug.** A 40-pixel correction while you are
scrolling is still a correction. The viewport still moves in a direction your
finger did not. The user still notices, because human vision is far better at
detecting unexpected motion than at judging its magnitude.
Estimation also fails hardest exactly where masonry is used. A masonry layout
exists because tiles have different heights — photos of varying aspect ratios,
cards with different amounts of text. A prototype item assumes a typical height,
and the whole premise of the layout is that there isn't one.
To see why this is structural rather than a tuning problem, it helps to know
what a scroll offset means.
A Scrollable holds a single number: how far the viewport has travelled from
the start of its content, in logical pixels. That number is the only thing
anchoring what you see. It is not "the third grid, second row" — it is 18432.0.
Slivers are laid out in order, each reporting a scrollExtent. To find what
belongs at offset 18432, the viewport walks the sliver list accumulating extents
until it reaches that point.
Now change the first sliver's extent from 9,000 to 9,400 because it just measured
four tiles it had estimated. Offset 18432 now lands 400 pixels earlier in the
content. Nothing moved in your data. Nothing moved on your finger. But the
mapping from number to content changed underneath the number, so the content
appears to jump.
That is the whole bug, and it explains the shape of the reports: it needs a
second grid because you need content below a revising sliver for anyone to
notice.
masonry_kit computes the layout once, in one pass, and never changes its mind.
A tile's position is decided when it is first laid out and it stays there. The
grid's reported height is monotonic — it grows as more is discovered and never
shrinks or shifts what came before.
That means the second grid's starting offset is stable from the first frame it
exists, so there is nothing for the viewport to be thrown by.
MasonryGridView.count( crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, itemCount: photos.length, itemBuilder: (context, index) => Photo(photos[index]),);
The trade is real and worth stating: because positions are final, a tile that
turns out much taller than estimated leaves its column slightly less balanced
than a revising layout would. In exchange, scrolling is correct. For a feed —
which is what almost every masonry layout is — that is the right side of the
trade, because a user notices being thrown backwards instantly and never notices
a column being forty pixels uneven.
The rule is narrower than "never recompute anything", so it is worth being
precise about what is and is not fixed.
Fixed once decided: a tile's column, and its offset within that column.
Once tile 40 has been placed in column 2 at offset 3,180, it stays there for the
lifetime of that layout. Anything already laid out above your viewport cannot
move.
Still free to change: anything not yet placed. Tiles below the fold have not
been assigned a column, so the balancing algorithm is working with complete
information for everything it has actually seen.
Recomputed deliberately: a change in crossAxisCount — a rotation, a window
resize, a breakpoint — invalidates the whole layout, because a two-column grid
and a four-column grid genuinely are different layouts. That is a reflow, and it
is expected; the guarantee is about scrolling, not about resizing.
That distinction is why the height is monotonic. It grows as more is
discovered. It never shrinks, and it never redistributes what came before —
which is exactly the property the viewport needs to keep its offset meaningful.
If you would rather not take a table on trust, the reproduction is small:
CustomScrollView with two SliverMasonryGrids, 200 tiles each.100 + (index * 37) % 220 is enough.ScrollController and log the offset on every change.drag direction is positive.
The negative delta is the whole test. You do not need to catch it by eye, and
you should not try — logging it is more reliable and gives you the magnitude,
which is how the 3,400px figure above was measured.
If you want a harness that already does this, it is in the masonry_kit
repository and it runs against both packages, so the comparison is reproducible
rather than asserted.
If you have exactly one grid and no plans for a second, flutter_staggered_grid_view
works and is battle-tested across a million apps. Nothing here says otherwise.
The problem is specific: two or more grids in one scroll view, which happens
the moment you build a page with sections — a "Recent" grid and a "Popular" grid,
a category header between two masonry blocks, a search screen with grouped
results.
That is when you hit it, and that is when it is unfixable from the outside,
because the revising behaviour is the whole design.
The specific bug is about masonry grids. The principle generalises to anything
you build with slivers.
**A sliver that revises its scrollExtent after other content has been laid out
below it will move that content.** If you write a custom sliver — a collapsing
header that measures its children, a grouped list that discovers section
heights, a chat that measures bubbles — that is the constraint to design around.
You get three options, and they are the only three:
the reason SliverFixedExtentList exists and is fast.
what masonry_kit does.
last, and a trap the moment someone adds a footer.
Most sliver scroll-jump bugs are option three used where option two was needed.
masonry_kit is on pub.dev — MIT, no dependencies, all
six platforms, and a perfect 160/160 pub score. It provides MasonryGridView
for the common case and the sliver directly when you are composing a
CustomScrollView yourself.
The benchmark harness is in the repository, so the table above is something you
can reproduce rather than something you have to believe.