React Nativeでドラッグ・アンド・ドロップで並べ替えできるリストを作る

react-native-draggable-flatlist を使って、ドラッグ・アンド・ドロップで並べ替えできるリストを作ってみます。

react-native-draggable-flatlist の公式ドキュメントによると、 react-native-reanimatedreact-native-gesture-handler を先にインストールしろ、とのことなので、まずはこの2つをインストールしていきます。

react-native-reanimated をインストール

shell
npm i react-native-reanimated

プロジェクトのルートディレクトリにある babel.config.js'react-native-reanimated/plugin' を追加します。

babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['react-native-reanimated/plugin'],
  };
};

react-native-gesture-handler をインストール

shell
npx expo install react-native-gesture-handler

react-native-draggable-flatlist をインストール

shell
npm i react-native-draggable-flatlist

実装

サンプルデータ用の util を作ります。これは実際のプロダクトでは不要です。

draggableUtil.ts
export function getColor(i: number, numItems: number = 25) {
  const multiplier = 255 / (numItems - 1);
  const colorVal = i * multiplier;
  return `rgb(${colorVal}, ${Math.abs(128 - colorVal)}, ${255 - colorVal})`;
}

export const mapIndexToData = (_d: any, index: number, arr: any[]) => {
  const backgroundColor = getColor(index, arr.length);
  return {
    text: `${index}`,
    key: `key-${index}`,
    backgroundColor,
    height: 75,
  };
};

export type Item = ReturnType<typeof mapIndexToData>;

上のサンプルデータを読み込んで表示させるコンポーネントです。

TopTabSettingScreen.tsx
import DraggableItem from '../uiParts/DraggableItem';
import { type Item, mapIndexToData } from './draggableUtil';
import * as Haptics from 'expo-haptics';
import { useCallback, useState } from 'react';
import { SafeAreaView, View } from 'react-native';
import DraggableFlatList, {
  OpacityDecorator,
  type RenderItemParams,
  ScaleDecorator,
  ShadowDecorator,
} from 'react-native-draggable-flatlist';

const NUM_ITEMS = 5;

const initialData: Item[] = [...Array(NUM_ITEMS)].map(mapIndexToData);
export default function TopTabSettingScreen() {
  const [data, setData] = useState(initialData);

  const renderItem = useCallback(({ item, drag, isActive }: RenderItemParams<Item>) => {
    return (
      <ShadowDecorator>
        <ScaleDecorator>
          <OpacityDecorator>
            <DraggableItem item={item} drag={drag} isActive={isActive} />
          </OpacityDecorator>
        </ScaleDecorator>
      </ShadowDecorator>
    );
  }, []);

  return (
    <SafeAreaView>
      <DraggableFlatList
        data={data}
        onDragBegin={() => {
          console.log('onDragBegin');
          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
        }}
        onDragEnd={({ data }) => {
          console.log('onDragEnd', data);
          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
          setData(data);
        }}
        keyExtractor={(item) => item.key}
        renderItem={renderItem}
        renderPlaceholder={() => <View style={{ flex: 1, backgroundColor: '#7F8C8D' }} />}
      />
    </SafeAreaView>
  );
}

一つ一つの行に相当するコンポーネントは以下です。

