スワイプして見ていた壁紙の一覧で、気になった一枚をタップして拡大し、戻るボタンを押す。すると一覧は一番上に戻っていて、さっきまで見ていた行を指でもう一度探し直す。個人開発で壁紙アプリを何本か運用していると、このひと手間がどれだけ離脱を生むかが、利用データからじわじわ見えてきます。
私自身、最初は「戻るときにスクロール位置を覚えておけばいい」とだけ考えていました。ところが実装してみると、位置がずれる場面は二つあって、それぞれ原因がまったく違うことに気づきます。一つは画面を行き来する「戻る」での消失。もう一つは、OS がメモリ不足でアプリを裏で落としたあと、再び開いたときの消失です。混ぜて考えると、どちらも中途半端にしか直りません。
二つの「位置ずれ」を最初に分ける
先に切り分けておきます。ここを曖昧にしたまま保存処理を足すと、不要な永続化でスクロールがかえってカクつきます。
場面 何が失われるか 必要な対処
詳細へ進んで戻る(スタック内) 原則は失われない。タブ切替や条件付きアンマウントで失われる 画面をアンマウントしない構成にする
アプリを閉じて開き直す(プロセス継続) 失われない(メモリ上の状態が残る) 対処不要
OS が裏で強制終了 → 再起動 メモリ上の状態がすべて消える オフセットを端末に保存して復元する
つまり、永続化が本当に要るのは三つ目だけです。一つ目はナビゲーションの構成の問題で、保存とは無関係に解けます。ここを取り違えると、毎フレーム位置をディスクに書きに行くような重い実装になりがちです。
「戻る」で先頭に飛ぶのは、たいてい画面が消えているから
ネイティブスタックのナビゲーションでは、詳細画面を上に重ねても一覧画面はマウントされたまま背後に残ります。FlatList の内部状態もそのまま残るので、戻れば同じ位置に表示されるのが本来の挙動です。
それでも先頭に飛ぶときは、一覧画面がどこかでアンマウントされています。よくあるのは次の二つです。
ひとつは、タブやセグメントの切り替えを condition ? <List/> : <Other/> のような条件付きレンダリングで実装している場合。切り替えるたびに <List/> が丸ごと作り直され、スクロール位置はゼロに戻ります。
もうひとつは、データ取得中に if (loading) return <Spinner/> で一覧そのものを差し替えている場合。再取得のたびにリストが一度消えて、再描画で先頭に戻ります。
対処はシンプルで、一覧を消さないことです。
// ❌ 切り替えのたびにListが作り直される
function Screen ({ tab } : { tab : 'all' | 'favorites' }) {
return tab === 'all' ? < WallpaperList /> : < FavoriteList />;
}
// ✅ 両方マウントしたまま、表示だけ切り替える
function Screen ({ tab } : { tab : 'all' | 'favorites' }) {
return (
<>
< View style = { { flex: 1 , display: tab === 'all' ? 'flex' : 'none' } } >
< WallpaperList />
</ View >
< View style = { { flex: 1 , display: tab === 'favorites' ? 'flex' : 'none' } } >
< FavoriteList />
</ View >
</>
);
}
display: 'none' で隠す方法なら、リストはマウントされ続けてスクロール位置を保ちます。タブが二つ三つなら、この素朴なやり方で十分実用になります。読み込み中のスピナーも、リスト全体を差し替えるのではなく、上に重ねるか、リスト内のヘッダーとして出すと位置が保たれます。
ここまでで「戻る」での位置ずれの大半は消えます。残りはプロセス復帰の方です。
プロセス復帰に備えてオフセットを持つ
OS による強制終了は、ユーザーには「アプリを閉じた」のと区別がつきません。数十分後に戻ってきて先頭から、では体験が途切れます。ここで初めて、スクロールオフセットを端末に保存する価値が出てきます。
保存するのは「画素単位のスクロールオフセット(contentOffset.y)」ではなく、「先頭に見えている項目のインデックス」にします。オフセットの実数値は、行の高さがフォントやサムネイル読み込みで微妙に変わると意味を失いますが、インデックスなら復元時に再計算できるからです。
onScroll から onViewableItemsChanged に切り替えて、先頭の可視項目を拾います。
import { useRef, useCallback } from 'react' ;
import { FlatList, ViewToken } from 'react-native' ;
import { storage } from '../lib/storage' ; // MMKV などの薄いラッパー
const KEY = 'wallpaper:list:firstIndex' ;
function WallpaperList ({ data } : { data : Wallpaper [] }) {
const firstIndex = useRef ( 0 );
const onViewableItemsChanged = useRef (
({ viewableItems } : { viewableItems : ViewToken [] }) => {
const top = viewableItems[ 0 ]?.index;
if ( typeof top === 'number' ) firstIndex.current = top;
}
).current;
// ... 描画は後述
}
onViewableItemsChanged のコールバックは安定参照でなければならない制約があるので、useRef に包んでいます。ここで値を ref に貯めておき、ディスクに書くのは別のタイミング、というのが負荷を抑える肝です。
保存は「スクロール中」ではなく「離れる瞬間」に確定する
スクロールするたびに storage.set() を呼ぶと、書き込みがスクロールのフレームに割り込んでカクつきの原因になります。私はバックグラウンドへ移る瞬間にだけ確定保存する形に落ち着きました。AppState の遷移を使います。
import { useEffect } from 'react' ;
import { AppState } from 'react-native' ;
function usePersistOnBackground ( getIndex : () => number ) {
useEffect (() => {
const sub = AppState. addEventListener ( 'change' , ( state ) => {
if (state === 'background' || state === 'inactive' ) {
storage. set ( KEY , getIndex ());
}
});
return () => sub. remove ();
}, [getIndex]);
}
プロセスが強制終了されるとき、その直前には必ず一度 background への遷移が挟まります。だからこの一回の保存で、強制終了からの復帰に必要な情報は確保できます。スクロール中に書きに行く必要はありません。画面を離れる beforeRemove を併用すれば、別画面へ深く潜るときも取りこぼしません。
復元はちらつかせない — 初回描画の前に位置を決める
保存より難しいのが復元です。素朴に「マウント後に scrollToIndex する」と、いったん先頭が描かれてから目的位置へ跳ぶので、一瞬だけ先頭がちらつきます。これを消すには、初回描画の時点で既に目的のオフセットにいる状態にします。initialScrollIndex と getItemLayout の組み合わせです。
getItemLayout を与えると、FlatList は各項目の位置を測定せずに計算で求められるため、initialScrollIndex の位置から直接描画できます。グリッド(numColumns)の場合は、行の高さから逆算します。
const COLUMNS = 3 ;
const ROW_HEIGHT = 180 ; // 1行ぶんの高さ(サムネイル + 余白)
const getItemLayout = ( _ : unknown , index : number ) => {
const row = Math. floor (index / COLUMNS );
return { length: ROW_HEIGHT , offset: ROW_HEIGHT * row, index };
};
// 復元値はデータ長でクランプする(前回より項目が減っている場合に備えて)
const saved = storage. getNumber ( KEY ) ?? 0 ;
const initialIndex = Math. min (saved, Math. max ( 0 , data. length - 1 ));
return (
< FlatList
data = { data }
numColumns = { COLUMNS }
getItemLayout = { getItemLayout }
initialScrollIndex = { initialIndex }
onViewableItemsChanged = { onViewableItemsChanged }
viewabilityConfig = { { itemVisiblePercentThreshold: 50 } }
renderItem = { renderItem }
keyExtractor = { ( item ) => item.id }
/>
);
二点、運用で踏んだ落とし穴があります。
getItemLayout の行高は実際の描画と一致していなければなりません。ずれていると、復元位置が少しずつ上下にずれます。サムネイルの縦横比が一定なら固定値で問題ありませんが、可変高なら initialScrollIndex は使えず、後述の「描画後に一度だけ跳ぶ」方式に切り替えます。
そして initialScrollIndex を使うと、対象が画面外にあるうちは中間の項目がレンダーされない関係で、稀に scrollToIndex out of range の警告が出ます。onScrollToIndexFailed を実装して、一拍おいてから scrollToOffset で逃がすのが安全です。
const listRef = useRef < FlatList >( null );
const onScrollToIndexFailed = ( info : { index : number }) => {
// 一度待ってからオフセット指定でリトライ
setTimeout (() => {
listRef.current?. scrollToOffset ({
offset: ROW_HEIGHT * Math. floor (info.index / COLUMNS ),
animated: false ,
});
}, 50 );
};
可変高のリストでは、initialScrollIndex をあきらめ、onContentSizeChange が初めて発火したときに一度だけ scrollToOffset する方式が現実的です。「一度だけ」を守るためのフラグを置くのを忘れないようにします。
const restored = useRef ( false );
< FlatList
// ...
onContentSizeChange = { () => {
if (restored.current) return ;
restored.current = true ;
listRef.current?. scrollToOffset ({ offset: savedOffset, animated: false });
} }
/>
可変高ではインデックスではなく生のオフセットを保存します。行高が一定でない以上インデックスから位置を復元できないため、ここだけは画素オフセットを持つ判断にしています。
実装を入れる順番
ここまでを実際のコードに落とすときは、次の順で進めると手戻りが少なくなります。
まずナビゲーション構成を見直し、一覧画面が条件付きレンダリングや読み込み差し替えでアンマウントされていないかを確認します。ここを直すだけで「戻る」の位置ずれはほぼ消えます。
onViewableItemsChanged で先頭の可視インデックスを ref に貯め、AppState の background 遷移でだけ端末に保存します。
マウント時に保存値をデータ長でクランプし、getItemLayout と initialScrollIndex で初回描画から目的位置に置きます。onScrollToIndexFailed のフォールバックも同時に入れておきます。
端末を強制終了させて再起動し、行がぴったり一致するかを実機で確認します。シミュレータでは強制終了の挙動が再現しづらいので、実機での確認を強くお勧めします。
この順番なら、各段階で効果を目で確かめながら進められます。私は一覧画面のリファクタを先に済ませてから永続化に進むやり方を推奨します。逆順だと、保存処理のバグなのか構成の問題なのかが切り分けにくくなるためです。
FlashList を使っているなら前提が少し変わる
@shopify/flash-list に移行している場合、getItemLayout は不要で、代わりに estimatedItemSize を与えます。復元は initialScrollIndex がそのまま使え、内部のリサイクル方式のおかげで先頭ちらつきも起きにくいです。一方で、推定サイズが実寸と大きくずれていると最初のスクロールが荒れるので、実測に近い値を入れておきます。私は壁紙グリッドを FlashList に寄せてから、復元まわりのコードがかなり短くなりました。可変高でも initialScrollIndex が効くのは大きな利点です。
どこまでやるかの線引き
すべてのリストにプロセス復帰の復元を入れる必要はありません。判断の目安はこうしています。
メインの一覧、検索結果、長くスクロールする縦長の画面には入れる価値があります。逆に、設定画面やせいぜい一画面ぶんの短いリストには入れません。復元のコードは小さくても、行高の管理やリトライの分岐といった保守コストが乗るためです。「戻る」での保持はナビゲーション構成で無料で手に入るので全画面で守り、ディスクへの永続化は本当に長いリストだけに絞る、という二段構えが、運用していて一番ほころびが少ないと感じています。
スクロール位置の保持は、入れても誰も褒めてくれない種類の機能です。けれど壊れていると、指がもう一度同じ場所を探すたびに、ほんの少しずつ信頼が削れていきます。静かに効く部分こそ丁寧に作りたい、と私は考えています。