├── .babelrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── pull-reqeust.md └── workflows │ └── ci_build_and_test.js.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .npmrc ├── .nvmrc ├── .stylelintrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── eslint.config.mjs ├── index.js ├── jest.config.js ├── jest.setupFilesAfterEnv.ts ├── package-lock.json ├── package.json ├── src ├── components │ ├── SwipeButton │ │ ├── __tests__ │ │ │ ├── SwipeButton.test.tsx │ │ │ ├── __snapshots__ │ │ │ │ └── SwipeButton.test.tsx.snap │ │ │ └── functionality.test.tsx │ │ ├── index.tsx │ │ └── styles.tsx │ └── SwipeThumb │ │ ├── __tests__ │ │ ├── SwipeThumb.test.tsx │ │ └── __snapshots__ │ │ │ └── SwipeThumb.test.tsx.snap │ │ ├── index.tsx │ │ └── styles.tsx └── constants │ └── index.tsx └── types.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["module:metro-react-native-babel-preset"] 3 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull-reqeust.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull reqeust 3 | about: Pull request title and description 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Issue link 11 | 12 | 13 | ### Root cause 14 | 15 | 16 | ### Description 17 | 18 | 19 | ### Screenshots And/Or GIFs 20 | 21 | 22 | ### Checks 23 | 24 | [] Added or updated tests 25 | [] Added or updated type definitions (in alphabetical order) 26 | [] Updated readme file 27 | [] Tested manually with demo app or with an other apporach -------------------------------------------------------------------------------- /.github/workflows/ci_build_and_test.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log 4 | 5 | # Runtime data 6 | tmp 7 | build 8 | dist 9 | 10 | # Dependency directory 11 | node_modules 12 | 13 | # Test Coverage 14 | coverage -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run format && npm run test && git add . 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log 4 | 5 | # Dependency directory 6 | node_modules 7 | 8 | # Runtime data 9 | tmp 10 | 11 | # git docs 12 | docs 13 | 14 | # Example 15 | examples 16 | 17 | # Buids 18 | builds 19 | 20 | # Coverage directory 21 | coverage 22 | coverage/* 23 | 24 | # Test 25 | test/ 26 | tests/ 27 | *.test.js 28 | *spec.js 29 | __tests__ 30 | __tests__/* 31 | 32 | # IDE 33 | .vscode 34 | .idea 35 | .DS_Store 36 | 37 | # Git 38 | .git 39 | CODE_OF_CONDUCT.md 40 | CONTRIBUTING.md 41 | .github/ 42 | .husky/ 43 | 44 | # Dev 45 | .babelrc 46 | .nvmrc 47 | .stylelintrc 48 | babel.config.js 49 | eslint.config.mjs 50 | jest.config.js 51 | jest.setupFilesAfterEnv.ts 52 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | 10.5.0 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.12.0 -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-prettier/recommended"] 3 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | udaysravank32@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Hello there 👋 2 | 3 | #### Thanks for checking the contribution guidelines 🎊 4 | 5 | - The `rn-swipe-button` is a very simple react-native component. 6 | - I request more developers from the open-source community to contributing to improve this project. 7 | - You can find the work by visiting the [project](https://github.com/users/UdaySravanK/projects/1) associated with this repository. You can find issues related to defects, new feature requests and dev only related tasks like writing unit tests. 8 | 9 | # Contribution Guidelines 10 | - Please fork the repository 11 | - Create a new branch 12 | - Before openeing the pull request, please test the code by using the [demo app](https://github.com/UdaySravanK/RNSwipeButtonDemo), either by `npm link` or simply by copy pasting the source code in the demo app. 13 | - Check and fix the related lint errors and warnings. 14 | - Please also update the readme file and documentation, along with the screenshots if applicable. 15 | - Open the PR. 16 | 17 | **Note**: PR approval and merge may take time since I'm not working on this project as a fulltime developer. I'm looking for someone to share the ownership. Please chat with me if you are interested. 18 | 19 | Please reach out at udaysravank32@gmail.com 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Uday Sravan Kumar Kamineni 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React Native Swipe Button Component 2 | [![star this repo](https://img.shields.io/github/stars/UdaySravanK/RNSwipeButton?style=flat-square&labelColor=purple)](https://github.com/UdaySravanK/RNSwipeButton) 3 | [![fork this repo](https://img.shields.io/github/forks/UdaySravanK/RNSwipeButton?style=flat-square&labelColor=purple)](https://github.com/UdaySravanK/RNSwipeButton) 4 | [![NPM Version](https://img.shields.io/npm/v/rn-swipe-button.svg?style=flat-square)](https://www.npmjs.com/package/rn-swipe-button) 5 | [![npm total downloads](https://img.shields.io/npm/dt/rn-swipe-button.svg)](https://www.npmjs.com/package/rn-swipe-button) 6 | [![Npm Downloads](https://img.shields.io/npm/dm/rn-swipe-button.svg)](https://www.npmjs.com/package/rn-swipe-button) 7 | [![Socke Score](https://socket.dev/api/badge/npm/package/rn-swipe-button/2.0.0#1731228050357)](https://socket.dev/npm/package/rn-swipe-button) 8 | [![Contribuutions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat&labelColor=darkgreen)](https://github.com/UdaySravanK/RNSwipeButton/blob/master/CONTRIBUTING.md) 9 | 10 | 11 |
12 | 13 | 14 | ## Description 15 | - Highly customizable "swipe to submit" category button. 16 | - Generally used in exchange of regular buttons to avoid accidental taps. 17 | - This component works for Android, iOS and Web application. 18 | - Supports RTL out of the box. 19 | - Provides accessiblity support. 20 | - Component has more than 85% test coverge. 21 | - 100% TypeScript 22 | - MIT License 23 | 24 | 25 | ## Installation 26 | 27 | ```bash 28 | npm install rn-swipe-button --save 29 | 30 | # OR 31 | 32 | yarn add rn-swipe-button 33 | 34 | ``` 35 | 36 | ## react-native compatibility 37 | 38 | |rn-swipe-button|react-native| react | 39 | |---------------|------------|---------| 40 | | <= v1.3.8 | >= 0.60.5 | >= 16.8.6| 41 | | >= v2.0.0 | >= 0.70.0 | >= 18.1.0| 42 | 43 | ## Usage 44 | ```js 45 | import SwipeButton from 'rn-swipe-button'; 46 | 47 | 48 | ``` 49 | 50 |
51 |
52 |

NPM Package

53 | https://www.npmjs.com/package/rn-swipe-button 54 |
55 |
56 |
57 |

Screenshots

58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 68 | 71 | 74 | 75 |
iOSAndroidiOS GIF
66 | 67 | 69 | 70 | 72 | 73 |
76 |

These screenshots are from the demo app.

77 |
78 |
79 | 80 |

Web Demo

81 |

82 | 83 |

84 |

Custom Title Demo

85 |

86 | 87 |

88 |

You can get the code for this from the demo project

89 | 90 |

Component properties

91 |
 92 |     containerStyles: PropTypes.object,
 93 |     disabled: PropTypes.bool,
 94 |     disableResetOnTap: PropTypes.bool,
 95 |     disabledRailBackgroundColor: PropTypes.string,
 96 |     disabledThumbIconBackgroundColor: PropTypes.string,
 97 |     disabledThumbIconBorderColor: PropTypes.string,
 98 |     enableReverseSwipe: PropTypes.bool,
 99 |     finishRemainingSwipeAnimationDuration: PropTypes.number,
100 |     forceCompleteSwipe: PropTypes.func, // RNSwipeButton will call this function by passing a  function as an argument. Calling the returned function will force complete the swipe.
101 |     forceReset: PropTypes.func,  // RNSwipeButton will call this function by passing a "reset" function as an argument. Calling "reset" will reset the swipe thumb.
102 |     height: PropTypes.oneOfType([
103 |        PropTypes.string,
104 |        PropTypes.number,
105 |     ]),
106 |     onSwipeFail: PropTypes.func,
107 |     onSwipeStart: PropTypes.func,
108 |     onSwipeSuccess: PropTypes.func, // Returns a boolean to indicate the swipe completed with real gesture or forceCompleteSwipe was called
109 |     railBackgroundColor: PropTypes.string,
110 |     railBorderColor: PropTypes.string,
111 |     railFillBackgroundColor: PropTypes.string,
112 |     railFillBorderColor: PropTypes.string,
113 |     railStyles: PropTypes.object,
114 |     resetAfterSuccessAnimDelay: PropTypes.number, // This is delay before resetting the button after successful swipe When shouldResetAfterSuccess = true 
115 |     screenReaderEnabled: PropTypes.bool, // Overrides the internal value 
116 |     shouldResetAfterSuccess: PropTypes.bool, // If set to true, buttun resets automatically after swipe success with default delay of 1000ms
117 |     swipeSuccessThreshold: PropTypes.number, // Ex: 70. Swipping 70% will be considered as successful swipe
118 |     thumbIconBackgroundColor: PropTypes.string,
119 |     thumbIconBorderColor: PropTypes.string,
120 |     thumbIconComponent: PropTypes.node, Pass any react component to replace swipable thumb icon
121 |     thumbIconImageSource: PropTypes.oneOfType([
122 |       PropTypes.string,
123 |       PropTypes.number,
124 |     ]),
125 |     thumbIconStyles: PropTypes.object,
126 |     thumbIconWidth: PropTypes.number,
127 |     titleComponent: PropTypes.node, Pass any react component to replace title text element
128 |     title: PropTypes.string,
129 |     titleColor: PropTypes.string,
130 |     titleFontSize: PropTypes.number,
131 |     titleMaxFontScale: PropTypes.number, // Ex: 2. will limit font size increasing to 200% when user increases font size in device properties
132 |     titleMaxLines: PropTypes.number, // Use other title related props for additional UI customization
133 |     titleStyles: PropTypes.object,
134 |     width: PropTypes.oneOfType([
135 |       PropTypes.string,
136 |       PropTypes.number,
137 |     ]),
138 | 
139 | 140 |

