React Native Swipeable List

Learn how to implement a Swipeable list with Reanimated.

In this post, we will learn how to make a swipeable list view in React Native with the Reanimated library.

Before you start implementing the Swipeable list component. you can check the Swipeable component from React Native Gesture Handler package. it does the same thing but it's not customizable as I wanted. (like conditionally disable gesture)

Let's see how to implement a swipeable list.

First import necessary packages.

import React from 'react';
import { StyleSheet, View, Text, Dimensions, Alert } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedGestureHandler,
  useAnimatedStyle,
  withSpring,
  withTiming,
  Easing,
  runOnJS,
} from 'react-native-reanimated';
import {
  PanGestureHandler,
  TouchableOpacity,
  FlatList,
} from 'react-native-gesture-handler';

We will need the device window dimension. and how much swipe the cell. this is up to your design.

const windowDimensions = Dimensions.get('window');
const BUTTON_WIDTH = 80;
const MAX_TRANSLATE = -BUTTON_WIDTH * 2;

then add the dummy data

type Data = {
  id: string;
  title: string;
};
const data: Data[] = [
  {
    id: '1',
    title: 'Kate Bell',
  },
  {
    id: '2',
    title: 'John Appleseed',
  },
  {
    id: '3',
    title: 'Steve Jobs',
  },
  {
    id: '4',
    title: 'Iron Man',
  },
  {
    id: '5',
    title: 'Captain America',
  },
  {
    id: '6',
    title: 'Batman',
  },
  {
    id: '7',
    title: 'Matt Smith',
  },
];

We have created our main component with Flatlist. Flatlist should import from React Native Gesture Handler package.

function SwipableList(): React.ReactElement {
  function onRemove() {
    Alert.alert('Removed');
  }

  return (
    <View style={s.container}>
      <FlatList
        data={data}
        renderItem={({ item }) => <ListItem item={item} onRemove={onRemove} />}
        keyExtractor={(item) => item.id}
      />
    </View>
  );
}

We have created springConfig and timingConfig  you can change how you want.

Now we came to the magic happens.

type ListItemProps = {
  item: Data;
  onRemove: () => void;
};
function ListItem({ item, onRemove }: ListItemProps) {
  const isRemoving = useSharedValue(false);
  const translateX = useSharedValue(0);

  type AnimatedGHContext = {
    startX: number;
  };
  const handler = useAnimatedGestureHandler<AnimatedGHContext>({
    onStart: (_evt, ctx) => {
      ctx.startX = translateX.value;
    },

    onActive: (evt, ctx) => {
      const nextTranslate = evt.translationX + ctx.startX;
      translateX.value = Math.min(0, Math.max(nextTranslate, MAX_TRANSLATE));
    },

    onEnd: (evt) => {
      if (evt.velocityX < -20) {
        translateX.value = withSpring(
          MAX_TRANSLATE,
          springConfig(evt.velocityX)
        );
      } else {
        translateX.value = withSpring(0, springConfig(evt.velocityX));
      }
    },
  });

  const styles = useAnimatedStyle(() => {
    if (isRemoving.value) {
      return {
        height: withTiming(0, timingConfig, () => {
          runOnJS(onRemove)();
        }),
        transform: [
          {
            translateX: withTiming(-windowDimensions.width, timingConfig),
          },
        ],
      };
    }

    return {
      height: 78,
      transform: [
        {
          translateX: translateX.value,
        },
      ],
    };
  });

  function handleRemove() {
    isRemoving.value = true;
  }
  return (
    <View style={s.item}>
      <PanGestureHandler activeOffsetX={[-10, 10]} onGestureEvent={handler}>
        <Animated.View style={styles}>
          <ListItemContent item={item} />
          <View style={s.buttonsContainer}>
            <View
              style={{
                backgroundColor: 'red',
                width: BUTTON_WIDTH,
                justifyContent: 'center',
              }}>
              <Button onPress={handleRemove} title="Delete" color="white" />
            </View>
            <View
              style={{
                backgroundColor: 'blue',
                width: BUTTON_WIDTH,
                justifyContent: 'center',
              }}>
              <Button onPress={handleRemove} title="Edit" color="white" />
            </View>
          </View>
        </Animated.View>
      </PanGestureHandler>
    </View>
  );
}

function ListItemContent({ item }: { item: Data }) {
  return (
    <View style={s.itemContainer}>
      <View style={s.avatarContainer}>
        <Text style={s.avatarText}>{item.title[0]}</Text>
      </View>
      <Text style={s.title}>{item.title}</Text>
    </View>
  );
}

const s = StyleSheet.create({
  container: {
    flex: 1,
  },
  item: {
    justifyContent: 'center',
  },
  itemContainer: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 8,
    borderBottomWidth: 1,
    borderBottomColor: '#ccc',
  },
  avatarContainer: {
    width: 50,
    height: 50,
    borderRadius: 25,
    backgroundColor: 'grey',
    justifyContent: 'center',
    alignItems: 'center',
  },
  avatarText: {
    fontSize: 18,
    color: 'white',
  },
  title: {
    fontSize: 18,
    marginLeft: 16,
  },
  buttonsContainer: {
    position: 'absolute',
    top: 0,
    backgroundColor: 'pink',
    bottom: 0,
    flexDirection: 'row',
    flex: 1,
    left: windowDimensions.width,
    width: windowDimensions.width,
  },
});

export default SwipableList;

as you can see above. We have a PanGestureHandler component. When the user starts dragging the cell from the list.  The cell would move until MAX_TRANSLATE value. When the dragging value is smaller than -20. Buttons will be visible otherwise cell will snap in the original position.

This part is so important to understand how it works.

const handler = useAnimatedGestureHandler<AnimatedGHContext>({
    onStart: (_evt, ctx) => {
      ctx.startX = translateX.value;
    },

    onActive: (evt, ctx) => {
      const nextTranslate = evt.translationX + ctx.startX;
      translateX.value = Math.min(0, Math.max(nextTranslate, MAX_TRANSLATE));
    },

    onEnd: (evt) => {
      if (evt.velocityX < -20) {
        translateX.value = withSpring(
          MAX_TRANSLATE,
          springConfig(evt.velocityX)
        );
      } else {
        translateX.value = withSpring(0, springConfig(evt.velocityX));
      }
    },
  });

You can check the full snack code here.