お気に入りに登録した順番を、ユーザーが自分の手で入れ替えたい——この要望は、一覧画面を持つアプリならほぼ必ず出てきます。私自身、個人開発で運用している壁紙アプリでも「気に入った壁紙をよく使う順に並べ替えたい」という声が一定数あり、お気に入り機能を足した直後から要望が来ました。
ところが Rork に「お気に入りをドラッグで並べ替えられるようにして」と頼むと、見た目は動くものの、実機で触ると指に追従せずカクついたり、離した瞬間に元の順番へ戻ったりすることがあります。生成されたコードは FlatList の上に手書きの PanResponder を載せただけ、という構成になりがちで、ここがつまずきの入口です。
前置きは抜きにして、実際に詰まる箇所から順に直していきます。題材は React Native(Expo)版の Rork が出力するコードですが、考え方は Rork Max の SwiftUI 出力で List に .onMove を足す場合にもそのまま応用できます。
ドラッグ並べ替えで詰まる3つの壁
長押しドラッグの並べ替えは、一見すると「指の位置にカードを動かすだけ」ですが、実装すると次の3つが同時に襲ってきます。
壁1: フレーム落ち
ひとつ目は、フレーム落ちです。指の移動に合わせて毎フレーム再レンダリングが走ると、JavaScript スレッドが詰まってカードが遅れて付いてきます。60fps を保つには、ドラッグ中の移動を JS スレッドではなく UI スレッド(worklet)で処理する必要があります。
壁2: 状態の二重管理
ふたつ目は、状態の二重管理です。ドラッグ中の「見た目の順番」と、確定後に保存する「データの順番」を別々に持つと、離した瞬間に片方へ寄せる処理が必要になります。ここをいい加減にすると、指を離したときに順番が巻き戻る不具合が出ます。
壁3: スクロールとの競合
みっつ目は、スクロールとの競合です。縦に並ぶ一覧では、ドラッグと縦スクロールがどちらも上下方向のジェスチャーなので、どちらを優先するか決めないとスクロールが効かなくなったり、逆にドラッグが始まらなくなったりします。
この3つは本番運用に入ってから端末差で表面化しやすく、自前の PanResponder で全部捌こうとすると、コード量が膨らむうえに端末差で壊れます。各壁の対処は後半で具体的に手当てしていきます。私はここを手書きで通そうとして数時間溶かした経験があり、個人的には実績のあるライブラリに任せるのを推奨します。個人開発では、これが一番堅い判断だと考えています。
ライブラリ選定 — draggable-flatlist という現実解
並べ替え可能なリストの選択肢は、大きく分けて3つあります。それぞれの向き不向きを整理します。
| 選択肢 | 強み | 注意点 |
| 自前 PanResponder | 依存ゼロ・完全に制御できる | worklet 化・スクロール競合・端末差を全部自分で背負う |
| react-native-draggable-flatlist | Reanimated/Gesture Handler ベースで滑らか・実装が短い | 内部が FlatList なので大量データは別途最適化が要る |
| FlashList + 手組み | 巨大リストの描画が速い | 並べ替えのジェスチャー層は自分で書く必要がある |
お気に入りのように「数十件、多くても数百件」の一覧であれば、react-native-draggable-flatlist が最短かつ最も安定します。内部で react-native-reanimated と react-native-gesture-handler を使っており、ドラッグ中の移動を worklet で処理してくれるので、フレーム落ちの壁を自分で越えなくて済みます。
Expo 管理下の Rork プロジェクトに足す場合は、Expo と互換のバージョンを入れるのが安全です。
# Expo プロジェクトに導入(reanimated と gesture-handler は Expo 互換版を入れる)
npx expo install react-native-reanimated react-native-gesture-handler
npm install react-native-draggable-flatlist
react-native-reanimated を入れたら、babel.config.js のプラグイン末尾に Reanimated のプラグインを置くのを忘れないでください。これが抜けていると worklet が動かず、ドラッグ自体が無反応になります。
// babel.config.js — reanimated のプラグインは必ず配列の最後に置く
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'], // ← 最後であることが必須
};
};
最小実装 — 長押しで並べ替える
まず動く最小構成です。アプリのルートを GestureHandlerRootView で包み、その内側で DraggableFlatList を使います。ルートの包み忘れは「ドラッグしても何も起きない」という症状の典型原因なので、最初に確認してください。
// App.tsx(または Rork が生成したルートコンポーネント)
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
// flex: 1 を付けないとリストが高さゼロで描画されない
<GestureHandlerRootView style={{ flex: 1 }}>
<FavoritesScreen />
</GestureHandlerRootView>
);
}
次にお気に入り一覧の本体です。renderItem の中で受け取る drag 関数を、長押しのトリガーに割り当てるのがポイントです。
// FavoritesScreen.tsx
import { useState, useCallback } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import DraggableFlatList, {
RenderItemParams,
ScaleDecorator,
} from 'react-native-draggable-flatlist';
type Favorite = { id: string; title: string };
const INITIAL: Favorite[] = [
{ id: 'w-001', title: '夜明けの海' },
{ id: 'w-002', title: '霧の杉並木' },
{ id: 'w-003', title: '紅葉の渓谷' },
];
export function FavoritesScreen() {
const [data, setData] = useState<Favorite[]>(INITIAL);
// renderItem は useCallback で固定し、毎レンダリングの再生成を避ける
const renderItem = useCallback(
({ item, drag, isActive }: RenderItemParams<Favorite>) => (
// ScaleDecorator がドラッグ中のカードをわずかに拡大して掴んでいる感を出す
<ScaleDecorator>
<TouchableOpacity
onLongPress={drag} // 長押しでドラッグ開始
disabled={isActive} // ドラッグ中はタップを無効化
style={{
padding: 20,
backgroundColor: isActive ? '#eef3ff' : '#fff',
}}
>
<Text style={{ fontSize: 16 }}>{item.title}</Text>
</TouchableOpacity>
</ScaleDecorator>
),
[],
);
return (
<DraggableFlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
onDragEnd={({ data }) => setData(data)} // 確定後の順番を1回だけ反映
/>
);
}
ここまでで、長押し→ドラッグ→離して確定、という基本動作が滑らかに動きます。onDragEnd は指を離した瞬間に一度だけ呼ばれるので、状態更新もこの1回に集約できます。ドラッグ中は内部で worklet が座標を動かしており、setData のような JS スレッドの更新は走りません。ここが自前実装との決定的な差です。
再レンダリングを抑える — どこで worklet と JS が切り替わるか
滑らかさを保つ鍵は、「ドラッグ中はUIスレッドだけで完結させ、確定時にだけ JS スレッドへ戻す」という境界を崩さないことです。react-native-draggable-flatlist はこの境界を内部で引いてくれていますが、renderItem の書き方しだいで簡単に台無しにできます。
つまずきやすいのは、renderItem の中で毎回新しい関数やオブジェクトを生成してしまうケースです。下のような書き方は、リスト全体が頻繁に再レンダリングされ、ドラッグの滑らかさを削ります。
// アンチパターン: renderItem 内で毎回インラインの style オブジェクトと
// 無名関数を生成している。item 数が増えるほど効いてくる
renderItem={({ item, drag }) => (
<TouchableOpacity
onLongPress={() => drag()} // 毎回新しい関数
style={{ padding: 20, backgroundColor: '#fff' }} // 毎回新しいオブジェクト
>
<Text>{item.title}</Text>
</TouchableOpacity>
)}
対策は地味ですが効果的です。renderItem を useCallback で固定し、style は StyleSheet.create で外出しし、行コンポーネントを React.memo で包みます。
// 行を独立コンポーネントにして memo 化する
import React from 'react';
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
const styles = StyleSheet.create({
row: { padding: 20, backgroundColor: '#fff' },
rowActive: { padding: 20, backgroundColor: '#eef3ff' },
});
type RowProps = { title: string; isActive: boolean; onLongPress: () => void };
export const FavoriteRow = React.memo(function FavoriteRow({
title,
isActive,
onLongPress,
}: RowProps) {
return (
<TouchableOpacity
onLongPress={onLongPress}
disabled={isActive}
style={isActive ? styles.rowActive : styles.row}
>
<Text style={{ fontSize: 16 }}>{title}</Text>
</TouchableOpacity>
);
});
私の壁紙アプリでは、お気に入りが200件を超えるユーザーが一定数いて、最初の素朴な実装ではドラッグ中に明らかな引っかかりが出ていました。行の memo 化と renderItem の固定を入れたところ、同じ端末(数世代前の中位機種)でドラッグ追従の体感がはっきり改善しました。派手な最適化ではありませんが、効果は確実に出ます。
並び順を永続化する — 再起動しても順番を保つ
並べ替えても、アプリを閉じて開いたら元通り——では意味がありません。確定した順番を端末に保存し、起動時に復元します。お気に入りのような頻繁に読み書きする小さなデータには、AsyncStorage よりも同期的に読める react-native-mmkv が向いています。
// storage.ts — MMKV に並び順(id の配列)を保存する
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
const ORDER_KEY = 'favorites.order.v1'; // 後方互換のためにバージョンを付ける
export function saveOrder(ids: string[]): void {
storage.set(ORDER_KEY, JSON.stringify(ids));
}
export function loadOrder(): string[] {
const raw = storage.getString(ORDER_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
}
保存するのは「id の配列」だけにします。お気に入りの本体(タイトルや画像URL)は別の取得元(API やローカルDB)が持っているので、並び順だけを軽いインデックスとして保存するのが管理しやすい型です。
復元時は、保存された id 順に本体を並べ直します。ここで大事なのは、保存後に追加された新しいお気に入りや、削除済みの id を取りこぼさないことです。次の関数は「保存順を尊重しつつ、知らない項目は末尾に回す」という挙動でこれを吸収します。
// 保存された順序を現在のデータに適用する
// - 保存順に存在する項目はその順で並べる
// - 保存順にない新規項目は末尾に追加する
// - 保存順にあるが今は存在しない id は自然に無視される
export function applyOrder<T extends { id: string }>(
items: T[],
savedOrder: string[],
): T[] {
const byId = new Map(items.map((it) => [it.id, it]));
const ordered: T[] = [];
for (const id of savedOrder) {
const hit = byId.get(id);
if (hit) {
ordered.push(hit);
byId.delete(id);
}
}
// 残り(=新規項目)を元の順序で末尾に足す
for (const it of items) {
if (byId.has(it.id)) ordered.push(it);
}
return ordered;
}
画面側では、起動時に復元し、onDragEnd で保存します。
import { useEffect, useState, useCallback } from 'react';
import { saveOrder, loadOrder, applyOrder } from './storage';
// fetchFavorites() は API/DB からお気に入り本体を取る既存の関数とする
const [data, setData] = useState<Favorite[]>([]);
useEffect(() => {
const items = fetchFavorites();
setData(applyOrder(items, loadOrder())); // 保存順を適用して初期表示
}, []);
const handleDragEnd = useCallback(({ data }: { data: Favorite[] }) => {
setData(data);
saveOrder(data.map((it) => it.id)); // 並び順だけを保存
}, []);
これで、並べ替えた順番が再起動後も保たれます。iCloud や Google Drive をまたいだ端末間同期まで踏み込みたい場合は、この id 配列を同期層に載せるだけで拡張できます(端末間同期は別の論点なので、ここでは端末内の永続化に絞ります)。
よくある落とし穴
実装が動き始めてから遭遇しやすい不具合を、原因とあわせて挙げます。
離した瞬間に順番が戻る場合は、onDragEnd で受け取った data を setData に渡し忘れているか、別の useEffect が初期データで上書きしています。初期化の useEffect は依存配列を空([])にして一度だけ走らせ、ドラッグ確定の更新と競合させないことが肝心です。
ドラッグ中にカードが残像のように二重に見える「ゴーストカード」は、keyExtractor が安定した一意キーを返していないと起きます。配列インデックスをキーにすると並べ替えで順序が変わるたびにキーが入れ替わるので、必ず項目固有の id を返してください。
縦スクロールが効かなくなる場合は、長押しの判定時間が短すぎてスクロールの初動をドラッグと誤認しています。activationDistance を少し広げる(例: activationDistance={10})と、軽いスワイプはスクロール、はっきりした長押しはドラッグ、と素直に分かれます。
Android でだけドラッグが無反応になるときは、GestureHandlerRootView でアプリ全体を包めているかを最初に疑ってください。iOS では包み忘れていても偶然動くことがある一方、Android では無反応になりやすく、プラットフォーム差として現れます。
どこまでやるか — 個人開発での線引き
ドラッグ並べ替えは、凝り始めると「ドロップ位置のプレビュー」「セクション間移動」「ドラッグ中の自動スクロール」と、いくらでも作り込めます。ただ、お気に入りの並べ替えという文脈では、ここまで挙げた「長押しで滑らかに動く・離したら確定して保存される・再起動後も保たれる」が満たせていれば、ユーザーの要望はほぼ吸収できます。
私自身、App Store と Google Play の両方でアプリを出していると、機能の作り込みより「想定外の端末で壊れないこと」の方がレビュー評価に効くと感じています。だからこそ、ジェスチャー層は実績のあるライブラリに任せ、自分は永続化と落とし穴の手当てに時間を使う、という配分にしています。
まずは、この記事の最小実装を自分のお気に入り画面に移植し、実機で200件ほどのダミーデータを流し込んでドラッグの追従を確かめてみてください。そこで引っかかりが出なければ、永続化を足して仕上げる、という順番が安全です。お読みいただきありがとうございました。