You can also check type definitions in types.d.ts file.

141 | 142 |


143 |

Example

144 | 145 | ```js 146 | import React, { useState } from 'react'; 147 | import { View, Text } from 'react-native'; 148 | 149 | import SwipeButton from 'rn-swipe-button'; 150 | 151 | 152 | function Example() { 153 | let forceResetLastButton: any = null; 154 | let forceCompleteCallback: any = null; 155 | const [finishSwipeAnimDuration, setFinishSwipeAnimDuration] = useState(400) 156 | 157 | const forceCompleteButtonCallback = useCallback(() => { 158 | setFinishSwipeAnimDuration(0) 159 | forceCompleteCallback() 160 | }, []) 161 | 162 | const forceResetButtonCallback = useCallback(() => { 163 | forceResetLastButton() 164 | setInterval(() => setFinishSwipeAnimDuration(400) , 1000) 165 | }, []) 166 | 167 | return ( 168 | 169 | { 172 | forceResetLastButton = reset 173 | }} 174 | finishRemainingSwipeAnimationDuration={finishSwipeAnimDuration} 175 | forceCompleteSwipe={ (forceComplete: any) => { 176 | forceCompleteCallback = forceComplete 177 | }} 178 | railBackgroundColor="#9fc7e8" 179 | railStyles={{ 180 | backgroundColor: '#147cbb', 181 | borderColor: '#880000FF', 182 | }} 183 | thumbIconBackgroundColor="#FFFFFF" 184 | thumbIconImageSource={require('@/assets/images/react-logo.png')} 185 | title="Slide to unlock" 186 | /> 187 | 188 | Force Complete 189 | Force Reset 190 | 191 | 192 | ) 193 | }; 194 | ``` 195 | 196 |

Please check the demo app for more examples.

197 | 198 |
199 | 200 | ### Note 201 | 205 | 206 |

Tech Stack

