cover-image

Tutorial: Tinder-like Swipe Cards

#programming
#typescript
#ios
#mobile
#reactnative
#android
#animations

Andrew Vo-Nguyen

November 16, 2021

10 min read

Demo

image-fd74f4ebd57e97fda6415a0209fa6f02d5c349fe-222x480-gif

1. Initialise a project either using the React Native CLI or Expo.

2. Install the following packages:

  • axios
  • typescript (dev dependency)

3. Sign up to a free account for https://randommer.io/ to generate random names. (Alternatively, you can hard code an array of names for this exercise).

4. Create a .env file with the randommer API key:

1API_KEY=xxxxxxxxxxxxxxxxxx 

If you want to use the hardcoded list of names instead, paste this into your code:

1const uniqueNames = [
2  "Zekiel",
3  "Vance",
4  "Buddy",
5  "Tyrell",
6  "Mohammed",
7  "Tehya",
8  "Wisdom",
9  "Ash",
10  "Mikel",
11  "Yazmine",
12  "Brystol",
13  "Merry",
14  "Tennyson",
15  "Jaxyn",
16  "Drea",
17  "Jase",
18  "Izabel",
19  "Christen",
20  "Terron",
21  "Shaylynn",
22  "Nicolle",
23  "Aldo",
24  "Dmitri",
25  "Sammie",
26  "Baltazar",
27  "Taevion",
28  "Jayvin",
29  "Aidyn",
30  "Aron",
31  "Maryann",
32  "Kathia",
33  "Raelynn",
34  "Eleazar",
35  "Atalia",
36  "Arda",
37  "Talitha",
38  "Ailin",
39  "Sione",
40  "Race",
41  "Reef",
42  "Giuseppe",
43  "Kelsy",
44  "Beck",
45  "Evalynn",
46  "Berkley",
47  "Adlee",
48  "Jeison",
49  "Asiyah",
50  "Samanvi",
51  "Elvis",
52  "Malayia",
53  "Mace",
54  "Novaleigh",
55  "Shneur",
56  "Damian",
57  "Kamdyn",
58  "Zayvion",
59  "Yahaira",
60  "Laiyah",
61  "Nitya",
62  "Anna",
63  "Skyleigh",
64  "Tremayne",
65  "Rocio",
66  "Vivianne",
67  "Bowen",
68  "Nathanial",
69  "Jeanne",
70  "Younis",
71  "Gibran",
72  "Emme",
73  "Kylo",
74  "Sergio",
75  "Manar",
76  "Dino",
77  "Devonta",
78  "Alexavier",
79  "Aric",
80  "Eleyna",
81  "Cristal",
82  "Maxim",
83  "Saanvi",
84  "Ayomide",
85  "Haniya",
86  "Alaa",
87  "Avyukth",
88  "Jane",
89  "Graciella",
90  "Livie",
91  "Alijah",
92  "Thalia",
93  "Alanis",
94  "Devon",
95  "Madelin",
96  "Nadir",
97  "Jermiah",
98  "Alesia",
99  "Elienai",
100  "Landon",
101];

For simplicity sake, we will be creating everything on a single page (App.tsx).

Imports

1import {
2  Animated,
3  Dimensions,
4  PanResponder,
5  StyleSheet,
6  Text,
7  TextStyle,
8  View,
9  ViewStyle,
10} from 'react-native';
11import { Fragment, useEffect, useRef, useState } from 'react';
12
13import Config from 'react-native-config';
14import React from 'react';
15import { SvgUri } from 'react-native-svg';
16import axios from 'axios';

Types

Here we declare the shape of our Person type which we will use to render our cards.

1interface Person {
2  name: string;
3  age: number;
4  photo: string;
5}
6
7interface Styles {
8  screenContainer: ViewStyle;
9  counterContainer: ViewStyle;
10  counterText: TextStyle;
11  imageContainer: ViewStyle;
12  nameLabel: TextStyle;
13  ageLabel: TextStyle;
14}
15
16interface CardStyles {
17  card: ViewStyle;
18  leftTextContainer: ViewStyle;
19  leftText: TextStyle;
20  rightTextContainer: ViewStyle;
21  rightText: TextStyle;
22}

API

Declare an api object for our asynchronous calls to generate names and avatar pictures.

1const api = {
2  names(quantity: number) {
3    return `https://randommer.io/api/Name?nameType=firstname&quantity=${quantity}`;
4  },
5  avatar(name: string) {
6    return `https://avatars.dicebear.com/api/micah/:${name}.svg`;
7  },
8};

State

Declare local state using useState hook:

