├── .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 | [](https://github.com/UdaySravanK/RNSwipeButton)
3 | [](https://github.com/UdaySravanK/RNSwipeButton)
4 | [](https://www.npmjs.com/package/rn-swipe-button)
5 | [](https://www.npmjs.com/package/rn-swipe-button)
6 | [](https://www.npmjs.com/package/rn-swipe-button)
7 | [](https://socket.dev/npm/package/rn-swipe-button)
8 | [](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 |
55 |
56 |
57 |
Screenshots
58 |
59 |
60 | iOS |
61 | Android |
62 | iOS GIF |
63 |
64 |
65 |
66 |
67 | |
68 |
69 |
70 | |
71 |
72 |
73 | |
74 |
75 |
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 |
202 | - In accessibility mode this component works like a regular button (double tap to activate)
203 | - We are supporting RTL out of the box. For RTL layouts, swipe button works by default as right to left swipe.
204 |
205 |
206 | Tech Stack
207 |
208 | - Node
209 | - Yarn/NPM
210 | - JavaScript
211 | - TypeScript
212 | - ReactNative
213 |
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 |
--------------------------------------------------------------------------------