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