1function App(): JSX.Element {
2	const [personList, setPersonList] = useState<Person[]>([]);
3	const [liked, setLiked] = useState<number>(0);
4	const [disLiked, setDisLiked] = useState<number>(0);
5	const [currentIndex, setCurrentIndex] = useState<number>(0);
6}
7
8export default App;

Animation Refs

Here we are maintaining the position of the card by using the Animated API. The PanResponder here is created to respond to the gestures of the cards.

onPanResponderMove keeps track of the current position and onPanResponderMove will perform an action upon swiping left or right. 120 is the threshold of distance required to perform the action. setCurrentIndex will update the list of cards of which card should be on top.

We store this data using the useRef hook instead of local state to prevent re-renders of the JSX elements every time a card moves position.

1const SCREEN_HEIGHT = Dimensions.get('window').height;
2const SCREEN_WIDTH = Dimensions.get('window').width;
3
4const position = useRef(new Animated.ValueXY());
5
6const panResponder = useRef(
7  PanResponder.create({
8    onStartShouldSetPanResponder: () => true,
9    onPanResponderMove: (_e, gestureState) => {
10      position.current.setValue({ x: gestureState.dx, y: gestureState.dy });
11    },
12    onPanResponderRelease: (_e, gestureState) => {
13      if (gestureState.dx > 120) {
14        Animated.spring(position.current, {
15          useNativeDriver: true,
16          toValue: { x: SCREEN_WIDTH + 100, y: gestureState.dy },
17        }).start(() => {
18          setCurrentIndex((prev) => prev + 1);
19          setLiked((prev) => prev + 1);
20          position.current.setValue({ x: 0, y: 0 });
21        });
22      } else if (gestureState.dx < -120) {
23        Animated.spring(position.current, {
24          useNativeDriver: true,
25          toValue: { x: -SCREEN_WIDTH - 100, y: gestureState.dy },
26        }).start(() => {
27          setCurrentIndex((prev) => prev + 1);
28          setDisLiked((prev) => prev + 1);
29          position.current.setValue({ x: 0, y: 0 });
30        });
31      } else {
32        Animated.spring(position.current, {
33          useNativeDriver: true,
34          toValue: { x: 0, y: 0 },
35          friction: 4,
36        }).start();
37      }
38    },
39  }),
40);

Fetching Data

Here we will use the useEffect hook to fetch out data from our API.

1const apiKey = Config.API_KEY;
2
3function generateAge(min: number, max: number) {
4  return Math.floor(Math.random() * (max - min) + min);
5}
6
7useEffect(() => {
8    async function fetchNames() {
9      const response = await axios.get<string[]>(api.names(100), {
10        headers: { 'X-API-Key': apiKey },
11      });
12      if (response.status !== 200) {
13        return;
14      }
15      const uniqueNames = [...new Set(response.data)];
16      const newPersonList = uniqueNames.map<Person>((name) => ({
17        name,
18        age: generateAge(18, 36),
19        photo: api.avatar(name),
20      }));
21      setPersonList(newPersonList);
22    }
23    fetchNames();
24  }, []);

Styling

We declare 2 sets of stylesheets. A styles object and a cardStyles object. The cardStyles object will be dynamic and will change based whether the card is the top card and its current gesture position.

By default the array of cards would render one card after another vertically on the screen. We need to make each card's position absolute ****in order to emulate the affect of having cards stacked on top of each other.

Unfortunately animated styles do not play nice with React Native's standard style types, so we'll just have to pop in some ts-ignore to tell TypeScript we know what we're doing.

