devShakib

Two Masonry Grids in One CustomScrollView Throw Scrolling Backwards

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.

What I measured

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.

Why it happens

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.

Why estimation makes it worse, not better

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.

What the scroll position actually is

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.

The fix is to stop revising

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.

What "computed once" means in practice

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.

Reproducing it yourself

If you would rather not take a table on trust, the reproduction is small:

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.

When you do not need this

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 wider lesson

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.

Try it

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.