●PUBLISH — Rork Max handles code signing and provisioning so you can submit to the App Store in two clicks●SIMULATOR — A browser streaming simulator feels close to real hardware, and a QR code installs to your device without TestFlight●MAX — Rork Max generates native Swift apps, powered by Claude Code and Claude Opus 4.6●NATIVE — Rork Max reaches Xcode-class territory: AR/LiDAR, Metal 3D, widgets, Live Activities, HealthKit, and Core ML●FUNDING — Rork raised $15M to power the next generation of App Store entrepreneurs●PRICE — Plans start free, paid tiers from $25/month, and Rork Max at $200/month●PUBLISH — Rork Max handles code signing and provisioning so you can submit to the App Store in two clicks●SIMULATOR — A browser streaming simulator feels close to real hardware, and a QR code installs to your device without TestFlight●MAX — Rork Max generates native Swift apps, powered by Claude Code and Claude Opus 4.6●NATIVE — Rork Max reaches Xcode-class territory: AR/LiDAR, Metal 3D, widgets, Live Activities, HealthKit, and Core ML●FUNDING — Rork raised $15M to power the next generation of App Store entrepreneurs●PRICE — Plans start free, paid tiers from $25/month, and Rork Max at $200/month
Laying Out Variable-Height Images in Two Columns: A Masonry Wallpaper Gallery in a Rork Expo App
From why numColumns cannot pack variable-aspect images cleanly, to a dependency-free column-balancing algorithm, to keeping virtualization with FlashList masonry and a pragmatic no-dependency fallback, building a wallpaper gallery with real code.
I was rebuilding the grid screen of my own wallpaper app. Tall photos, wide illustrations, and square minimal designs all share one shelf. With a naive grid built by handing numColumns={2} to a FlatList, each row's height was pulled up to the taller image, and a large gap opened below its shorter neighbor. Every swipe brought that gap back into view.
For a wallpaper app, the gallery is the shelf itself. A run of gaps reads as "arranged carelessly." I wanted the images packed together like Pinterest, with no wasted vertical space. That is a masonry layout. Here is the order I actually followed as an indie developer, including where I tripped.
Why numColumns leaves a wallpaper gallery looking sparse
FlatList with numColumns groups your data into rows internally. When you place two per row, that row's height is dictated by the taller of the two images. The shorter one is aligned to the top, and space is left below it.
This is not a bug; it is the nature of a grid. If every image shares the same aspect ratio, nothing goes wrong. The moment portrait, landscape, and square are mixed, as wallpapers are, the gaps appear.
The decision point is clear. If your aspect ratios are uniform, a grid is enough. If they vary and a "densely packed" feel is tied to the quality of the experience, it is worth moving to masonry. Wallpapers, photos, and work portfolios are the classic cases.
The idea behind masonry is to stop aligning by rows and instead stack each column independently. With two columns, you place the next image into whichever column is currently shortest. Repeat that, and the two columns naturally converge in height, minimizing the ragged bottom edge.
Have each image's aspect ratio ahead of time
The first thing masonry needs is each image's aspect ratio. If you design it so the ratio is measured only after the image loads at render time, layout becomes two-phase and stutters during scrolling. You cannot assign an image to a column until its height is known.
There is one clean answer: carry the aspect ratio in the data from the start. I store width and height in each wallpaper's metadata and have the list API return them. Adding a column to a CMS or Supabase table is enough.
You will not use the raw width and height values themselves; only the ratio matters. Still, keeping the source dimensions is convenient later for thumbnail generation and download-size decisions.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How numColumns creates empty space with variable-height images, and when masonry becomes worth it
✦A dependency-free column-balancing algorithm that places each image into the shortest column, with working code
✦Keeping virtualization with FlashList masonry, plus a realistic fallback when you cannot add a dependency
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the heart of masonry. You look at one image at a time and drop it into the column that is currently shortest. A column's "height" can be approximated by the sum of height / width, treating the column width as 1. Actual pixels do not matter here.
export function splitIntoColumns( items: Wallpaper[], columnCount: number,): Wallpaper[][] { const columns: Wallpaper[][] = Array.from( { length: columnCount }, () => [], ); const heights = new Array(columnCount).fill(0); for (const item of items) { // This image's relative height when the column width is normalized to 1 const ratio = item.height / item.width; // Find the currently shortest column let target = 0; for (let i = 1; i < columnCount; i++) { if (heights[i] < heights[target]) target = i; } columns[target].push(item); heights[target] += ratio; } return columns;}
This "place into the shortest column" approach is a simple greedy method. It is not the exact optimum for minimizing the difference between column heights, but with the many images a wallpaper list carries, the result is visually indistinguishable from optimal. The balance between implementation simplicity and a clean result is good, so I keep it as my default.
On the render side, you just stack each column vertically. Let aspectRatio handle the image height rather than computing a number yourself. React Native's aspectRatio derives the height from the width, so a width of 100% lets the height follow.
I reach for expo-image here because of recyclingKey and its built-in memory and disk cache. Masonry shows many images on screen at once, so cache effectiveness translates directly into perceived speed.
Keeping virtualization with FlashList masonry
The ScrollView version above has a weakness. It renders every image at once, so beyond a few hundred items the initial render and memory grow heavy. There is no virtualization to drop off-screen elements from rendering.
For large lists, using the masonry mode of @shopify/flash-list is the pragmatic choice. The library handles column assignment, and you only pass the aspect ratio.
With optimizeItemArrangement enabled, FlashList performs an assignment close to the greedy method above and tries to even out the column heights. You get the masonry look while keeping virtualization, so for an infinite-scroll wallpaper list I almost always choose this.
One caveat: in masonry mode the concept of a row disappears, and each item's height varies. estimatedItemSize is used to estimate scroll position, so setting it near your thumbnails' actual average height keeps the scrollbar behavior stable. Leave it wildly off and a brief blank flashes when you scroll quickly.
A realistic fallback when you cannot add a dependency
There are reasons you may not be able to add FlashList: you do not want to grow an existing app's dependencies, or you want to keep changes to a Rork-generated setup minimal.
In that case, using the ScrollView version with splitIntoColumns, drawing a line by item count, is realistic. My rule of thumb is as follows.
Images handled at once
Recommendation
Reason
Up to roughly 60
ScrollView + splitIntoColumns
Light initial render even without virtualization, zero dependencies
Hundreds, infinite scroll
FlashList masonry
Virtualization keeps memory and initial render down
Even the zero-dependency version holds up in practice when combined with paging. Break the data into pages of about 40 items and load the next page near the end, and ScrollView will not fall apart. Concatenating each loaded page onto the column arrays keeps the masonry stacking intact, which is a nice bonus.
One more small trick works in both versions: warm up the images likely to appear next, before you scroll to them.
import { Image } from "expo-image";// Warm the next page's thumbnails a step ahead of displayexport function prefetchNext(items: Wallpaper[]) { Image.prefetch(items.map((w) => w.uri));}
Prefetching the next page before you scroll through the current one reduces how often the loading gray shows. In a wallpaper app, that "there is always artwork in place" feeling maps straight onto the sense of quality.
What I only learned by measuring
The numbers made the effect of moving to masonry clear. Below is a hand measurement comparing my wallpaper app's gallery on a device (a recent high-end iPhone). Treat it not as a rigorous benchmark but as a comparison of the same 200-item list rearranged under the same conditions.
Metric
numColumns grid
FlashList masonry
Average empty-space ratio per screen
~22%
~4%
Perceived time to first render
A beat of hesitation
Nearly instant
Tap-through from list to detail (my measurement)
Baseline
About 1.3x
More than the drop in empty space, the rise in taps through to the detail screen stood out. A tightly packed list is not only better looking; it fits more items per screen. If more wallpapers meet the eye for the same scroll distance, the odds of landing on a favorite rise too. It sounds obvious, yet when the number actually moved, I felt a quiet warmth.
Masonry is not a flashy technique. But for an app that handles images of varying aspect ratios, it quietly raises the density of the experience. If empty space bothers you in a wallpaper or photo list, start with the few lines of splitIntoColumns. I hope it helps with your own implementation. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.