├── assets
└── images
│ ├── icon.png
│ ├── splash.png
│ ├── favicon.png
│ ├── react-logo.png
│ ├── adaptive-icon.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── partial-react-logo.png
├── babel.config.js
├── tsconfig.json
├── .gitignore
├── utils
└── index.tsx
├── app
├── _layout.tsx
└── index.tsx
├── mock
└── DATA.ts
├── app.json
├── components
└── CustomButton.tsx
├── package.json
└── README.md
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/splash.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arunabhverma/expo-action-menu/HEAD/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
17 | # The following patterns were generated by expo-cli
18 |
19 | expo-env.d.ts
20 | # @end expo-cli
--------------------------------------------------------------------------------
/utils/index.tsx:
--------------------------------------------------------------------------------
1 | export function convertRgbToRgba(rgb: string, opacity: number): string {
2 | const rgbValues: number[] = rgb.match(/\d+/g)?.map(Number) ?? [];
3 | if (opacity < 0 || opacity > 1) {
4 | throw new Error("Opacity must be between 0 and 1");
5 | }
6 | const rgba: string = `rgba(${rgbValues[0]}, ${rgbValues[1]}, ${rgbValues[2]}, ${opacity})`;
7 | return rgba;
8 | }
9 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from "@react-navigation/native";
6 | import { Stack } from "expo-router";
7 | import { useColorScheme } from "react-native";
8 | import "react-native-reanimated";
9 |
10 | export default function RootLayout() {
11 | const colorScheme = useColorScheme();
12 | return (
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/mock/DATA.ts:
--------------------------------------------------------------------------------
1 | export const DATA = [
2 | {
3 | id: "1",
4 | name: "Emily Parker",
5 | image: "https://randomuser.me/api/portraits/women/67.jpg",
6 | },
7 | {
8 | id: "2",
9 | name: "Michael Johnson",
10 | image: "https://randomuser.me/api/portraits/men/74.jpg",
11 | },
12 | {
13 | id: "3",
14 | name: "Sophia Anderson",
15 | image: "https://randomuser.me/api/portraits/women/44.jpg",
16 | },
17 | {
18 | id: "4",
19 | name: "James Miller",
20 | image: "https://randomuser.me/api/portraits/men/73.jpg",
21 | },
22 | {
23 | id: "5",
24 | name: "Olivia Davis",
25 | image: "https://randomuser.me/api/portraits/women/71.jpg",
26 | },
27 | ];
28 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "expo-action-menu",
4 | "slug": "expo-action-menu",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "splash": {
11 | "image": "./assets/images/splash.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "ios": {
16 | "supportsTablet": true
17 | },
18 | "android": {
19 | "adaptiveIcon": {
20 | "foregroundImage": "./assets/images/adaptive-icon.png",
21 | "backgroundColor": "#ffffff"
22 | }
23 | },
24 | "web": {
25 | "bundler": "metro",
26 | "output": "static",
27 | "favicon": "./assets/images/favicon.png"
28 | },
29 | "plugins": ["expo-router"],
30 | "experiments": {
31 | "typedRoutes": true
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/components/CustomButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Pressable, PressableProps, View } from "react-native";
3 | import * as Haptics from "expo-haptics";
4 | import Animated, {
5 | useAnimatedStyle,
6 | withTiming,
7 | } from "react-native-reanimated";
8 |
9 | const Button = (props: PressableProps) => {
10 | const [isPressed, setIsPressed] = useState(false);
11 |
12 | const animatedStyle = useAnimatedStyle(() => {
13 | return {
14 | transform: [
15 | {
16 | scale: isPressed
17 | ? withTiming(0.5, { duration: 200 })
18 | : withTiming(1, { duration: 200 }),
19 | },
20 | ],
21 | };
22 | });
23 |
24 | const onTouchEnd = () => {
25 | setIsPressed(false);
26 | };
27 |
28 | return (
29 | {
32 | setIsPressed(true);
33 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
34 | setTimeout(onTouchEnd, 100);
35 | }}
36 | onTouchEnd={onTouchEnd}
37 | >
38 |
39 | {props.children}
40 |
41 |
42 | );
43 | };
44 |
45 | export default Button;
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expo-action-menu",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo start --android",
9 | "ios": "expo start --ios",
10 | "web": "expo start --web",
11 | "test": "jest --watchAll",
12 | "lint": "expo lint"
13 | },
14 | "jest": {
15 | "preset": "jest-expo"
16 | },
17 | "dependencies": {
18 | "@expo/vector-icons": "^14.0.2",
19 | "@react-navigation/native": "^6.0.2",
20 | "expo": "~51.0.28",
21 | "expo-constants": "~16.0.2",
22 | "expo-font": "~12.0.9",
23 | "expo-linking": "~6.3.1",
24 | "expo-router": "~3.5.23",
25 | "expo-splash-screen": "~0.27.5",
26 | "expo-status-bar": "~1.12.1",
27 | "expo-system-ui": "~3.0.7",
28 | "expo-web-browser": "~13.0.3",
29 | "react": "18.2.0",
30 | "react-dom": "18.2.0",
31 | "react-native": "0.74.5",
32 | "react-native-gesture-handler": "~2.16.1",
33 | "react-native-reanimated": "~3.10.1",
34 | "react-native-safe-area-context": "4.10.5",
35 | "react-native-screens": "3.31.1",
36 | "react-native-web": "~0.19.10",
37 | "expo-image": "~1.12.15",
38 | "expo-blur": "~13.0.2",
39 | "expo-haptics": "~13.0.1"
40 | },
41 | "devDependencies": {
42 | "@babel/core": "^7.20.0",
43 | "@types/jest": "^29.5.12",
44 | "@types/react": "~18.2.45",
45 | "@types/react-test-renderer": "^18.0.7",
46 | "jest": "^29.2.1",
47 | "jest-expo": "~51.0.3",
48 | "react-test-renderer": "18.2.0",
49 | "typescript": "~5.3.3"
50 | },
51 | "private": true
52 | }
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Expo app 👋
2 |
3 | This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
4 |
5 | ## Get started
6 |
7 | 1. Install dependencies
8 |
9 | ```bash
10 | npm install
11 | ```
12 |
13 | 2. Start the app
14 |
15 | ```bash
16 | npx expo start
17 | ```
18 |
19 | In the output, you'll find options to open the app in a
20 |
21 | - [development build](https://docs.expo.dev/develop/development-builds/introduction/)
22 | - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
23 | - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
24 | - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
25 |
26 | You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
27 |
28 | ## Get a fresh project
29 |
30 | When you're ready, run:
31 |
32 | ```bash
33 | npm run reset-project
34 | ```
35 |
36 | This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
37 |
38 | ## Learn more
39 |
40 | To learn more about developing your project with Expo, look at the following resources:
41 |
42 | - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
43 | - [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
44 |
45 | ## Join the community
46 |
47 | Join our community of developers creating universal apps.
48 |
49 | - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
50 | - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
51 |
--------------------------------------------------------------------------------
/app/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | ImageBackground,
4 | StyleSheet,
5 | Text,
6 | useColorScheme,
7 | View,
8 | } from "react-native";
9 | import { Image } from "expo-image";
10 | import * as Haptics from "expo-haptics";
11 | import Animated, {
12 | Easing,
13 | Extrapolation,
14 | interpolate,
15 | runOnJS,
16 | SharedValue,
17 | useAnimatedStyle,
18 | useDerivedValue,
19 | useSharedValue,
20 | withDelay,
21 | withTiming,
22 | ZoomInEasyDown,
23 | ZoomOutEasyDown,
24 | } from "react-native-reanimated";
25 | import {
26 | Gesture,
27 | GestureDetector,
28 | GestureHandlerRootView,
29 | } from "react-native-gesture-handler";
30 | import { useTheme } from "@react-navigation/native";
31 | import { Ionicons } from "@expo/vector-icons";
32 | import { convertRgbToRgba } from "@/utils";
33 | import { DATA } from "@/mock/DATA";
34 | import Button from "@/components/CustomButton";
35 |
36 | type Person = {
37 | id: string;
38 | name: string;
39 | image: string;
40 | };
41 |
42 | const IMAGE_WIDTH = 45;
43 | const BUTTON_WIDTH = 50;
44 | const HOVER_HEIGHT = IMAGE_WIDTH + 20;
45 | const CARD_HEIGHT = DATA.length * IMAGE_WIDTH + 20 * DATA.length;
46 | const SEGMENT_HEIGHT = (CARD_HEIGHT - HOVER_HEIGHT) / (DATA.length - 1);
47 | const TIMING_CONFIG = { duration: 250, easing: Easing.out(Easing.ease) };
48 |
49 | const RenderItem = ({
50 | item,
51 | index,
52 | hover,
53 | offset,
54 | }: {
55 | item: Person;
56 | index: number;
57 | hover: SharedValue;
58 | offset: SharedValue;
59 | }) => {
60 | const theme = useTheme();
61 |
62 | const animatedHoverStyle = useAnimatedStyle(() => {
63 | const itemPosition = index * SEGMENT_HEIGHT;
64 | const scaleInterpolate = interpolate(
65 | offset.value,
66 | [
67 | itemPosition - SEGMENT_HEIGHT,
68 | itemPosition,
69 | itemPosition + SEGMENT_HEIGHT,
70 | ],
71 | [1, 1.1, 1],
72 | Extrapolation.CLAMP
73 | );
74 | return {
75 | transform: [
76 | {
77 | scale: withTiming(hover.value ? scaleInterpolate : 1, {
78 | easing: Easing.out(Easing.ease),
79 | duration: 100,
80 | }),
81 | },
82 | ],
83 | };
84 | });
85 | return (
86 |
87 |
88 |
89 | {item.name}
90 |
91 |
92 | );
93 | };
94 |
95 | const Main = () => {
96 | const colorScheme = useColorScheme();
97 | const theme = useTheme();
98 | const [open, setOpen] = useState(false);
99 | const offset = useSharedValue(CARD_HEIGHT - HOVER_HEIGHT);
100 | const extra = useSharedValue(0);
101 | const pressed = useSharedValue(false);
102 | const hover = useSharedValue(false);
103 |
104 | const hapticOnSnap = () => {
105 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
106 | };
107 |
108 | useDerivedValue(() => {
109 | if (offset.value % 1 === 0) {
110 | runOnJS(hapticOnSnap)();
111 | }
112 | }, [offset.value]);
113 |
114 | const Pan = Gesture.Pan()
115 | .onBegin(() => {})
116 | .onChange((event) => {
117 | extra.value = event.translationY;
118 | if (-event.translationY < HOVER_HEIGHT) {
119 | let val = CARD_HEIGHT - HOVER_HEIGHT;
120 | offset.value = withTiming(val, TIMING_CONFIG);
121 | } else if (-event.translationY > CARD_HEIGHT) {
122 | offset.value = withTiming(0, TIMING_CONFIG);
123 | } else {
124 | hover.value = true;
125 | const rawOffset = CARD_HEIGHT + event.translationY;
126 | const snappedOffset =
127 | Math.round(rawOffset / SEGMENT_HEIGHT) * SEGMENT_HEIGHT;
128 | offset.value = withTiming(snappedOffset, TIMING_CONFIG);
129 | }
130 | })
131 | .onFinalize(() => {
132 | hover.value = false;
133 | pressed.value = false;
134 | offset.value = withDelay(200, withTiming(CARD_HEIGHT - HOVER_HEIGHT));
135 | extra.value = withTiming(0);
136 | runOnJS(setOpen)(false);
137 | });
138 |
139 | const animatedBarStyle = useAnimatedStyle(() => {
140 | return {
141 | opacity: withTiming(hover.value ? 1 : 0),
142 | transform: [
143 | {
144 | translateY: offset.value,
145 | },
146 | ],
147 | };
148 | });
149 |
150 | const animatedCardStyle = useAnimatedStyle(() => {
151 | const inputRange = [
152 | -2 * CARD_HEIGHT,
153 | -CARD_HEIGHT,
154 | -HOVER_HEIGHT,
155 | -HOVER_HEIGHT + CARD_HEIGHT,
156 | ];
157 | const scaleYVal = interpolate(
158 | extra.value,
159 | inputRange,
160 | [1.05, 1, 1, 1.05],
161 | Extrapolation.CLAMP
162 | );
163 | const scaleXVal = interpolate(
164 | extra.value,
165 | inputRange,
166 | [0.97, 1, 1, 0.97],
167 | Extrapolation.CLAMP
168 | );
169 | const origin = interpolate(
170 | extra.value,
171 | [-CARD_HEIGHT, -HOVER_HEIGHT],
172 | [
173 | (CARD_HEIGHT * (1 - scaleYVal)) / 2,
174 | -((CARD_HEIGHT * (1 - scaleYVal)) / 2),
175 | ],
176 | Extrapolation.CLAMP
177 | );
178 | const translateY = interpolate(
179 | extra.value,
180 | inputRange,
181 | [-10, 1, 1, 10],
182 | Extrapolation.CLAMP
183 | );
184 |
185 | return {
186 | transform: [
187 | { scaleY: scaleYVal },
188 | { scaleX: scaleXVal },
189 | { translateY: origin },
190 | { translateY: translateY },
191 | ],
192 | };
193 | });
194 |
195 | const AllGesture = Pan;
196 |
197 | return (
198 |
199 |
205 |
206 |
207 | {open && (
208 |
212 |
221 |
229 | {DATA.map((item, index) => (
230 |
237 | ))}
238 |
239 |
240 | )}
241 |
266 |
267 |
268 |
269 |
270 | );
271 | };
272 |
273 | export default Main;
274 |
275 | const styles = StyleSheet.create({
276 | container: {
277 | flex: 1,
278 | alignItems: "center",
279 | flexDirection: "column-reverse",
280 | paddingBottom: 250,
281 | },
282 | cardContainer: {
283 | width: 330,
284 | borderRadius: IMAGE_WIDTH / 2,
285 | position: "absolute",
286 | alignSelf: "center",
287 | bottom: BUTTON_WIDTH / 2,
288 | },
289 | normalShadow: {
290 | shadowColor: "#000",
291 | shadowOffset: {
292 | width: 0,
293 | height: 5,
294 | },
295 | shadowOpacity: 0.2,
296 | shadowRadius: 10,
297 | },
298 | hoverStyle: {
299 | width: "110%",
300 | position: "absolute",
301 | alignSelf: "center",
302 | height: HOVER_HEIGHT,
303 | borderRadius: IMAGE_WIDTH / 2,
304 | },
305 | buttonStyle: {
306 | width: BUTTON_WIDTH,
307 | aspectRatio: 1,
308 | borderRadius: BUTTON_WIDTH / 2,
309 | justifyContent: "center",
310 | alignItems: "center",
311 | },
312 | cardItemContainer: {
313 | flexDirection: "row",
314 | alignItems: "center",
315 | gap: 20,
316 | paddingHorizontal: 15,
317 | paddingVertical: 10,
318 | },
319 | itemImage: {
320 | width: IMAGE_WIDTH,
321 | aspectRatio: 1,
322 | borderRadius: IMAGE_WIDTH / 2,
323 | },
324 | itemText: { fontSize: 15 },
325 | });
326 |
--------------------------------------------------------------------------------