├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ └── tests.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .watchmanconfig ├── .yarn └── releases │ └── yarn-4.5.1.cjs ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── babel.local-namespace-config.js ├── docs └── images │ ├── customized-picker.gif │ ├── simple-picker-android.gif │ └── simple-picker-ios.gif ├── example ├── .gitignore ├── .yarnrc.yml ├── App.js ├── app.json ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── index.js ├── metro.config.js ├── package.json ├── src │ ├── App.tsx │ ├── components │ │ ├── base │ │ │ ├── Box.tsx │ │ │ ├── Divider.tsx │ │ │ ├── Header.tsx │ │ │ ├── ListItemCheckBox.tsx │ │ │ └── index.ts │ │ └── example-blocks │ │ │ ├── AvatarCustomizedPickerBlockExample │ │ │ ├── Overlay.tsx │ │ │ ├── PickerItem.tsx │ │ │ ├── PickerItemContainer.tsx │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ │ ├── CompareWithNativeIOSBlockExample.tsx │ │ │ ├── SimplePickerBlockExample.tsx │ │ │ └── index.ts │ ├── contants.ts │ └── picker-config │ │ ├── PickerConfigPanel.tsx │ │ ├── PickerConfigProvider.tsx │ │ ├── index.ts │ │ └── withExamplePickerConfig.tsx ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── jest.config.js ├── lefthook.yml ├── local-namespace-config.js ├── package.json ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ └── index.test.tsx ├── base │ ├── contexts │ │ ├── PickerItemHeightContext.tsx │ │ └── ScrollContentOffsetContext.tsx │ ├── index.ts │ ├── item │ │ ├── PickerItem.tsx │ │ ├── PickerItemContainer.tsx │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── faces.test.ts.snap │ │ │ └── faces.test.ts │ │ └── faces.ts │ ├── list │ │ └── List.tsx │ ├── overlay │ │ └── Overlay.tsx │ ├── picker │ │ ├── Picker.tsx │ │ └── hooks │ │ │ ├── useSyncScrollEffect.ts │ │ │ └── useValueEventsEffect.ts │ └── types.ts ├── hoc │ └── virtualized │ │ ├── VirtualizedList.tsx │ │ ├── index.ts │ │ └── withVirtualized.tsx ├── index.tsx └── utils │ ├── debounce │ └── index.ts │ ├── math │ └── index.ts │ ├── react │ ├── index.ts │ ├── typedMemo.ts │ └── useBoolean.ts │ └── scrolling │ ├── __tests__ │ └── getPageIndex.test.ts │ ├── getPageIndex.ts │ ├── index.ts │ └── withScrollEndEvent.tsx ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const prettierConfig = require('./.prettierrc.js'); 2 | 3 | module.exports = { 4 | root: true, 5 | extends: ['@react-native-community', 'prettier'], 6 | rules: { 7 | 'prettier/prettier': ['error', prettierConfig], 8 | }, 9 | ignorePatterns: ['node_modules/', 'dest/'], 10 | }; 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Setup Node.js and install dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup Node.js 8 | uses: actions/setup-node@v4 9 | with: 10 | node-version: '18' 11 | 12 | - name: Cache dependencies 13 | id: yarn-cache 14 | uses: actions/cache@v3 15 | with: 16 | path: | 17 | **/node_modules 18 | **/.yarn/install-state.gz 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock', '!node_modules/**') }}-${{ hashFiles('**/package.json', '!node_modules/**') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock', '!node_modules/**') }} 22 | ${{ runner.os }}-yarn- 23 | 24 | - name: Install dependencies 25 | if: steps.yarn-cache.outputs.cache-hit != 'true' 26 | run: | 27 | yarn install --immutable 28 | yarn --cwd example install --immutable 29 | shell: bash 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | static-check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup 18 | uses: ./.github/actions/setup 19 | 20 | - name: Lint files 21 | run: yarn lint:check 22 | 23 | - name: Type check files 24 | run: yarn tsc:check 25 | 26 | test: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup 33 | uses: ./.github/actions/setup 34 | 35 | - name: Run unit tests 36 | run: yarn test:run 37 | 38 | build: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | 44 | - name: Setup 45 | uses: ./.github/actions/setup 46 | 47 | - name: Build package 48 | run: yarn build:bob 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | android.iml 42 | 43 | # Cocoapods 44 | # 45 | example/ios/Pods 46 | 47 | # Ruby 48 | example/vendor/ 49 | 50 | # node.js 51 | # 52 | node_modules/ 53 | 54 | # yarn https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 55 | .pnp.* 56 | .yarn/* 57 | !.yarn/patches 58 | !.yarn/plugins 59 | !.yarn/releases 60 | !.yarn/sdks 61 | !.yarn/versions 62 | 63 | # BUCK 64 | buck-out/ 65 | \.buckd/ 66 | android/app/libs 67 | android/keystores/debug.keystore 68 | 69 | # Expo 70 | .expo/ 71 | 72 | # Turborepo 73 | .turbo/ 74 | 75 | # generated by bob 76 | dest/ 77 | 78 | # local temps 79 | copied-src/ 80 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.18.1 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: false, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | }; 6 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: true 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.5.1.cjs 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [INSERT CONTACT METHOD]. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are always welcome, no matter how large or small! 4 | 5 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. Before contributing, please read the [code of conduct](./CODE_OF_CONDUCT.md). 6 | 7 | ## Development workflow 8 | 9 | To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: 10 | 11 | ```sh 12 | yarn 13 | ``` 14 | 15 | > While it's possible to use [`npm`](https://github.com/npm/cli), the tooling is built around [`yarn`](https://classic.yarnpkg.com/), so you'll have an easier time if you use `yarn` for development. 16 | 17 | While developing, you can run the [example app](/example/) to test your changes. Any changes you make in your library's JavaScript code will be reflected in the example app without a rebuild. If you change any native code, then you'll need to rebuild the example app. 18 | 19 | To start the packager: 20 | 21 | ```sh 22 | yarn example start 23 | ``` 24 | 25 | To run the example app on Android: 26 | 27 | ```sh 28 | yarn example android 29 | ``` 30 | 31 | To run the example app on iOS: 32 | 33 | ```sh 34 | yarn example ios 35 | ``` 36 | 37 | To run the example app on Web: 38 | 39 | ```sh 40 | yarn example web 41 | ``` 42 | 43 | Make sure your code passes TypeScript and ESLint. Run the following to verify: 44 | 45 | ```sh 46 | yarn tsc:check 47 | yarn lint:check 48 | ``` 49 | 50 | To fix formatting errors, run the following: 51 | 52 | ```sh 53 | yarn lint:fix 54 | ``` 55 | 56 | Remember to add tests for your change if possible. Run the unit tests by: 57 | 58 | ```sh 59 | yarn test:run 60 | ``` 61 | 62 | 63 | ### Commit message convention 64 | 65 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: 66 | 67 | - `fix`: bug fixes, e.g. fix crash due to deprecated method. 68 | - `feat`: new features, e.g. add new method to the module. 69 | - `refactor`: code refactor, e.g. migrate from class components to hooks. 70 | - `docs`: changes into documentation, e.g. add usage example for the module.. 71 | - `test`: adding or updating tests, e.g. add integration tests using detox. 72 | - `chore`: tooling changes, e.g. change CI config. 73 | 74 | Our pre-commit hooks verify that your commit message matches this format when committing. 75 | 76 | ### Linting and tests 77 | 78 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) 79 | 80 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. 81 | 82 | Our pre-commit hooks verify that the linter and tests pass when committing. 83 | 84 | ### Publishing to npm 85 | 86 | We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. 87 | 88 | To publish new versions, run the following: 89 | 90 | ```sh 91 | yarn release 92 | ``` 93 | 94 | ### Scripts 95 | 96 | The `package.json` file contains various scripts for common tasks: 97 | 98 | - `yarn bootstrap`: setup project by installing all dependencies and pods. 99 | - `yarn tsc:check`: type-check files with TypeScript. 100 | - `yarn lint:check`: lint files with ESLint. 101 | - `yarn test:run`: run unit tests with Jest. 102 | - `yarn example start`: start the Metro server for the example app. 103 | - `yarn example android`: run the example app on Android. 104 | - `yarn example ios`: run the example app on iOS. 105 | 106 | ### Sending a pull request 107 | 108 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). 109 | 110 | When you're sending a pull request: 111 | 112 | - Prefer small pull requests focused on one change. 113 | - Verify that linters and tests are passing. 114 | - Review the documentation to make sure it looks good. 115 | - Follow the pull request template when opening a pull request. 116 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Quidone React Native Wheel Picker 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [AUTHOR]: https://github.com/rozhkovs 2 | [FEEDBACK_GITHUB]: https://github.com/quidone/react-native-wheel-picker-feedback 3 | [EXPO_SNACK]: https://snack.expo.dev/@sergeyrozhkov/quidone-react-native-wheel-picker 4 | 5 | # React Native Wheel Picker 6 |

7 | 8 | React Native Wheel Picker is released under the MIT license. 9 | 10 | 11 | CI Tests 12 | 13 | 14 | Current npm package version. 15 | 16 | 17 | Number of downloads per week. 18 | 19 |

20 | 21 | A flexible React Native Wheel Picker for iOS and Android without using the native side. 22 | 23 | 24 | 25 | 30 | 35 | 40 | 41 |
26 | 27 |
28 | On iOS 29 |
31 | 32 |
33 | On Android 34 |
36 | 37 |
38 | Customization 39 |
42 | 43 | 44 | ## Features 45 | - Without native side. 46 | - Unified API. 47 | - Only native animations. 48 | - [Support native feedback](#Native-Feedback). 49 | - [Support virtualization](#withVirtualized). 50 | - Compatible with Expo ([Snack][EXPO_SNACK]). 51 | - Deep customization 52 | - Written ```TypeScript```. 53 | 54 | ## Installation 55 | ```shell 56 | yarn add @quidone/react-native-wheel-picker 57 | ``` 58 | 59 | ## Navigation 60 | 61 | - [Usage](#Usage) 62 | - [Native Feedback](#Native-Feedback) 63 | - [API](#API) 64 | - [WheelPicker](#WheelPicker) 65 | - [usePickerItemHeight](#usePickerItemHeight) 66 | - [useScrollContentOffset](#useScrollContentOffset) 67 | - [withVirtualized](#withVirtualized) 68 | - [Footer](#-Author) 69 | 70 | ## Usage 71 | 72 | ### [🚀 Expo Snack example][EXPO_SNACK] 73 | 74 | If you want to see more examples and experiment, run the examples locally. 75 | 76 | ```shell 77 | git clone git@github.com:quidone/react-native-wheel-picker.git 78 | cd react-native-wheel-picker 79 | yarn install 80 | cd example && yarn install && yarn ios 81 | ``` 82 | 83 | ### Simple case 84 | 85 | ```jsx 86 | import React, {useState} from 'react'; 87 | import WheelPicker from '@quidone/react-native-wheel-picker'; 88 | 89 | const data = [...Array(100).keys()].map((index) => ({ 90 | value: index, 91 | label: index.toString(), 92 | })) 93 | 94 | const App = () => { 95 | const [value, setValue] = useState(0); 96 | return ( 97 | setValue(value)} 101 | /> 102 | ); 103 | }; 104 | 105 | export default App; 106 | ``` 107 | 108 | ## Native Feedback 109 | 110 | You can trigger native sound and impact with [@quidone/react-native-wheel-picker-feedback][FEEDBACK_GITHUB] 111 | and onValueChanging event 112 | 113 | ```jsx 114 | // ... 115 | import WheelPickerFeedback from '@quidone/react-native-wheel-picker-feedback'; 116 | 117 | const App = () => { 118 | return ( 119 | { 121 | WheelPickerFeedback.triggerSoundAndImpact(); 122 | }} 123 | /> 124 | ); 125 | }; 126 | ``` 127 | 128 | ## API 129 | 130 | ### WheelPicker 131 | 132 | #### Props 133 | - ```data``` [array] - items of picker 134 | - ```value?``` [any] - current value of picker item 135 | - ```itemHeight?``` [number] - height of picker item in the center. 136 | - ```visibleItemCount?``` [number] - number of displayed items: 1, 3, 5... (default = 5). For 5, the WheelPicker height is calculated incorrectly, left for backward compatibility. 137 | - ```width?``` [number | string] - width of picker. 138 | - ```readOnly?``` [boolean] - read only mode. 139 | - ```testID?``` [string] - Used to locate this component in end-to-end tests. 140 | - ```onValueChanging?``` [function] - An event that is triggered when the value is changing. 141 | - ```onValueChanged?``` [function] - An event that is triggered when the value is changed (wheel is stopped and no touch). 142 | - ```keyExtractor?``` [function] - key extractor from picker item. 143 | - ```renderItem?``` [function] - render picker item content. 144 | - ```renderItemContainer?``` [function] - render picker item container (there is animated container). 145 | - ```renderOverlay?``` [function | null] - render overlay over the picker. 146 | - ```renderList?``` [function] - render list (Advanced, It is not recommended to use). 147 | - ```style?``` [object | array] - root style. 148 | - ```itemTextStyle?``` [object | array] - item text style for picker item. 149 | - ```overlayItemStyle?``` [object | array] - style for the overlay element in the center 150 | - ```contentContainerStyle?``` [object | array] - style which wraps all of the child views [original](https://reactnative.dev/docs/scrollview#contentcontainerstyle) 151 | - ```scrollEventThrottle?``` [object | array] - [original](https://reactnative.dev/docs/scrollview#scrolleventthrottle-ios) 152 | 153 | 154 | ### usePickerItemHeight 155 | This hook returns the item height which was passed via props. 156 | 157 | ### useScrollContentOffset 158 | This hook returns the animated value of the ScrollView offset. 159 | 160 | ### withVirtualized 161 | This HOC returns virtualized picker 162 | 163 | ```jsx 164 | import WheelPicker, {withVirtualized} from '@quidone/react-native-wheel-picker'; 165 | 166 | const VirtualizedWheelPicker = withVirtualized(WheelPicker); 167 | ``` 168 | 169 | #### Additional props 170 | - ```initialNumToRender?``` (default = ```Math.ceil(visibleItemCount / 2)```) - [original](https://reactnative.dev/docs/flatlist#initialnumtorender). 171 | - ```maxToRenderPerBatch?``` (default = ```Math.ceil(visibleItemCount / 2)```) - [original](https://reactnative.dev/docs/flatlist#maxtorenderperbatch). 172 | - ```windowSize?``` - [original](https://reactnative.dev/docs/flatlist#windowsize). 173 | - ```updateCellsBatchingPeriod?``` (default = 10) - [original](https://reactnative.dev/docs/flatlist#updatecellsbatchingperiod). 174 | 175 | ## 👨‍💻 Author 176 | [Sergey Rozhkov][AUTHOR] 177 | 178 | ## 🎯 Was it helpful? 179 | Do you like it and find it helpful? You can help this project in the following way: 180 | - ⭐ Put the star. 181 | - 💡 Suggest your ideas. 182 | - 😉 Open a founded issue. 183 | 184 | ## 🤝 Contributing 185 | See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow. 186 | 187 | ## 📄 License 188 | Quidone React Native Wheel Picker is MIT licensed, as found in the [LICENSE](LICENSE) file. 189 | 190 | --- 191 | 192 | Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) 193 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:@react-native/babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /babel.local-namespace-config.js: -------------------------------------------------------------------------------- 1 | const localPkgs = require('./local-namespace-config'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | ['@babel/plugin-syntax-typescript', {isTSX: true}], 6 | [ 7 | 'module-resolver', 8 | { 9 | extensions: ['.tsx', '.ts', '.js', '.json'], 10 | alias: { 11 | ...Object.entries(localPkgs).reduce((r, [name, ph]) => { 12 | r[name] = ph; 13 | return r; 14 | }, {}), 15 | }, 16 | }, 17 | ], 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /docs/images/customized-picker.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/docs/images/customized-picker.gif -------------------------------------------------------------------------------- /docs/images/simple-picker-android.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/docs/images/simple-picker-android.gif -------------------------------------------------------------------------------- /docs/images/simple-picker-ios.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/docs/images/simple-picker-ios.gif -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Android/IJ 6 | # 7 | .classpath 8 | .cxx 9 | .gradle 10 | .idea 11 | .project 12 | .settings 13 | local.properties 14 | android.iml 15 | 16 | # yarn https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 17 | .pnp.* 18 | .yarn/* 19 | !.yarn/patches 20 | !.yarn/plugins 21 | !.yarn/releases 22 | !.yarn/sdks 23 | !.yarn/versions 24 | 25 | # node.js 26 | # 27 | node_modules/ 28 | 29 | # XDE 30 | .expo/ 31 | 32 | # build 33 | ios/ 34 | android/ 35 | -------------------------------------------------------------------------------- /example/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | export {default} from './src/App'; 2 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "example", 4 | "slug": "example", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "newArchEnabled": true, 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "assetBundlePatterns": [ 16 | "**/*" 17 | ], 18 | "ios": { 19 | "supportsTablet": true, 20 | "bundleIdentifier": "com.quidone.wheelpicker.example" 21 | }, 22 | "android": { 23 | "adaptiveIcon": { 24 | "foregroundImage": "./assets/adaptive-icon.png", 25 | "backgroundColor": "#ffffff" 26 | }, 27 | "package": "com.quidone.wheelpicker.example" 28 | }, 29 | "web": { 30 | "favicon": "./assets/favicon.png" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/example/assets/adaptive-icon.png -------------------------------------------------------------------------------- /example/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/example/assets/favicon.png -------------------------------------------------------------------------------- /example/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/example/assets/icon.png -------------------------------------------------------------------------------- /example/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quidone/react-native-wheel-picker/84825e3eea90757687c896d739a40b226f2aec9b/example/assets/splash.png -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pak = require('../package.json'); 3 | const localPkgs = require('../local-namespace-config'); 4 | 5 | module.exports = function (api) { 6 | api.cache(true); 7 | 8 | return { 9 | presets: ['babel-preset-expo'], 10 | plugins: [ 11 | [ 12 | 'module-resolver', 13 | { 14 | extensions: ['.tsx', '.ts', '.js', '.json'], 15 | alias: { 16 | // For development, we want to alias the library to the source 17 | [pak.name]: path.join(__dirname, '..', pak.source), 18 | ...Object.entries(localPkgs).reduce((r, [name, ph]) => { 19 | r[name] = path.join(__dirname, '..', ph); 20 | return r; 21 | }, {}), 22 | }, 23 | }, 24 | ], 25 | ], 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import {registerRootComponent} from 'expo'; 2 | 3 | import App from './App'; 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App); 9 | -------------------------------------------------------------------------------- /example/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const escape = require('escape-string-regexp'); 3 | const {getDefaultConfig} = require('@expo/metro-config'); 4 | const exclusionList = require('metro-config/src/defaults/exclusionList'); 5 | const pak = require('../package.json'); 6 | const localPkgs = require('../local-namespace-config'); 7 | 8 | const root = path.resolve(__dirname, '..'); 9 | 10 | const modules = Object.keys({ 11 | ...pak.peerDependencies, 12 | }); 13 | 14 | const defaultConfig = getDefaultConfig(__dirname); 15 | 16 | module.exports = { 17 | ...defaultConfig, 18 | 19 | projectRoot: __dirname, 20 | watchFolders: [root], 21 | 22 | // We need to make sure that only one version is loaded for peerDependencies 23 | // So we block them at the root, and alias them to the versions in example's node_modules 24 | resolver: { 25 | ...defaultConfig.resolver, 26 | 27 | blacklistRE: exclusionList( 28 | modules.map( 29 | (m) => 30 | new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`), 31 | ), 32 | ), 33 | 34 | extraNodeModules: modules.reduce((acc, name) => { 35 | acc[name] = path.join(__dirname, 'node_modules', name); 36 | Object.entries(localPkgs).forEach(([nm, ph]) => { 37 | acc[nm] = path.join(root, ph); 38 | }); 39 | return acc; 40 | }, {}), 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "expo start --dev-client", 6 | "android": "expo run:android", 7 | "ios": "expo run:ios", 8 | "web": "expo start --web" 9 | }, 10 | "dependencies": { 11 | "@expo/ngrok": "^4.1.1", 12 | "@faker-js/faker": "^8.0.2", 13 | "@quidone/react-native-wheel-picker-feedback": "^2.0.0", 14 | "@react-native-picker/picker": "2.9.0", 15 | "@rozhkov/react-useful-hooks": "^1.0.9", 16 | "expo": "^52.0.41", 17 | "expo-splash-screen": "~0.29.22", 18 | "expo-status-bar": "~2.0.1", 19 | "react": "18.3.1", 20 | "react-dom": "18.3.1", 21 | "react-native": "0.76.7", 22 | "react-native-builder-bob": "^0.38.4", 23 | "react-native-elements": "^4.0.0-rc.2", 24 | "react-native-safe-area-context": "4.12.0", 25 | "react-native-web": "~0.19.10" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.24.0", 29 | "@expo/webpack-config": "~19.0.1", 30 | "babel-loader": "^8.1.0", 31 | "babel-plugin-module-resolver": "^4.1.0" 32 | }, 33 | "private": true, 34 | "resolutions": { 35 | "@types/react": "^18.2.0", 36 | "@types/react-native": "0.71.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ScrollView, StyleSheet} from 'react-native'; 3 | import {PickerConfigProvider} from './picker-config'; 4 | import {Box} from './components/base'; 5 | import { 6 | AvatarCustomizedPickerBlockExample, 7 | CompareWithNativeIOSBlockExample, 8 | SimplePickerBlockExample, 9 | } from './components/example-blocks'; 10 | 11 | const App = () => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | const styles = StyleSheet.create({ 23 | contentContainer: { 24 | paddingVertical: 60, 25 | paddingHorizontal: 20, 26 | alignItems: 'center', 27 | }, 28 | }); 29 | 30 | const Providers = () => { 31 | return ( 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default Providers; 39 | -------------------------------------------------------------------------------- /example/src/components/base/Box.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {View} from 'react-native'; 3 | 4 | type BoxProps = { 5 | height: number; 6 | }; 7 | 8 | const Box = ({height}: BoxProps) => { 9 | return ; 10 | }; 11 | 12 | export default Box; 13 | -------------------------------------------------------------------------------- /example/src/components/base/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | 4 | const Divider = () => { 5 | return ; 6 | }; 7 | 8 | const styles = StyleSheet.create({ 9 | root: { 10 | borderBottomColor: '#d9d9d9', 11 | borderBottomWidth: 0.66, 12 | alignSelf: 'stretch', 13 | }, 14 | }); 15 | 16 | export default memo(Divider); 17 | -------------------------------------------------------------------------------- /example/src/components/base/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import { 3 | type StyleProp, 4 | StyleSheet, 5 | Text, 6 | View, 7 | type ViewStyle, 8 | } from 'react-native'; 9 | import Divider from './Divider'; 10 | 11 | type HeaderProps = {title: string; style?: StyleProp}; 12 | 13 | const Header = ({title, style}: HeaderProps) => { 14 | return ( 15 | 16 | {title} 17 | 18 | 19 | ); 20 | }; 21 | 22 | const styles = StyleSheet.create({ 23 | container: { 24 | width: '100%', 25 | marginTop: 40, 26 | }, 27 | title: { 28 | fontSize: 20, 29 | paddingHorizontal: 20, 30 | fontWeight: '500', 31 | alignSelf: 'stretch', 32 | marginBottom: 12, 33 | }, 34 | }); 35 | 36 | export default memo(Header); 37 | -------------------------------------------------------------------------------- /example/src/components/base/ListItemCheckBox.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {StyleSheet, Switch, Text, View} from 'react-native'; 3 | 4 | type ListItemCheckBoxProps = { 5 | title: string; 6 | value: boolean; 7 | onToggle: () => void; 8 | }; 9 | 10 | const ListItemCheckBox = ({title, value, onToggle}: ListItemCheckBoxProps) => { 11 | return ( 12 | 13 | {title} 14 | 15 | 16 | ); 17 | }; 18 | 19 | const styles = StyleSheet.create({ 20 | root: {flexDirection: 'row', alignItems: 'center', paddingVertical: 8}, 21 | title: {flex: 1, fontSize: 16}, 22 | }); 23 | 24 | export default memo(ListItemCheckBox); 25 | -------------------------------------------------------------------------------- /example/src/components/base/index.ts: -------------------------------------------------------------------------------- 1 | export {default as Box} from './Box'; 2 | export {default as Divider} from './Divider'; 3 | export {default as Header} from './Header'; 4 | export {default as ListItemCheckBox} from './ListItemCheckBox'; 5 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/AvatarCustomizedPickerBlockExample/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | import type {RenderOverlayProps} from '@quidone/react-native-wheel-picker'; 4 | 5 | const Overlay = ({itemHeight, overlayItemStyle}: RenderOverlayProps) => { 6 | return ( 7 | 8 | 11 | 12 | ); 13 | }; 14 | 15 | const styles = StyleSheet.create({ 16 | overlayContainer: { 17 | ...StyleSheet.absoluteFillObject, 18 | justifyContent: 'center', 19 | alignItems: 'center', 20 | }, 21 | selection: { 22 | borderColor: 'gray', 23 | borderTopWidth: 0.33, 24 | borderBottomWidth: 0.33, 25 | alignSelf: 'stretch', 26 | }, 27 | }); 28 | 29 | export default memo(Overlay); 30 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/AvatarCustomizedPickerBlockExample/PickerItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {StyleSheet} from 'react-native'; 3 | import {Avatar, ListItem} from 'react-native-elements'; 4 | import { 5 | usePickerItemHeight, 6 | type RenderItemProps, 7 | } from '@quidone/react-native-wheel-picker'; 8 | import type {CusPickerItem} from './types'; 9 | 10 | const PickerItem = ({ 11 | item: { 12 | value: {firstName, lastName, job, avatarUrl}, 13 | }, 14 | }: RenderItemProps) => { 15 | const height = usePickerItemHeight(); 16 | return ( 17 | 18 | 19 | 20 | {`${firstName} ${lastName}`} 21 | {job} 22 | 23 | 24 | ); 25 | }; 26 | 27 | const styles = StyleSheet.create({ 28 | container: {backgroundColor: 'transparent'}, 29 | }); 30 | 31 | export default memo(PickerItem); 32 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/AvatarCustomizedPickerBlockExample/PickerItemContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo, useMemo} from 'react'; 2 | import {Animated} from 'react-native'; 3 | import { 4 | PickerItem, 5 | RenderItemContainerProps, 6 | usePickerItemHeight, 7 | useScrollContentOffset, 8 | } from '@quidone/react-native-wheel-picker'; 9 | 10 | const PickerItemContainer = ({ 11 | index, 12 | item, 13 | faces, 14 | renderItem, 15 | itemTextStyle, 16 | }: RenderItemContainerProps>) => { 17 | const offset = useScrollContentOffset(); 18 | const height = usePickerItemHeight(); 19 | 20 | const inputRange = useMemo( 21 | () => faces.map((f) => height * (index + f.index)), 22 | [faces, height, index], 23 | ); 24 | 25 | const {opacity, translateY, translateX} = useMemo( 26 | () => ({ 27 | opacity: offset.interpolate({ 28 | inputRange: inputRange, 29 | outputRange: faces.map((x) => x.opacity), 30 | extrapolate: 'clamp', 31 | }), 32 | translateY: offset.interpolate({ 33 | inputRange: inputRange, 34 | outputRange: faces.map((x) => x.offsetY), 35 | extrapolate: 'extend', 36 | }), 37 | translateX: offset.interpolate({ 38 | inputRange: [ 39 | height * (index - 1), 40 | height * index, 41 | height * (index + 1), 42 | ], 43 | outputRange: [-10, 0, -10], 44 | extrapolate: 'extend', 45 | }), 46 | }), 47 | [faces, height, index, inputRange, offset], 48 | ); 49 | 50 | return ( 51 | 54 | {renderItem({item, index, itemTextStyle})} 55 | 56 | ); 57 | }; 58 | 59 | export default memo(PickerItemContainer); 60 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/AvatarCustomizedPickerBlockExample/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo, useCallback, useState} from 'react'; 2 | import {StyleSheet, View} from 'react-native'; 3 | import WheelPicker, { 4 | PickerItem, 5 | RenderItem, 6 | RenderItemContainer, 7 | RenderOverlay, 8 | type ValueChangedEvent, 9 | } from '@quidone/react-native-wheel-picker'; 10 | import {useInit} from '@rozhkov/react-useful-hooks'; 11 | import {faker} from '@faker-js/faker'; 12 | import {withExamplePickerConfig} from '../../../picker-config'; 13 | import {Header} from '../../base'; 14 | import PickerItemContainer from './PickerItemContainer'; 15 | import PickerItemComponent from './PickerItem'; 16 | import Overlay from './Overlay'; 17 | import type {CusPickerItem} from './types'; 18 | 19 | const ExampleWheelPicker = withExamplePickerConfig(WheelPicker); 20 | const createPickerItem = (index: number): CusPickerItem => { 21 | const sex = index % 2 === 0 ? 'male' : 'female'; 22 | 23 | return { 24 | value: { 25 | firstName: faker.person.firstName(sex), 26 | lastName: faker.person.lastName(sex), 27 | job: faker.person.jobTitle(), 28 | avatarUrl: `https://randomuser.me/api/portraits/${sex === 'male' ? 'men' : 'women'}/${index % 50}.jpg`, // eslint-disable-line prettier/prettier 29 | }, 30 | }; 31 | }; 32 | 33 | const renderItem: RenderItem> = (props) => ( 34 | 35 | ); 36 | const renderItemContainer: RenderItemContainer> = (props) => ( 37 | 38 | ); 39 | const renderOverlay: RenderOverlay = (props) => ; 40 | 41 | const CustomizedPicker = () => { 42 | const data = useInit(() => [...Array(100).keys()].map(createPickerItem)); 43 | const [value, setValue] = useState(0); 44 | 45 | const onValueChanged = useCallback( 46 | ({item: {value: val}}: ValueChangedEvent>) => { 47 | setValue(val); 48 | }, 49 | [], 50 | ); 51 | 52 | return ( 53 | <> 54 |
55 | 56 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | const styles = StyleSheet.create({ 73 | outerContainer: {width: '100%'}, 74 | picker: {marginTop: 20, overflow: 'visible'}, 75 | contentContainerStyle: {paddingLeft: 32}, 76 | }); 77 | 78 | export default memo(CustomizedPicker); 79 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/AvatarCustomizedPickerBlockExample/types.ts: -------------------------------------------------------------------------------- 1 | import type {PickerItem} from '@quidone/react-native-wheel-picker'; 2 | 3 | export type CusPickerItem = PickerItem<{ 4 | firstName: string; 5 | lastName: string; 6 | job: string; 7 | avatarUrl: string; 8 | }>; 9 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/CompareWithNativeIOSBlockExample.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC, memo, useCallback, useState} from 'react'; 2 | import {Platform, StyleSheet, Text, View} from 'react-native'; 3 | import Picker, { 4 | OnValueChanged, 5 | PickerItem, 6 | } from '@quidone/react-native-wheel-picker'; 7 | import {Picker as IOSPicker} from '@react-native-picker/picker'; 8 | import {useInit} from '@rozhkov/react-useful-hooks'; 9 | import {withExamplePickerConfig} from '../../picker-config'; 10 | import {Header} from '../base'; 11 | 12 | let WheelPickers: FC; 13 | 14 | if (Platform.OS === 'ios') { 15 | const ExamplePicker = withExamplePickerConfig(Picker); 16 | const createPickerItem = (index: number): PickerItem => ({ 17 | value: index, 18 | label: index.toString(), 19 | }); 20 | 21 | WheelPickers = memo(() => { 22 | const items = useInit(() => [...Array(100).keys()].map(createPickerItem)); 23 | const [value, setValue] = useState(0); 24 | const onValueChangedEx = useCallback>>( 25 | ({item}) => { 26 | setValue(item.value); 27 | }, 28 | [], 29 | ); 30 | const onValueChangeIOS = useCallback((itemValue: number) => { 31 | // itemValue is a magic number: 1 (is itemValue) === 1 return false; 32 | // parseInt helps to fix this 33 | setValue(parseInt(itemValue as any)); 34 | }, []); 35 | 36 | return ( 37 | 38 | 39 | 45 | WheelPicker 46 | 47 | 48 | 53 | {items.map((item) => { 54 | return ( 55 | 61 | ); 62 | })} 63 | 64 | Native iOS 65 | 66 | 67 | ); 68 | }); 69 | 70 | const styles = StyleSheet.create({ 71 | root: { 72 | flexDirection: 'row', 73 | alignItems: 'stretch', 74 | paddingVertical: 20, 75 | minHeight: 240, 76 | }, 77 | itemContainer: { 78 | alignItems: 'center', 79 | justifyContent: 'space-between', 80 | marginHorizontal: 10, 81 | }, 82 | subtitle: {color: 'gray', fontSize: 10, padding: 16}, 83 | iosPicker: {width: 120, marginTop: 12}, 84 | iosPickerItem: {fontSize: 20}, 85 | }); 86 | } else { 87 | WheelPickers = memo(() => { 88 | return ( 89 | 90 | To compare with iOS native wheel picker, run the example on an emulator 91 | or a real device on the iOS operating system 92 | 93 | ); 94 | }); 95 | const styles = StyleSheet.create({root: {textAlign: 'center', padding: 20}}); 96 | } 97 | 98 | const CompareWithNativeIOSBlockExample = () => { 99 | return ( 100 | <> 101 |
102 | 103 | 104 | ); 105 | }; 106 | 107 | export default CompareWithNativeIOSBlockExample; 108 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/SimplePickerBlockExample.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useState} from 'react'; 2 | import WheelPicker, { 3 | PickerItem, 4 | type ValueChangedEvent, 5 | } from '@quidone/react-native-wheel-picker'; 6 | import {useInit} from '@rozhkov/react-useful-hooks'; 7 | import {withExamplePickerConfig, PickerConfigPanel} from '../../picker-config'; 8 | import {Header} from '../base'; 9 | 10 | const ExampleWheelPicker = withExamplePickerConfig(WheelPicker); 11 | const createPickerItem = (index: number): PickerItem => ({ 12 | value: index, 13 | label: index.toString(), 14 | }); 15 | 16 | const SimplePicker = () => { 17 | const data = useInit(() => [...Array(100).keys()].map(createPickerItem)); 18 | const [value, setValue] = useState(0); 19 | 20 | const onValueChanged = useCallback( 21 | ({item: {value: val}}: ValueChangedEvent>) => { 22 | setValue(val); 23 | }, 24 | [], 25 | ); 26 | 27 | return ( 28 | <> 29 |
30 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default SimplePicker; 42 | -------------------------------------------------------------------------------- /example/src/components/example-blocks/index.ts: -------------------------------------------------------------------------------- 1 | export {default as SimplePickerBlockExample} from './SimplePickerBlockExample'; 2 | export {default as AvatarCustomizedPickerBlockExample} from './AvatarCustomizedPickerBlockExample'; 3 | export {default as CompareWithNativeIOSBlockExample} from './CompareWithNativeIOSBlockExample'; 4 | -------------------------------------------------------------------------------- /example/src/contants.ts: -------------------------------------------------------------------------------- 1 | export const WP_FEEDBACK_GITHUB_URL = 2 | 'https://github.com/quidone/react-native-wheel-picker-feedback'; 3 | export const IS_EXPO_SNACK = false; 4 | -------------------------------------------------------------------------------- /example/src/picker-config/PickerConfigPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; 3 | import {ListItemCheckBox, Divider} from '../components/base'; 4 | import {usePickerConfig} from './PickerConfigProvider'; 5 | import {WP_FEEDBACK_GITHUB_URL} from '../contants'; 6 | import {ButtonGroup} from 'react-native-elements'; 7 | import {useInit} from '@rozhkov/react-useful-hooks'; 8 | 9 | const PickerConfigPanel = () => { 10 | const { 11 | enabledVirtualized, 12 | enabledSound, 13 | enabledImpact, 14 | readOnly, 15 | visibleItemCount, 16 | toggleVirtualized, 17 | toggleSound, 18 | toggleImpact, 19 | toggleReadOnly, 20 | changeVisibleItemCount, 21 | } = usePickerConfig(); 22 | 23 | const visibleItemCounts = useInit(() => ['1', '3', '5', '7', '9']); 24 | 25 | return ( 26 | 27 | 28 | 33 | 34 | 39 | 40 | 45 | 46 | 51 | 52 | 53 | Visible Count: 54 | { 58 | changeVisibleItemCount(parseInt(visibleItemCounts[value]!)); 59 | }} 60 | containerStyle={styles.buttonGroupContainer} 61 | /> 62 | 63 | 64 | { 67 | Linking.openURL(WP_FEEDBACK_GITHUB_URL); 68 | }} 69 | > 70 | Sound and impact only work on iOS 71 | 72 | 73 | ); 74 | }; 75 | 76 | const styles = StyleSheet.create({ 77 | root: {paddingVertical: 12, alignSelf: 'stretch'}, 78 | subtitle: { 79 | color: 'gray', 80 | fontSize: 12, 81 | textDecorationLine: 'underline', 82 | textAlign: 'center', 83 | }, 84 | touchable: {paddingVertical: 12}, 85 | buttonGroupListItem: { 86 | flexDirection: 'row', 87 | alignItems: 'center', 88 | paddingVertical: 8, 89 | justifyContent: 'space-between', 90 | }, 91 | buttonGroupContainer: { 92 | flex: 1, 93 | maxWidth: 200, 94 | marginLeft: 40, 95 | marginRight: 0, 96 | }, 97 | }); 98 | 99 | export default memo(PickerConfigPanel); 100 | -------------------------------------------------------------------------------- /example/src/picker-config/PickerConfigProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | PropsWithChildren, 4 | useContext, 5 | useState, 6 | } from 'react'; 7 | import {useMemoObject, useStableCallback} from '@rozhkov/react-useful-hooks'; 8 | import {IS_EXPO_SNACK} from '../contants'; 9 | import {Alert} from 'react-native'; 10 | 11 | type PickerConfig = { 12 | enabledSound: boolean; 13 | enabledImpact: boolean; 14 | enabledVirtualized: boolean; 15 | readOnly: boolean; 16 | visibleItemCount: number; 17 | }; 18 | 19 | type ContextVal = { 20 | toggleSound: () => void; 21 | toggleImpact: () => void; 22 | toggleVirtualized: () => void; 23 | toggleReadOnly: () => void; 24 | changeVisibleItemCount: (count: number) => void; 25 | } & PickerConfig; 26 | 27 | const Context = createContext(undefined); 28 | 29 | const alertNotAvailableFeedback = () => { 30 | Alert.alert( 31 | 'Feedback is not available in the Snack', 32 | 'You need to go to the @quidone/react-native-wheel-picker repository' + 33 | ' and build the example project using the "npx expo run:ios" console command.', 34 | ); 35 | }; 36 | 37 | const PickerConfigProvider = ({children}: PropsWithChildren) => { 38 | const [config, setConfig] = useState(() => ({ 39 | enabledSound: false, 40 | enabledImpact: false, 41 | enabledVirtualized: false, 42 | readOnly: false, 43 | visibleItemCount: 5, 44 | })); 45 | const toggleSound = useStableCallback(() => { 46 | if (IS_EXPO_SNACK) { 47 | alertNotAvailableFeedback(); 48 | return; 49 | } 50 | setConfig((prev) => ({...prev, enabledSound: !prev.enabledSound})); 51 | }); 52 | const toggleImpact = useStableCallback(() => { 53 | if (IS_EXPO_SNACK) { 54 | alertNotAvailableFeedback(); 55 | return; 56 | } 57 | setConfig((prev) => ({...prev, enabledImpact: !prev.enabledImpact})); 58 | }); 59 | const toggleVirtualized = useStableCallback(() => { 60 | setConfig((prev) => ({ 61 | ...prev, 62 | enabledVirtualized: !prev.enabledVirtualized, 63 | })); 64 | }); 65 | const toggleReadOnly = useStableCallback(() => { 66 | setConfig((prev) => ({ 67 | ...prev, 68 | readOnly: !prev.readOnly, 69 | })); 70 | }); 71 | const changeVisibleItemCount = useStableCallback( 72 | (count: 1 | 3 | 5 | 7 | 9 | number) => { 73 | setConfig((prev) => ({ 74 | ...prev, 75 | visibleItemCount: count, 76 | })); 77 | }, 78 | ); 79 | 80 | const value = useMemoObject({ 81 | ...config, 82 | toggleSound, 83 | toggleImpact, 84 | toggleVirtualized, 85 | toggleReadOnly, 86 | changeVisibleItemCount, 87 | }); 88 | 89 | return {children}; 90 | }; 91 | 92 | export default PickerConfigProvider; 93 | 94 | export const usePickerConfig = () => { 95 | const value = useContext(Context); 96 | if (value === undefined) { 97 | throw new Error( 98 | `usePickerConfig must be called from within PickerConfigProvider!`, 99 | ); 100 | } 101 | return value; 102 | }; 103 | -------------------------------------------------------------------------------- /example/src/picker-config/index.ts: -------------------------------------------------------------------------------- 1 | export {default as PickerConfigProvider} from './PickerConfigProvider'; 2 | export {default as withExamplePickerConfig} from './withExamplePickerConfig'; 3 | export {default as PickerConfigPanel} from './PickerConfigPanel'; 4 | -------------------------------------------------------------------------------- /example/src/picker-config/withExamplePickerConfig.tsx: -------------------------------------------------------------------------------- 1 | import React, {FC, memo, useCallback, useMemo} from 'react'; 2 | import type { 3 | default as WheelPicker, 4 | OnValueChanging, 5 | WheelPickerProps, 6 | } from '@quidone/react-native-wheel-picker'; 7 | import {usePickerConfig} from './PickerConfigProvider'; 8 | import WheelPickerFeedback from '@quidone/react-native-wheel-picker-feedback'; 9 | import {withVirtualized} from '@quidone/react-native-wheel-picker'; 10 | 11 | const useCallFeedback = () => { 12 | const {enabledSound, enabledImpact} = usePickerConfig(); 13 | return useMemo(() => { 14 | switch (true) { 15 | case enabledSound && enabledImpact: return WheelPickerFeedback.triggerSoundAndImpact; // eslint-disable-line prettier/prettier 16 | case enabledSound: return WheelPickerFeedback.triggerSound; // eslint-disable-line prettier/prettier 17 | case enabledImpact: return WheelPickerFeedback.triggerImpact; // eslint-disable-line prettier/prettier 18 | default: return () => {}; // eslint-disable-line prettier/prettier 19 | } 20 | }, [enabledImpact, enabledSound]); 21 | }; 22 | 23 | const withExamplePickerConfig = ( 24 | WrappedComponent: FC>, 25 | ) => { 26 | const Wrapper = ({ 27 | onValueChanging: onValueChangingProp, 28 | ...restProps 29 | }: WheelPickerProps) => { 30 | const {enabledVirtualized, readOnly, visibleItemCount} = usePickerConfig(); 31 | const callFeedback = useCallFeedback(); 32 | 33 | const onValueChanging = useCallback>( 34 | (...args) => { 35 | callFeedback(); 36 | onValueChangingProp?.(...args); 37 | }, 38 | [callFeedback, onValueChangingProp], 39 | ); 40 | 41 | const ResultComponent = useMemo(() => { 42 | if (!enabledVirtualized) { 43 | return WrappedComponent; 44 | } 45 | return withVirtualized(WrappedComponent as any); 46 | }, [enabledVirtualized]); 47 | 48 | return ( 49 | 55 | ); 56 | }; 57 | 58 | Wrapper.displayName = `withExamplePickerConfig(${WrappedComponent.displayName})`; 59 | 60 | return memo(Wrapper) as typeof WheelPicker; 61 | }; 62 | 63 | export default withExamplePickerConfig; 64 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | // Avoid expo-cli auto-generating a tsconfig 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const createExpoWebpackConfigAsync = require('@expo/webpack-config'); 3 | const {resolver} = require('./metro.config'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const node_modules = path.join(__dirname, 'node_modules'); 7 | 8 | module.exports = async function (env, argv) { 9 | const config = await createExpoWebpackConfigAsync(env, argv); 10 | 11 | config.module.rules.push({ 12 | test: /\.(js|jsx|ts|tsx)$/, 13 | include: path.resolve(root, 'src'), 14 | use: 'babel-loader', 15 | }); 16 | 17 | // We need to make sure that only one version is loaded for peerDependencies 18 | // So we alias them to the versions in example's node_modules 19 | Object.assign(config.resolve.alias, { 20 | ...resolver.extraNodeModules, 21 | 'react-native-web': path.join(node_modules, 'react-native-web'), 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const localPkgs = require('./local-namespace-config'); 3 | 4 | module.exports = { 5 | preset: 'react-native', 6 | modulePathIgnorePatterns: [ 7 | '/example/node_modules', 8 | '/dest/', 9 | ], 10 | moduleNameMapper: Object.entries(localPkgs).reduce((r, [name, ph]) => { 11 | r[name] = path.resolve(__dirname, ph); 12 | return r; 13 | }, {}), 14 | }; 15 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | lint: 5 | files: git diff --name-only @{push} 6 | glob: "*.{js,ts,jsx,tsx}" 7 | run: npx eslint {files} 8 | types: 9 | files: git diff --name-only @{push} 10 | glob: "*.{js,ts, jsx, tsx}" 11 | run: npx tsc --noEmit 12 | commit-msg: 13 | parallel: true 14 | commands: 15 | commitlint: 16 | run: npx commitlint --edit 17 | -------------------------------------------------------------------------------- /local-namespace-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '@implementation/virtualized': './src/hoc/virtualized/index', 3 | '@implementation/base': './src/base/index', 4 | '@utils/react': './src/utils/react/index', 5 | '@utils/math': './src/utils/math/index', 6 | '@utils/debounce': './src/utils/debounce/index', 7 | '@utils/scrolling': './src/utils/scrolling/index', 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@quidone/react-native-wheel-picker", 3 | "version": "1.4.1", 4 | "description": "Picker is a UI component for selecting an item from a list of options.", 5 | "main": "dest/commonjs/index", 6 | "module": "dest/module/index", 7 | "types": "dest/typescript/index.d.ts", 8 | "react-native": "src/index", 9 | "source": "src/index", 10 | "files": [ 11 | "src", 12 | "dest", 13 | "android", 14 | "ios", 15 | "cpp", 16 | "*.podspec", 17 | "!dest/typescript/example", 18 | "!ios/build", 19 | "!android/build", 20 | "!android/gradle", 21 | "!android/gradlew", 22 | "!android/gradlew.bat", 23 | "!android/local.properties", 24 | "!**/__tests__", 25 | "!**/__fixtures__", 26 | "!**/__mocks__", 27 | "!**/.*" 28 | ], 29 | "scripts": { 30 | "test:run": "jest", 31 | "tsc:check": "tsc --noEmit", 32 | "lint:check": "eslint \"**/*.{js,ts,tsx}\"", 33 | "lint:fix": "eslint --fix \"**/*.{js,ts,tsx}\"", 34 | "build:check-all": "yarn lint:fix && yarn tsc:check && yarn test:run", 35 | "build:resolve": "babel --config-file ./babel.local-namespace-config.js ./src --out-dir ./src --extensions .ts,.js,.tsx,.json --keep-file-extension", 36 | "build:bob": "cp -r ./src ./copied-src && yarn run build:resolve && yarn run lint:fix && bob build", 37 | "prepack": "yarn build:check-all && yarn build:bob", 38 | "postpublish": "rm -r ./src && cp -r ./copied-src ./src && rm -r ./copied-src", 39 | "release": "release-it", 40 | "example": "yarn --cwd example", 41 | "bootstrap": "yarn example && yarn install" 42 | }, 43 | "keywords": [ 44 | "react-native", 45 | "wheel", 46 | "picker", 47 | "expo", 48 | "ios", 49 | "android", 50 | "select", 51 | "field" 52 | ], 53 | "repository": "https://github.com/quidone/react-native-wheel-picker", 54 | "author": "Sergey Rozhkov (https://github.com/quidone/react-native-wheel-picker)", 55 | "license": "MIT", 56 | "bugs": { 57 | "url": "https://github.com/quidone/react-native-wheel-picker/issues" 58 | }, 59 | "homepage": "https://github.com/quidone/react-native-wheel-picker#readme", 60 | "publishConfig": { 61 | "registry": "https://registry.npmjs.org/" 62 | }, 63 | "peerDependencies": { 64 | "react": ">=16.8", 65 | "react-native": ">=0.71.6" 66 | }, 67 | "dependencies": { 68 | "@rozhkov/react-useful-hooks": "^1.0.10" 69 | }, 70 | "devDependencies": { 71 | "@babel/cli": "^7.21.5", 72 | "@babel/core": "^7.26.0", 73 | "@babel/plugin-syntax-typescript": "^7.21.4", 74 | "@commitlint/config-conventional": "^17.0.2", 75 | "@evilmartians/lefthook": "^1.2.2", 76 | "@react-native-community/eslint-config": "^3.0.2", 77 | "@release-it/conventional-changelog": "^5.0.0", 78 | "@types/jest": "^28.1.2", 79 | "@types/react": "^18.2.0", 80 | "@types/react-test-renderer": "^18", 81 | "babel-plugin-module-resolver": "^4.1.0", 82 | "commitlint": "^17.0.2", 83 | "del-cli": "^5.0.0", 84 | "eslint": "^8.4.1", 85 | "eslint-config-prettier": "^8.5.0", 86 | "eslint-plugin-ft-flow": "^3.0.11", 87 | "eslint-plugin-prettier": "^4.0.0", 88 | "jest": "^28.1.1", 89 | "metro-react-native-babel-preset": "^0.77.0", 90 | "pod-install": "^0.1.0", 91 | "prettier": "^2.0.5", 92 | "react": "18.3.1", 93 | "react-native": "0.76.7", 94 | "react-native-builder-bob": "^0.20.0", 95 | "react-test-renderer": "18.2.0", 96 | "release-it": "^15.0.0", 97 | "typescript": "^4.5.2" 98 | }, 99 | "resolutions": { 100 | "@types/react": "^18.2.0" 101 | }, 102 | "engines": { 103 | "node": ">= 16.0.0" 104 | }, 105 | "packageManager": "yarn@4.5.1", 106 | "commitlint": { 107 | "extends": [ 108 | "@commitlint/config-conventional" 109 | ] 110 | }, 111 | "release-it": { 112 | "git": { 113 | "commitMessage": "chore: release ${version}", 114 | "tagName": "v${version}" 115 | }, 116 | "npm": { 117 | "publish": true 118 | }, 119 | "github": { 120 | "release": true 121 | }, 122 | "plugins": { 123 | "@release-it/conventional-changelog": { 124 | "preset": "angular" 125 | } 126 | } 127 | }, 128 | "react-native-builder-bob": { 129 | "source": "src", 130 | "output": "dest", 131 | "targets": [ 132 | "commonjs", 133 | "module", 134 | [ 135 | "typescript", 136 | { 137 | "project": "tsconfig.build.json" 138 | } 139 | ] 140 | ] 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`WheelPicker should match snapshot 1`] = ` 4 | 20 | 62 | 63 | 83 | 97 | Item 1 98 | 99 | 100 | 101 | 102 | 118 | 134 | 135 | 136 | `; 137 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import WheelPicker from '@implementation/base'; 2 | import React from 'react'; 3 | import renderer from 'react-test-renderer'; 4 | 5 | describe('WheelPicker', () => { 6 | it('should match snapshot', () => { 7 | const tree = renderer 8 | .create( 9 | , 13 | ) 14 | .toJSON(); 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/base/contexts/PickerItemHeightContext.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useContext} from 'react'; 2 | 3 | type ContextValue = number; 4 | 5 | export const PickerItemHeightContext = createContext( 6 | undefined, 7 | ); 8 | 9 | export const usePickerItemHeight = () => { 10 | const value = useContext(PickerItemHeightContext); 11 | if (value === undefined) { 12 | throw new Error( 13 | 'usePickerItemHeight must be called from within PickerItemHeightContext.Provider!', 14 | ); 15 | } 16 | return value; 17 | }; 18 | -------------------------------------------------------------------------------- /src/base/contexts/ScrollContentOffsetContext.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, useContext} from 'react'; 2 | import type {Animated} from 'react-native'; 3 | 4 | type ContextValue = Animated.Value; 5 | 6 | export const ScrollContentOffsetContext = createContext< 7 | ContextValue | undefined 8 | >(undefined); 9 | 10 | export const useScrollContentOffset = () => { 11 | const value = useContext(ScrollContentOffsetContext); 12 | if (value === undefined) { 13 | throw new Error( 14 | 'useScrollContentOffset must be called from within ScrollContentOffsetContext.Provider!', 15 | ); 16 | } 17 | return value; 18 | }; 19 | -------------------------------------------------------------------------------- /src/base/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | PickerItem, 3 | ValueChangedEvent, 4 | ValueChangingEvent, 5 | OnValueChanged, 6 | OnValueChanging, 7 | RenderItemProps, 8 | RenderItemContainerProps, 9 | RenderOverlayProps, 10 | RenderListProps, 11 | RenderItem, 12 | RenderItemContainer, 13 | RenderOverlay, 14 | RenderList, 15 | } from './types'; 16 | 17 | export {useScrollContentOffset} from './contexts/ScrollContentOffsetContext'; 18 | export {usePickerItemHeight} from './contexts/PickerItemHeightContext'; 19 | 20 | export {PickerProps} from './picker/Picker'; 21 | import WheelPicker from './picker/Picker'; 22 | export default WheelPicker; 23 | -------------------------------------------------------------------------------- /src/base/item/PickerItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {StyleProp, StyleSheet, Text, TextStyle} from 'react-native'; 3 | import {usePickerItemHeight} from '../contexts/PickerItemHeightContext'; 4 | 5 | type PickerItemProps = { 6 | value: any; 7 | label?: string; 8 | itemTextStyle: StyleProp; 9 | }; 10 | 11 | const PickerItem = ({value, label, itemTextStyle}: PickerItemProps) => { 12 | const height = usePickerItemHeight(); 13 | 14 | return ( 15 | 16 | {label ?? value} 17 | 18 | ); 19 | }; 20 | 21 | const styles = StyleSheet.create({ 22 | root: { 23 | textAlign: 'center', 24 | fontSize: 20, 25 | }, 26 | }); 27 | 28 | export default memo(PickerItem); 29 | -------------------------------------------------------------------------------- /src/base/item/PickerItemContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo, useMemo} from 'react'; 2 | import {Animated, StyleProp, TextStyle} from 'react-native'; 3 | import {useScrollContentOffset} from '../contexts/ScrollContentOffsetContext'; 4 | import {usePickerItemHeight} from '../contexts/PickerItemHeightContext'; 5 | import type {RenderItem} from '../types'; 6 | import type {Faces} from './faces'; 7 | 8 | type PickerItemContainerProps = { 9 | item: any; 10 | index: number; 11 | faces: ReadonlyArray; 12 | renderItem: RenderItem; 13 | itemTextStyle: StyleProp | undefined; 14 | }; 15 | 16 | const PickerItemContainer = ({ 17 | index, 18 | item, 19 | faces, 20 | renderItem, 21 | itemTextStyle, 22 | }: PickerItemContainerProps) => { 23 | const offset = useScrollContentOffset(); 24 | const height = usePickerItemHeight(); 25 | 26 | const {opacity, rotateX, translateY} = useMemo(() => { 27 | const inputRange = faces.map((f) => height * (index + f.index)); 28 | return { 29 | opacity: offset.interpolate({ 30 | inputRange: inputRange, 31 | outputRange: faces.map((x) => x.opacity), 32 | extrapolate: 'clamp', 33 | }), 34 | rotateX: offset.interpolate({ 35 | inputRange: inputRange, 36 | outputRange: faces.map((x) => `${x.deg}deg`), 37 | extrapolate: 'extend', 38 | }), 39 | translateY: offset.interpolate({ 40 | inputRange: inputRange, 41 | outputRange: faces.map((x) => x.offsetY), 42 | extrapolate: 'extend', 43 | }), 44 | }; 45 | }, [faces, height, index, offset]); 46 | 47 | return ( 48 | 61 | {renderItem({item, index, itemTextStyle})} 62 | 63 | ); 64 | }; 65 | 66 | export default memo(PickerItemContainer); 67 | -------------------------------------------------------------------------------- /src/base/item/__tests__/__snapshots__/faces.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createFaces Matches snapshot for visible item count of 1. 1`] = ` 4 | Array [ 5 | Object { 6 | "deg": 90, 7 | "index": -1, 8 | "offsetY": -24, 9 | "opacity": 0, 10 | "screenHeight": 2.9391523179536475e-15, 11 | }, 12 | Object { 13 | "deg": 0, 14 | "index": 0, 15 | "offsetY": 0, 16 | "opacity": 1, 17 | "screenHeight": 48, 18 | }, 19 | Object { 20 | "deg": -90, 21 | "index": 1, 22 | "offsetY": 24, 23 | "opacity": 0, 24 | "screenHeight": 2.9391523179536475e-15, 25 | }, 26 | ] 27 | `; 28 | 29 | exports[`createFaces Matches snapshot for visible item count of 3. 1`] = ` 30 | Array [ 31 | Object { 32 | "deg": 90, 33 | "index": -2, 34 | "offsetY": -38.058874503045715, 35 | "opacity": 0, 36 | "screenHeight": 2.9391523179536475e-15, 37 | }, 38 | Object { 39 | "deg": 45, 40 | "index": -1, 41 | "offsetY": -7.029437251522857, 42 | "opacity": 0.2, 43 | "screenHeight": 33.941125496954285, 44 | }, 45 | Object { 46 | "deg": 0, 47 | "index": 0, 48 | "offsetY": 0, 49 | "opacity": 1, 50 | "screenHeight": 48, 51 | }, 52 | Object { 53 | "deg": -45, 54 | "index": 1, 55 | "offsetY": 7.029437251522857, 56 | "opacity": 0.2, 57 | "screenHeight": 33.941125496954285, 58 | }, 59 | Object { 60 | "deg": -90, 61 | "index": 2, 62 | "offsetY": 38.058874503045715, 63 | "opacity": 0, 64 | "screenHeight": 2.9391523179536475e-15, 65 | }, 66 | ] 67 | `; 68 | 69 | exports[`createFaces Matches snapshot for visible item count of 5. 1`] = ` 70 | Array [ 71 | Object { 72 | "deg": 90, 73 | "index": -3, 74 | "offsetY": -54.43078061834694, 75 | "opacity": 0, 76 | "screenHeight": 2.9391523179536475e-15, 77 | }, 78 | Object { 79 | "deg": 60, 80 | "index": -2, 81 | "offsetY": -18.43078061834694, 82 | "opacity": 0.2, 83 | "screenHeight": 24.000000000000007, 84 | }, 85 | Object { 86 | "deg": 30, 87 | "index": -1, 88 | "offsetY": -3.215390309173472, 89 | "opacity": 0.35, 90 | "screenHeight": 41.569219381653056, 91 | }, 92 | Object { 93 | "deg": 0, 94 | "index": 0, 95 | "offsetY": 0, 96 | "opacity": 1, 97 | "screenHeight": 48, 98 | }, 99 | Object { 100 | "deg": -30, 101 | "index": 1, 102 | "offsetY": 3.215390309173472, 103 | "opacity": 0.35, 104 | "screenHeight": 41.569219381653056, 105 | }, 106 | Object { 107 | "deg": -60, 108 | "index": 2, 109 | "offsetY": 18.43078061834694, 110 | "opacity": 0.2, 111 | "screenHeight": 24.000000000000007, 112 | }, 113 | Object { 114 | "deg": -90, 115 | "index": 3, 116 | "offsetY": 54.43078061834694, 117 | "opacity": 0, 118 | "screenHeight": 2.9391523179536475e-15, 119 | }, 120 | ] 121 | `; 122 | 123 | exports[`createFaces Matches snapshot for visible item count of 7. 1`] = ` 124 | Array [ 125 | Object { 126 | "deg": 90, 127 | "index": -4, 128 | "offsetY": -71.34385218897964, 129 | "opacity": 0, 130 | "screenHeight": 2.9391523179536475e-15, 131 | }, 132 | Object { 133 | "deg": 67.5, 134 | "index": -3, 135 | "offsetY": -32.5282545657418, 136 | "opacity": 0.2, 137 | "screenHeight": 18.368804753524312, 138 | }, 139 | Object { 140 | "deg": 45, 141 | "index": -2, 142 | "offsetY": -10.683219690981097, 143 | "opacity": 0.35, 144 | "screenHeight": 33.941125496954285, 145 | }, 146 | Object { 147 | "deg": 22.5, 148 | "index": -1, 149 | "offsetY": -1.82689121972912, 150 | "opacity": 0.45, 151 | "screenHeight": 44.34621756054176, 152 | }, 153 | Object { 154 | "deg": 0, 155 | "index": 0, 156 | "offsetY": 0, 157 | "opacity": 1, 158 | "screenHeight": 48, 159 | }, 160 | Object { 161 | "deg": -22.5, 162 | "index": 1, 163 | "offsetY": 1.82689121972912, 164 | "opacity": 0.45, 165 | "screenHeight": 44.34621756054176, 166 | }, 167 | Object { 168 | "deg": -45, 169 | "index": 2, 170 | "offsetY": 10.683219690981097, 171 | "opacity": 0.35, 172 | "screenHeight": 33.941125496954285, 173 | }, 174 | Object { 175 | "deg": -67.5, 176 | "index": 3, 177 | "offsetY": 32.5282545657418, 178 | "opacity": 0.2, 179 | "screenHeight": 18.368804753524312, 180 | }, 181 | Object { 182 | "deg": -90, 183 | "index": 4, 184 | "offsetY": 71.34385218897964, 185 | "opacity": 0, 186 | "screenHeight": 2.9391523179536475e-15, 187 | }, 188 | ] 189 | `; 190 | -------------------------------------------------------------------------------- /src/base/item/__tests__/faces.test.ts: -------------------------------------------------------------------------------- 1 | import {createFaces} from '../faces'; 2 | 3 | const FACE_HEIGHT = 48; 4 | 5 | describe('createFaces', () => { 6 | const visibleItemCounts = [1, 3, 5, 7]; 7 | 8 | visibleItemCounts.forEach((count) => { 9 | test(`Matches snapshot for visible item count of ${count}.`, () => { 10 | expect(createFaces(FACE_HEIGHT, count)).toMatchSnapshot(); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/base/item/faces.ts: -------------------------------------------------------------------------------- 1 | import {degToRad} from '@utils/math'; 2 | 3 | export type Faces = { 4 | index: number; 5 | deg: number; 6 | offsetY: number; 7 | opacity: number; 8 | screenHeight: number; 9 | }; 10 | 11 | /** 12 | * Calculates the height of the element after rotating it relative to the user's screen. 13 | * @param degree - the angle relative to the screen plane. 14 | * @param itemHeight - original height 15 | */ 16 | const calcHeight = (degree: number, itemHeight: number) => 17 | itemHeight * Math.cos(degToRad(degree)); 18 | 19 | export const calcPickerHeight = (faces: Faces[], itemHeight: number) => { 20 | // TODO left for backward compatibility, it must be removed after updating the major version. 21 | if (faces.length === 7) { 22 | return itemHeight * 5; 23 | } 24 | return faces.reduce((r, v) => r + calcHeight(Math.abs(v.deg), itemHeight), 0); 25 | }; 26 | 27 | export const createFaces = ( 28 | itemHeight: number, 29 | visibleCount: number, 30 | ): Faces[] => { 31 | if (__DEV__) { 32 | if (visibleCount < 1 || visibleCount % 2 === 0) { 33 | throw new Error( 34 | `WheelPicker cannot display the number of visible items "${visibleCount}".` + 35 | ` The value must be greater than 0 and be an odd number. E.g 1, 3, 5, 7...`, 36 | ); 37 | } 38 | } 39 | 40 | // e.g [30, 60, 90] 41 | const getDegreesRelativeCenter = () => { 42 | const maxStep = Math.trunc((visibleCount + 2) / 2); // + 2 because there are 2 more faces at 90 degrees 43 | const stepDegree = 90 / maxStep; 44 | 45 | const result = []; 46 | for (let i = 1; i <= maxStep; i++) { 47 | result.push(i * stepDegree); 48 | } 49 | return result; 50 | }; 51 | 52 | const getScreenHeightsAndOffsets = ( 53 | degrees: T, 54 | ): [T, T] => { 55 | const screenHeights = degrees.map((deg) => 56 | calcHeight(deg, itemHeight), 57 | ) as unknown as T; 58 | const freeSpaces = screenHeights.map( 59 | (screenHeight) => itemHeight - screenHeight, 60 | ); 61 | const offsets = freeSpaces.map((freeSpace, index) => { 62 | let offset = freeSpace / 2; 63 | for (let i = 0; i < index; i++) { 64 | offset += freeSpaces[i]!; 65 | } 66 | return offset; 67 | }) as unknown as T; 68 | return [screenHeights, offsets]; 69 | }; 70 | 71 | const getOpacity = (index: number) => { 72 | const map: Record = { 73 | 0: 0, 74 | 1: 0.2, 75 | 2: 0.35, 76 | 3: 0.45, 77 | 4: 0.5, 78 | }; 79 | return map[index] ?? Math.min(1, map[4]! + index * 0.5); 80 | }; 81 | 82 | const degrees = getDegreesRelativeCenter(); 83 | const [screenHeight, offsets] = getScreenHeightsAndOffsets(degrees); 84 | 85 | return [ 86 | // top items 87 | ...degrees 88 | .map((degree, index) => { 89 | return { 90 | index: -1 * (index + 1), 91 | deg: degree, 92 | opacity: getOpacity(degrees.length - 1 - index), 93 | offsetY: -1 * offsets[index]!, 94 | screenHeight: screenHeight[index]!, 95 | }; 96 | }) 97 | .reverse(), 98 | 99 | // center item 100 | {index: 0, deg: 0, opacity: 1, offsetY: 0, screenHeight: itemHeight}, 101 | 102 | // bottom items 103 | ...degrees.map((degree, index) => { 104 | return { 105 | index: index + 1, 106 | deg: -1 * degree, 107 | opacity: getOpacity(degrees.length - 1 - index), 108 | offsetY: offsets[index]!, 109 | screenHeight: screenHeight[index]!, 110 | }; 111 | }), 112 | ]; 113 | }; 114 | -------------------------------------------------------------------------------- /src/base/list/List.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ForwardedRef, 3 | forwardRef, 4 | memo, 5 | useImperativeHandle, 6 | useMemo, 7 | useRef, 8 | } from 'react'; 9 | import type { 10 | KeyExtractor, 11 | ListMethods, 12 | PickerItem, 13 | RenderPickerItem, 14 | } from '../types'; 15 | import { 16 | Animated, 17 | ScrollView, 18 | StyleProp, 19 | StyleSheet, 20 | ViewStyle, 21 | } from 'react-native'; 22 | import {useInit} from '@rozhkov/react-useful-hooks'; 23 | import {withScrollEndEvent} from '@utils/scrolling'; 24 | 25 | const ExtendedAnimatedScrollView = withScrollEndEvent(Animated.ScrollView); 26 | 27 | const OFFSET_X = 0; 28 | const getOffsetY = (index: number, itemHeight: number) => index * itemHeight; 29 | 30 | export type ListProps> = { 31 | data: ReadonlyArray; 32 | keyExtractor: KeyExtractor; 33 | renderItem: RenderPickerItem; 34 | itemHeight: number; 35 | pickerHeight: number; 36 | readOnly: boolean; 37 | initialIndex: number; 38 | scrollOffset: Animated.Value; 39 | onTouchStart: () => void; 40 | onTouchEnd: () => void; 41 | onTouchCancel: () => void; 42 | onScrollEnd: () => void; 43 | contentContainerStyle: StyleProp | undefined; 44 | }; 45 | 46 | const List = >( 47 | { 48 | initialIndex, 49 | data, 50 | keyExtractor, 51 | renderItem, 52 | itemHeight, 53 | pickerHeight, 54 | readOnly, 55 | scrollOffset, 56 | onTouchEnd, 57 | onTouchStart, 58 | onTouchCancel, 59 | onScrollEnd, 60 | contentContainerStyle: contentContainerStyleProp, 61 | ...restProps 62 | }: ListProps, 63 | forwardedRef: ForwardedRef, 64 | ) => { 65 | const listRef = useRef(null); 66 | useImperativeHandle( 67 | forwardedRef, 68 | () => ({ 69 | scrollToIndex: ({index, animated}) => { 70 | listRef.current?.scrollTo({ 71 | x: OFFSET_X, 72 | y: getOffsetY(index, itemHeight), 73 | animated, 74 | }); 75 | }, 76 | }), 77 | [itemHeight], 78 | ); 79 | const initialOffset = useInit(() => ({ 80 | x: OFFSET_X, 81 | y: getOffsetY(initialIndex, itemHeight), 82 | })); 83 | 84 | const snapToOffsets = useMemo( 85 | () => data.map((_, i) => i * itemHeight), 86 | [data, itemHeight], 87 | ); 88 | const onScroll = useMemo( 89 | () => 90 | Animated.event([{nativeEvent: {contentOffset: {y: scrollOffset}}}], { 91 | useNativeDriver: true, 92 | }), 93 | [scrollOffset], 94 | ); 95 | 96 | const contentContainerStyle = useMemo(() => { 97 | return [ 98 | { 99 | paddingVertical: (pickerHeight - itemHeight) / 2, 100 | }, 101 | contentContainerStyleProp, 102 | ]; 103 | }, [pickerHeight, itemHeight, contentContainerStyleProp]); 104 | 105 | return ( 106 | 125 | {data.map((item, index) => 126 | renderItem({key: keyExtractor(item, index), item, index}), 127 | )} 128 | 129 | ); 130 | }; 131 | 132 | const styles = StyleSheet.create({ 133 | list: {width: '100%', overflow: 'visible'}, 134 | }); 135 | 136 | export default memo(forwardRef(List)); 137 | -------------------------------------------------------------------------------- /src/base/overlay/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react'; 2 | import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; 3 | 4 | type OverlayProps = { 5 | itemHeight: number; 6 | pickerWidth: number | 'auto' | `${number}%`; 7 | overlayItemStyle: StyleProp | undefined; 8 | }; 9 | 10 | const Overlay = ({itemHeight, overlayItemStyle}: OverlayProps) => { 11 | return ( 12 | 13 | 16 | 17 | ); 18 | }; 19 | 20 | const styles = StyleSheet.create({ 21 | overlayContainer: { 22 | ...StyleSheet.absoluteFillObject, 23 | justifyContent: 'center', 24 | alignItems: 'center', 25 | }, 26 | selection: { 27 | opacity: 0.05, 28 | backgroundColor: '#000', 29 | borderRadius: 8, 30 | alignSelf: 'stretch', 31 | }, 32 | }); 33 | 34 | export default memo(Overlay); 35 | -------------------------------------------------------------------------------- /src/base/picker/Picker.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useMemo, useRef} from 'react'; 2 | import type {TextStyle} from 'react-native'; 3 | import {Animated, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; 4 | import PickerItemComponent from '../item/PickerItem'; 5 | import {ScrollContentOffsetContext} from '../contexts/ScrollContentOffsetContext'; 6 | import {PickerItemHeightContext} from '../contexts/PickerItemHeightContext'; 7 | import useValueEventsEffect from './hooks/useValueEventsEffect'; 8 | import useSyncScrollEffect from './hooks/useSyncScrollEffect'; 9 | import type { 10 | KeyExtractor, 11 | ListMethods, 12 | OnValueChanged, 13 | OnValueChanging, 14 | PickerItem, 15 | RenderItem, 16 | RenderItemContainer, 17 | RenderList, 18 | RenderOverlay, 19 | RenderPickerItem, 20 | } from '../types'; 21 | import Overlay from '../overlay/Overlay'; 22 | import {calcPickerHeight, createFaces} from '../item/faces'; 23 | import PickerItemContainer from '../item/PickerItemContainer'; 24 | import {useBoolean} from '@utils/react'; 25 | import {useInit} from '@rozhkov/react-useful-hooks'; 26 | import List from '../list/List'; 27 | 28 | export type PickerProps> = { 29 | data: ReadonlyArray; 30 | value?: ItemT['value']; 31 | itemHeight?: number; 32 | visibleItemCount?: number; 33 | width?: number | 'auto' | `${number}%`; 34 | readOnly?: boolean; 35 | testID?: string; 36 | 37 | onValueChanging?: OnValueChanging; 38 | onValueChanged?: OnValueChanged; 39 | 40 | keyExtractor?: KeyExtractor; 41 | renderItem?: RenderItem; 42 | renderItemContainer?: RenderItemContainer; 43 | renderOverlay?: RenderOverlay | null; 44 | renderList?: RenderList; 45 | 46 | style?: StyleProp; 47 | itemTextStyle?: StyleProp; 48 | overlayItemStyle?: StyleProp; 49 | contentContainerStyle?: StyleProp; 50 | 51 | scrollEventThrottle?: number; 52 | }; 53 | 54 | const defaultKeyExtractor: KeyExtractor = (_, index) => index.toString(); 55 | const defaultRenderItem: RenderItem> = ({ 56 | item: {value, label}, 57 | itemTextStyle, 58 | }) => ( 59 | 64 | ); 65 | const defaultRenderItemContainer: RenderItemContainer = ({ 66 | key, 67 | ...props 68 | }) => ; 69 | const defaultRenderOverlay: RenderOverlay = (props) => ; 70 | const defaultRenderList: RenderList = (props) => { 71 | return ; 72 | }; 73 | 74 | const useValueIndex = (data: ReadonlyArray>, value: any) => { 75 | return useMemo(() => { 76 | const index = data.findIndex((x) => x.value === value); 77 | return index >= 0 ? index : 0; 78 | }, [data, value]); 79 | }; 80 | 81 | const Picker = >({ 82 | data, 83 | value, 84 | width = 'auto', 85 | itemHeight = 48, 86 | visibleItemCount = 5, 87 | readOnly = false, 88 | testID, 89 | 90 | onValueChanged, 91 | onValueChanging, 92 | 93 | keyExtractor = defaultKeyExtractor, 94 | renderItem = defaultRenderItem, 95 | renderItemContainer = defaultRenderItemContainer, 96 | renderOverlay = defaultRenderOverlay, 97 | renderList = defaultRenderList, 98 | 99 | style, 100 | itemTextStyle, 101 | overlayItemStyle, 102 | contentContainerStyle, 103 | ...restProps 104 | }: PickerProps) => { 105 | const valueIndex = useValueIndex(data, value); 106 | const initialIndex = useInit(() => valueIndex); 107 | const offsetY = useRef(new Animated.Value(valueIndex * itemHeight)).current; 108 | const listRef = useRef(null); 109 | const touching = useBoolean(false); 110 | 111 | const [faces, pickerHeight] = useMemo(() => { 112 | const items = createFaces(itemHeight, visibleItemCount); 113 | const height = calcPickerHeight(items, itemHeight); 114 | return [items, height]; 115 | }, [itemHeight, visibleItemCount]); 116 | const renderPickerItem = useCallback>( 117 | ({item, index, key}) => 118 | renderItemContainer({key, item, index, faces, renderItem, itemTextStyle}), 119 | [faces, itemTextStyle, renderItem, renderItemContainer], 120 | ); 121 | 122 | const {activeIndexRef, onScrollEnd} = useValueEventsEffect( 123 | { 124 | data, 125 | valueIndex, 126 | itemHeight, 127 | offsetYAv: offsetY, 128 | }, 129 | {onValueChanging, onValueChanged}, 130 | ); 131 | useSyncScrollEffect({ 132 | listRef, 133 | valueIndex, 134 | activeIndexRef, 135 | touching: touching.value, 136 | }); 137 | 138 | return ( 139 | 140 | 141 | 145 | {renderList({ 146 | ...restProps, 147 | ref: listRef, 148 | data, 149 | initialIndex, 150 | itemHeight, 151 | pickerHeight, 152 | visibleItemCount, 153 | readOnly, 154 | keyExtractor, 155 | renderItem: renderPickerItem, 156 | scrollOffset: offsetY, 157 | onTouchStart: touching.setTrue, 158 | onTouchEnd: touching.setFalse, 159 | onTouchCancel: touching.setFalse, 160 | onScrollEnd, 161 | contentContainerStyle, 162 | })} 163 | {renderOverlay && 164 | renderOverlay({ 165 | itemHeight, 166 | pickerWidth: width, 167 | pickerHeight, 168 | overlayItemStyle, 169 | })} 170 | 171 | 172 | 173 | ); 174 | }; 175 | 176 | const styles = StyleSheet.create({ 177 | root: {justifyContent: 'center', alignItems: 'center'}, 178 | }); 179 | 180 | export default Picker; 181 | -------------------------------------------------------------------------------- /src/base/picker/hooks/useSyncScrollEffect.ts: -------------------------------------------------------------------------------- 1 | import {type RefObject, useEffect} from 'react'; 2 | import type {ListMethods} from '../../types'; 3 | 4 | const useSyncScrollEffect = ({ 5 | listRef, 6 | valueIndex, 7 | activeIndexRef, 8 | touching, 9 | }: { 10 | listRef: RefObject; 11 | valueIndex: number; 12 | activeIndexRef: RefObject; 13 | touching: boolean; 14 | }) => { 15 | useEffect(() => { 16 | if ( 17 | listRef.current == null || 18 | touching || 19 | activeIndexRef.current === valueIndex 20 | ) { 21 | return; 22 | } 23 | 24 | listRef.current.scrollToIndex({index: valueIndex, animated: true}); 25 | }, [valueIndex]); // eslint-disable-line react-hooks/exhaustive-deps 26 | }; 27 | 28 | export default useSyncScrollEffect; 29 | -------------------------------------------------------------------------------- /src/base/picker/hooks/useValueEventsEffect.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from 'react'; 2 | import type {Animated} from 'react-native'; 3 | import {useStableCallback} from '@rozhkov/react-useful-hooks'; 4 | import {getPageIndex} from '@utils/scrolling'; 5 | 6 | const useValueEventsEffect = ( 7 | // in 8 | { 9 | valueIndex, 10 | data, 11 | itemHeight, 12 | offsetYAv, 13 | }: { 14 | valueIndex: number; 15 | data: ReadonlyArray; 16 | itemHeight: number; 17 | offsetYAv: Animated.Value; 18 | }, 19 | // events 20 | { 21 | onValueChanging, 22 | onValueChanged, 23 | }: { 24 | onValueChanging: 25 | | ((event: {item: ItemT; index: number}) => void) 26 | | undefined; 27 | onValueChanged: ((event: {item: ItemT; index: number}) => void) | undefined; 28 | }, 29 | ) => { 30 | const activeIndexRef = useRef(valueIndex); 31 | const getIndex = useStableCallback((offset: number) => 32 | getPageIndex(offset, { 33 | maxIndex: data.length - 1, 34 | pageLength: itemHeight, 35 | }), 36 | ); 37 | 38 | useEffect(() => { 39 | const id = offsetYAv.addListener(({value: offset}) => { 40 | const index = getIndex(offset); 41 | const activeIndex = activeIndexRef.current; 42 | if (index !== activeIndex) { 43 | activeIndexRef.current = index; 44 | onValueChanging?.({item: data[index]!, index}); 45 | } 46 | }); 47 | return () => { 48 | offsetYAv.removeListener(id); 49 | }; 50 | }, [data, getIndex, itemHeight, offsetYAv, onValueChanging]); 51 | 52 | const onStableValueChanged = useStableCallback(() => { 53 | const activeIndex = activeIndexRef.current; 54 | if (activeIndex !== valueIndex) { 55 | onValueChanged?.({index: activeIndex, item: data[activeIndex]!}); 56 | } 57 | }); 58 | 59 | return {onScrollEnd: onStableValueChanged, activeIndexRef}; 60 | }; 61 | 62 | export default useValueEventsEffect; 63 | -------------------------------------------------------------------------------- /src/base/types.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import type {Faces} from './item/faces'; 3 | import type {Animated, StyleProp, TextStyle, ViewStyle} from 'react-native'; 4 | 5 | export type ListMethods = { 6 | scrollToIndex: (params: {index: number; animated: boolean}) => void; 7 | }; 8 | 9 | export type PickerItem = { 10 | value: T; 11 | label?: string; 12 | } & Record; 13 | 14 | export type KeyExtractor> = ( 15 | item: ItemT, 16 | index: number, 17 | ) => string; 18 | 19 | // renders 20 | export type RenderItemProps> = { 21 | item: ItemT; 22 | index: number; 23 | itemTextStyle: StyleProp | undefined; 24 | }; 25 | export type RenderItem> = ( 26 | props: RenderItemProps, 27 | ) => React.ReactElement | null; 28 | export type RenderItemContainerProps> = { 29 | key?: string; 30 | item: ItemT; 31 | index: number; 32 | faces: ReadonlyArray; 33 | renderItem: RenderItem; 34 | itemTextStyle: StyleProp | undefined; 35 | }; 36 | export type RenderItemContainer> = ( 37 | props: RenderItemContainerProps, 38 | ) => React.ReactElement; 39 | export type RenderPickerItem> = (info: { 40 | key?: string; 41 | item: ItemT; 42 | index: number; 43 | }) => React.ReactElement; 44 | export type RenderListProps> = { 45 | ref: React.RefObject; 46 | data: ReadonlyArray; 47 | keyExtractor: KeyExtractor; 48 | renderItem: RenderPickerItem; 49 | itemHeight: number; 50 | pickerHeight: number; 51 | visibleItemCount: number; 52 | readOnly: boolean; 53 | initialIndex: number; 54 | scrollOffset: Animated.Value; 55 | onTouchStart: () => void; 56 | onTouchEnd: () => void; 57 | onTouchCancel: () => void; 58 | onScrollEnd: () => void; 59 | contentContainerStyle: StyleProp | undefined; 60 | } & Record; 61 | export type RenderList> = ( 62 | props: RenderListProps, 63 | ) => React.ReactElement; 64 | export type RenderOverlayProps = { 65 | itemHeight: number; 66 | pickerWidth: number | 'auto' | `${number}%`; 67 | pickerHeight: number; 68 | overlayItemStyle: StyleProp | undefined; 69 | }; 70 | export type RenderOverlay = ( 71 | props: RenderOverlayProps, 72 | ) => React.ReactElement | null; 73 | 74 | // events 75 | export type ValueChangingEvent = {item: ItemT; index: number}; 76 | export type ValueChangedEvent = {item: ItemT; index: number}; 77 | export type OnValueChanging = (event: ValueChangingEvent) => void; 78 | export type OnValueChanged = (event: ValueChangedEvent) => void; 79 | -------------------------------------------------------------------------------- /src/hoc/virtualized/VirtualizedList.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ForwardedRef, 3 | forwardRef, 4 | memo, 5 | RefObject, 6 | useCallback, 7 | useMemo, 8 | } from 'react'; 9 | import { 10 | Animated, 11 | FlatList, 12 | FlatListProps, 13 | StyleProp, 14 | StyleSheet, 15 | type ViewStyle, 16 | } from 'react-native'; 17 | import {withScrollEndEvent} from '@utils/scrolling'; 18 | import type { 19 | KeyExtractor, 20 | ListMethods, 21 | PickerItem, 22 | RenderPickerItem, 23 | } from '../../base/types'; 24 | 25 | // TODO "any" is not an exact type. How to pass the generic type? 26 | const ExtendedAnimatedFlatList = withScrollEndEvent(Animated.FlatList); 27 | 28 | export type AdditionalProps = Pick< 29 | FlatListProps, 30 | | 'initialNumToRender' 31 | | 'maxToRenderPerBatch' 32 | | 'windowSize' 33 | | 'updateCellsBatchingPeriod' 34 | >; 35 | 36 | type VirtualizedListProps> = { 37 | data: ReadonlyArray; 38 | keyExtractor: KeyExtractor; 39 | renderItem: RenderPickerItem; 40 | itemHeight: number; 41 | pickerHeight: number; 42 | visibleItemCount: number; 43 | readOnly: boolean; 44 | initialIndex: number; 45 | scrollOffset: Animated.Value; 46 | onTouchStart: () => void; 47 | onTouchEnd: () => void; 48 | onTouchCancel: () => void; 49 | onScrollEnd: () => void; 50 | contentContainerStyle: StyleProp | undefined; 51 | } & AdditionalProps; 52 | 53 | const VirtualizedList = >( 54 | { 55 | initialIndex, 56 | data, 57 | keyExtractor, 58 | renderItem, 59 | itemHeight, 60 | pickerHeight, 61 | visibleItemCount, 62 | readOnly, 63 | scrollOffset, 64 | onTouchEnd, 65 | onTouchStart, 66 | onTouchCancel, 67 | onScrollEnd, 68 | contentContainerStyle: contentContainerStyleProp, 69 | 70 | initialNumToRender, 71 | maxToRenderPerBatch, 72 | updateCellsBatchingPeriod = 10, 73 | windowSize, 74 | 75 | ...restProps 76 | }: VirtualizedListProps, 77 | forwardedRef: ForwardedRef, 78 | ) => { 79 | const snapToOffsets = useMemo( 80 | () => data.map((_, i) => i * itemHeight), 81 | [data, itemHeight], 82 | ); 83 | const onScroll = useMemo( 84 | () => 85 | Animated.event([{nativeEvent: {contentOffset: {y: scrollOffset}}}], { 86 | useNativeDriver: true, 87 | }), 88 | [scrollOffset], 89 | ); 90 | const getItemLayout = useCallback( 91 | (_: any, index: number) => ({ 92 | length: itemHeight, 93 | offset: itemHeight * index, 94 | index, 95 | }), 96 | [itemHeight], 97 | ); 98 | 99 | const contentContainerStyle = useMemo(() => { 100 | return [ 101 | { 102 | paddingVertical: (pickerHeight - itemHeight) / 2, 103 | }, 104 | contentContainerStyleProp, 105 | ]; 106 | }, [pickerHeight, itemHeight, contentContainerStyleProp]); 107 | 108 | return ( 109 | } 116 | data={data as Animated.WithAnimatedObject} 117 | renderItem={renderItem} 118 | keyExtractor={keyExtractor} 119 | getItemLayout={getItemLayout} 120 | initialScrollIndex={initialIndex} 121 | onScroll={onScroll} 122 | snapToOffsets={snapToOffsets} 123 | style={styles.list} 124 | contentContainerStyle={contentContainerStyle} 125 | onTouchStart={onTouchStart} 126 | onTouchEnd={onTouchEnd} 127 | onTouchCancel={onTouchCancel} 128 | onScrollEnd={onScrollEnd} 129 | initialNumToRender={initialNumToRender ?? Math.ceil(visibleItemCount / 2)} 130 | maxToRenderPerBatch={ 131 | maxToRenderPerBatch ?? Math.ceil(visibleItemCount / 2) 132 | } 133 | updateCellsBatchingPeriod={updateCellsBatchingPeriod} 134 | windowSize={windowSize} 135 | nestedScrollEnabled={true} 136 | removeClippedSubviews={false} 137 | /> 138 | ); 139 | }; 140 | 141 | const styles = StyleSheet.create({ 142 | list: {width: '100%', overflow: 'visible'}, 143 | }); 144 | 145 | export default memo(forwardRef(VirtualizedList)); 146 | -------------------------------------------------------------------------------- /src/hoc/virtualized/index.ts: -------------------------------------------------------------------------------- 1 | export type {WithVirtualizedProps} from './withVirtualized'; 2 | export {default as withVirtualized} from './withVirtualized'; 3 | -------------------------------------------------------------------------------- /src/hoc/virtualized/withVirtualized.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type {PickerProps, RenderList} from '@implementation/base'; 3 | import Picker, {PickerItem} from '@implementation/base'; 4 | import type {AdditionalProps} from './VirtualizedList'; 5 | import VirtualizedList from './VirtualizedList'; 6 | 7 | export type WithVirtualizedProps> = 8 | AdditionalProps & PickerProps; 9 | 10 | type WithVirtualizedComponent = >( 11 | props: WithVirtualizedProps, 12 | ) => React.ReactElement; 13 | 14 | const renderList: RenderList = (props) => { 15 | return ; 16 | }; 17 | 18 | const withVirtualized = ( 19 | WrappedComponent: typeof Picker, 20 | ): WithVirtualizedComponent => { 21 | const Wrapper = >( 22 | props: WithVirtualizedProps, 23 | ) => { 24 | return ; 25 | }; 26 | 27 | // @ts-ignore 28 | Wrapper.displayName = `withVirtualized(${WrappedComponent.displayName})`; 29 | 30 | return Wrapper; 31 | }; 32 | 33 | export default withVirtualized; 34 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export type { 2 | PickerItem, 3 | ValueChangedEvent, 4 | ValueChangingEvent, 5 | OnValueChanged, 6 | OnValueChanging, 7 | RenderItemProps, 8 | RenderItemContainerProps, 9 | RenderOverlayProps, 10 | RenderListProps, 11 | RenderItem, 12 | RenderItemContainer, 13 | RenderOverlay, 14 | RenderList, 15 | } from '@implementation/base'; 16 | 17 | export { 18 | usePickerItemHeight, 19 | useScrollContentOffset, 20 | } from '@implementation/base'; 21 | 22 | export {PickerProps as WheelPickerProps} from '@implementation/base'; 23 | import {default as WheelPicker} from '@implementation/base'; 24 | export default WheelPicker; 25 | 26 | export { 27 | withVirtualized, 28 | WithVirtualizedProps, 29 | } from '@implementation/virtualized'; 30 | -------------------------------------------------------------------------------- /src/utils/debounce/index.ts: -------------------------------------------------------------------------------- 1 | type AnyFunc = (...args: ReadonlyArray) => any; 2 | 3 | const debounce = ( 4 | func: T, 5 | delay: number, 6 | ): ((...args: Parameters) => void) & {clear: () => void} => { 7 | let timer: any; 8 | const wrapper = (...args: ReadonlyArray) => { 9 | clearTimeout(timer); 10 | timer = setTimeout(() => { 11 | func(...args); 12 | }, delay); 13 | }; 14 | wrapper.clear = () => { 15 | clearTimeout(timer); 16 | }; 17 | return wrapper; 18 | }; 19 | 20 | export default debounce; 21 | -------------------------------------------------------------------------------- /src/utils/math/index.ts: -------------------------------------------------------------------------------- 1 | export const degToRad = (deg: number) => (Math.PI * deg) / 180; 2 | -------------------------------------------------------------------------------- /src/utils/react/index.ts: -------------------------------------------------------------------------------- 1 | export {default as typedMemo} from './typedMemo'; 2 | export {default as useBoolean} from './useBoolean'; 3 | -------------------------------------------------------------------------------- /src/utils/react/typedMemo.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Fixes bug with useMemo + generic types: 4 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087#issuecomment-542793243 5 | const typedMemo: (c: T) => T = React.memo; 6 | 7 | export default typedMemo; 8 | -------------------------------------------------------------------------------- /src/utils/react/useBoolean.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useState} from 'react'; 2 | 3 | const useBoolean = (defaultValue: boolean) => { 4 | const [value, setValue] = useState(defaultValue); 5 | const setTrue = useCallback(() => setValue(true), []); 6 | const setFalse = useCallback(() => setValue(false), []); 7 | return {value, setTrue, setFalse}; 8 | }; 9 | 10 | export default useBoolean; 11 | -------------------------------------------------------------------------------- /src/utils/scrolling/__tests__/getPageIndex.test.ts: -------------------------------------------------------------------------------- 1 | import {getPageIndex as getPageIndexOriginal} from '../getPageIndex'; 2 | 3 | const PAGE_LENGTH = 10; 4 | const MAX_INDEX = 2; 5 | 6 | const getPageIndex = (offset: number) => 7 | getPageIndexOriginal(offset, { 8 | pageLength: PAGE_LENGTH, 9 | maxIndex: MAX_INDEX, 10 | }); 11 | 12 | describe('getPageIndex', () => { 13 | test('Should return correct page index', () => { 14 | expect(getPageIndex(-100)).toEqual(0); 15 | expect(getPageIndex(0)).toEqual(0); 16 | expect(getPageIndex(4.99)).toEqual(0); 17 | 18 | expect(getPageIndex(5)).toEqual(1); 19 | expect(getPageIndex(10)).toEqual(1); 20 | expect(getPageIndex(14.99)).toEqual(1); 21 | 22 | expect(getPageIndex(15)).toEqual(2); 23 | expect(getPageIndex(20)).toEqual(2); 24 | expect(getPageIndex(100)).toEqual(2); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/utils/scrolling/getPageIndex.ts: -------------------------------------------------------------------------------- 1 | export const getPageIndex = ( 2 | offset: number, 3 | {maxIndex, pageLength}: {maxIndex: number; pageLength: number}, 4 | ) => { 5 | let index = Math.floor((offset + pageLength / 2) / pageLength); 6 | index = Math.max(0, index); 7 | index = Math.min(index, maxIndex); 8 | return index; 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/scrolling/index.ts: -------------------------------------------------------------------------------- 1 | export {default as withScrollEndEvent} from './withScrollEndEvent'; 2 | export {getPageIndex} from './getPageIndex'; 3 | -------------------------------------------------------------------------------- /src/utils/scrolling/withScrollEndEvent.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | type ComponentRef, 3 | type ComponentType, 4 | type ForwardedRef, 5 | forwardRef, 6 | memo, 7 | useCallback, 8 | useMemo, 9 | } from 'react'; 10 | import type { 11 | ScrollViewProps, 12 | NativeSyntheticEvent, 13 | NativeScrollEvent, 14 | } from 'react-native'; 15 | import debounce from '@utils/debounce'; 16 | 17 | type ComponentProps = Pick< 18 | ScrollViewProps, 19 | | 'onScrollBeginDrag' 20 | | 'onScrollEndDrag' 21 | | 'onMomentumScrollBegin' 22 | | 'onMomentumScrollEnd' 23 | >; 24 | 25 | type ExtendProps = PropsT & { 26 | onScrollEnd?: () => void; 27 | }; 28 | 29 | const withScrollEndEvent = ( 30 | Component: ComponentType, 31 | ) => { 32 | const Wrapper = ( 33 | { 34 | onScrollEnd: onScrollEndProp = () => {}, 35 | onScrollEndDrag: onScrollEndDragProp, 36 | onMomentumScrollBegin: onMomentumScrollBeginProp, 37 | onMomentumScrollEnd: onMomentumScrollEndProp, 38 | ...rest 39 | }: ExtendProps, 40 | forwardedRef: ForwardedRef>>, 41 | ) => { 42 | const onScrollEnd = useMemo( 43 | () => debounce(onScrollEndProp, 0), // This works well with onScrollEndDrag -> onMomentumScrollBegin transitions 44 | [onScrollEndProp], 45 | ); 46 | 47 | const onScrollEndDrag = useCallback( 48 | (args: NativeSyntheticEvent) => { 49 | onScrollEndDragProp?.(args); 50 | onScrollEnd(); 51 | }, 52 | [onScrollEnd, onScrollEndDragProp], 53 | ); 54 | 55 | const onMomentumScrollBegin = useCallback( 56 | (args: NativeSyntheticEvent) => { 57 | onScrollEnd.clear(); 58 | onMomentumScrollBeginProp?.(args); 59 | }, 60 | [onScrollEnd, onMomentumScrollBeginProp], 61 | ); 62 | 63 | const onMomentumScrollEnd = useCallback( 64 | (args: NativeSyntheticEvent) => { 65 | onMomentumScrollEndProp?.(args); 66 | onScrollEnd(); 67 | }, 68 | [onScrollEnd, onMomentumScrollEndProp], 69 | ); 70 | 71 | return ( 72 | 79 | ); 80 | }; 81 | 82 | Wrapper.displayName = `withScrollEndEvent(${ 83 | Component.displayName || 'Component' 84 | })`; 85 | 86 | return memo( 87 | forwardRef>, ExtendProps>( 88 | Wrapper as any, 89 | ), 90 | ); 91 | }; 92 | 93 | export default withScrollEndEvent; 94 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "exclude": ["example", "copied-src"], 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@quidone/react-native-wheel-picker": ["./src/index"], 6 | "@implementation/base": ["./src/base/index"], 7 | "@implementation/virtualized": ["./src/hoc/virtualized/index"], 8 | "@utils/react": ["./src/utils/react/index"], 9 | "@utils/math": ["./src/utils/math/index"], 10 | "@utils/debounce": ["./src/utils/debounce/index"], 11 | "@utils/scrolling": ["./src/utils/scrolling/index"], 12 | }, 13 | "allowUnreachableCode": false, 14 | "allowUnusedLabels": false, 15 | "esModuleInterop": true, 16 | "importsNotUsedAsValues": "error", 17 | "forceConsistentCasingInFileNames": true, 18 | "jsx": "react", 19 | "lib": ["esnext"], 20 | "module": "esnext", 21 | "moduleResolution": "node", 22 | "noFallthroughCasesInSwitch": true, 23 | "noImplicitReturns": true, 24 | "noImplicitUseStrict": false, 25 | "noStrictGenericChecks": false, 26 | "noUncheckedIndexedAccess": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "resolveJsonModule": true, 30 | "skipLibCheck": true, 31 | "strict": true, 32 | "target": "esnext" 33 | } 34 | } 35 | --------------------------------------------------------------------------------