├── .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 |
--------------------------------------------------------------------------------