1const styles: Styles = StyleSheet.create({
2  screenContainer: {
3    flex: 1,
4    padding: 8,
5    backgroundColor: '#FEFBF3',
6    flexDirection: 'column',
7    justifyContent: 'flex-end',
8    alignItems: 'stretch',
9  },
10  counterContainer: {
11    flexDirection: 'row',
12    justifyContent: 'space-between',
13    padding: 32,
14  },
15  counterText: {
16    fontSize: 32,
17  },
18  imageContainer: {
19    borderRadius: 150,
20    overflow: 'hidden',
21    backgroundColor: '#FED2AA',
22  },
23  nameLabel: {
24    fontSize: 48,
25    fontWeight: 'bold',
26    padding: 16,
27  },
28  ageLabel: {
29    fontSize: 36,
30    padding: 16,
31  },
32});
33
34function getCardStyles(
35  position: Animated.ValueXY,
36  topCard: boolean,
37): CardStyles {
38  let card: ViewStyle = {
39    height: SCREEN_HEIGHT - 192,
40    width: SCREEN_WIDTH - 32,
41    left: 16,
42    top: 64,
43    padding: 12,
44    position: 'absolute',
45    borderWidth: 1,
46    borderColor: '#EBE6E6',
47    borderRadius: 8,
48    backgroundColor: '#E8F6EF',
49    alignItems: 'center',
50  };
51  if (topCard) {
52    card = {
53      ...card,
54      transform: [
55        {
56          // @ts-ignore
57          rotate: position.x.interpolate({
58            inputRange: [-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
59            outputRange: ['-10deg', '0deg', '10deg'],
60            extrapolate: 'clamp',
61          }),
62        },
63        // @ts-ignore
64        ...position.getTranslateTransform(),
65      ],
66    };
67  } else {
68    card = {
69      ...card,
70      transform: [
71        {
72          // @ts-ignore
73          scale: position.x.interpolate({
74            inputRange: [-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
75            outputRange: [1, 0.8, 1],
76            extrapolate: 'clamp',
77          }),
78        },
79      ],
80    };
81  }
82
83  const leftTextContainer: ViewStyle = {
84    // @ts-ignore
85    opacity: position.x.interpolate({
86      inputRange: [-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
87      outputRange: [0, 0, 1],
88      extrapolate: 'clamp',
89    }),
90    transform: [{ rotate: '-40deg' }],
91    position: 'absolute',
92    top: 60,
93    left: 40,
94    zIndex: 2,
95  };
96  const rightTextContainer: ViewStyle = {
97    // @ts-ignore
98    opacity: position.x.interpolate({
99      inputRange: [-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
100      outputRange: [1, 0, 0],
101      extrapolate: 'clamp',
102    }),
103    transform: [{ rotate: '40deg' }],
104    position: 'absolute',
105    top: 60,
106    right: 40,
107    zIndex: 2,
108  };
109  const text: TextStyle = {
110    borderWidth: 1,
111    borderRadius: 8,
112    borderColor: '#FF5151',
113    color: '#FF5151',
114    fontSize: 32,
115    fontWeight: '800',
116    padding: 8,
117    lineHeight: 0,
118  };
119
120  return StyleSheet.create({
121    screenContainer: {
122      flex: 1,
123      padding: 8,
124    },
125    card,
126    leftTextContainer,
127    leftText: {
128      ...text,
129      borderColor: '#4E9F3D',
130      color: '#4E9F3D',
131    },
132    rightTextContainer,
133    rightText: {
134      ...text,
135      borderColor: '#FF5151',
136      color: '#FF5151',
137    },
138  });
139}

JSX Code

Here, we finally put it all together by rendering the array of personList as animated cards. We determine the current card by comparing the currentIndex stored in state, and the index of the card in the array. We will pass down whether this card is the top card down the to the getCardStyles function get its dynamic styles.

If the index is less than the currentIndex, it means the card as been swiped and dismissed, so we will return null as a result.

The counter container at the bottom will maintain how many Liked and Disliked swipes have occurred which gets updated in the PanResponder created previously.

Lastly, to make sure that the first element in the array is showing at the top of the card deck. It is sitting at the bottom of the deck because it was the first element to get rendered. We use the .reverse() array method on the cards to bring the first element visually to the top.

1<View style={styles.screenContainer}>
2    {personList
3      .map((person, index) => {
4        const currentCard = index === currentIndex;
5        const propsSpread = currentCard
6          ? panResponder.current.panHandlers
7          : undefined;
8        const cardStyles = getCardStyles(position.current, currentCard);
9
10        if (index < currentIndex) {
11          return null;
12        }
13        return (
14          <Animated.View
15            {...propsSpread}
16            key={person.name}
17            style={cardStyles.card}>
18            {currentCard && (
19              <Fragment>
20                <Animated.View style={cardStyles.leftTextContainer}>
21                  <Text style={cardStyles.leftText}>LIKE</Text>
22                </Animated.View>
23                <Animated.View style={cardStyles.rightTextContainer}>
24                  <Text style={cardStyles.rightText}>DISLIKE</Text>
25                </Animated.View>
26              </Fragment>
27            )}
28            <View style={styles.imageContainer}>
29              <SvgUri width={300} height={300} uri={person.photo} />
30            </View>
31            <Text style={styles.nameLabel}>{person.name}</Text>
32            <Text style={styles.ageLabel}>Age: {person.age}</Text>
33          </Animated.View>
34        );
35      })
36      .reverse()}
37    <View style={styles.counterContainer}>
38      <View>
39        <Text style={styles.counterText}>Liked: {liked}</Text>
40      </View>
41      <View>
42        <Text style={styles.counterText}>Disliked: {disLiked}</Text>
43      </View>
44    </View>
45  </View>

There we have it, a super simple component to handle Tinder like swipe cards.

The source code for this project can be found on here: GitHub ✌️.