207 | 214 | 215 |
216 | 217 | ## Contributing 218 | I request more developers from the open-source community to contributing to improve this project. You can find the work by visiting the [project](https://github.com/users/UdaySravanK/projects/1) associated with this repository. You can find issues related to defects, new feature requests and dev only related tasks like writing unit tests. 219 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginReactConfig from "eslint-plugin-react/configs/recommended.js"; 5 | 6 | 7 | export default [ 8 | {languageOptions: { globals: globals.browser }}, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | pluginReactConfig, 12 | ]; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import SwipeButton from './src/components/SwipeButton'; 2 | 3 | export default SwipeButton; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals:{ 3 | "__DEV__": true, 4 | }, 5 | collectCoverage: true, 6 | coverageDirectory: "coverage", 7 | preset: 'react-native', 8 | setupFilesAfterEnv: ['./jest.setupFilesAfterEnv.ts'], 9 | }; -------------------------------------------------------------------------------- /jest.setupFilesAfterEnv.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-native/extend-expect' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rn-swipe-button", 3 | "version": "3.0.1", 4 | "description": "react native swipe/slide button component", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --config jest.config.js", 8 | "lint": "eslint .", 9 | "format": "prettier --write 'src/**/*.tsx'", 10 | "prepare": "husky", 11 | "updateSnapshot": "jest --config jest.config.js --updateSnapshot" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/UdaySravanK/RNSwipeButton.git" 16 | }, 17 | "keywords": [ 18 | "react-native-swipe-button", 19 | "swipe-button", 20 | "rn-swipe-button", 21 | "swipeable-button", 22 | "swipeable", 23 | "swipe", 24 | "slide-to-unlock", 25 | "slide-button", 26 | "react-native-slide-button", 27 | "react-native-slide", 28 | "swipe-to-unlock", 29 | "rn-slide-button", 30 | "right-to-left-swipe-button", 31 | "right-to-left-slide-button", 32 | "swipe-right-to-left", 33 | "swipe-left-to-right", 34 | "slide" 35 | ], 36 | "author": "Uday Sravan Kumar Kamineni", 37 | "maintainers": [ 38 | "UdaySravanK" 39 | ], 40 | "contributors": [ 41 | "UdaySravanK" 42 | ], 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/UdaySravanK/RNSwipeButton/issues" 46 | }, 47 | "homepage": "https://github.com/UdaySravanK/RNSwipeButton#readme", 48 | "devDependencies": { 49 | "@babel/core": "^7.26.0", 50 | "@babel/preset-react": "^7.25.9", 51 | "@babel/preset-typescript": "^7.26.0", 52 | "@babel/runtime": "^7.26.0", 53 | "@eslint/js": "^9.4.0", 54 | "@jest/globals": "^29.7.0", 55 | "@react-native-community/eslint-config": "^3.2.0", 56 | "@react-native/babel-preset": "^0.76.1", 57 | "@testing-library/jest-native": "^5.4.3", 58 | "@testing-library/react-native": "^12.8.1", 59 | "@tsconfig/react-native": "^2.0.2", 60 | "@types/jest": "^29.5.14", 61 | "babel-jest": "^29.7.0", 62 | "eslint": "^8.57.0", 63 | "eslint-plugin-react": "^7.34.2", 64 | "globals": "^15.3.0", 65 | "husky": "^9.0.1", 66 | "jest": "^29.7.0", 67 | "metro-react-native-babel-preset": "^0.77.0", 68 | "pretty-quick": "^2.0.1", 69 | "stylelint-config-prettier": "^8.0.1", 70 | "stylelint-prettier": "^1.1.2", 71 | "ts-jest": "^29.2.5", 72 | "typescript-eslint": "^7.12.0" 73 | }, 74 | "peerDependencies": { 75 | "react": ">=18.1.0", 76 | "react-native": ">=0.70.0" 77 | }, 78 | "types": "./types.d.ts" 79 | } 80 | -------------------------------------------------------------------------------- /src/components/SwipeButton/__tests__/SwipeButton.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | render, 4 | screen, 5 | fireEvent, 6 | waitFor, 7 | act, 8 | } from "@testing-library/react-native"; 9 | 10 | import SwipeButton from "../index"; 11 | import { expect } from "@jest/globals"; 12 | import { Text } from "react-native"; 13 | 14 | describe("Component: SwipeButton UI Rendering Tree & Props", () => { 15 | afterEach(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | it("should render correctly with default props", async () => { 20 | // Setup 21 | const { getByTestId } = render(); 22 | let button; 23 | await waitFor(() => { 24 | button = getByTestId("SwipeButton"); 25 | }); 26 | act(() => { 27 | fireEvent(button, "onLayout", { 28 | nativeEvent: { layout: { width: 100 } }, 29 | }); 30 | }); 31 | // Assert 32 | expect(screen.toJSON()).toMatchSnapshot(); 33 | }); 34 | 35 | it("should render correctly with containerStyles prop", async () => { 36 | // Setup 37 | const { getByTestId } = render( 38 | , 39 | ); 40 | let button; 41 | await waitFor(() => { 42 | button = getByTestId("SwipeButton"); 43 | }); 44 | act(() => { 45 | fireEvent(button, "onLayout", { 46 | nativeEvent: { layout: { width: 100 } }, 47 | }); 48 | }); 49 | 50 | // Assert 51 | expect(screen.toJSON()).toMatchSnapshot(); 52 | }); 53 | 54 | it("should render with correct styles when disable", async () => { 55 | // Setup 56 | const { getByTestId } = render(); 57 | let button; 58 | await waitFor(() => { 59 | button = getByTestId("SwipeButton"); 60 | }); 61 | act(() => { 62 | fireEvent(button, "onLayout", { 63 | nativeEvent: { layout: { width: 100 } }, 64 | }); 65 | }); 66 | 67 | // Assert 68 | expect(screen.toJSON()).toMatchSnapshot(); 69 | }); 70 | 71 | it("should render correctly with custom height", async () => { 72 | // Setup 73 | const { getByTestId } = render(); 74 | let button; 75 | await waitFor(() => { 76 | button = getByTestId("SwipeButton"); 77 | }); 78 | act(() => { 79 | fireEvent(button, "onLayout", { 80 | nativeEvent: { layout: { width: 100 } }, 81 | }); 82 | }); 83 | 84 | // Assert 85 | expect(screen.toJSON()).toMatchSnapshot(); 86 | }); 87 | 88 | it("should render correctly with custom width", async () => { 89 | // Setup 90 | const { getByTestId } = render(); 91 | let button; 92 | await waitFor(() => { 93 | button = getByTestId("SwipeButton"); 94 | }); 95 | act(() => { 96 | fireEvent(button, "onLayout", { 97 | nativeEvent: { layout: { width: 100 } }, 98 | }); 99 | }); 100 | 101 | // Assert 102 | expect(screen.toJSON()).toMatchSnapshot(); 103 | }); 104 | 105 | it("should render correctly with custom rail background color and border color", async () => { 106 | // Setup 107 | const { getByTestId } = render( 108 | , 114 | ); 115 | let button; 116 | await waitFor(() => { 117 | button = getByTestId("SwipeButton"); 118 | }); 119 | act(() => { 120 | fireEvent(button, "onLayout", { 121 | nativeEvent: { layout: { width: 100 } }, 122 | }); 123 | }); 124 | 125 | // Assert 126 | expect(screen.toJSON()).toMatchSnapshot(); 127 | }); 128 | 129 | it("should render correctly with custom rail styles", async () => { 130 | // Setup 131 | const { getByTestId } = render( 132 | , 133 | ); 134 | let button; 135 | await waitFor(() => { 136 | button = getByTestId("SwipeButton"); 137 | }); 138 | act(() => { 139 | fireEvent(button, "onLayout", { 140 | nativeEvent: { layout: { width: 100 } }, 141 | }); 142 | }); 143 | 144 | // Assert 145 | expect(screen.toJSON()).toMatchSnapshot(); 146 | }); 147 | 148 | it("should render correctly with custom thumb icon border and backgroun color", async () => { 149 | // Setup 150 | render( 151 | , 155 | ); 156 | const { getByTestId } = render( 157 | , 158 | ); 159 | let button; 160 | await waitFor(() => { 161 | button = getByTestId("SwipeButton"); 162 | }); 163 | act(() => { 164 | fireEvent(button, "onLayout", { 165 | nativeEvent: { layout: { width: 100 } }, 166 | }); 167 | }); 168 | 169 | // Assert 170 | expect(screen.toJSON()).toMatchSnapshot(); 171 | }); 172 | 173 | it("Thumb icon customm styles should not override important styles", async () => { 174 | // Setup 175 | const { getByTestId } = render( 176 | , 186 | ); 187 | let button; 188 | await waitFor(() => { 189 | button = getByTestId("SwipeButton"); 190 | }); 191 | act(() => { 192 | fireEvent(button, "onLayout", { 193 | nativeEvent: { layout: { width: 100 } }, 194 | }); 195 | }); 196 | 197 | // Assert 198 | expect(screen.toJSON()).toMatchSnapshot(); 199 | }); 200 | 201 | it("should apply thumbIconWidth", async () => { 202 | // Setup 203 | const { getByTestId } = render(); 204 | let button; 205 | await waitFor(() => { 206 | button = getByTestId("SwipeButton"); 207 | }); 208 | act(() => { 209 | fireEvent(button, "onLayout", { 210 | nativeEvent: { layout: { width: 100 } }, 211 | }); 212 | }); 213 | 214 | // Assert 215 | expect(screen.toJSON()).toMatchSnapshot(); 216 | }); 217 | 218 | it("should be able to change title styling", async () => { 219 | // Setup 220 | const { getByTestId } = render( 221 | , 228 | ); 229 | let button; 230 | await waitFor(() => { 231 | button = getByTestId("SwipeButton"); 232 | }); 233 | act(() => { 234 | fireEvent(button, "onLayout", { 235 | nativeEvent: { layout: { width: 100 } }, 236 | }); 237 | }); 238 | 239 | // Assert 240 | expect(screen.toJSON()).toMatchSnapshot(); 241 | }); 242 | 243 | it("should render with custom thumb component", async () => { 244 | // Setup 245 | const CustomComp = () => { 246 | return USK; 247 | }; 248 | const { getByTestId } = render( 249 | , 250 | ); 251 | let button; 252 | await waitFor(() => { 253 | button = getByTestId("SwipeButton"); 254 | }); 255 | act(() => { 256 | fireEvent(button, "onLayout", { 257 | nativeEvent: { layout: { width: 100 } }, 258 | }); 259 | }); 260 | 261 | // Assert 262 | expect(screen.toJSON()).toMatchSnapshot(); 263 | }); 264 | 265 | it("should render correctly with screen reader enabled", async () => { 266 | // Setup 267 | const { getByTestId } = render(); 268 | let button; 269 | await waitFor(() => { 270 | button = getByTestId("SwipeButton"); 271 | }); 272 | act(() => { 273 | fireEvent(button, "onLayout", { 274 | nativeEvent: { layout: { width: 100 } }, 275 | }); 276 | }); 277 | 278 | // Assert 279 | expect(screen.toJSON()).toMatchSnapshot(); 280 | }); 281 | 282 | it("should render correctly with screen reader disabled", async () => { 283 | // Setup 284 | const { getByTestId } = render(); 285 | let button; 286 | await waitFor(() => { 287 | button = getByTestId("SwipeButton"); 288 | }); 289 | act(() => { 290 | fireEvent(button, "onLayout", { 291 | nativeEvent: { layout: { width: 100 } }, 292 | }); 293 | }); 294 | 295 | // Assert 296 | expect(screen.toJSON()).toMatchSnapshot(); 297 | }); 298 | 299 | it("should render with custom title component", async () => { 300 | // Setup 301 | const CustomComp = () => { 302 | return USK; 303 | }; 304 | const { getByTestId } = render(); 305 | let button; 306 | await waitFor(() => { 307 | button = getByTestId("SwipeButton"); 308 | }); 309 | act(() => { 310 | fireEvent(button, "onLayout", { 311 | nativeEvent: { layout: { width: 100 } }, 312 | }); 313 | }); 314 | 315 | // Assert 316 | expect(screen.toJSON()).toMatchSnapshot(); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /src/components/SwipeButton/__tests__/__snapshots__/SwipeButton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Component: SwipeButton UI Rendering Tree & Props Thumb icon customm styles should not override important styles 1`] = ` 4 | 49 | 65 | Swipe to submit 66 | 67 | 97 | 119 | 120 | 121 | `; 122 | 123 | exports[`Component: SwipeButton UI Rendering Tree & Props should apply thumbIconWidth 1`] = ` 124 | 169 | 185 | Swipe to submit 186 | 187 | 217 | 238 | 239 | 240 | `; 241 | 242 | exports[`Component: SwipeButton UI Rendering Tree & Props should be able to change title styling 1`] = ` 243 | 288 | 306 | Swipe to submit 307 | 308 | 338 | 359 | 360 | 361 | `; 362 | 363 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with containerStyles prop 1`] = ` 364 | 409 | 425 | Swipe to submit 426 | 427 | 457 | 478 | 479 | 480 | `; 481 | 482 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with custom height 1`] = ` 483 | 528 | 544 | Swipe to submit 545 | 546 | 576 | 597 | 598 | 599 | `; 600 | 601 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with custom rail background color and border color 1`] = ` 602 | 647 | 663 | Swipe to submit 664 | 665 | 695 | 716 | 717 | 718 | `; 719 | 720 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with custom rail styles 1`] = ` 721 | 766 | 782 | Swipe to submit 783 | 784 | 814 | 835 | 836 | 837 | `; 838 | 839 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with custom thumb icon border and backgroun color 1`] = ` 840 | 885 | 901 | Swipe to submit 902 | 903 | 933 | 954 | 955 | 956 | `; 957 | 958 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with custom width 1`] = ` 959 | 1005 | 1021 | Swipe to submit 1022 | 1023 | 1053 | 1074 | 1075 | 1076 | `; 1077 | 1078 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with default props 1`] = ` 1079 | 1124 | 1140 | Swipe to submit 1141 | 1142 | 1172 | 1193 | 1194 | 1195 | `; 1196 | 1197 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with screen reader disabled 1`] = ` 1198 | 1243 | 1259 | Swipe to submit 1260 | 1261 | 1291 | 1312 | 1313 | 1314 | `; 1315 | 1316 | exports[`Component: SwipeButton UI Rendering Tree & Props should render correctly with screen reader enabled 1`] = ` 1317 | 1362 | 1378 | Swipe to submit 1379 | 1380 | 1410 | 1431 | 1432 | 1433 | `; 1434 | 1435 | exports[`Component: SwipeButton UI Rendering Tree & Props should render with correct styles when disable 1`] = ` 1436 | 1481 | 1497 | Swipe to submit 1498 | 1499 | 1529 | 1550 | 1551 | 1552 | `; 1553 | 1554 | exports[`Component: SwipeButton UI Rendering Tree & Props should render with custom thumb component 1`] = ` 1555 | 1600 | 1616 | Swipe to submit 1617 | 1618 | 1648 | 1669 | 1670 | 1671 | USK 1672 | 1673 | 1674 | 1675 | 1676 | 1677 | `; 1678 | 1679 | exports[`Component: SwipeButton UI Rendering Tree & Props should render with custom title component 1`] = ` 1680 | 1725 | 1733 | 1734 | USK 1735 | 1736 | 1737 | 1767 | 1788 | 1789 | 1790 | `; 1791 | -------------------------------------------------------------------------------- /src/components/SwipeButton/__tests__/functionality.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | render, 3 | screen, 4 | fireEvent, 5 | waitFor, 6 | act, 7 | } from "@testing-library/react-native"; 8 | 9 | import SwipeButton from "../"; 10 | import { expect } from "@jest/globals"; 11 | import React from "react"; 12 | import { AccessibilityInfo } from "react-native"; 13 | 14 | describe("Component: SwipeButton Functionality", () => { 15 | beforeEach(() => { 16 | jest.useFakeTimers(); 17 | }); 18 | 19 | afterEach(() => { 20 | jest.clearAllMocks(); 21 | jest.clearAllTimers(); 22 | }); 23 | 24 | it("moves the thumb icon when swiped", async () => { 25 | jest.useRealTimers(); 26 | const { getByTestId } = render(); 27 | 28 | let button; 29 | await waitFor(() => { 30 | button = getByTestId("SwipeButton"); 31 | }); 32 | await act(async () => { 33 | fireEvent(button, "onLayout", { 34 | nativeEvent: { layout: { width: 100 } }, 35 | }); 36 | }); 37 | 38 | let thumb; 39 | await waitFor(() => { 40 | thumb = getByTestId("SwipeThumb"); 41 | }); 42 | await act(async () => { 43 | fireEvent(thumb, "onPanResponderMove", { 44 | nativeEvent: { touches: [{ clientX: 50 }] }, 45 | }); 46 | }); 47 | await waitFor(async () => { 48 | expect(thumb).toHaveStyle({ width: 50 }); 49 | }); 50 | }, 10000); 51 | 52 | it("should call onSwipeSuccess when swipe completed with forceCompleteSwipe", async () => { 53 | // Setup 54 | const onSwipeSuccess = jest.fn(); 55 | 56 | let forceComplete; 57 | const { getByTestId } = render( 58 | (forceComplete = complete)} 62 | />, 63 | ); 64 | 65 | let button; 66 | await waitFor(() => { 67 | button = getByTestId("SwipeButton"); 68 | }); 69 | act(() => { 70 | fireEvent(button, "onLayout", { 71 | nativeEvent: { layout: { width: 100 } }, 72 | }); 73 | }); 74 | 75 | await act(async () => { 76 | // Execute 77 | forceComplete(); 78 | 79 | // Assert 80 | expect(onSwipeSuccess).toHaveBeenCalledTimes(1); 81 | }); 82 | }); 83 | 84 | it("should return forceReset callback", async () => { 85 | // Setup 86 | let forceReset; 87 | const { getByTestId } = render( 88 | (forceReset = reset)} 91 | />, 92 | ); 93 | 94 | // Execute 95 | let button; 96 | await waitFor(() => { 97 | button = getByTestId("SwipeButton"); 98 | }); 99 | act(() => { 100 | fireEvent(button, "onLayout", { 101 | nativeEvent: { layout: { width: 100 } }, 102 | }); 103 | }); 104 | 105 | // Assert 106 | expect(forceReset).not.toBeNull(); 107 | }); 108 | 109 | it("triggers onSwipeSuccess when swipe threshold is met", async () => { 110 | const onSwipeStart = jest.fn(); 111 | const onSwipeSuccess = jest.fn(); 112 | const onSwipeFail = jest.fn(); 113 | let getByTestId; 114 | await waitFor(async () => { 115 | getByTestId = render( 116 | , 121 | ).getByTestId; 122 | }); 123 | 124 | // Simulate the onLayout event to set the layoutWidth 125 | let button; 126 | await waitFor(() => { 127 | button = getByTestId("SwipeButton"); 128 | }); 129 | 130 | await act(async () => { 131 | fireEvent(button, "layout", { 132 | nativeEvent: { 133 | layout: { 134 | width: 300, // Set a realistic width for the button 135 | height: 50, 136 | }, 137 | }, 138 | }); 139 | }); 140 | 141 | // Get the thumb element 142 | let thumb; 143 | await waitFor(() => { 144 | thumb = getByTestId("SwipeThumb"); 145 | }); 146 | 147 | act(() => { 148 | // Simulate the start of the gesture 149 | fireEvent(thumb, "responderGrant", { 150 | nativeEvent: { 151 | touches: [{ pageX: 0, pageY: 0 }], // Initial touch position 152 | changedTouches: [], 153 | target: thumb, 154 | identifier: 1, 155 | }, 156 | touchHistory: { mostRecentTimeStamp: "2", touchBank: [] }, 157 | }); 158 | }); 159 | 160 | await waitFor(() => { 161 | // Simulate the movement during the gesture 162 | fireEvent(thumb, "responderMove", { 163 | touchHistory: { 164 | mostRecentTimeStamp: "1", 165 | touchBank: [ 166 | { 167 | touchActive: true, 168 | currentTimeStamp: 1, 169 | currentPageX: 200, 170 | previousPageX: 0, 171 | }, 172 | ], 173 | numberActiveTouches: 1, 174 | indexOfSingleActiveTouch: 0, 175 | }, 176 | }); 177 | }); 178 | 179 | await waitFor(() => { 180 | // Simulate the end of the gesture 181 | fireEvent(thumb, "responderRelease", { 182 | touchHistory: { mostRecentTimeStamp: "1", touchBank: [] }, 183 | }); 184 | expect(onSwipeStart).toHaveBeenCalled(); 185 | expect(onSwipeSuccess).toHaveBeenCalled(); 186 | expect(onSwipeFail).not.toHaveBeenCalled(); 187 | }); 188 | }); 189 | 190 | it("should trigger onSwipeFail when swipe threshold is not met", async () => { 191 | const onSwipeStart = jest.fn(); 192 | const onSwipeFail = jest.fn(); 193 | const onSwipeSuccess = jest.fn(); 194 | let getByTestId; 195 | await waitFor(async () => { 196 | getByTestId = render( 197 | , 202 | ).getByTestId; 203 | }); 204 | 205 | // Simulate the onLayout event to set the layoutWidth 206 | let button; 207 | await waitFor(() => { 208 | button = getByTestId("SwipeButton"); 209 | }); 210 | await act(async () => { 211 | fireEvent(button, "layout", { 212 | nativeEvent: { 213 | layout: { 214 | width: 300, // Set a realistic width for the button 215 | height: 50, 216 | }, 217 | }, 218 | }); 219 | }); 220 | 221 | // Get the thumb element 222 | let thumb; 223 | await waitFor(() => { 224 | thumb = getByTestId("SwipeThumb"); 225 | }); 226 | 227 | act(() => { 228 | // Simulate the start of the gesture 229 | fireEvent(thumb, "responderGrant", { 230 | nativeEvent: { 231 | touches: [{ pageX: 0, pageY: 0 }], // Initial touch position 232 | changedTouches: [], 233 | target: thumb, 234 | identifier: 1, 235 | }, 236 | touchHistory: { mostRecentTimeStamp: "2", touchBank: [] }, 237 | }); 238 | }); 239 | 240 | await waitFor(() => { 241 | // Simulate the movement during the gesture 242 | fireEvent(thumb, "responderMove", { 243 | touchHistory: { 244 | mostRecentTimeStamp: "1", 245 | touchBank: [ 246 | { 247 | touchActive: true, 248 | currentTimeStamp: 1, 249 | currentPageX: 100, 250 | previousPageX: 0, 251 | }, 252 | ], 253 | numberActiveTouches: 1, 254 | indexOfSingleActiveTouch: 0, 255 | }, 256 | }); 257 | }); 258 | 259 | await waitFor(() => { 260 | // Simulate the end of the gesture 261 | fireEvent(thumb, "responderRelease", { 262 | touchHistory: { mostRecentTimeStamp: "1", touchBank: [] }, 263 | }); 264 | 265 | expect(onSwipeStart).toHaveBeenCalled(); 266 | expect(onSwipeFail).toHaveBeenCalled(); 267 | expect(onSwipeSuccess).not.toHaveBeenCalled(); 268 | }); 269 | }); 270 | 271 | it("should not call onSwipeStart when disabled", async () => { 272 | const onSwipeStart = jest.fn(); 273 | const onSwipeFail = jest.fn(); 274 | const onSwipeSuccess = jest.fn(); 275 | const { getByTestId } = render( 276 | , 282 | ); 283 | 284 | // Simulate the onLayout event to set the layoutWidth 285 | let button; 286 | await waitFor(() => { 287 | button = screen.getByTestId("SwipeButton"); 288 | }); 289 | await act(async () => { 290 | fireEvent(button, "layout", { 291 | nativeEvent: { 292 | layout: { 293 | width: 300, // Set a realistic width for the button 294 | height: 50, 295 | }, 296 | }, 297 | }); 298 | }); 299 | 300 | // Get the thumb element 301 | let thumb; 302 | await waitFor(() => { 303 | thumb = getByTestId("SwipeThumb"); 304 | }); 305 | 306 | act(() => { 307 | // Simulate the start of the gesture 308 | fireEvent(thumb, "responderGrant", { 309 | nativeEvent: { 310 | touches: [{ pageX: 0, pageY: 0 }], // Initial touch position 311 | changedTouches: [], 312 | target: thumb, 313 | identifier: 1, 314 | }, 315 | touchHistory: { mostRecentTimeStamp: "2", touchBank: [] }, 316 | }); 317 | 318 | // Simulate the movement during the gesture 319 | fireEvent(thumb, "responderMove", { 320 | touchHistory: { 321 | mostRecentTimeStamp: "1", 322 | touchBank: [ 323 | { 324 | touchActive: true, 325 | currentTimeStamp: 1, 326 | currentPageX: 100, 327 | previousPageX: 0, 328 | }, 329 | ], 330 | numberActiveTouches: 1, 331 | indexOfSingleActiveTouch: 0, 332 | }, 333 | }); 334 | 335 | // Simulate the end of the gesture 336 | fireEvent(thumb, "responderRelease", { 337 | touchHistory: { mostRecentTimeStamp: "1", touchBank: [] }, 338 | }); 339 | 340 | expect(onSwipeStart).not.toHaveBeenCalled(); 341 | expect(onSwipeFail).not.toHaveBeenCalled(); 342 | expect(onSwipeSuccess).not.toHaveBeenCalled(); 343 | }); 344 | }); 345 | 346 | it("does not move the thumb icon when disabled", async () => { 347 | const { getByTestId } = render(); 348 | let button; 349 | await waitFor(async () => { 350 | button = screen.getByTestId("SwipeButton"); 351 | }); 352 | await act(async () => { 353 | fireEvent(button, "layout", { 354 | nativeEvent: { 355 | layout: { 356 | width: 300, // Set a realistic width for the button 357 | height: 50, 358 | }, 359 | }, 360 | }); 361 | }); 362 | let thumb; 363 | await waitFor(() => { 364 | thumb = getByTestId("SwipeThumb"); 365 | }); 366 | act(() => { 367 | fireEvent(thumb, "onPanResponderMove", { 368 | nativeEvent: { touches: [{ clientX: 50 }] }, 369 | }); 370 | expect(thumb).toHaveStyle({ width: 50 }); // Should not change 371 | }); 372 | }); 373 | 374 | it("is accessible to screen readers", async () => { 375 | const { getByLabelText } = render(); 376 | await waitFor(async () => { 377 | expect(getByLabelText("Swipe to submit")).toBeTruthy(); 378 | }); 379 | }); 380 | 381 | it("moves thumb icon in reverse direction when enableReverseSwipe is true", async () => { 382 | const { getByTestId } = render(); 383 | // Simulate the onLayout event to set the layoutWidth 384 | let button; 385 | await waitFor(async () => { 386 | button = screen.getByTestId("SwipeButton"); 387 | }); 388 | await act(async () => { 389 | fireEvent(button, "layout", { 390 | nativeEvent: { 391 | layout: { 392 | width: 300, // Set a realistic width for the button 393 | height: 50, 394 | }, 395 | }, 396 | }); 397 | }); 398 | let thumb; 399 | await waitFor(async () => { 400 | // Get the thumb element 401 | thumb = getByTestId("SwipeThumb"); 402 | }); 403 | act(() => { 404 | // Simulate the movement during the gesture 405 | fireEvent(thumb, "responderMove", { 406 | touchHistory: { 407 | mostRecentTimeStamp: "1", 408 | touchBank: [ 409 | { 410 | touchActive: true, 411 | currentTimeStamp: 1, 412 | currentPageX: -100, 413 | previousPageX: 0, 414 | }, 415 | ], 416 | numberActiveTouches: 1, 417 | indexOfSingleActiveTouch: 0, 418 | }, 419 | }); 420 | 421 | expect(thumb).toHaveStyle({ width: 50 }); 422 | }); 423 | }); 424 | 425 | it("should call onSwipeSuccess upon a tap when screen reader enabled", async () => { 426 | // Setup 427 | const onSwipeSuccess = jest.fn(); 428 | 429 | render( 430 | , 435 | ); 436 | 437 | let button; 438 | await waitFor(async () => { 439 | button = screen.getByTestId("SwipeButton"); 440 | }); 441 | await act(async () => { 442 | fireEvent(button, "onLayout", { 443 | nativeEvent: { layout: { width: 100 } }, 444 | }); 445 | 446 | // Execute 447 | fireEvent(button, "onPress"); 448 | 449 | // Assert 450 | expect(onSwipeSuccess).toHaveBeenCalledTimes(1); 451 | }); 452 | }); 453 | 454 | it("should call screen reader toggle on focus change", async () => { 455 | // Setup 456 | const onSwipeSuccess = jest.fn(); 457 | AccessibilityInfo.addEventListener = jest.fn(); // Mock the event listener 458 | 459 | render(); 460 | let button; 461 | await waitFor(async () => { 462 | button = screen.getByTestId("SwipeButton"); 463 | }); 464 | await act(async () => { 465 | fireEvent(button, "onLayout", { 466 | nativeEvent: { layout: { width: 100 } }, 467 | }); 468 | fireEvent(button, "onPress"); 469 | expect(onSwipeSuccess).not.toHaveBeenCalledTimes(1); 470 | 471 | // Execute 472 | AccessibilityInfo.isScreenReaderEnabled = jest 473 | .fn() 474 | .mockResolvedValue(true); 475 | await waitFor(() => { 476 | fireEvent(button, "onFocus"); 477 | }); 478 | // await new Promise((resolve) => setTimeout(resolve, 0)); // Allow the effect to run 479 | fireEvent(button, "onPress"); 480 | 481 | // Assert 482 | expect(onSwipeSuccess).toHaveBeenCalledTimes(1); 483 | }); 484 | }); 485 | 486 | it("press should invoke on success callback when the screen reader enabled internally", async () => { 487 | // Setup 488 | const onSwipeSuccess = jest.fn(); 489 | AccessibilityInfo.isScreenReaderEnabled = jest.fn().mockResolvedValue(true); 490 | AccessibilityInfo.addEventListener = jest.fn(); // Mock the event listener 491 | render(); 492 | await waitFor(() => { 493 | const button = screen.getByTestId("SwipeButton"); 494 | fireEvent(button, "onLayout", { 495 | nativeEvent: { layout: { width: 100 } }, 496 | }); 497 | 498 | // await new Promise((resolve) => setTimeout(resolve, 0)); // Allow the effect to run 499 | act(() => { 500 | fireEvent(button, "onPress"); 501 | }); 502 | expect(onSwipeSuccess).toHaveBeenCalledTimes(1); 503 | }); 504 | }); 505 | 506 | it("screen reader internally should not override the prop value", async () => { 507 | // Setup 508 | const onSwipeSuccess = jest.fn(); 509 | AccessibilityInfo.isScreenReaderEnabled = jest.fn().mockResolvedValue(true); 510 | AccessibilityInfo.addEventListener = jest.fn(); // Mock the event listener 511 | render( 512 | , 516 | ); 517 | await waitFor(() => { 518 | const button = screen.getByTestId("SwipeButton"); 519 | fireEvent(button, "onLayout", { 520 | nativeEvent: { layout: { width: 100 } }, 521 | }); 522 | 523 | // await new Promise((resolve) => setTimeout(resolve, 0)); // Allow the effect to run 524 | 525 | fireEvent(button, "onPress"); 526 | expect(onSwipeSuccess).not.toHaveBeenCalledTimes(1); 527 | }); 528 | }); 529 | 530 | it("press should not invoke on success callback when the screen reader enabled internally and button disabled", async () => { 531 | // Setup 532 | const onSwipeSuccess = jest.fn(); 533 | AccessibilityInfo.isScreenReaderEnabled = jest.fn().mockResolvedValue(true); 534 | AccessibilityInfo.addEventListener = jest.fn(); // Mock the event listener 535 | render(); 536 | await waitFor(() => { 537 | const button = screen.getByTestId("SwipeButton"); 538 | fireEvent(button, "onLayout", { 539 | nativeEvent: { layout: { width: 100 } }, 540 | }); 541 | 542 | // await new Promise((resolve) => setTimeout(resolve, 0)); // Allow the effect to run 543 | 544 | fireEvent(button, "onPress"); 545 | expect(onSwipeSuccess).not.toHaveBeenCalledTimes(1); 546 | }); 547 | }); 548 | }); 549 | -------------------------------------------------------------------------------- /src/components/SwipeButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, ReactElement, useCallback } from "react"; 2 | import { 3 | View, 4 | Text, 5 | AccessibilityInfo, 6 | TouchableOpacity, 7 | ViewStyle, 8 | TextStyle, 9 | ImageSourcePropType, 10 | LayoutChangeEvent, 11 | TouchableOpacityProps, 12 | } from "react-native"; 13 | 14 | // Components 15 | import SwipeThumb from "../SwipeThumb"; 16 | 17 | // Styles 18 | import styles from "./styles"; 19 | 20 | // Constants 21 | import { 22 | DEFAULT_ANIMATION_DURATION, 23 | DEFAULT_HEIGHT, 24 | DEFAULT_TITLE, 25 | DEFAULT_TITLE_FONT_SIZE, 26 | DEFAULT_TITLE_MAX_LINES, 27 | DISABLED_RAIL_BACKGROUND_COLOR, 28 | DISABLED_THUMB_ICON_BACKGROUND_COLOR, 29 | DISABLED_THUMB_ICON_BORDER_COLOR, 30 | RAIL_BACKGROUND_COLOR, 31 | RAIL_BORDER_COLOR, 32 | RAIL_FILL_BACKGROUND_COLOR, 33 | RAIL_FILL_BORDER_COLOR, 34 | SWIPE_SUCCESS_THRESHOLD, 35 | THUMB_ICON_BACKGROUND_COLOR, 36 | THUMB_ICON_BORDER_COLOR, 37 | TITLE_COLOR, 38 | } from "../../constants"; 39 | 40 | interface SwipeButtonProps extends TouchableOpacityProps { 41 | containerStyles?: ViewStyle; 42 | disabled?: boolean; 43 | disabledRailBackgroundColor?: string; 44 | disabledThumbIconBackgroundColor?: string; 45 | disabledThumbIconBorderColor?: string; 46 | disableResetOnTap?: boolean; 47 | enableReverseSwipe?: boolean; 48 | finishRemainingSwipeAnimationDuration?: number; 49 | forceCompleteSwipe?: (forceComplete: () => void) => void; 50 | forceReset?: (forceReset: () => void) => void; 51 | height?: number; 52 | onSwipeFail?: () => void; 53 | onSwipeStart?: () => void; 54 | onSwipeSuccess?: (isForceComplete: boolean) => void; 55 | railBackgroundColor?: string; 56 | railBorderColor?: string; 57 | railFillBackgroundColor?: string; 58 | railFillBorderColor?: string; 59 | railStyles?: ViewStyle; 60 | resetAfterSuccessAnimDelay?: number; 61 | screenReaderEnabled?: boolean; 62 | shouldResetAfterSuccess?: boolean; 63 | swipeSuccessThreshold?: number; 64 | thumbIconBackgroundColor?: string; 65 | thumbIconBorderColor?: string; 66 | thumbIconComponent?: () => ReactElement; 67 | thumbIconImageSource?: ImageSourcePropType; 68 | thumbIconStyles?: ViewStyle; 69 | thumbIconWidth?: number; 70 | title?: string; 71 | titleColor?: string; 72 | titleComponent?: () => ReactElement; 73 | titleFontSize?: number; 74 | titleMaxFontScale?: number; 75 | titleMaxLines?: number; 76 | titleStyles?: TextStyle; 77 | width?: number; 78 | } 79 | 80 | /** 81 | * A swipe to submit button 82 | * 83 | * - Height of the RNSwipeButton will be determined by the height of the inner ThumbIcon which we interact with to swipe. 84 | * 85 | * @param {*} param0 86 | * @returns 87 | */ 88 | const SwipeButton: React.FC = ({ 89 | containerStyles, 90 | disabled = false, 91 | disabledRailBackgroundColor = DISABLED_RAIL_BACKGROUND_COLOR, 92 | disabledThumbIconBackgroundColor = DISABLED_THUMB_ICON_BACKGROUND_COLOR, 93 | disabledThumbIconBorderColor = DISABLED_THUMB_ICON_BORDER_COLOR, 94 | disableResetOnTap = false, 95 | enableReverseSwipe, 96 | finishRemainingSwipeAnimationDuration = DEFAULT_ANIMATION_DURATION, 97 | forceCompleteSwipe, 98 | forceReset, 99 | height = DEFAULT_HEIGHT, 100 | onSwipeFail, 101 | onSwipeStart, 102 | onSwipeSuccess, 103 | railBackgroundColor = RAIL_BACKGROUND_COLOR, 104 | railBorderColor = RAIL_BORDER_COLOR, 105 | railFillBackgroundColor = RAIL_FILL_BACKGROUND_COLOR, 106 | railFillBorderColor = RAIL_FILL_BORDER_COLOR, 107 | railStyles, 108 | resetAfterSuccessAnimDelay, 109 | screenReaderEnabled, 110 | shouldResetAfterSuccess, 111 | swipeSuccessThreshold = SWIPE_SUCCESS_THRESHOLD, 112 | thumbIconBackgroundColor = THUMB_ICON_BACKGROUND_COLOR, 113 | thumbIconBorderColor = THUMB_ICON_BORDER_COLOR, 114 | thumbIconComponent, 115 | thumbIconImageSource, 116 | thumbIconStyles = {}, 117 | thumbIconWidth, 118 | title = DEFAULT_TITLE, 119 | titleColor = TITLE_COLOR, 120 | titleComponent: TitleComponent, 121 | titleFontSize = DEFAULT_TITLE_FONT_SIZE, 122 | titleMaxFontScale, 123 | titleMaxLines = DEFAULT_TITLE_MAX_LINES, 124 | titleStyles = {}, 125 | width, 126 | ...rest // Include other TouchableOpacity props 127 | }) => { 128 | const [layoutWidth, setLayoutWidth] = useState(0); 129 | const [isScreenReaderEnabled, setIsScreenReaderEnabled] = 130 | useState(screenReaderEnabled); 131 | const [isUnmounting, setIsUnmounting] = useState(false); 132 | const [activationMessage, setActivationMessage] = useState(title); 133 | const [disableInteraction, setDisableInteraction] = useState(false); 134 | /** 135 | * Retrieve layoutWidth to set maximum swipeable area. 136 | * Correct layout width will be received only after first render but we need it before render. 137 | * So render SwipeThumb only if layoutWidth > 0 138 | */ 139 | const onLayoutContainer = useCallback( 140 | (e: LayoutChangeEvent) => { 141 | const newWidth = e.nativeEvent.layout.width; 142 | if (!isUnmounting && newWidth !== layoutWidth) { 143 | setLayoutWidth(newWidth); 144 | } 145 | }, 146 | [isUnmounting, layoutWidth], 147 | ); 148 | 149 | /** 150 | * If we don't update `disabled` prop of TouchableOpacity through state changes, 151 | * switching from a11y to normal mode would still keep the button in disabled state. 152 | * Which results to all interactions disabled. Swipe gesture won't work. 153 | */ 154 | useEffect(() => { 155 | if (disabled && isScreenReaderEnabled) { 156 | setDisableInteraction(true); 157 | } else { 158 | setDisableInteraction(false); 159 | } 160 | }, [disabled, isScreenReaderEnabled]); 161 | 162 | const handleScreenReaderToggled = useCallback( 163 | (isEnabled: boolean) => { 164 | if (isUnmounting || isScreenReaderEnabled === isEnabled) { 165 | return; 166 | } 167 | if (screenReaderEnabled !== undefined) { 168 | setIsScreenReaderEnabled(screenReaderEnabled); 169 | // Return to avoid overriding the externally set value 170 | return; 171 | } 172 | 173 | setIsScreenReaderEnabled(isEnabled); 174 | }, 175 | [isScreenReaderEnabled, screenReaderEnabled], 176 | ); 177 | 178 | useEffect(() => { 179 | setIsUnmounting(false); 180 | const subscription = AccessibilityInfo.addEventListener( 181 | "screenReaderChanged", 182 | handleScreenReaderToggled, 183 | ); 184 | 185 | AccessibilityInfo.isScreenReaderEnabled().then(handleScreenReaderToggled); 186 | 187 | return () => { 188 | setIsUnmounting(true); 189 | if (subscription) { 190 | subscription.remove(); 191 | } 192 | }; 193 | }, [isScreenReaderEnabled, handleScreenReaderToggled]); 194 | 195 | useEffect(() => { 196 | // Update activation message based on disabled state and screen reader status 197 | if (disabled) { 198 | setActivationMessage("Button disabled"); 199 | } else if (isScreenReaderEnabled) { 200 | setActivationMessage("Double tap to activate"); 201 | } else { 202 | setActivationMessage(title); 203 | } 204 | }, [disabled, isScreenReaderEnabled]); 205 | 206 | const handlePress = useCallback(() => { 207 | if (disabled) return; 208 | if (isScreenReaderEnabled) { 209 | // Simulate swipe success for screen readers 210 | onSwipeSuccess && onSwipeSuccess(false); 211 | } 212 | }, [disabled, isScreenReaderEnabled, onSwipeSuccess]); 213 | 214 | const handleFocus = useCallback(() => { 215 | AccessibilityInfo.isScreenReaderEnabled().then(handleScreenReaderToggled); 216 | }, [handleScreenReaderToggled]); 217 | 218 | const dynamicContainerStyles: ViewStyle = { 219 | ...containerStyles, 220 | backgroundColor: disabled 221 | ? disabledRailBackgroundColor 222 | : railBackgroundColor, 223 | borderColor: railBorderColor, 224 | ...(width ? { width } : {}), 225 | }; 226 | 227 | return ( 228 | 242 | {TitleComponent ? ( 243 | 244 | 245 | 246 | ) : ( 247 | 260 | {title} 261 | 262 | )} 263 | {layoutWidth > 0 && ( 264 | 293 | )} 294 | 295 | ); 296 | }; 297 | 298 | export default React.memo(SwipeButton); 299 | -------------------------------------------------------------------------------- /src/components/SwipeButton/styles.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from "react-native"; 2 | 3 | const Styles = StyleSheet.create({ 4 | container: { 5 | borderRadius: 100 / 2, 6 | borderWidth: 1, 7 | justifyContent: "center", 8 | margin: 5, 9 | }, 10 | title: { 11 | alignSelf: "center", 12 | position: "absolute", 13 | }, 14 | }); 15 | 16 | export default Styles; 17 | -------------------------------------------------------------------------------- /src/components/SwipeThumb/__tests__/SwipeThumb.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react-native"; 3 | import SwipeThumb from "../index"; 4 | import { 5 | RAIL_FILL_BACKGROUND_COLOR, 6 | RAIL_FILL_BORDER_COLOR, 7 | SWIPE_SUCCESS_THRESHOLD, 8 | THUMB_ICON_BACKGROUND_COLOR, 9 | THUMB_ICON_BORDER_COLOR, 10 | } from "../../../constants"; 11 | import { expect } from "@jest/globals"; 12 | 13 | describe("SwipeThumb Component", () => { 14 | beforeEach(() => { 15 | jest.useFakeTimers(); 16 | }); 17 | 18 | afterEach(() => { 19 | jest.useRealTimers(); 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | it("should render correctly with default props", async () => { 24 | const onSwipeStart = jest.fn(); 25 | 26 | render( 27 | , 38 | ); 39 | 40 | expect(screen.toJSON()).toMatchSnapshot(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/SwipeThumb/__tests__/__snapshots__/SwipeThumb.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SwipeThumb Component should render correctly with default props 1`] = ` 4 | 34 | 55 | 56 | `; 57 | -------------------------------------------------------------------------------- /src/components/SwipeThumb/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useState, 4 | useEffect, 5 | useRef, 6 | ReactElement, 7 | useMemo, 8 | } from "react"; 9 | import { 10 | I18nManager, 11 | Animated, 12 | Image, 13 | PanResponder, 14 | View, 15 | ViewStyle, 16 | ImageSourcePropType, 17 | PanResponderGestureState, 18 | } from "react-native"; 19 | 20 | // Styles 21 | import styles, { borderWidth, margin } from "./styles"; 22 | 23 | // Constants 24 | import { 25 | DEFAULT_ANIMATION_DURATION, 26 | SWIPE_SUCCESS_THRESHOLD, 27 | TRANSPARENT_COLOR, 28 | } from "../../constants"; 29 | const RESET_AFTER_SUCCESS_DEFAULT_DELAY = 1000; 30 | 31 | interface SwipeThumbProps { 32 | disabled?: boolean; 33 | disableResetOnTap?: boolean; 34 | disabledThumbIconBackgroundColor?: string; 35 | disabledThumbIconBorderColor?: string; 36 | enableReverseSwipe?: boolean; 37 | finishRemainingSwipeAnimationDuration?: number; 38 | forceCompleteSwipe?: (forceComplete: () => void) => void; 39 | forceReset?: (forceReset: () => void) => void; 40 | layoutWidth?: number; 41 | onSwipeFail?: () => void; 42 | onSwipeStart?: () => void; 43 | onSwipeSuccess?: (isForceComplete: boolean) => void; 44 | railFillBackgroundColor?: string; 45 | railFillBorderColor?: string; 46 | railStyles?: ViewStyle; 47 | resetAfterSuccessAnimDelay?: number; 48 | shouldResetAfterSuccess?: boolean; 49 | swipeSuccessThreshold?: number; 50 | thumbIconBackgroundColor?: string; 51 | thumbIconBorderColor?: string; 52 | thumbIconComponent?: () => ReactElement; 53 | thumbIconHeight?: number; 54 | thumbIconImageSource?: ImageSourcePropType | undefined; 55 | thumbIconStyles?: ViewStyle; 56 | thumbIconWidth?: number; 57 | } 58 | 59 | const SwipeThumb: React.FC = React.memo((props) => { 60 | const { 61 | disabled = false, 62 | disableResetOnTap = false, 63 | disabledThumbIconBackgroundColor, 64 | disabledThumbIconBorderColor, 65 | enableReverseSwipe, 66 | finishRemainingSwipeAnimationDuration = DEFAULT_ANIMATION_DURATION, 67 | forceCompleteSwipe, 68 | forceReset, 69 | layoutWidth = 0, 70 | onSwipeFail, 71 | onSwipeStart, 72 | onSwipeSuccess, 73 | railFillBackgroundColor, 74 | railFillBorderColor, 75 | railStyles, 76 | resetAfterSuccessAnimDelay, 77 | shouldResetAfterSuccess, 78 | swipeSuccessThreshold, 79 | thumbIconBackgroundColor, 80 | thumbIconBorderColor, 81 | thumbIconComponent: ThumbIconComponent, 82 | thumbIconHeight, 83 | thumbIconImageSource, 84 | thumbIconStyles = {}, 85 | thumbIconWidth, 86 | } = props; 87 | 88 | const paddingAndMarginsOffset = borderWidth + 2 * margin; 89 | var defaultContainerWidth = 0; 90 | if (thumbIconWidth == undefined && thumbIconHeight != undefined) { 91 | defaultContainerWidth = thumbIconHeight; 92 | } else if (thumbIconWidth != undefined) { 93 | defaultContainerWidth = thumbIconWidth; 94 | } 95 | const maxWidth = layoutWidth - paddingAndMarginsOffset; 96 | const isRTL = I18nManager.isRTL; 97 | 98 | const animatedWidth = useRef( 99 | new Animated.Value(defaultContainerWidth), 100 | ).current; 101 | const [shouldDisableTouch, disableTouch] = useState(false); 102 | 103 | const [backgroundColor, setBackgroundColor] = useState(TRANSPARENT_COLOR); 104 | const [borderColor, setBorderColor] = useState(TRANSPARENT_COLOR); 105 | 106 | useEffect(() => { 107 | forceReset && forceReset(reset); 108 | }, [forceReset]); 109 | 110 | useEffect(() => { 111 | forceCompleteSwipe && forceCompleteSwipe(forceComplete); 112 | }, [forceCompleteSwipe]); 113 | 114 | function updateWidthWithAnimation(newWidth: number) { 115 | Animated.timing(animatedWidth, { 116 | toValue: newWidth, 117 | duration: finishRemainingSwipeAnimationDuration, 118 | useNativeDriver: false, 119 | }).start(); 120 | } 121 | 122 | function updateWidthWithoutAnimation(newWidth: number) { 123 | Animated.timing(animatedWidth, { 124 | toValue: newWidth, 125 | duration: 0, 126 | useNativeDriver: false, 127 | }).start(); 128 | } 129 | 130 | function onSwipeNotMetSuccessThreshold() { 131 | // Animate to initial position 132 | updateWidthWithAnimation(defaultContainerWidth); 133 | onSwipeFail && onSwipeFail(); 134 | } 135 | 136 | function onSwipeMetSuccessThreshold(newWidth: number) { 137 | if (newWidth !== maxWidth) { 138 | // Animate to final position 139 | finishRemainingSwipe(); 140 | return; 141 | } 142 | invokeOnSwipeSuccess(false); 143 | } 144 | 145 | function onPanResponderStart() { 146 | if (disabled) { 147 | return; 148 | } 149 | onSwipeStart && onSwipeStart(); 150 | } 151 | 152 | const onPanResponderMove = useCallback( 153 | (_: any, gestureState: PanResponderGestureState) => { 154 | if (disabled) return; 155 | 156 | const reverseMultiplier = enableReverseSwipe ? -1 : 1; 157 | const rtlMultiplier = isRTL ? -1 : 1; 158 | const newWidth = 159 | defaultContainerWidth + 160 | rtlMultiplier * reverseMultiplier * gestureState.dx; 161 | 162 | if (newWidth < defaultContainerWidth) { 163 | reset(); 164 | } else if (newWidth > maxWidth) { 165 | setBackgroundColors(); 166 | updateWidthWithoutAnimation(maxWidth); 167 | } else { 168 | setBackgroundColors(); 169 | updateWidthWithoutAnimation(newWidth); 170 | } 171 | }, 172 | [ 173 | disabled, 174 | defaultContainerWidth, 175 | maxWidth, 176 | isRTL, 177 | enableReverseSwipe, 178 | reset, 179 | setBackgroundColors, 180 | animatedWidth, 181 | ], 182 | ); 183 | 184 | function onPanResponderRelease( 185 | _: any, 186 | gestureState: PanResponderGestureState, 187 | ) { 188 | if (disabled) { 189 | return; 190 | } 191 | const threshold = swipeSuccessThreshold 192 | ? swipeSuccessThreshold 193 | : SWIPE_SUCCESS_THRESHOLD; 194 | const reverseMultiplier = enableReverseSwipe ? -1 : 1; 195 | const rtlMultiplier = isRTL ? -1 : 1; 196 | const newWidth = 197 | defaultContainerWidth + 198 | rtlMultiplier * reverseMultiplier * gestureState.dx; 199 | const successThresholdWidth = maxWidth * (threshold / 100); 200 | newWidth < successThresholdWidth 201 | ? onSwipeNotMetSuccessThreshold() 202 | : onSwipeMetSuccessThreshold(newWidth); 203 | } 204 | 205 | function setBackgroundColors() { 206 | if (railFillBackgroundColor != undefined) { 207 | setBackgroundColor(railFillBackgroundColor); 208 | } 209 | if (railFillBorderColor != undefined) { 210 | setBorderColor(railFillBorderColor); 211 | } 212 | } 213 | 214 | function finishRemainingSwipe() { 215 | // Animate to final position 216 | updateWidthWithAnimation(maxWidth); 217 | invokeOnSwipeSuccess(false); 218 | 219 | //Animate back to initial position after successfully swiped 220 | const resetDelay = 221 | DEFAULT_ANIMATION_DURATION + 222 | (resetAfterSuccessAnimDelay !== undefined 223 | ? resetAfterSuccessAnimDelay 224 | : RESET_AFTER_SUCCESS_DEFAULT_DELAY); 225 | setTimeout(() => { 226 | shouldResetAfterSuccess && reset(); 227 | }, resetDelay); 228 | } 229 | 230 | function invokeOnSwipeSuccess(isForceComplete: boolean) { 231 | disableTouch(disableResetOnTap); 232 | onSwipeSuccess && onSwipeSuccess(isForceComplete); 233 | } 234 | 235 | function reset() { 236 | disableTouch(false); 237 | updateWidthWithAnimation(defaultContainerWidth); 238 | } 239 | 240 | function forceComplete() { 241 | updateWidthWithAnimation(maxWidth); 242 | invokeOnSwipeSuccess(true); 243 | } 244 | 245 | const dynamicStyles: ViewStyle = useMemo(() => { 246 | const iconWidth = thumbIconWidth ?? thumbIconHeight ?? 0; 247 | return { 248 | ...thumbIconStyles, 249 | height: thumbIconHeight, 250 | width: iconWidth, 251 | backgroundColor: disabled 252 | ? disabledThumbIconBackgroundColor 253 | : thumbIconBackgroundColor, 254 | borderColor: disabled 255 | ? disabledThumbIconBorderColor 256 | : thumbIconBorderColor, 257 | overflow: "hidden", 258 | }; 259 | }, [ 260 | thumbIconWidth, 261 | thumbIconHeight, 262 | thumbIconStyles, 263 | disabled, 264 | disabledThumbIconBackgroundColor, 265 | thumbIconBackgroundColor, 266 | disabledThumbIconBorderColor, 267 | thumbIconBorderColor, 268 | ]); 269 | 270 | const renderThumbIcon = useCallback(() => { 271 | return ( 272 | 276 | {!ThumbIconComponent && thumbIconImageSource && ( 277 | 278 | )} 279 | {ThumbIconComponent && ( 280 | 281 | 282 | 283 | )} 284 | 285 | ); 286 | }, [ThumbIconComponent, thumbIconImageSource, dynamicStyles]); 287 | 288 | const panResponder = useCallback( 289 | PanResponder.create({ 290 | onStartShouldSetPanResponder: (e: any, s: any) => true, 291 | onStartShouldSetPanResponderCapture: (e: any, s: any) => true, 292 | onMoveShouldSetPanResponder: (e: any, s: any) => true, 293 | onMoveShouldSetPanResponderCapture: (e: any, s: any) => true, 294 | onShouldBlockNativeResponder: (e: any, s: any) => true, 295 | onPanResponderGrant: onPanResponderStart, 296 | onPanResponderMove: onPanResponderMove, 297 | onPanResponderRelease: onPanResponderRelease, 298 | }) as any, 299 | [props], // [disabled, enableReverseSwipe, defaultContainerWidth, maxWidth, setBackgroundColors, animatedWidth], 300 | ); 301 | 302 | const panStyle = { 303 | backgroundColor, 304 | borderColor, 305 | width: animatedWidth, 306 | ...(enableReverseSwipe ? styles.containerRTL : styles.container), 307 | ...railStyles, 308 | }; 309 | 310 | return ( 311 | 317 | {renderThumbIcon()} 318 | 319 | ); 320 | }); 321 | 322 | export default SwipeThumb; 323 | -------------------------------------------------------------------------------- /src/components/SwipeThumb/styles.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from "react-native"; 2 | 3 | const borderWidth = 3; 4 | const margin = 1; 5 | const maxContainerHeight = 100; 6 | const Styles = StyleSheet.create({ 7 | container: { 8 | alignItems: "flex-end", 9 | alignSelf: "flex-start", 10 | borderRadius: maxContainerHeight / 2, 11 | borderRightWidth: 0, 12 | borderWidth, 13 | margin, 14 | }, 15 | containerRTL: { 16 | alignItems: "flex-start", 17 | alignSelf: "flex-end", 18 | borderRadius: maxContainerHeight / 2, 19 | borderLeftWidth: 0, 20 | borderWidth, 21 | margin, 22 | }, 23 | icon: { 24 | alignItems: "center", 25 | borderRadius: maxContainerHeight / 2, 26 | borderWidth: 2, 27 | justifyContent: "center", 28 | marginVertical: -borderWidth, 29 | }, 30 | }); 31 | 32 | export default Styles; 33 | export { borderWidth, margin }; 34 | -------------------------------------------------------------------------------- /src/constants/index.tsx: -------------------------------------------------------------------------------- 1 | export const TITLE_COLOR = "#083133"; 2 | export const TRANSPARENT_COLOR = "#00000000"; 3 | 4 | export const RAIL_BORDER_COLOR = "#073436"; 5 | export const RAIL_BACKGROUND_COLOR = "#7CF3F9"; 6 | export const DISABLED_RAIL_BACKGROUND_COLOR = "#CBCBCB"; 7 | 8 | export const RAIL_FILL_BORDER_COLOR = "#3D797C"; 9 | export const RAIL_FILL_BACKGROUND_COLOR = "#D27030AA"; 10 | 11 | export const THUMB_ICON_BORDER_COLOR = "#3D797C"; 12 | export const THUMB_ICON_BACKGROUND_COLOR = "#D27030"; 13 | 14 | export const DISABLED_THUMB_ICON_BORDER_COLOR = "#3C3C3C"; 15 | export const DISABLED_THUMB_ICON_BACKGROUND_COLOR = "#D3D3D3"; 16 | 17 | export const SWIPE_SUCCESS_THRESHOLD = 70; 18 | export const DEFAULT_ANIMATION_DURATION = 400; 19 | export const DEFAULT_HEIGHT = 50; 20 | export const DEFAULT_TITLE = "Swipe to submit"; 21 | export const DEFAULT_TITLE_FONT_SIZE = 20; 22 | export const DEFAULT_TITLE_MAX_LINES = 1; 23 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import { Component, ReactElement } from 'react'; 2 | import { StyleProp, ViewStyle, TextStyle, ImageSourcePropType } from 'react-native'; 3 | 4 | interface Props { 5 | containerStyles?: StyleProp; 6 | disabled?: boolean; 7 | disabledRailBackgroundColor?: string; 8 | disabledThumbIconBackgroundColor?: string; 9 | disabledThumbIconBorderColor?: string; 10 | /** 11 | * Without setting this to true, the completed swipe will be reset to start position upon a tap. 12 | */ 13 | disableResetOnTap?: boolean; 14 | /** 15 | * Enable swipe from right to left for RTL apps support 16 | */ 17 | enableReverseSwipe?: boolean; 18 | /** 19 | * When the swipe reaches a default 70% threshold or a custom `swipeSuccessThreshold`, the remaining swipe will be auto completed with an animation. 20 | * The default animation duration is 400ms. This value can be contolled with this prop. 21 | */ 22 | finishRemainingSwipeAnimationDuration?: number; 23 | /** 24 | * This funtion returns an inner function. The returned inner function can be invoked to complete the swipe programmatically. 25 | * @returns function 26 | */ 27 | forceCompleteSwipe?: (forceComplete: () => void) => void; 28 | /** 29 | * This funtion returns an inner function. The returned inner function can be invoked to reset a successfully completed swipe. 30 | * @returns function 31 | */ 32 | forceReset?: (forceReset: () => void) => void; 33 | /** 34 | * This is the height of thumb which we interact to swipe. 35 | * The width of the thumb will be automatically set to the height by default. But the thumb can be costomized with `thumbIconComponent`. 36 | * Default value is 50 37 | */ 38 | height?: number; 39 | onSwipeFail?: () => void; 40 | onSwipeStart?: () => void; 41 | /** 42 | * A successful swipe invokes this callback. 43 | * @param isForceComplete Indicates whether the swipe is completed by real gesture of programmatically using the forceCompleteSwipe 44 | */ 45 | onSwipeSuccess?: (isForceComplete: boolean) => void; 46 | railBackgroundColor?: string; 47 | railBorderColor?: string; 48 | railFillBackgroundColor?: string; 49 | railFillBorderColor?: string; 50 | railStyles?: StyleProp; 51 | /** 52 | * The button can be reset to original state upon a succesful swipe by setting `shouldResetAfterSuccess` to true. This prop is to set the delay. 53 | */ 54 | resetAfterSuccessAnimDelay?: number; 55 | /** 56 | * Detecting screen reader enabled is not very reliable across platforms. So, exposing it to override the internal value. 57 | */ 58 | screenReaderEnabled?: boolean; 59 | shouldResetAfterSuccess?: boolean; 60 | /** 61 | * If you set it to 50, it means after swiping 50%, the remaining will be auto completed. 62 | * 63 | * Default value is 70. 64 | */ 65 | swipeSuccessThreshold?: number; 66 | thumbIconBackgroundColor?: string; 67 | thumbIconBorderColor?: string; 68 | thumbIconComponent?: () => ReactElement; 69 | thumbIconImageSource?: ImageSourcePropType; 70 | thumbIconStyles?: StyleProp; 71 | thumbIconHeight?: number; 72 | thumbIconWidth?: number; 73 | title?: string; 74 | titleColor?: string; 75 | /** 76 | * Use a component as title 77 | */ 78 | titleComponent?: () => ReactElement; 79 | /** 80 | * Default value is 20 81 | */ 82 | titleFontSize?: number; 83 | /** 84 | * Allows scaling the title font size when the button width increases. A value of 1.5 means the font size will scale up to 150% of the base `titleFontSize`. 85 | */ 86 | titleMaxFontScale?: number; 87 | /** 88 | * Default value is 1 89 | */ 90 | titleMaxLines?: number; 91 | titleStyles?: StyleProp; 92 | /** 93 | * Width of the entire SwipeButton not just the draggable thumb icon. 94 | */ 95 | width?: number; 96 | } 97 | 98 | interface State { 99 | layoutWidth: number; 100 | screenReaderEnabled: boolean; 101 | } 102 | 103 | export default class RNSwipeButton extends Component {} 104 | 105 | export { Props }; 106 | 107 | --------------------------------------------------------------------------------