DraggableItem.tsx
import { AntDesign, EvilIcons, MaterialIcons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';

export interface DraggableItemProps {
  item: {
    text: string;
    backgroundColor: string;
  };
  drag: () => void;
  isActive: boolean;
}

export default function DraggableItem({ item, drag, isActive }: DraggableItemProps) {
  return (
    <TouchableOpacity
      activeOpacity={1}
      onLongPress={drag}
      disabled={isActive}
      style={[styles.rowItem, { backgroundColor: isActive ? '#ECF0F1' : '#fff' }]}
    >
      <View style={{ flexDirection: 'row', alignItems: 'center' }}>
        <MaterialIcons
          name="drag-handle"
          style={styles.draggableHandler}
          size={24}
          color="#BDC3C7"
        />
        <Text style={styles.text}>{item.text}</Text>
      </View>
      <View style={{ flexDirection: 'row' }}>
        <AntDesign
          name="edit"
          style={styles.editIcon}
          size={24}
          color="gray"
          onPress={async () => {
            console.log('edit', item);
            await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
          }}
        />
        <EvilIcons
          name="trash"
          style={styles.trashIcon}
          size={24}
          color="gray"
          onPress={async () => {
            console.log('trash', item);
            await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
          }}
        />
      </View>
    </TouchableOpacity>
  );
}
const styles = StyleSheet.create({
  rowItem: {
    flexDirection: 'row',
    height: 70,
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#fff',
    borderWidth: 1,
    borderColor: '#ECF0F1',
  },
  draggableHandler: {
    marginHorizontal: 16,
  },
  editIcon: {
    alignItems: 'flex-end',
    marginHorizontal: 12,
  },
  trashIcon: {
    alignItems: 'flex-end',
    marginHorizontal: 12,
  },
  text: {
    color: '#2C3E50',
    fontSize: 18,
  },
});

以下のように動作します。

サンプル

上の例は最もシンプルなサンプルを使っていますが、 swipeableList のような、スワイプできそうなサンプルもありました。ただ、ちょっと複雑に見えました。

エラー対応

Error: [Reanimated] Mismatch between JavaScript part and native part of Reanimated (3.3.0 vs. 2.14.4). Did you forget to re-build the app after upgrading react-native-reanimated?

shell
 ERROR  Error: [Reanimated] Mismatch between JavaScript part and native part of Reanimated (3.3.0 vs. 2.14.4). Did you forget to re-build the app after upgrading react-native-reanimated? If you use Expo Go, you must downgrade to 2.14.4 which is bundled into Expo SDK., js engine: hermes
 ERROR  Invariant Violation: "main" has not been registered. This can happen if:
* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.
* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called., js engine: hermes

うまくいった解決策

react-native-reanimated のバージョンを 3.3.0から 2.14.4 に変更します。

"react-native-reanimated": "^2.14.4", で、再度 npm i を実行すれば、エラーが出なくなりました。

shell
npm uninstall react-native-reanimated
npm install react-native-reanimated@2.14.4

再インストールした後は、 --clear をつけて起動します。

shell
 npx expo start --tunnel --clear

うまくいかなかったパターン(expo-doctorを実行)

shell
npx expo-doctor

Error: GestureDetector must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized

2023年7月に新規でインストールして動かそうとすると、以下のようなエラーが出ました。

shell
 ERROR  Error: GestureDetector must be used as a descendant of GestureHandlerRootView. Otherwise the gestures will not be recognized. See https://docs.swmansion.com/react-native-gesture-handler/docs/installation for more details.

This error is located at:
    in GestureDetector (created by DraggableFlatListInner)
    in DraggableFlatListProvider (created by DraggableFlatListInner)

"react-native-reanimated": "^2.14.4" にしても動きません。

package.json
"expo": "^49.0.3",
 "react-native-draggable-flatlist": "^4.0.1",
 "react-native-gesture-handler": "~2.12.0",
 "react-native-reanimated": "^2.14.4"

expo が2023年7月にバージョンアップされたニュースを見たので、とりあえず "expo": "~48.0.18", まで下げてみました。 React native Gesture Handler の公式ページにも、最新 Expo では動かないかもしれん、みたいなことが書いてあるためです。

The Expo SDK incorporates the latest version of react-native-gesture-handler available at the time of each SDK release, so managed Expo apps might not always support all our latest features as soon as they are available.

Expo SDKは、各SDKのリリース時に利用可能なreact-native-gesture-handlerの最新バージョンを組み込んでいるため、管理されたExpoアプリは、最新の機能が利用可能になり次第、必ずしもすべてをサポートするとは限りません。

https://docs.swmansion.com/react-native-gesture-handler/docs/installation/

node_modulespackage-lock.json を削除して、以下の package.json で再度 npm i を実行したら動きました。

react-native-reanimated2.14.1 まで downgrade しています。原因は「ライブラリのバージョン齟齬」で、結局、何のライブラリが原因なのかはよくわからん。

package.json
{
  "name": "shoppinglist",
  "version": "1.0.0",
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  },
  "dependencies": {
    "expo": "~48.0.18",
    "expo-haptics": "~12.4.0",
    "expo-status-bar": "~1.4.4",
    "react": "18.2.0",
    "react-native": "0.71.8",
    "react-native-draggable-flatlist": "^4.0.1",
    "react-native-gesture-handler": "~2.9.0",
    "react-native-reanimated": "2.14.1"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@trivago/prettier-plugin-sort-imports": "^4.1.1",
    "@types/react": "~18.0.14",
    "@typescript-eslint/eslint-plugin": "^5.62.0",
    "eslint": "^8.45.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-config-standard-with-typescript": "^36.1.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-n": "^16.0.1",
    "eslint-plugin-promise": "^6.1.1",
    "eslint-plugin-react": "^7.32.2",
    "prettier": "^2.8.0",
    "typescript": "^4.9.5"
  },
  "private": true
}