reanimated-dnd について
React Native 開発者向けのドラッグ&ドロップ UI 統合スキルです。コンポーネントと低レベルフックを備え、並べ替え可能なリストやグリッドに対応し、アニメーションは UI スレッドで実行します。
主な機能
- 01ドラッグ&ドロップ用コンポーネントと領域
- 02縦横方向の並べ替え可能なリスト
- 03挿入・交換方式のグリッド並べ替え
- 04軸・範囲・衝突の制約
- 05高さを測定できるリスト
- 06Reanimated worklets によるアニメーション
活用シーン
- 01UI の領域間で項目を移動
- 02並べ替え可能なリストやグリッドを構築
- 03ドラッグ方向と範囲を制限
- 04高さが異なるリスト項目に対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ entropyconquers/ react-native-reanimated-dnd --skill reanimated-dndインストール後、Claude Code で/reanimated-dndを入力して読み込まれたか確認します。
詳細
react-native-reanimated-dnd 統合スキル
バージョン: 2.0.0 カテゴリ: UI/ドラッグ&ドロップ プラットフォーム: React Native(react-native-reanimated >=4.2.0、react-native-gesture-handler >=2.28.0、react-native-worklets >=0.7.0 が必要)
概要
react-native-reanimated-dnd は、React Native 向けの高性能なドラッグ&ドロップ用プリミティブを提供します。高レベルコンポーネントと低レベルフックの両方を備えており、次の用途に使用できます。
- ドラッグ&ドロップ:ドロップゾーン間でアイテムを移動します
- 並べ替え可能なリスト:縦方向および横方向に並べ替え可能なリスト
- 並べ替え可能なグリッド:挿入または入れ替えによる並べ替えが可能な2次元グリッド
- 制約:軸の固定、ドラッグ範囲の制限、衝突検出
- 動的な高さ:リスト内の可変高アイテムを自動計測します
すべてのアニメーションは、Reanimated のワークレットを介して UI スレッド上で実行されます。
インストール
npm install react-native-reanimated-dnd
# or
yarn add react-native-reanimated-dnd
ピア依存関係(個別にインストールする必要があります)
npm install react-native-reanimated react-native-gesture-handler react-native-worklets
必須のセットアップ
アプリのルートを GestureHandlerRootView でラップします。
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* Your app content */}
</GestureHandlerRootView>
);
}
コアアーキテクチャ
DropProvider (context - required for Draggable/Droppable)
├── Draggable (items that can be picked up)
│ └── Draggable.Handle (optional restricted drag area)
└── Droppable (zones that accept drops)
Sortable (self-contained vertical/horizontal list)
├── SortableItem (individual reorderable item)
│ └── SortableItem.Handle (optional restricted drag area)
SortableGrid (self-contained 2D grid)
├── SortableGridItem (individual grid cell)
│ └── SortableGridItem.Handle (optional restricted drag area)
重要なルール:追跡のため、すべてのデータアイテムに id: string プロパティを必ず指定してください。
パターン1:基本的なドラッグ&ドロップ
DropProvider + Draggable + Droppable を使用して、アイテムをドロップゾーンに移動します。
import {
DropProvider,
Draggable,
Droppable,
} from 'react-native-reanimated-dnd';
function DragDropExample() {
const [droppedItem, setDroppedItem] = useState<string | null>(null);
return (
<DropProvider>
<View style={styles.items}>
<Draggable data={{ id: '1', label: 'Item A' }}>
<View style={styles.item}>
<Text>Item A</Text>
</View>
</Draggable>
<Draggable data={{ id: '2', label: 'Item B' }}>
<View style={styles.item}>
<Text>Item B</Text>
</View>
</Draggable>
</View>
<Droppable onDrop={(data) => setDroppedItem(data.label)}>
<View style={styles.dropZone}>
<Text>{droppedItem ?? 'Drop here'}</Text>
</View>
</Droppable>
</DropProvider>
);
}
Draggable の Props
| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
data | TData | 必須 | ドロップハンドラーに渡されるペイロード |
draggableId | string | 自動 | 一意の識別子 |
dragDisabled | boolean | false | ドラッグを無効にします |
preDragDelay | number | 0 | ドラッグ開始前の遅延時間(ミリ秒) |
dragAxis | "x" | "y" | "both" | "both" | 移動軸を制限します |
dragBoundsRef | RefObject<View> | - | ビュー内に移動を制限します |
collisionAlgorithm | "center" | "intersect" | "contain" | "intersect" | ドロップ可能な領域との重なりを検出する方法 |
animationFunction | (toValue: number) => number | - | カスタムの復帰アニメーション |
onDragStart | (data: TData) => void | - | ドラッグ開始時に呼び出されます |
onDragEnd | (data: TData) => void | - | ドラッグ終了時に呼び出されます |
onDragging | ({ x, y, tx, ty, itemData }) => void | - | リアルタイムの位置更新 |
onStateChange | (state: DraggableState) => void | - | 状態遷移時のコールバック |
Droppable の Props
| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
onDrop | (data: TData) => void | 必須 | ドロップされたアイテムを処理します |
dropDisabled | boolean | false | ドロップを無効にします |
capacity | number | 1 | 許可されるアイテムの最大数 |
dropAlignment | DropAlignment | "center" | ドロップされたアイテムの位置合わせ |
dropOffset | { x: number, y: number } | - | 位置合わせ後の位置を微調整します |
activeStyle | StyleProp<ViewStyle> | - | アイテムが上にあるときに適用されるスタイル |
onActiveChange | (isActive: boolean) => void | - | ホバー状態の変化時に呼び出されます |
droppableId | string | 自動 | ドロップゾーンの一意の識別子 |
DropProvider の Props
| Prop | 型 | 説明 |
|---|---|---|
onDroppedItemsUpdate | (items: DroppedItemsMap) => void | すべてのゾーンにわたるアイテムを追跡します |
onDragging | ({ x, y, tx, ty, itemData }) => void | ドラッグ位置をグローバルに追跡します |
onDragStart | (data) => void | ドラッグ開始時に呼び出されます |
onDragEnd | (data) => void | ドラッグ終了時に呼び出されます |
onLayoutUpdateComplete | () => void | レイアウトの更新完了時に呼び出されます |
DropAlignment の値
"center" | "top-left" | "top-center" | "top-right" | "center-left" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"
パターン2:ドラッグハンドル
Draggable.Handle を使用して、ドラッグ可能な領域を特定のハンドル領域に限定します。
<Draggable data={{ id: '1', label: 'Card' }}>
<View style={styles.card}>
<Draggable.Handle>
<View style={styles.handleBar}>
<Text>Drag here</Text>
</View>
</Draggable.Handle>
<View style={styles.cardContent}>
<Text>This area does NOT initiate drag</Text>
</View>
</View>
</Draggable>
同じパターンを、並べ替え可能なアイテムの SortableItem.Handle にも使用できます。
パターン3:縦方向の並べ替え可能なリスト
並べ替え可能なリストには Sortable コンポーネントを使用します。
import { Sortable, SortableItem } from 'react-native-reanimated-dnd';
interface Item {
id: string;
title: string;
}
const ITEM_HEIGHT = 60;
function SortableListExample() {
const [items, setItems] = useState<Item[]>([
{ id: '1', title: 'First' },
{ id: '2', title: 'Second' },
{ id: '3', title: 'Third' },
]);
const renderItem = useCallback(({ item, ...props }) => (
<SortableItem
key={item.id}
id={item.id}
data={item}
onMove={(id, from, to) => {
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
{...props}
>
<View style={styles.listItem}>
<Text>{item.title}</Text>
</View>
</SortableItem>
), []);
return (
<Sortable
data={items}
renderItem={renderItem}
itemHeight={ITEM_HEIGHT}
/>
);
}
Sortable Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | required | アイテム(それぞれに id: string が必要です) |
renderItem | (props) => ReactNode | required | アイテムのレンダラー |
direction | "vertical" | "horizontal" | "vertical" | リストの方向 |
itemHeight | number | number[] | (item, i) => number | - | アイテムの高さ(垂直方向では必須) |
itemWidth | number | - | アイテムの幅(水平方向では必須) |
gap | number | 0 | アイテム間の間隔(水平方向のみ) |
paddingHorizontal | number | 0 | コンテナーのパディング(水平方向のみ) |
enableDynamicHeights | boolean | false | アイテムの高さを自動測定します |
estimatedItemHeight | number | 60 | 未測定アイテムのフォールバックの高さ |
onHeightsMeasured | (heights) => void | - | 測定した高さを引数として呼び出されます |
useFlatList | boolean | true | 仮想化に FlatList を使用します |
itemKeyExtractor | (item, index) => string | item.id | カスタムキー抽出関数 |
style | StyleProp<ViewStyle> | - | ScrollView/FlatList のスタイル |
contentContainerStyle | StyleProp<ViewStyle> | - | コンテンツコンテナーのスタイル |
SortableItem Props
| Prop | Type | Description |
|---|---|---|
id | string | アイテムの一意な識別子 |
data | T | アイテムデータ |
onMove | (id, from, to) => void | 位置が変更されたときに呼び出されます |
onDragStart | (id, position) => void | ドラッグ開始時 |
onDrop | (id, position, allPositions?) => void | アイテムがドロップされたとき |
onDragging | (id, overItemId, yPosition) => void | ドラッグ位置をリアルタイムで通知します |
style | StyleProp<ViewStyle> | コンテナーのスタイル |
animatedStyle | StyleProp<ViewStyle> | アニメーションスタイル |
Pattern 4: 水平方向の Sortable リスト
import {
Sortable,
SortableItem,
SortableDirection,
} from 'react-native-reanimated-dnd';
const ITEM_WIDTH = 120;
function HorizontalSortableExample() {
const [items, setItems] = useState([
{ id: '1', label: 'Tag A' },
{ id: '2', label: 'Tag B' },
{ id: '3', label: 'Tag C' },
]);
const renderItem = useCallback(({ item, ...props }) => (
<SortableItem
key={item.id}
id={item.id}
data={item}
onMove={(id, from, to) => {
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
{...props}
>
<View style={styles.tag}>
<Text>{item.label}</Text>
</View>
</SortableItem>
), []);
return (
<Sortable
data={items}
renderItem={renderItem}
direction={SortableDirection.Horizontal}
itemWidth={ITEM_WIDTH}
gap={12}
paddingHorizontal={12}
/>
);
}
Pattern 5: Sortable グリッド
import {
SortableGrid,
SortableGridItem,
GridOrientation,
GridStrategy,
} from 'react-native-reanimated-dnd';
function GridExample() {
const [items, setItems] = useState([
{ id: '1', label: 'A' },
{ id: '2', label: 'B' },
{ id: '3', label: 'C' },
{ id: '4', label: 'D' },
{ id: '5', label: 'E' },
{ id: '6', label: 'F' },
]);
const renderItem = useCallback(({ item, ...props }) => (
<SortableGridItem
key={item.id}
id={item.id}
data={item}
onMove={(id, from, to) => {
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
{...props}
>
<View style={styles.gridCell}>
<Text>{item.label}</Text>
</View>
</SortableGridItem>
), []);
return (
<SortableGrid
data={items}
renderItem={renderItem}
dimensions={{
columns: 3,
itemWidth: 100,
itemHeight: 100,
rowGap: 8,
columnGap: 8,
}}
orientation={GridOrientation.Vertical}
strategy={GridStrategy.Insert}
/>
);
}
SortableGrid Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | required | グリッドアイテム(それぞれに id: string が必要です) |
renderItem | (props) => ReactNode | required | アイテムのレンダラー |
dimensions | GridDimensions | required | グリッド設定(以下を参照) |
orientation | "vertical" | "horizontal" | "vertical" | グリッドの向き |
strategy | "insert" | "swap" | "insert" | アイテムの並べ替え方法:insert は他のアイテムをずらし、swap は 2 つのアイテムを入れ替えます |
scrollEnabled | boolean | true | スクロールを有効にします |
itemKeyExtractor | (item, index) => string | item.id | カスタムキー抽出 |
style | StyleProp<ViewStyle> | - | ScrollView のスタイル |
contentContainerStyle | StyleProp<ViewStyle> | - | コンテンツのスタイル |
GridDimensions
| Prop | Type | Description |
|---|---|---|
columns | number | 列数(垂直方向の場合) |
rows | number | 行数(水平方向の場合) |
itemWidth | number | アイテムの幅 |
itemHeight | number | アイテムの高さ |
rowGap | number | 行間の間隔 |
columnGap | number | 列間の間隔 |
SortableGridItem Props
| Prop | Type | Description |
|---|---|---|
id | string | 一意な識別子 |
data | T | アイテムデータ |
activationDelay | number | ドラッグ開始までの遅延時間(ミリ秒) |
onMove | (id, from, to) => void | 位置が変更されました |
onDragStart | (id, position) => void | ドラッグ開始時 |
onDrop | (id, position, allPositions?) => void | アイテムがドロップされたとき |
onDragging | (id, overItemId, x, y) => void | 位置をリアルタイムで通知します |
isBeingRemoved | boolean | 削除アニメーションを開始します |
style | StyleProp<ViewStyle> | コンテナーのスタイル |
animatedStyle | StyleProp<ViewStyle> | アニメーションスタイル |
Pattern 6: 動的な高さ
アイテムの高さが可変のリストの場合:
<Sortable
data={items}
renderItem={renderItem}
enableDynamicHeights
estimatedItemHeight={80}
onHeightsMeasured={(heights) => {
// { [id]: measuredHeight }
}}
/>
または、アイテムごとに高さを明示的に指定します:
<Sortable
data={items}
renderItem={renderItem}
itemHeight={(item, index) => item.expanded ? 120 : 60}
/>
// Or as an array:
<Sortable
data={items}
renderItem={renderItem}
itemHeight={[60, 80, 120, 60, 100]}
/>
Pattern 7: 軸の制約
ドラッグを単一の軸に制限します:
// Horizontal only
<Draggable data={data} dragAxis="x">
<View>{/* content */}</View>
</Draggable>
// Vertical only
<Draggable data={data} dragAxis="y">
<View>{/* content */}</View>
</Draggable>
パターン 8:ドラッグ範囲の制限
コンテナ内でドラッグできる範囲を制限します:
function BoundedExample() {
const boundsRef = useRef<View>(null);
return (
<DropProvider>
<View ref={boundsRef} style={styles.boundary}>
<Draggable data={{ id: '1' }} dragBoundsRef={boundsRef}>
<View style={styles.item}>
<Text>Cannot escape boundary</Text>
</View>
</Draggable>
</View>
</DropProvider>
);
}
軸の制約と組み合わせます:
<Draggable data={data} dragBoundsRef={boundsRef} dragAxis="y">
{/* Vertical movement only, within bounds */}
</Draggable>
パターン 9:衝突検出
3 つのアルゴリズムによって、ドラッグ可能なアイテムがドロップ可能な領域をいつ「アクティブ化」するかを制御します:
// Default: any overlap triggers
<Draggable data={data} collisionAlgorithm="intersect">
// Center point must be over the droppable
<Draggable data={data} collisionAlgorithm="center">
// Entire draggable must be inside the droppable
<Draggable data={data} collisionAlgorithm="contain">
パターン 10:ドロップゾーンの容量
ゾーンが受け入れるアイテム数を制限します:
<Droppable onDrop={handleDrop} capacity={1}>
{/* Accepts exactly one item */}
</Droppable>
<Droppable onDrop={handleDrop} capacity={3}>
{/* Accepts up to three items */}
</Droppable>
<Droppable onDrop={handleDrop} capacity={Infinity}>
{/* Unlimited */}
</Droppable>
パターン 11:ゾーン間でのアイテムの追跡
DropProvider.onDroppedItemsUpdate を使って、各ゾーンにどのアイテムがあるかを追跡します:
<DropProvider
onDroppedItemsUpdate={(droppedItems) => {
// droppedItems is: { [draggableId]: { droppableId, data } }
setMapping(droppedItems);
}}
>
{/* Draggables and Droppables */}
</DropProvider>
ref を介してプログラムからアクセスします:
const providerRef = useRef<DropProviderRef>(null);
<DropProvider ref={providerRef}>
{/* ... */}
</DropProvider>
// Later:
const items = providerRef.current?.getDroppedItems();
providerRef.current?.requestPositionUpdate();
パターン 12:ドロップゾーンのアクティブ時のスタイル
アイテムがドロップゾーンに重なったときに視覚的なフィードバックを表示します:
<Droppable
onDrop={handleDrop}
activeStyle={{
borderColor: 'blue',
borderWidth: 2,
backgroundColor: 'rgba(0, 0, 255, 0.1)',
}}
onActiveChange={(isActive) => {
// true when item is hovering, false when it leaves
}}
>
<View style={styles.zone}>
<Text>Drop Zone</Text>
</View>
</Droppable>
パターン 13:カスタムアニメーション
アイテムが元の位置または所定の位置に戻るときのカスタムアニメーションを指定します:
import { withSpring, withTiming, Easing } from 'react-native-reanimated';
// Spring animation
<Draggable
data={data}
animationFunction={(toValue) =>
withSpring(toValue, { damping: 15, stiffness: 150 })
}
>
// Timing animation with easing
<Draggable
data={data}
animationFunction={(toValue) =>
withTiming(toValue, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1) })
}
>
パターン 14:ドラッグ状態の追跡
ドラッグのライフサイクルを監視します:
import { DraggableState } from 'react-native-reanimated-dnd';
<Draggable
data={data}
onStateChange={(state) => {
// DraggableState.IDLE | DraggableState.DRAGGING | DraggableState.DROPPED
}}
onDragStart={(data) => { /* drag began */ }}
onDragEnd={(data) => { /* drag ended */ }}
onDragging={({ x, y, tx, ty, itemData }) => {
// real-time position while dragging
}}
>
フック API(低レベル)
完全に制御するには、コンポーネントの代わりにフックを使います:
useDraggable
import { useDraggable } from 'react-native-reanimated-dnd';
import { GestureDetector } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
function CustomDraggable({ data }) {
const {
animatedViewProps, // { style, onLayout }
gesture, // pan gesture
state, // DraggableState
} = useDraggable({
data,
onDragStart: (d) => {},
onDragEnd: (d) => {},
collisionAlgorithm: 'intersect',
});
return (
<GestureDetector gesture={gesture}>
<Animated.View {...animatedViewProps}>
<Text>Custom Draggable</Text>
</Animated.View>
</GestureDetector>
);
}
useDroppable
import { useDroppable } from 'react-native-reanimated-dnd';
function CustomDropZone() {
const {
viewProps, // { onLayout, style? }
isActive, // boolean
} = useDroppable({
onDrop: (data) => {},
capacity: 1,
activeStyle: { borderColor: 'green' },
});
return (
<Animated.View {...viewProps}>
<Text>{isActive ? 'Release to drop' : 'Drop here'}</Text>
</Animated.View>
);
}
useSortableList + useSortable
カスタムのソート可能なリストを実装する場合:
import { useSortableList, useSortable } from 'react-native-reanimated-dnd';
import { GestureDetector } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
function CustomSortableList({ data }) {
const {
positions,
scrollY,
autoScroll,
scrollViewRef,
handleScroll,
handleScrollEnd,
contentHeight,
getItemProps,
} = useSortableList({ data, itemHeight: 60 });
return (
<Animated.ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
onMomentumScrollEnd={handleScrollEnd}
style={{ height: 400 }}
contentContainerStyle={{ height: contentHeight }}
>
{data.map((item, index) => (
<CustomSortableItem
key={item.id}
item={item}
{...getItemProps(item, index)}
/>
))}
</Animated.ScrollView>
);
}
function CustomSortableItem({ item, ...sortableProps }) {
const {
animatedStyle,
panGestureHandler,
} = useSortable({
...sortableProps,
onMove: (id, from, to) => {},
});
return (
<GestureDetector gesture={panGestureHandler}>
<Animated.View style={animatedStyle}>
<Text>{item.title}</Text>
</Animated.View>
</GestureDetector>
);
}
useHorizontalSortableList + useHorizontalSortable
垂直方向の場合と同じパターンですが、水平方向のリストに使用します:
import {
useHorizontalSortableList,
useHorizontalSortable,
} from 'react-native-reanimated-dnd';
const {
positions,
scrollX,
autoScroll,
scrollViewRef,
handleScroll,
handleScrollEnd,
contentWidth,
getItemProps,
} = useHorizontalSortableList({
data,
itemWidth: 120,
gap: 12,
paddingHorizontal: 12,
});
useGridSortableList + useGridSortable
カスタムのグリッドを実装する場合:
import {
useGridSortableList,
useGridSortable,
GridOrientation,
GridStrategy,
} from 'react-native-reanimated-dnd';
const {
positions,
scrollY,
scrollX,
autoScrollDirection,
scrollViewRef,
handleScroll,
handleScrollEnd,
contentWidth,
contentHeight,
getItemProps,
} = useGridSortableList({
data,
dimensions: { columns: 3, itemWidth: 100, itemHeight: 100 },
orientation: GridOrientation.Vertical,
strategy: GridStrategy.Insert,
});
型リファレンス
// All sortable data must have an id
interface SortableData {
id: string;
}
// Drag state enum
enum DraggableState {
IDLE = "IDLE",
DRAGGING = "DRAGGING",
DROPPED = "DROPPED",
}
// Scroll directions
enum ScrollDirection { None, Up, Down }
enum HorizontalScrollDirection { None, Left, Right }
// List direction
enum SortableDirection { Vertical = "vertical", Horizontal = "horizontal" }
// Grid types
enum GridOrientation { Vertical = "vertical", Horizontal = "horizontal" }
enum GridStrategy { Insert = "insert", Swap = "swap" }
enum GridScrollDirection { None, Up, Down, Left, Right, UpLeft, UpRight, DownLeft, DownRight }
interface GridDimensions {
columns?: number;
rows?: number;
itemWidth: number;
itemHeight: number;
rowGap?: number;
columnGap?: number;
}
interface GridPosition {
index: number;
row: number;
column: number;
x: number;
y: number;
}
// Collision detection
type CollisionAlgorithm = "center" | "intersect" | "contain";
// Drop alignment
type DropAlignment =
| "center" | "top-left" | "top-center" | "top-right"
| "center-left" | "center-right"
| "bottom-left" | "bottom-center" | "bottom-right";
// Dropped items tracking
interface DroppedItemsMap<TData = unknown> {
[draggableId: string]: {
droppableId: string;
data: TData;
};
}
// DropProvider imperative handle
interface DropProviderRef {
requestPositionUpdate: () => void;
getDroppedItems: () => DroppedItemsMap;
}
// Grid positions shared value type
interface GridPositions {
[id: string]: GridPosition;
}
よく使われるレシピ
ハンドル付きのソート可能なリスト
<SortableItem id={item.id} data={item} {...props}>
<View style={styles.row}>
<SortableItem.Handle>
<View style={styles.handle}>
<Text>|||</Text>
</View>
</SortableItem.Handle>
<Text style={styles.content}>{item.title}</Text>
</View>
</SortableItem>
入れ替えによる並べ替えに対応したグリッド
<SortableGrid
data={items}
renderItem={renderItem}
dimensions={{ columns: 4, itemWidth: 80, itemHeight: 80 }}
strategy={GridStrategy.Swap}
/>
ドラッグハンドル付きのグリッドアイテム
<SortableGridItem id={item.id} data={item} {...props}>
<View style={styles.cell}>
<SortableGridItem.Handle>
<View style={styles.handle}>
<Text>Drag</Text>
</View>
</SortableGridItem.Handle>
<Text>{item.label}</Text>
</View>
</SortableGridItem>
アクティブ化に遅延を設けたグリッド(誤操作によるドラッグを防止)
<SortableGridItem
id={item.id}
data={item}
activationDelay={300}
{...props}
>
ドラッグ可能なアイテムのドラッグ開始前の遅延
<Draggable data={data} preDragDelay={200}>
{/* Must hold 200ms before drag activates */}
</Draggable>
オフセットを使ったドロップ位置の調整
<Droppable
onDrop={handleDrop}
dropAlignment="top-left"
dropOffset={{ x: 10, y: 10 }}
>
ソート可能なリストへのアイテムの動的な追加
アイテムには常に一意の id フィールドが必要です。状態配列を更新するだけです:
const addItem = () => {
setItems(prev => [...prev, { id: String(Date.now()), title: 'New Item' }]);
};
アニメーション付きでグリッドからアイテムを削除する
<SortableGridItem isBeingRemoved={item.removing} {...props}>
注意点と避けるべきこと
データ要件
- すべてのアイテムに
id: stringが必須です — ID がない、または未定義の場合、並べ替えが正常に動作せず、エラーが通知されないまま失敗します。開発モードではconsole.errorが出力されますが、本番環境では通知なしに動作しなくなります。 - ID は一意である必要があります — ID が重複すると、複数のアイテムが同じ位置を共有します。このライブラリは内部で
id -> indexにマッピングしているため、重複した ID は互いに上書きされます。 - アイテムオブジェクトを変更しないでください — 状態を更新するときは、常に新しい配列やオブジェクトを作成してください。このライブラリはジェスチャーのクロージャ内にデータを保持するため、元のオブジェクトを変更すると、コールバック内のデータが古くなります。
コンテキストとラップ
- Draggable/Droppable には DropProvider の祖先が必須です — 祖先に DropProvider がないと、ドロップは通知なしに失敗します(クラッシュせず、本番環境ではエラーも発生しません)。Draggable/Droppable の使用箇所は必ず
<DropProvider>でラップしてください。 - Sortable または SortableGrid を DropProvider でラップしないでください — これらのコンポーネントは独自の DropProvider を内部で作成します。プロバイダーをネストすると、衝突検出が正常に動作しなくなります。
- Sortable または SortableGrid を GestureHandlerRootView でラップしないでください — これらのコンポーネントは内部で自身をラップします。二重にラップすると、ジェスチャーが競合します。
GestureHandlerRootViewは、Draggable/Droppable パターンではアプリのルートでのみ使用してください。 - ハンドルコンポーネントは、親の直下の子である必要があります —
Draggable.HandleはDraggableの内側、SortableItem.HandleはSortableItemの内側、SortableGridItem.HandleはSortableGridItemの内側に配置する必要があります。これらを別の場所で使用すると、子要素はレンダリングされますが、ドラッグ機能は動作しません(開発モードでは警告が表示されます)。
必須プロパティ(指定がないとエラーになります)
- 垂直方向の Sortable には
itemHeightが必須です — 固定値、配列、関数のいずれかを渡すか、enableDynamicHeights={true}を設定してください。指定がない場合はエラーが発生します。 - 水平方向の Sortable には
itemWidthが必須です — 動的な幅を指定するモードはありません。固定値を指定する必要があります。 - グリッドの寸法には、
itemWidth、itemHeight、columns(垂直方向)またはrows(水平方向)が必要です — いずれかが指定されていない場合はエラーが発生します。
状態管理
onMoveでは状態配列を必ず更新してください — このライブラリは位置を視覚的にアニメーションするだけです。onMoveでdata配列を並べ替えないと、画面上の順序とデータの順序が一致しなくなります。
// CORRECT
onMove={(id, from, to) => {
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
// WRONG — visual reorder happens but data stays stale
onMove={(id, from, to) => {
console.log('moved', id, from, to); // not updating state!
}}
-
onDragStart/onDragEnd内で外部状態からdataprops を読み取らないでください — これらのコールバックは、ジェスチャーの作成時点のdataをキャプチャします。ジェスチャーの作成後、コールバックが実行されるまでにデータオブジェクトが変わると、コールバックは古いバージョンを参照します。代わりに、コールバックに渡されるdata引数を使用してください。 -
Droppable のデフォルト容量は無限ではなく 1 です —
capacityを設定しない場合、各ゾーンにドロップできるアイテムは 1 つだけです。無制限にするにはcapacity={Infinity}を設定してください。
アニメーション
animationFunctionは Reanimated のアニメーション値を必ず返す必要があります — worklet を介して UI スレッドで実行されます。必ずwithSpring、withTiming、または別の Reanimated アニメーションを返してください。通常の数値を返すと、アニメーションは完全に省略されます(アイテムは遷移せず、瞬時に移動します)。
// CORRECT — returns a Reanimated animation
animationFunction={(toValue) => withSpring(toValue, { damping: 15 })}
// CORRECT — timing with easing
animationFunction={(toValue) => withTiming(toValue, { duration: 200 })}
// BAD — returns plain number, item snaps with no animation
animationFunction={(toValue) => toValue}
- デフォルトのアニメーションは
withSpring(弾む動き)です — デフォルトでは、アイテムは元の位置に跳ね返ります。素早く動く感じにしたい場合は、withTimingを指定してください。
animationFunction={(toValue) => withTiming(toValue, { duration: 200 })}
- Droppable の
activeStyleはアニメーションしません — 遷移せず、即座に適用されます。アニメーション付きのホバー時の反応が必要な場合は、Reanimated のuseAnimatedStyleとonActiveChangeを使用してください。
ジェスチャー処理
-
ソート可能なアイテムの長押しによるアクティベーション時間は 200ms に固定されています —
SortableItemや横方向のソート機能では、この時間をカスタマイズできません。activationDelayを指定できるのはSortableGridItemのみです。これにより誤操作によるドラッグを防げますが、ソート可能なアイテムを操作するには、常に短時間押し続ける必要があります。 -
Draggable での
preDragDelay={0}は ScrollView と競合します — 遅延が 0 の場合、パンジェスチャーが即座に有効になり、スクロールジェスチャーからタッチを奪います。Draggable が ScrollView 内にある場合は、preDragDelay={100}以上を使用してください。 -
Droppable が重なっている場合は、最初に登録されたものが優先されます — 2 つの Droppable が空間的に重なっている場合、最初に登録されたものが衝突判定で優先されます。「中心に最も近いもの」を選ぶアルゴリズムはありません。Droppable が重ならないようにしてください。
-
ドラッグ可能なアイテムが Droppable より大きい場合、
containによる衝突判定は発生しません — ドラッグ可能なアイテム全体が Droppable の内側に収まる必要があります。アイテムのほうが大きい場合は、代わりに"intersect"または"center"を使用してください。
プラットフォーム固有の事項
-
ドラッグ時の影は iOS 専用です — アイテムのドラッグ中、ライブラリは視覚的なフィードバックとして
shadowColor/shadowOpacity/shadowRadiusを適用します。これらは iOS 専用のプロパティです。Android ではドラッグ時の影は表示されません。Android でフィードバックが必要な場合は、animatedStyleを介して独自にelevationを適用してください。 -
Android では
collapsable={false}が重要です — ライブラリは Draggable/Droppable のビューに内部でこれを設定します。フック API(useDraggable/useDroppable)をカスタムビューとともに使用する場合は、Animated.Viewにcollapsable={false}を必ず設定してください。設定しないと、Android がネイティブビューを最適化で削除し、計測値が 0 になります。
// When using hooks directly
<Animated.View collapsable={false} {...animatedViewProps}>
パフォーマンス
-
Sortable はデータ配列が変わるたびにリスト全体を再マウントします —
Sortableはすべてのアイテム ID のハッシュを React のキーとして使用するため、データが変わるたび(並べ替え、追加、削除を含む)に完全な再マウントが発生します。これにより、スクロール位置とすべてのアニメーション状態がリセットされます。データが頻繁に変わる場合は、より細かく制御できるフック API(useSortableList+useSortable)の使用を検討してください。 -
SortableGridはデータが変更されても再マウントしません — Sortable とは異なり、SortableGrid は完全な再マウントを行わず、より効率的にデータの変更を処理します。 -
onDraggingハンドラーは軽量に保ってください — 約 20 回/秒(50ms のスロットリング)で呼び出され、UI スレッドから JS に処理が渡されます。これらのコールバックで重い処理を行うと、動作がカクつきます。 -
Sortable の
backgroundColor: "white"は固定値です — 内部の ScrollView/FlatList にはbackgroundColor: "white"が固定で設定されています。ダークモードでは、styleprop を使って上書きしてください。
<Sortable
data={items}
renderItem={renderItem}
itemHeight={60}
style={{ backgroundColor: '#1a1a1a' }}
/>
動的な高さ
-
1px 未満の動的な高さの変更は無視されます — このライブラリは高さを丸め、無限の再レンダリングループを防ぐために 1px 未満の変更を無視します。サブピクセル単位の高さ調整は破棄されます。
-
初期の動的な高さは、最初のデータスナップショットから一度だけ計算されます — 初回レンダリングの完了前にデータが変更されると、項目が再計測されるまで高さが古いままになることがあります。
ベストプラクティス
データ構造
// GOOD — simple, flat data with string IDs
const items = [
{ id: '1', title: 'Item 1' },
{ id: '2', title: 'Item 2' },
];
// GOOD — use stable unique IDs (not array indices)
const items = tasks.map(task => ({ ...task, id: task.uuid }));
// BAD — numeric IDs (must be strings)
const items = [{ id: 1, title: 'Item 1' }];
// BAD — using array index as ID (breaks on reorder)
const items = data.map((d, i) => ({ ...d, id: String(i) }));
// BAD — missing ID field
const items = [{ title: 'Item 1' }];
renderItem をメモ化する
不要な再レンダリングを避けるため、renderItem には必ず useCallback を使用してください。
// GOOD
const renderItem = useCallback(({ item, ...props }) => (
<SortableItem key={item.id} id={item.id} data={item} {...props}>
<MyItemComponent item={item} />
</SortableItem>
), []);
// BAD — creates new function on every render
const renderItem = ({ item, ...props }) => (
<SortableItem key={item.id} id={item.id} data={item} {...props}>
<MyItemComponent item={item} />
</SortableItem>
);
renderItem の props をスプレッドする
renderItem コールバックは、親の Sortable/SortableGrid から共有値と設定を受け取ります。必ずそれらを項目コンポーネントにスプレッドしてください。
// GOOD — spread all props from renderItem
const renderItem = useCallback(({ item, ...props }) => (
<SortableItem id={item.id} data={item} {...props}>
{/* content */}
</SortableItem>
), []);
// BAD — manually passing individual props (easy to miss required ones)
const renderItem = useCallback(({ item, positions, lowerBound }) => (
<SortableItem id={item.id} data={item} positions={positions} lowerBound={lowerBound}>
{/* missing autoScrollDirection, itemsCount, etc. */}
</SortableItem>
), []);
インタラクティブなコンテンツにはハンドルを使用する
ソート可能な項目にボタン、入力欄、その他のインタラクティブな要素が含まれる場合は、ドラッグとの競合を避けるためにハンドルを使用してください。
// GOOD — only the handle initiates drag, buttons work normally
<SortableItem id={item.id} data={item} {...props}>
<View style={styles.row}>
<SortableItem.Handle>
<View style={styles.dragHandle} />
</SortableItem.Handle>
<Text>{item.title}</Text>
<Button onPress={onDelete} title="Delete" />
</View>
</SortableItem>
// PROBLEMATIC — entire item is draggable, button taps may trigger drag
<SortableItem id={item.id} data={item} {...props}>
<View style={styles.row}>
<Text>{item.title}</Text>
<Button onPress={onDelete} title="Delete" />
</View>
</SortableItem>
グリッドのアクティベーション遅延
項目をタップできるグリッドでは、タップとドラッグを区別するために activationDelay を使用してください。
<SortableGridItem
id={item.id}
data={item}
activationDelay={250}
{...props}
>
<Pressable onPress={() => navigateTo(item)}>
{/* Grid cell content */}
</Pressable>
</SortableGridItem>
複雑なレイアウトには Hooks API を使用する
コンテナを完全に制御する必要がある場合(カスタムのスクロール動作、ネストされたリスト、標準外のレイアウトなど)は、コンポーネント API ではなく Hooks API を使用してください。
// Hooks give you raw shared values and gesture handlers
const { positions, scrollY, autoScroll, scrollViewRef, handleScroll, contentHeight, getItemProps } =
useSortableList({ data, itemHeight: 60 });
// You control the ScrollView entirely
<Animated.ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
contentContainerStyle={{ height: contentHeight }}
// Add your own custom scroll props
showsVerticalScrollIndicator={false}
bounces={false}
>
{data.map((item, index) => (
<CustomItem key={item.id} item={item} {...getItemProps(item, index)} />
))}
</Animated.ScrollView>
Sortable のネストを避ける
Sortable を別の Sortable または SortableGrid の内側にネストしないでください。それぞれが独自の DropProvider と GestureHandlerRootView を作成するため、ネストされたジェスチャーハンドラーが競合します。ネストされた並べ替え可能なリストが必要な場合は、単一の共有ジェスチャールートで Hooks API を使用してください。