自分の壁紙アプリの一覧画面を作り直していたときのことです。縦長の写真も、横長のイラストも、正方形のミニマルな図案も、同じ棚に並びます。ところが FlatList に numColumns={2} を渡しただけの素朴なグリッドでは、行ごとに背の高い画像へ高さが揃ってしまい、隣の低い画像の下に大きな余白が空きました。指を滑らせるたびに、その余白が目に入ります。
壁紙アプリにとって一覧画面は商品棚そのものです。余白の連なりは、そのまま「雑に並べた」という印象になります。Pinterest のように、高さの違う画像を隙間なく積み上げたい。いわゆる段組み(メイソンリー)レイアウトです。ここでは、私が実際に個人開発で組み直した順序で、つまずいた箇所まで含めて書いていきます。
なぜ numColumns では壁紙ギャラリーが間延びするのか
FlatList の numColumns は、内部的にデータを「行」の単位でまとめて描画します。1行に2枚ずつ並べる場合、その行の高さは背の高いほうの画像に引きずられます。低いほうの画像は上に寄せられ、下に余白が残ります。
これは不具合ではなく、グリッドという仕組みそのものの性質です。すべての画像が同じ縦横比なら問題は起きません。壁紙のように縦・横・正方形が混ざる瞬間に、余白が生まれます。
段組みが必要かどうかの分岐点は、はっきりしています。画像の縦横比が揃っているならグリッドで十分です。縦横比がばらつき、かつ「隙間なく詰めた密度」が体験の質に直結するなら、段組みへ移る価値があります。壁紙・写真・作品ポートフォリオはその典型です。
段組みの考え方は、行で揃えるのをやめて、列ごとに独立して積むことです。2列なら、次の画像を「今いちばん背の低い列」へ置く。これを繰り返すだけで、両列の高さが自然に近づき、下端の凸凹が最小になります。
画像の縦横比を先に持っておく
段組みで最初に必要になるのは、各画像の縦横比です。ここを描画時に画像を読み込んでから測る設計にすると、レイアウトが二段階になり、スクロール中にカクつきます。読み込む前から高さが決まっていないと、列の振り分けができないためです。
対策はひとつで、縦横比をデータ側に最初から持たせることです。私は壁紙のメタデータに width と height を保存しておき、一覧の API がそれを返すようにしました。CMS や Supabase のテーブルに列を足すだけで済みます。
持たせる場所 利点 注意点
API/DB のメタデータ 描画前に確定・最速 登録時に寸法を記録する運用が要る
ファイル名やパスに埋め込む DB 変更が不要 命名規約が崩れると破綻する
初回に一度だけ計測してキャッシュ 既存データを変えずに済む 初回だけレイアウトが揺れる
型としては、次のように最小限を持てば十分です。
type Wallpaper = {
id : string ;
uri : string ;
width : number ; // 元画像のピクセル幅
height : number ; // 元画像のピクセル高さ
};
width と height の実数値そのものは使いません。必要なのは比だけです。ただし比を都度計算するより、元寸を持っておくほうが、後でサムネイル生成やダウンロードサイズの判断にも流用できます。
列の高さを見て振り分ける(段組みの心臓部)
ここが段組みの心臓部です。画像を1枚ずつ見て、そのとき最も背の低い列へ入れていきます。列の「高さ」は、列幅を 1 とみなしたときの相対的な積み上げ、つまり height / width の合計で近似できます。実ピクセルを気にする必要はありません。
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) {
// 列幅を1に正規化したときの、この画像の相対的な高さ
const ratio = item.height / item.width;
// いま最も背の低い列を探す
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;
}
この「最短の列へ置く」方式は、貪欲法と呼ばれる素朴なやり方です。厳密に両列の高さを最小化する最適解ではありませんが、壁紙一覧のように画像点数が多い場面では、見た目にはほとんど最適と区別がつきません。実装の単純さと結果の美しさの釣り合いが良く、まずはこの貪欲法を推奨します。私はこれを標準にしています。
描画側は、各列を縦に積むだけです。画像の高さは aspectRatio に任せ、自前で計算した数値を渡さないのがコツです。React Native の aspectRatio は幅に対する高さを自動で決めてくれるため、幅 100% を指定すれば高さは追随します。
import { Image } from "expo-image" ;
import { ScrollView, View } from "react-native" ;
import { useMemo } from "react" ;
export function MasonryGallery ({ items } : { items : Wallpaper [] }) {
const columns = useMemo (() => splitIntoColumns (items, 2 ), [items]);
return (
< ScrollView contentContainerStyle = { { flexDirection: "row" , gap: 8 , padding: 8 } } >
{ columns. map (( col , index ) => (
< View key = { index } style = { { flex: 1 , gap: 8 } } >
{ col. map (( w ) => (
< Image
key = { w.id }
source = { { uri: w.uri } }
style = { {
width: "100%" ,
aspectRatio: w.width / w.height, // 幅/高さ を渡す
borderRadius: 12 ,
} }
contentFit = "cover"
recyclingKey = { w.id }
transition = { 200 }
/>
)) }
</ View >
)) }
</ ScrollView >
);
}
ここで expo-image を選んでいるのは、recyclingKey と組み込みのメモリ/ディスクキャッシュがあるためです。段組みは画面内に多くの画像を同時に見せるので、キャッシュの効きがそのまま体感速度になります。
FlashList の masonry で仮想化を保つ
上の ScrollView 版には弱点があります。すべての画像を一度に描画するため、点数が数百を超えると初期描画とメモリが重くなります。仮想化、つまり画面外の要素を描画から外す仕組みがありません。
点数が多い一覧では、@shopify/flash-list の段組みモードを使うのが現実的です。列の振り分けはライブラリ側が担い、こちらは縦横比を渡すだけで済みます。
import { FlashList } from "@shopify/flash-list" ;
import { Image } from "expo-image" ;
export function MasonryList ({ items } : { items : Wallpaper [] }) {
return (
< FlashList
data = { items }
numColumns = { 2 }
masonry
optimizeItemArrangement
estimatedItemSize = { 220 }
keyExtractor = { ( w ) => w.id }
contentContainerStyle = { { padding: 8 } }
renderItem = { ({ item }) => (
< Image
source = { { uri: item.uri } }
style = { {
width: "100%" ,
aspectRatio: item.width / item.height,
borderRadius: 12 ,
margin: 4 ,
} }
contentFit = "cover"
recyclingKey = { item.id }
transition = { 200 }
/>
) }
/>
);
}
optimizeItemArrangement を有効にすると、FlashList は先ほどの貪欲法に近い振り分けを内部で行い、列の高さを均そうとします。仮想化を保ったまま段組みの見た目が得られるため、無限スクロールの壁紙一覧では、私はほぼこちらを選びます。
ひとつ注意点があります。masonry モードでは、行という概念が消える代わりに、各アイテムの高さがまちまちになります。estimatedItemSize はスクロール位置の見積もりに使われるので、実際のサムネイルの平均高さに近い値を入れておくと、スクロールバーの挙動が安定します。極端にずれた値のままだと、素早くスクロールしたときに一瞬の空白が出ます。
依存を増やさずに組む場合の現実的な落としどころ
FlashList を足せない事情もあります。既存アプリの依存を増やしたくない、あるいは Rork が生成した構成に手を入れる範囲を最小にしたい、といった場面です。
この場合は、先ほどの splitIntoColumns を使った ScrollView 版を、点数で線引きして使うのを推奨します。私の目安は次のとおりです。
同時に扱う画像点数 推奨 理由
おおむね60点まで ScrollView + splitIntoColumns 仮想化なしでも初期描画が軽い・依存ゼロ
数百点・無限スクロール FlashList masonry 仮想化でメモリと初期描画を抑える
依存ゼロ版でも、ページングと組み合わせれば実用に耐えます。1ページ40点ほどに区切り、末尾で次ページを読み込む形にすれば、ScrollView でも破綻しません。読み込み済みのページを列配列にそのまま連結していくと、段組みの積み上げが崩れないのも利点です。
もうひとつ、依存版・FlashList 版のどちらでも効く小さな工夫があります。次に見えるであろう画像を、スクロール前に先読みしておくことです。
import { Image } from "expo-image" ;
// 次ページのサムネイルを、表示より一歩先に温めておく
export function prefetchNext ( items : Wallpaper []) {
Image. prefetch (items. map (( w ) => w.uri));
}
一覧をスクロールし切る前に次ページを prefetch しておくと、読み込み中のグレー地が目に入る頻度が下がります。壁紙アプリでは、この「常に絵が置いてある」感触が、そのまま質の印象につながります。
段組みへ移すときの手順は、次の3ステップに整理できます。
画像のメタデータに width と height を持たせ、一覧 API がそれを返すようにします。
点数が少なければ splitIntoColumns と ScrollView で組み、多ければ FlashList の masonry に切り替えます。
次ページのサムネイルを prefetch で先読みし、読み込み中の余白を減らします。
計測して初めて分かったこと
数値で見ると、段組みへ移した効果ははっきりしていました。以下は私の壁紙アプリの一覧画面を、実機(iPhone 上位機)で比べたときの手元の計測です。厳密なベンチマークではなく、同じ200点の一覧を同じ条件で並べ替えた比較として捉えてください。
指標 numColumns グリッド FlashList masonry
画面あたりの平均余白率 約22% 約4%
初期描画までの体感 ひと呼吸置く ほぼ即時
一覧からの詳細遷移率(自分の計測) 基準 1.3倍前後
余白率が下がったこと以上に、詳細画面への遷移が増えたのが印象的でした。隙間なく詰まった一覧は、単に見栄えが良いだけでなく、一画面に載る点数が増えます。同じスクロール量で出会える壁紙が増えれば、気に入った一枚に当たる確率も上がる。当たり前のようでいて、実際に数字が動いたときは胸が少し熱くなりました。
段組みは派手な技術ではありません。けれど、縦横比のばらつく画像を扱うアプリでは、体験の density を静かに底上げしてくれます。もし壁紙や写真の一覧で余白が気になっているなら、まず splitIntoColumns の数行から試してみてください。実装の参考になれば幸いです。お読みいただきありがとうございました。