├── .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 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | A flexible React Native Wheel Picker for iOS and Android without using the native side.
22 |
23 |
24 |
25 |
26 |
27 |
28 | On iOS
29 | |
30 |
31 |
32 |
33 | On Android
34 | |
35 |
36 |
37 |
38 | Customization
39 | |
40 |
41 |
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 |
--------------------------------------------------------------------------------