├── .eslintignore ├── .gitattributes ├── expo-example ├── tsconfig.json ├── assets │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ └── adaptive-icon.png ├── .gitignore ├── .expo-shared │ └── assets.json ├── babel.config.js ├── webpack.config.js ├── app.json ├── package.json └── App.tsx ├── .eslintrc.js ├── .prettierrc.js ├── .gitignore ├── .release-it.json ├── tsconfig.json ├── .github └── workflows │ └── CodeQuality.yml ├── LICENSE.md ├── package.json ├── README.md └── src └── index.tsx /.eslintignore: -------------------------------------------------------------------------------- 1 | expo-example/ 2 | lib/ 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /expo-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | -------------------------------------------------------------------------------- /expo-example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kanzitelli/rn-bounceable/HEAD/expo-example/assets/icon.png -------------------------------------------------------------------------------- /expo-example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kanzitelli/rn-bounceable/HEAD/expo-example/assets/favicon.png -------------------------------------------------------------------------------- /expo-example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kanzitelli/rn-bounceable/HEAD/expo-example/assets/splash.png -------------------------------------------------------------------------------- /expo-example/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kanzitelli/rn-bounceable/HEAD/expo-example/assets/adaptive-icon.png -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@react-native-community', 'plugin:@typescript-eslint/recommended'], 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | printWidth: 100, 5 | bracketSpacing: false, 6 | arrowParens: 'avoid', 7 | }; -------------------------------------------------------------------------------- /expo-example/.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 | -------------------------------------------------------------------------------- /expo-example/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /expo-example/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 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | lib/ 4 | *.env 5 | assets 6 | 7 | # Expo 8 | .expo/ 9 | npm-debug.* 10 | *.jks 11 | *.p8 12 | *.p12 13 | *.key 14 | *.mobileprovision 15 | *.orig.* 16 | web-build/ 17 | 18 | # macOS 19 | .DS_Store -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": true, 3 | "git": { 4 | "requireCleanWorkingDir": false 5 | }, 6 | "npm": { 7 | "publish": false 8 | }, 9 | "src": { 10 | "tagName": "v${version}" 11 | }, 12 | "github": { 13 | "release": true 14 | } 15 | } -------------------------------------------------------------------------------- /expo-example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const createExpoWebpackConfigAsync = require('@expo/webpack-config'); 2 | 3 | module.exports = async function (env, argv) { 4 | const config = await createExpoWebpackConfigAsync( 5 | { 6 | ...env, 7 | babel: {dangerouslyAddModulePathsToTranspile: ['rn-bounceable']}, 8 | }, 9 | argv, 10 | ); 11 | 12 | return config; 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/react-native/tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "noEmit": false, 6 | "rootDir": "src", 7 | "outDir": "lib", 8 | "declaration": true, 9 | "skipLibCheck": true, 10 | "module": "ESNext", 11 | "types": ["node"] 12 | }, 13 | "exclude": ["node_modules", "lib", "expo-example"] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/CodeQuality.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | 3 | on: pull_request 4 | jobs: 5 | test_and_lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | 10 | - uses: actions/cache@master 11 | id: node_modules_cache 12 | with: 13 | path: node_modules 14 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 15 | 16 | - run: yarn install --frozen-lockfile 17 | if: steps.node_modules_cache.outputs.cache-hit != 'true' 18 | 19 | 20 | - run: yarn lint 21 | - run: yarn build 22 | - run: yarn format:check 23 | -------------------------------------------------------------------------------- /expo-example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "rn-bounceable-example", 4 | "slug": "rn-bounceable-example", 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 | -------------------------------------------------------------------------------- /expo-example/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": "^43.0.0", 12 | "expo-status-bar": "~1.1.0", 13 | "react": "17.0.1", 14 | "react-dom": "17.0.1", 15 | "react-native": "0.64.3", 16 | "react-native-gesture-handler": "~1.10.2", 17 | "react-native-reanimated": "~2.2.0", 18 | "react-native-web": "0.17.1", 19 | "rn-bounceable": "^0.2.1-rc.20" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.12.9", 23 | "@expo/webpack-config": "^0.16.13", 24 | "@types/react": "~17.0.21", 25 | "@types/react-native": "~0.64.12", 26 | "tslib": "^2.3.1", 27 | "typescript": "~4.3.5" 28 | }, 29 | "private": true 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Batr Kanzitelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rn-bounceable", 3 | "version": "1.2.0", 4 | "description": "Native bounceable effect for any React Native component. Built with Reanimated. Compatible with Expo.", 5 | "author": "Batyr (dev@batyr.io)", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "scripts": { 9 | "prebuild": "rm -rf lib", 10 | "build": "tsc && echo Build completed!", 11 | "postbuild": "prettier --write ./lib", 12 | "clean": "rm -rf ./node_modules ./package-lock.json && yarn", 13 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 14 | "format:check": "prettier --check ./src", 15 | "format:write": "prettier --write ./src", 16 | "release:github": "npm run build && dotenv release-it", 17 | "release:npm:public": "npm run build && npm publish --access public", 18 | "release:npm:private": "npm run build && npm publish", 19 | "publish:npm": "npm run build && npm publish", 20 | "publish:npm:next": "npm run build && npm publish --tag next" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "react-native", 25 | "expo", 26 | "bounceable", 27 | "reanimated" 28 | ], 29 | "files": [ 30 | "lib" 31 | ], 32 | "peerDependencies": { 33 | "react": "*", 34 | "react-native": "*", 35 | "react-native-gesture-handler": ">= 1.4.x", 36 | "react-native-reanimated": ">= 2.x.x" 37 | }, 38 | "devDependencies": { 39 | "@react-native-community/eslint-config": "^3.1.0", 40 | "@tsconfig/react-native": "^2.0.2", 41 | "@types/react": "^18.0.17", 42 | "@types/react-native": "^0.69.5", 43 | "dotenv-cli": "^6.0.0", 44 | "eslint": "^8.23.0", 45 | "prettier": "^2.7.1", 46 | "react": "^18.2.0", 47 | "react-native": "^0.69.5", 48 | "react-native-gesture-handler": "^2.6.0", 49 | "react-native-reanimated": "^2.10.0", 50 | "typescript": "^4.8.2" 51 | }, 52 | "license": "MIT" 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | ## Quick start 6 | 7 | ```bash 8 | > yarn add rn-bounceable 9 | ``` 10 | 11 | Make sure you have [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated) and [react-native-gesture-handler](https://github.com/software-mansion/react-native-gesture-handler) installed in your project. 12 | 13 | ## Usage 14 | 15 | ```tsx 16 | import {Image} from 'react-native'; 17 | import {Bounceable} from 'rn-bounceable'; 18 | 19 | class Screen = () => { 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | ``` 27 | 28 | ### Expo Web 29 | 30 | Since `rn-bounceable` uses Reanimated 2, we need its babel plugin to be applied. Expo Web doesn't transpile modules by default, so we'll need to tell it to transpile the library. 31 | 32 | 1. Install `@expo/webpack-config`: 33 | 34 | ``` 35 | yarn add -D @expo/webpack-config 36 | ``` 37 | 38 | 2. Create `webpack.config.js` in the root of your project: 39 | 40 | ``` 41 | const createExpoWebpackConfigAsync = require('@expo/webpack-config') 42 | 43 | module.exports = async function (env, argv) { 44 | const config = await createExpoWebpackConfigAsync( 45 | { 46 | ...env, 47 | babel: { dangerouslyAddModulePathsToTranspile: ['rn-bounceable'] }, 48 | }, 49 | argv 50 | ) 51 | 52 | return config 53 | } 54 | ``` 55 | 56 | Don't forget to add `webpack.config.js` into `tsconfig.json` under `exclude` section, if needed. 57 | 58 | ##### Available props 59 | 60 | ```tsx 61 | type BounceableProps = { 62 | onPress?: () => void; 63 | onLongPress?: () => void; 64 | disabled?: boolean; // default: false 65 | noBounce?: boolean; // default: false 66 | immediatePress?: boolean; // default: true 67 | delayLongPress?: number; // default: 800 68 | activeScale?: number; // default: 0.95 69 | delayActiveScale?: number; // default: 0 70 | springConfig?: Animated.WithSpringConfig; // default: { damping: 10, mass: 1, stiffness: 300 } 71 | contentContainerStyle?: StyleProp; 72 | }; 73 | ``` 74 | 75 | ## Example 76 | 77 | Examples could be found in `expo-example` folder or in [expo-starter](https://github.com/kanzitelli/expo-starter), [rn-starter](https://github.com/kanzitelli/rn-starter) and [rnn-starter](https://github.com/kanzitelli/rnn-starter). 78 | 79 | See it with [Expo Web](https://rn-bounceable.batyr.io) or [Expo Go](https://expo.io/@kanzitelli/rn-bounceable-example). 80 | 81 | This library was bootstrapped from [kanzitelli/if-component](https://github.com/kanzitelli/if-component). 82 | 83 | https://user-images.githubusercontent.com/4402166/142771470-a1210a92-9a74-4205-b023-b8ac2403dd84.MP4 84 | 85 | ## License 86 | 87 | This project is [MIT licensed](https://github.com/kanzitelli/rn-bounceable/blob/master/LICENSE.md) 88 | -------------------------------------------------------------------------------- /expo-example/App.tsx: -------------------------------------------------------------------------------- 1 | import 'react-native-gesture-handler'; 2 | import React, {useState} from 'react'; 3 | import {StyleSheet, Text, View, Image, Linking} from 'react-native'; 4 | import {ScrollView} from 'react-native-gesture-handler'; 5 | import {StatusBar} from 'expo-status-bar'; 6 | import {Bounceable} from 'rn-bounceable'; 7 | 8 | export default function App() { 9 | const imageSource = {uri: 'https://static.expo.dev/static/brand/square-512x512.png'}; 10 | const githubLink = 'https://github.com/kanzitelli/rn-bounceable'; 11 | 12 | const [text, setText] = useState('Press any component below'); 13 | const changeText = (text: string) => () => setText(text); 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | Linking.openURL(githubLink)}> 21 | RN Bounceable ⎆ 22 | 23 | 24 | 25 | 26 | {text} 27 | 28 | 29 | 33 | Bounceable Text 34 | 35 | 36 | 37 | 38 | 42 | 43 | 44 | 45 | 46 | 47 | 51 | 52 | 53 | Bounceable image and text 54 | 55 | 56 | 57 | 58 | 59 | 64 | 65 | 66 | Bounceable image and text with active scale delay 500ms 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | } 74 | 75 | const S = StyleSheet.create({ 76 | container: { 77 | flex: 1, 78 | backgroundColor: '#fff', 79 | alignItems: 'center', 80 | justifyContent: 'center', 81 | }, 82 | 83 | bounceables: { 84 | alignItems: 'center', 85 | }, 86 | bounceable: { 87 | marginVertical: 20, 88 | }, 89 | text: { 90 | fontSize: 22, 91 | marginTop: 8, 92 | textAlign: 'center', 93 | }, 94 | image: { 95 | height: 100, 96 | width: 100, 97 | borderRadius: 20, 98 | }, 99 | 100 | linkContainer: { 101 | paddingBottom: 24, 102 | }, 103 | linkText: { 104 | fontSize: 26, 105 | textDecorationLine: 'underline', 106 | color: 'blue', 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {StyleProp, ViewStyle} from 'react-native'; 3 | import Animated, { 4 | useSharedValue, 5 | useAnimatedStyle, 6 | withSpring, 7 | runOnJS, 8 | WithSpringConfig, 9 | } from 'react-native-reanimated'; 10 | import {TapGestureHandler, State} from 'react-native-gesture-handler'; 11 | import {useMemo} from 'react'; 12 | 13 | type PureFunc = () => void; 14 | export type BounceableProps = React.PropsWithChildren<{ 15 | disabled?: boolean; 16 | noBounce?: boolean; 17 | onPress?: PureFunc; 18 | immediatePress?: boolean; // indicates if press action should be fired right after or w/ small delay (when end animation will be finished) 19 | onLongPress?: PureFunc; 20 | delayLongPress?: number; 21 | activeScale?: number; 22 | delayActiveScale?: number; 23 | springConfig?: WithSpringConfig; 24 | contentContainerStyle?: StyleProp; 25 | }>; 26 | 27 | export const Bounceable: React.FC = ({ 28 | children, 29 | disabled = false, 30 | noBounce = false, 31 | onPress, 32 | activeScale = 0.95, 33 | springConfig = { 34 | damping: 10, 35 | mass: 1, 36 | stiffness: 300, 37 | }, 38 | contentContainerStyle, 39 | 40 | delayLongPress = 800, 41 | delayActiveScale = 0, 42 | onLongPress, 43 | immediatePress = true, 44 | }) => { 45 | const onLongPressTimeoutId = useSharedValue>(null); 46 | const scale = useSharedValue(1); 47 | const isActive = useSharedValue(0); 48 | 49 | const sz = useAnimatedStyle(() => { 50 | 'worklet'; 51 | 52 | return { 53 | transform: [ 54 | { 55 | scale: scale.value, 56 | }, 57 | ], 58 | }; 59 | }); 60 | 61 | const beginScale = () => { 62 | scale.value = withSpring(activeScale, springConfig); 63 | }; 64 | const endScale = () => { 65 | // clearing up 66 | isActive.value = 0; 67 | if (onLongPressTimeoutId.value !== null) { 68 | clearTimeout(Number(onLongPressTimeoutId.value)); 69 | onLongPressTimeoutId.value = null; 70 | } 71 | 72 | scale.value = withSpring(1, springConfig); 73 | }; 74 | 75 | const Children = useMemo( 76 | () => {children}, 77 | [contentContainerStyle, sz, children], 78 | ); 79 | 80 | if (noBounce) { 81 | return Children; 82 | } 83 | 84 | return ( 85 | { 89 | if (disabled) { 90 | return; 91 | } 92 | 93 | const {state} = nativeEvent; 94 | 95 | if (state === State.BEGAN) { 96 | isActive.value = 1; 97 | 98 | // delaying scale beginning 99 | if (delayActiveScale <= 0) { 100 | beginScale(); 101 | } else { 102 | setTimeout(() => { 103 | if (isActive.value === 1) { 104 | beginScale(); 105 | } 106 | }, delayActiveScale); 107 | } 108 | 109 | // onLongPress 110 | if (onLongPress) { 111 | onLongPressTimeoutId.value = setTimeout(() => { 112 | if (isActive.value === 1) { 113 | endScale(); 114 | runOnJS(onLongPress)(); 115 | } 116 | }, delayLongPress + delayActiveScale); 117 | } 118 | 119 | return; 120 | } 121 | 122 | if (state === State.END) { 123 | if (onPress && isActive.value === 1) { 124 | // mimicing bounce effect if delay active scale is set 125 | if (delayActiveScale > 0) { 126 | beginScale(); 127 | } 128 | 129 | setTimeout(() => { 130 | endScale(); 131 | if (!immediatePress) { 132 | runOnJS(onPress)(); 133 | } 134 | }, 50); 135 | 136 | if (immediatePress) { 137 | runOnJS(onPress)(); 138 | } 139 | 140 | return; 141 | } 142 | 143 | endScale(); // ending scaling here just in case 144 | return; 145 | } 146 | 147 | if (state === State.UNDETERMINED || state === State.FAILED || state === State.CANCELLED) { 148 | endScale(); 149 | return; 150 | } 151 | }} 152 | > 153 | {Children} 154 | 155 | ); 156 | }; 157 | --------------------------------------------------------------------------------