Loading devShakib…
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.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:
SliverFixedExtentList exists and is fast.masonry_kit does.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.