├── .expo-shared └── assets.json ├── .gitignore ├── App.js ├── BoxDetails.js ├── DetailsView.js ├── README.md ├── app.json ├── assets ├── adaptive-icon.png ├── favicon.png ├── headphones.png ├── icon.png └── splash.png ├── babel.config.js ├── package.json └── yarn.lock /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | npm-debug.* 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | web-build/ 11 | 12 | # macOS 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | StyleSheet, 4 | Text, 5 | Image, 6 | View, 7 | TouchableOpacity, 8 | StatusBar, 9 | } from "react-native"; 10 | import BoxDetails from "./BoxDetails"; 11 | 12 | StatusBar.setBarStyle("dark-content"); 13 | 14 | export default function App() { 15 | const [detailsVisible, setDetailsVisible] = useState(false); 16 | return ( 17 | 18 | setDetailsVisible(true)} 27 | > 28 | 33 | 34 | 42 | Open headphone details 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | const styles = StyleSheet.create({ 51 | container: { 52 | flex: 1, 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /BoxDetails.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { View, StyleSheet, Image, Dimensions } from "react-native"; 3 | import Animated, { 4 | useAnimatedGestureHandler, 5 | useSharedValue, 6 | useAnimatedStyle, 7 | withTiming, 8 | runOnJS, 9 | useDerivedValue, 10 | } from "react-native-reanimated"; 11 | import { PanGestureHandler } from "react-native-gesture-handler"; 12 | import * as ImageManipulator from "expo-image-manipulator"; 13 | import ViewShot from "react-native-view-shot"; 14 | import DetailsView from "./DetailsView"; 15 | 16 | const { width, height } = Dimensions.get("screen"); 17 | 18 | const AnimatedImage = Animated.createAnimatedComponent(Image); 19 | 20 | const AUTOMATIC_DURATION = 80; 21 | const STRIP_COUNT = 30; 22 | const MANUAL_DURATION = 30; 23 | 24 | export default function BoxDetails({ show, setShow, stripCount = STRIP_COUNT }) { 25 | const prevShownRef = useRef(false); 26 | const contentRef = useRef(null); 27 | const [captured, setCaptured] = useState([]); 28 | const translateX = new Array(stripCount) 29 | .fill(0) 30 | .map(() => useSharedValue(width)); 31 | const animatedIndex = useSharedValue(0); 32 | const opacityIndex = useSharedValue(stripCount - 1); 33 | 34 | useEffect(() => { 35 | if (prevShownRef.current !== show) { 36 | prevShownRef.current = show; 37 | if (show) { 38 | opacityIndex.value = stripCount - 1; 39 | animatedIndex.value = 0; 40 | translateX[0].value = withTiming(0, { duration: AUTOMATIC_DURATION }); 41 | } 42 | } 43 | }, [show]); 44 | 45 | useEffect(() => { 46 | setTimeout(() => { 47 | onPageLoaded(); 48 | }, 200); 49 | }, []); 50 | 51 | const onGestureEvent = useAnimatedGestureHandler({ 52 | onStart: ({ absoluteY }, ctx) => { 53 | animatedIndex.value = ~~(absoluteY / (height / stripCount)); 54 | opacityIndex.value = 55 | animatedIndex.value > stripCount / 2 ? 0 : stripCount - 1; 56 | 57 | ctx.offsetX = translateX[animatedIndex.value].value; 58 | }, 59 | onActive: (event, ctx) => { 60 | let nextX = ctx.offsetX + event.translationX; 61 | if (nextX < 0) nextX = 0; 62 | translateX[animatedIndex.value].value = nextX; 63 | }, 64 | onEnd: (event) => { 65 | let nextX = 0; 66 | if (event.velocityX > 800) { 67 | nextX = width; 68 | } 69 | 70 | translateX[animatedIndex.value].value = withTiming( 71 | nextX, 72 | { duration: 200 }, 73 | (isFinished) => { 74 | if (isFinished && nextX > 0) { 75 | runOnJS(setShow)(false); 76 | } 77 | } 78 | ); 79 | }, 80 | }); 81 | 82 | useDerivedValue(() => { 83 | let inCount = 0; 84 | let outCount = 0; 85 | 86 | for (let i = animatedIndex.value + 1; i < stripCount; i++) { 87 | const nextIndex = animatedIndex.value + outCount++; 88 | translateX[i].value = withTiming(translateX[nextIndex].value, { 89 | duration: MANUAL_DURATION, 90 | }); 91 | } 92 | 93 | for (let i = animatedIndex.value - 1; i >= 0; i--) { 94 | const nextIndex = animatedIndex.value + inCount--; 95 | translateX[i].value = withTiming(translateX[nextIndex].value, { 96 | duration: MANUAL_DURATION, 97 | }); 98 | } 99 | }); 100 | 101 | const boxAnimations = translateX.map((_, i) => { 102 | return useAnimatedStyle(() => { 103 | return { 104 | transform: [{ translateX: translateX[i].value }], 105 | opacity: translateX[opacityIndex.value].value === 0 ? 0 : 1, 106 | }; 107 | }); 108 | }); 109 | const contentOpacityAnim = useAnimatedStyle(() => { 110 | return { 111 | opacity: translateX[opacityIndex.value].value === 0 ? 1 : 0, 112 | }; 113 | }); 114 | 115 | const onPageLoaded = async () => { 116 | const uri = await contentRef.current.capture({ 117 | format: "jpg", 118 | quality: 0.8, 119 | }); 120 | Image.getSize(uri, async (width, height) => { 121 | let reqHeight = height / stripCount; 122 | 123 | let images = []; 124 | for (let i = 0; i < stripCount; i++) { 125 | const res = await ImageManipulator.manipulateAsync( 126 | uri, 127 | [ 128 | { 129 | crop: { 130 | originX: 0, 131 | originY: i * reqHeight, 132 | width, 133 | height: reqHeight, 134 | }, 135 | }, 136 | ], 137 | { compress: 1, format: ImageManipulator.SaveFormat.JPEG } 138 | ); 139 | images.push(res.uri); 140 | } 141 | setCaptured(images); 142 | }); 143 | }; 144 | 145 | return ( 146 | 150 | 151 | 152 | 153 | {captured.map((uri, i) => { 154 | return ( 155 | 163 | ); 164 | })} 165 | 166 | 169 | 170 | { 172 | opacityIndex.value = 0; 173 | animatedIndex.value = 0; 174 | translateX[0].value = withTiming( 175 | width, 176 | { 177 | duration: AUTOMATIC_DURATION, 178 | }, 179 | () => { 180 | runOnJS(setShow)(false); 181 | } 182 | ); 183 | }} 184 | /> 185 | 186 | 187 | 188 | 189 | 190 | ); 191 | } 192 | const styles = StyleSheet.create({ 193 | root: { 194 | flex: 1, 195 | }, 196 | fakeContent: (stripCount) => ({ 197 | width, 198 | height: height / stripCount, 199 | }), 200 | }); 201 | -------------------------------------------------------------------------------- /DetailsView.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import { 3 | View, 4 | Text, 5 | ScrollView, 6 | TouchableOpacity, 7 | Dimensions, 8 | Image, 9 | StyleSheet, 10 | } from "react-native"; 11 | import { FontAwesome5 } from "@expo/vector-icons"; 12 | import Animated, { 13 | useAnimatedStyle, 14 | useSharedValue, 15 | interpolate, 16 | withTiming, 17 | Easing, 18 | } from "react-native-reanimated"; 19 | import { 20 | getBottomSpace, 21 | getStatusBarHeight, 22 | } from "react-native-iphone-x-helper"; 23 | 24 | const colorPrimary = "rgb(11,70,245)"; 25 | 26 | const { width } = Dimensions.get("window"); 27 | 28 | const ORIGINAL_BUTTON_WIDTH = width - 100; 29 | const BUTTON_SIZE = 50; 30 | 31 | const COORDS = { x: 0, y: 0 }; 32 | 33 | export default function DetailsView({ onBackPress }) { 34 | const buttonRef = useRef(null); 35 | const cartRef = useRef(null); 36 | 37 | const cartCoords = useSharedValue({ ...COORDS }); 38 | const ballCoords = useSharedValue({ ...COORDS }); 39 | 40 | const ballOpacity = useSharedValue(0); 41 | const ballAnimation = useSharedValue(0); 42 | const buttonWidth = useSharedValue(1); 43 | const buttonOpacity = useSharedValue(1); 44 | 45 | function calcBezier(interpolatedValue, p0, p1, p2) { 46 | "worklet"; 47 | return Math.round( 48 | Math.pow(1 - interpolatedValue, 2) * p0 + 49 | 2 * (1 - interpolatedValue) * interpolatedValue * p1 + 50 | Math.pow(interpolatedValue, 2) * p2 51 | ); 52 | } 53 | 54 | const ballStyle = useAnimatedStyle(() => { 55 | const cart = cartCoords.value; 56 | const ball = ballCoords.value; 57 | 58 | const translateX = calcBezier( 59 | ballAnimation.value, 60 | ball.x, 61 | ball.x, 62 | cart.x 63 | ); 64 | const translateY = calcBezier( 65 | ballAnimation.value, 66 | ball.y, 67 | cart.y - 10, 68 | cart.y - 10 69 | ); 70 | 71 | return { 72 | opacity: ballOpacity.value, 73 | transform: [ 74 | { translateX }, 75 | { translateY }, 76 | { scale: interpolate(ballAnimation.value, [0, 1], [1, 0.2]) }, 77 | ], 78 | }; 79 | }); 80 | 81 | const buttonStyle = useAnimatedStyle(() => { 82 | return { 83 | opacity: buttonOpacity.value, 84 | width: interpolate( 85 | buttonWidth.value, 86 | [0, 1], 87 | [BUTTON_SIZE, ORIGINAL_BUTTON_WIDTH] 88 | ), 89 | }; 90 | }); 91 | 92 | const labelStyle = useAnimatedStyle(() => { 93 | return { 94 | opacity: buttonWidth.value, 95 | }; 96 | }); 97 | 98 | function setBallPosition(y) { 99 | ballCoords.value = { x: width / 2 - BUTTON_SIZE / 2, y }; 100 | } 101 | 102 | return ( 103 | 104 | 108 | 109 | 110 | 115 | 116 | 117 | 118 | 4TWIGGERS NEO - (2021 Edition) 119 | 120 | - Unparalleled sound 121 | - Ear comfort 122 | - Bluetooth 5.0 123 | - 15 Hour battery life 124 | - Quick charge 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Was: 133 | $ 350 134 | 135 | 136 | Price: 137 | $ 199 138 | 139 | 140 | 141 | {/** Wrapped into View since TouchableOpacity can't animate opacity */} 142 | 143 | { 148 | buttonRef.current.measure( 149 | (_x, _y, _width, _height, _px, py) => { 150 | setBallPosition(py); 151 | 152 | buttonWidth.value = withTiming( 153 | 0, 154 | { 155 | duration: 300, 156 | easing: Easing.bezier(0.11, 0, 0.5, 0), 157 | }, 158 | () => { 159 | ballOpacity.value = 1; 160 | buttonOpacity.value = 0; 161 | ballAnimation.value = withTiming(1, { 162 | duration: 900, 163 | easing: Easing.bezier(0.12, 0, 0.39, 0), 164 | }); 165 | } 166 | ); 167 | } 168 | ); 169 | }} 170 | > 171 | 172 | Add to Cart 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 186 | 187 | { 191 | //Precalculate cart button position 192 | if ( 193 | cartRef.current && 194 | !cartCoords.value.x && 195 | !cartCoords.value.y 196 | ) 197 | cartRef.current.measure( 198 | (_x, _y, _width, _height, px, py) => { 199 | cartCoords.value = { x: px, y: py }; 200 | } 201 | ); 202 | }} 203 | > 204 | 209 | 210 | 211 | 212 | 213 | 214 | ); 215 | } 216 | 217 | const styles = StyleSheet.create({ 218 | flex: { 219 | flex: 1, 220 | backgroundColor: "white", 221 | }, 222 | navContainer: { 223 | top: getStatusBarHeight() + 40, 224 | position: "absolute", 225 | width: "100%", 226 | flexDirection: "row", 227 | justifyContent: "space-between", 228 | alignItems: "center", 229 | paddingHorizontal: 24, 230 | }, 231 | navButton: { 232 | height: 44, 233 | width: 44, 234 | backgroundColor: "white", 235 | borderRadius: 12, 236 | alignItems: "center", 237 | justifyContent: "center", 238 | }, 239 | scrollContent: { 240 | flexGrow: 1, 241 | justifyContent: "space-between", 242 | paddingBottom: getBottomSpace() || 20, 243 | }, 244 | content: { 245 | paddingHorizontal: 20, 246 | }, 247 | imageContainer: { 248 | backgroundColor: "rgb(242,242,242)", 249 | paddingHorizontal: 20, 250 | paddingTop: 60, 251 | paddingBottom: 20, 252 | marginBottom: 40, 253 | alignItems: "center", 254 | justifyContent: "center", 255 | }, 256 | image: { 257 | height: 300, 258 | width: "100%", 259 | }, 260 | divider: { 261 | width: "100%", 262 | height: 1, 263 | backgroundColor: "#8a8a8a", 264 | marginBottom: 10, 265 | opacity: 0.2, 266 | }, 267 | title: { 268 | fontSize: 30, 269 | fontWeight: "bold", 270 | color: "#2b2b2b", 271 | marginBottom: 10, 272 | }, 273 | description: { 274 | color: "#454545", 275 | fontSize: 14, 276 | marginVertical: 5, 277 | }, 278 | priceKey: { 279 | color: "#454545", 280 | width: 40, 281 | }, 282 | priceContainer: { 283 | marginLeft: 20, 284 | marginTop: 30, 285 | }, 286 | priceRow: { 287 | flexDirection: "row", 288 | alignItems: "center", 289 | marginLeft: 20, 290 | }, 291 | priceOld: { 292 | color: "#595959", 293 | fontWeight: "bold", 294 | fontSize: 18, 295 | textDecorationLine: "line-through", 296 | opacity: 0.7, 297 | }, 298 | price: { 299 | color: "#454545", 300 | fontSize: 20, 301 | fontWeight: "bold", 302 | }, 303 | buttonContainer: { 304 | marginTop: 10, 305 | borderRadius: BUTTON_SIZE / 2, 306 | width: ORIGINAL_BUTTON_WIDTH, 307 | height: BUTTON_SIZE, 308 | alignSelf: "center", 309 | overflow: "hidden", 310 | }, 311 | button: { 312 | backgroundColor: colorPrimary, 313 | alignItems: "center", 314 | justifyContent: "center", 315 | flex: 1, 316 | }, 317 | buttonLabel: { 318 | color: "white", 319 | width: ORIGINAL_BUTTON_WIDTH, 320 | textAlign: "center", 321 | }, 322 | cartItemBall: { 323 | position: "absolute", 324 | height: BUTTON_SIZE, 325 | width: BUTTON_SIZE, 326 | borderRadius: BUTTON_SIZE / 2, 327 | backgroundColor: colorPrimary, 328 | }, 329 | }); 330 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Wave navigation implemented with Reanimated 2 2 | 3 | ### Here's the result 4 | https://user-images.githubusercontent.com/5978212/127493780-921fd196-f860-4f1b-89ae-ffc527e03b57.mp4 5 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "BoxDismiss", 4 | "slug": "BoxDismiss", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "splash": { 9 | "image": "./assets/splash.png", 10 | "resizeMode": "contain", 11 | "backgroundColor": "#ffffff" 12 | }, 13 | "updates": { 14 | "fallbackToCacheTimeout": 0 15 | }, 16 | "assetBundlePatterns": [ 17 | "**/*" 18 | ], 19 | "ios": { 20 | "supportsTablet": true 21 | }, 22 | "android": { 23 | "adaptiveIcon": { 24 | "foregroundImage": "./assets/adaptive-icon.png", 25 | "backgroundColor": "#FFFFFF" 26 | } 27 | }, 28 | "web": { 29 | "favicon": "./assets/favicon.png" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrius/react-native-wave-navigation/6c80287e7967b167f62d7781ef3055bff7f46eb2/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrius/react-native-wave-navigation/6c80287e7967b167f62d7781ef3055bff7f46eb2/assets/favicon.png -------------------------------------------------------------------------------- /assets/headphones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrius/react-native-wave-navigation/6c80287e7967b167f62d7781ef3055bff7f46eb2/assets/headphones.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrius/react-native-wave-navigation/6c80287e7967b167f62d7781ef3055bff7f46eb2/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandrius/react-native-wave-navigation/6c80287e7967b167f62d7781ef3055bff7f46eb2/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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject" 9 | }, 10 | "dependencies": { 11 | "expo": "~41.0.1", 12 | "expo-image-manipulator": "~9.1.0", 13 | "react": "16.13.1", 14 | "react-dom": "16.13.1", 15 | "react-native": "https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz", 16 | "react-native-gesture-handler": "~1.10.2", 17 | "react-native-iphone-x-helper": "^1.3.1", 18 | "react-native-reanimated": "~2.1.0", 19 | "react-native-view-shot": "3.1.2", 20 | "react-native-web": "~0.13.12" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.14.2" 24 | }, 25 | "private": true 26 | } 27 | --------------------------------------------------------------------------------