├── .gitignore ├── App.tsx ├── README.md ├── app.json ├── assets ├── adaptive-icon.png ├── favicon.png ├── icon.png └── splash.png ├── babel.config.js ├── components ├── AnimatedButton.tsx ├── SwipeableCard.tsx └── index.ts ├── package.json ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialCommunityIcons } from "@expo/vector-icons"; 2 | import React, { useState } from "react"; 3 | import { SafeAreaView, StyleSheet, Text, View } from "react-native"; 4 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 5 | import Animated, { SlideInDown, SlideInUp } from "react-native-reanimated"; 6 | import { AnimatedButton, SwipeableCard } from "./components"; 7 | 8 | const tasks = [ 9 | "Drink 5 glasses of water", 10 | "Read a book", 11 | "Go for a walk", 12 | "Eat a healthy meal", 13 | "Sleep for 8 hours", 14 | ]; 15 | 16 | export default function App() { 17 | const [todos, setTodos] = useState([...tasks, ...tasks, ...tasks]); 18 | const [swipeDirection, setSwipeDirection] = useState<"LEFT" | "RIGHT" | "">( 19 | "" 20 | ); 21 | 22 | const updateTodos = () => { 23 | setTodos(todos.slice(1)); 24 | }; 25 | 26 | return ( 27 | 32 | 37 | 38 | Goals 39 | 46 | {todos.length === 0 ? ( 47 | 56 | Swiped all the cards! 🎉 57 | 58 | ) : ( 59 | 64 | )} 65 | 66 | {todos.length > 0 ? ( 67 | 68 | setSwipeDirection("LEFT")} 70 | _animatedWrapperStyle={[ 71 | StyleSheet.absoluteFill, 72 | styles.animatedWrapper, 73 | { 74 | top: -40, 75 | left: -40, 76 | }, 77 | ]} 78 | style={[ 79 | { 80 | alignItems: "flex-start", 81 | }, 82 | ]} 83 | > 84 | 89 | Save it later 90 | 91 | setSwipeDirection("RIGHT")} 93 | _animatedWrapperStyle={[ 94 | StyleSheet.absoluteFill, 95 | styles.animatedWrapper, 96 | { 97 | top: -40, 98 | left: 5, 99 | }, 100 | ]} 101 | style={[ 102 | { 103 | alignItems: "flex-end", 104 | }, 105 | ]} 106 | > 107 | 115 | Complete 116 | 117 | 118 | ) : null} 119 | 120 | 121 | ); 122 | } 123 | 124 | const styles = StyleSheet.create({ 125 | container: { 126 | backgroundColor: "#98B9D4", 127 | flex: 1, 128 | }, 129 | buttonText: { 130 | color: "#f1f1f1", 131 | fontSize: 18, 132 | fontWeight: "bold", 133 | }, 134 | animatedWrapper: { 135 | backgroundColor: "#fff", 136 | borderRadius: 999, 137 | height: 120, 138 | width: 120, 139 | opacity: 0.3, 140 | }, 141 | heading: { 142 | fontSize: 28, 143 | fontWeight: "bold", 144 | color: "#f1f1f1", 145 | textAlign: "center", 146 | marginVertical: 32, 147 | }, 148 | ctaWrapper: { 149 | justifyContent: "space-between", 150 | flexDirection: "row", 151 | width: "100%", 152 | paddingHorizontal: 24, 153 | marginTop: 72, 154 | }, 155 | }); 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | 1. Install dependencies 4 | 5 | ```bash 6 | yarn 7 | ``` 8 | 9 | 2. Running development server 10 | 11 | ```bash 12 | # For web 13 | yarn web 14 | 15 | # For android 16 | yarn android 17 | 18 | # For ios 19 | yarn ios 20 | ``` 21 | ### Demo 22 | 23 | 24 | 25 | https://github.com/ankit-tailor/react-native-swipeable-deck/assets/44310861/50d4775d-3aae-4a68-819a-5634b0ec1c75 26 | 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "gyroscope-swipable-cards", 4 | "slug": "gyroscope-swipable-cards", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": [ 15 | "**/*" 16 | ], 17 | "ios": { 18 | "supportsTablet": true 19 | }, 20 | "android": { 21 | "adaptiveIcon": { 22 | "foregroundImage": "./assets/adaptive-icon.png", 23 | "backgroundColor": "#ffffff" 24 | } 25 | }, 26 | "web": { 27 | "favicon": "./assets/favicon.png" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankit-tailor/react-native-swipeable-deck/48df00a16b06084de927bfaac7e5879ec464d847/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankit-tailor/react-native-swipeable-deck/48df00a16b06084de927bfaac7e5879ec464d847/assets/favicon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankit-tailor/react-native-swipeable-deck/48df00a16b06084de927bfaac7e5879ec464d847/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankit-tailor/react-native-swipeable-deck/48df00a16b06084de927bfaac7e5879ec464d847/assets/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: ["react-native-reanimated/plugin"], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /components/AnimatedButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Pressable, PressableProps, StyleSheet, ViewProps } from "react-native"; 3 | import Animated, { 4 | Extrapolate, 5 | interpolate, 6 | useAnimatedStyle, 7 | useSharedValue, 8 | withSequence, 9 | withTiming, 10 | } from "react-native-reanimated"; 11 | 12 | type IAnimatedButtonProps = Omit & { 13 | children?: React.ReactNode; 14 | _animatedWrapperStyle?: ViewProps["style"]; 15 | }; 16 | 17 | export const AnimatedButton = ({ 18 | children, 19 | style, 20 | _animatedWrapperStyle, 21 | ...props 22 | }: IAnimatedButtonProps) => { 23 | const scaleValue = useSharedValue(0); 24 | const opacityValue = useSharedValue(0); 25 | 26 | const circleStyleAnimation = 27 | ( 28 | scaleValue: Animated.SharedValue, 29 | opacityValue: Animated.SharedValue 30 | ) => 31 | () => { 32 | "worklet"; 33 | return { 34 | transform: [ 35 | { 36 | scale: interpolate( 37 | scaleValue.value, 38 | [0, 1], 39 | [0, 1], 40 | Extrapolate.CLAMP 41 | ), 42 | }, 43 | ], 44 | opacity: interpolate( 45 | opacityValue.value, 46 | [0, 1], 47 | [0, 1], 48 | Extrapolate.CLAMP 49 | ), 50 | }; 51 | }; 52 | 53 | const animatedStyles = useAnimatedStyle( 54 | circleStyleAnimation(scaleValue, opacityValue) 55 | ); 56 | 57 | const handlePressIn = 58 | ( 59 | scaleValue: Animated.SharedValue, 60 | opacityValue: Animated.SharedValue 61 | ) => 62 | () => { 63 | opacityValue.value = withTiming(0.3); 64 | scaleValue.value = withSequence( 65 | withTiming(1, { duration: 600 }), 66 | withTiming(0.98, {}, (isCompleted) => { 67 | if (isCompleted) { 68 | opacityValue.value = withTiming( 69 | 0, 70 | { duration: 200 }, 71 | (isCompleted) => { 72 | if (isCompleted) { 73 | scaleValue.value = 0; 74 | opacityValue.value = 0; 75 | } 76 | } 77 | ); 78 | } 79 | }) 80 | ); 81 | }; 82 | 83 | const pressableStyles = Array.isArray(style) 84 | ? [styles.container, ...style] 85 | : [styles.container, style]; 86 | 87 | return ( 88 | 93 | 94 | {children} 95 | 96 | ); 97 | }; 98 | 99 | const styles = StyleSheet.create({ 100 | container: { 101 | gap: 4, 102 | }, 103 | }); 104 | -------------------------------------------------------------------------------- /components/SwipeableCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Dimensions, StyleSheet, Text } from "react-native"; 3 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; 4 | import Animated, { 5 | Extrapolate, 6 | interpolate, 7 | runOnJS, 8 | useAnimatedStyle, 9 | useSharedValue, 10 | withDelay, 11 | withRepeat, 12 | withSequence, 13 | withTiming, 14 | } from "react-native-reanimated"; 15 | 16 | interface ISwipeableCardProps { 17 | swipeDirection: "LEFT" | "RIGHT" | ""; 18 | setSwipeDirection: (direction: "LEFT" | "RIGHT" | "") => void; 19 | todos: any; 20 | } 21 | 22 | const windowWidth = Dimensions.get("window").width; 23 | const LAST_SWIPABLE_POINT = 120; 24 | const DURATION = 700; 25 | const DELAY = 200; 26 | 27 | export const SwipeableCard = ({ 28 | swipeDirection, 29 | setSwipeDirection, 30 | todos, 31 | }: ISwipeableCardProps) => { 32 | const swipeOffset = useSharedValue(0); 33 | const initialAnimation = useSharedValue(0); 34 | const cardScale = useSharedValue(0); 35 | 36 | const [currentTodo, setCurrentTodo] = useState(0); 37 | const [nextTodo, setNextTodo] = useState(currentTodo + 1); 38 | 39 | const currentTodoData = todos[currentTodo]; 40 | const nextTodoData = todos[nextTodo]; 41 | 42 | const resetAnimations = () => { 43 | "worklet"; 44 | initialAnimation.value = 0; 45 | swipeOffset.value = 0; 46 | cardScale.value = 0; 47 | 48 | cardScale.value = withRepeat( 49 | withTiming(1, { 50 | duration: DURATION, 51 | }), 52 | 1, 53 | false 54 | ); 55 | initialAnimation.value = withRepeat( 56 | withTiming(1, { 57 | duration: DURATION, 58 | }), 59 | 1, 60 | false 61 | ); 62 | }; 63 | 64 | const onAnimationComplete = (isFinished?: boolean) => { 65 | "worklet"; 66 | if (isFinished) { 67 | runOnJS(setCurrentTodo)(nextTodo); 68 | runOnJS(setSwipeDirection)(""); 69 | } 70 | }; 71 | 72 | const pan = Gesture.Pan() 73 | .onTouchesDown(() => { 74 | cardScale.value = withTiming(0.9); 75 | }) 76 | .onTouchesUp(() => { 77 | cardScale.value = withTiming(1); 78 | }) 79 | .onChange((event) => { 80 | const translationX = event.translationX; 81 | const velocityX = event.velocityX; 82 | 83 | if (Math.abs(velocityX) > 0) { 84 | swipeOffset.value = translationX; 85 | } 86 | }) 87 | .onFinalize((event) => { 88 | const absX = event.absoluteX; 89 | const velocityX = event.velocityX; 90 | 91 | if (Math.abs(velocityX) > 0) { 92 | if ( 93 | absX <= LAST_SWIPABLE_POINT || 94 | Math.abs(windowWidth - absX) <= LAST_SWIPABLE_POINT 95 | ) { 96 | if (absX <= LAST_SWIPABLE_POINT) { 97 | swipeOffset.value = withTiming( 98 | -windowWidth, 99 | { duration: DURATION }, 100 | onAnimationComplete 101 | ); 102 | } else if (Math.abs(windowWidth - absX) <= LAST_SWIPABLE_POINT) { 103 | swipeOffset.value = withTiming( 104 | windowWidth, 105 | { duration: DURATION }, 106 | onAnimationComplete 107 | ); 108 | } 109 | } else { 110 | swipeOffset.value = withTiming(0); 111 | } 112 | } 113 | }); 114 | 115 | React.useEffect(() => { 116 | resetAnimations(); 117 | setNextTodo((prev) => prev + 1); 118 | }, [currentTodoData]); 119 | 120 | React.useEffect(() => { 121 | if (swipeDirection === "LEFT") { 122 | swipeOffset.value = withSequence( 123 | withTiming(-windowWidth / 3, { duration: DURATION }), 124 | withDelay( 125 | DELAY, 126 | withTiming(-windowWidth, { duration: DURATION }, onAnimationComplete) 127 | ) 128 | ); 129 | } else if (swipeDirection === "RIGHT") { 130 | swipeOffset.value = withSequence( 131 | withTiming(windowWidth / 4, { duration: DURATION }), 132 | withDelay( 133 | DELAY, 134 | withTiming(windowWidth, { duration: DURATION }, onAnimationComplete) 135 | ) 136 | ); 137 | } 138 | }, [swipeDirection]); 139 | 140 | const swipeableCardStyles = useAnimatedStyle(() => { 141 | return { 142 | transform: [ 143 | { 144 | translateX: swipeOffset.value, 145 | }, 146 | { 147 | rotate: `${interpolate( 148 | swipeOffset.value, 149 | [0, LAST_SWIPABLE_POINT], 150 | [0, 10], 151 | Extrapolate.EXTEND 152 | )}deg`, 153 | }, 154 | { 155 | scale: interpolate( 156 | cardScale.value, 157 | [0, 1], 158 | [0.86, 1], 159 | Extrapolate.CLAMP 160 | ), 161 | }, 162 | { 163 | translateY: interpolate( 164 | initialAnimation.value, 165 | [0, 1], 166 | [-9, 30], 167 | Extrapolate.CLAMP 168 | ), 169 | }, 170 | ], 171 | opacity: interpolate( 172 | initialAnimation.value, 173 | [0, 1], 174 | [0.9, 1], 175 | Extrapolate.CLAMP 176 | ), 177 | }; 178 | }); 179 | 180 | const queuedCardStyles = useAnimatedStyle(() => { 181 | return { 182 | transform: [ 183 | { 184 | scale: interpolate( 185 | Math.abs(swipeOffset.value), 186 | [0, windowWidth], 187 | [0.8, 0.85], 188 | Extrapolate.CLAMP 189 | ), 190 | }, 191 | { 192 | translateY: interpolate( 193 | Math.abs(swipeOffset.value), 194 | [0, windowWidth], 195 | [-16, -9], 196 | Extrapolate.CLAMP 197 | ), 198 | }, 199 | ], 200 | opacity: interpolate( 201 | Math.abs(swipeOffset.value), 202 | [0, windowWidth], 203 | [0.5, 0.9], 204 | Extrapolate.CLAMP 205 | ), 206 | }; 207 | }); 208 | 209 | return ( 210 | <> 211 | {nextTodoData ? ( 212 | 215 | {nextTodoData} 216 | 217 | ) : null} 218 | 219 | {currentTodoData ? ( 220 | 221 | 222 | {currentTodoData} 223 | 224 | 225 | ) : null} 226 | 227 | ); 228 | }; 229 | 230 | const styles = StyleSheet.create({ 231 | card: { 232 | height: 300, 233 | width: 300, 234 | borderRadius: 20, 235 | backgroundColor: "#fff", 236 | alignItems: "center", 237 | justifyContent: "center", 238 | }, 239 | text: { 240 | fontSize: 18, 241 | fontWeight: "bold", 242 | color: "#5C98AA", 243 | }, 244 | }); 245 | -------------------------------------------------------------------------------- /components/index.ts: -------------------------------------------------------------------------------- 1 | export { AnimatedButton } from "./AnimatedButton"; 2 | export { SwipeableCard } from "./SwipeableCard"; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swipable-cards", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "dependencies": { 12 | "@expo/vector-icons": "^13.0.0", 13 | "@expo/webpack-config": "^19.0.0", 14 | "expo": "~49.0.15", 15 | "expo-status-bar": "~1.6.0", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0", 18 | "react-native": "^0.73.2", 19 | "react-native-gesture-handler": "~2.12.0", 20 | "react-native-reanimated": "~3.3.0", 21 | "react-native-web": "~0.19.6" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.20.0", 25 | "@types/react": "~18.2.14", 26 | "typescript": "^5.1.3" 27 | }, 28 | "private": true 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------