@amabeth/repeating-wheel-picker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 amabeth
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.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # Readme
2
+
3
+ A React Native wheel picker that allows endless scrolling through repeating content
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install repeating-wheel-picker
9
+ ```
10
+
11
+ ## Usage
12
+
13
+
14
+ ```tsx
15
+ import RepeatingWheelPicker, {
16
+ type RepeatingWheelPickerProps,
17
+ } from "repeating-wheel-picker";
18
+
19
+ // ...
20
+ const [, setSelected] = useState<string>();
21
+
22
+ return (
23
+ <RepeatingWheelPicker<string>
24
+ setSelected={setSelected}
25
+ initialIndex={0}
26
+ data={["first", "second", "third"]}
27
+ />
28
+ );
29
+ ```
30
+
31
+
32
+ ## Contributing
33
+
34
+ Contributions are currently not intended.
35
+
36
+ ## License
37
+
38
+ [MIT](LICENSE)
39
+
40
+ ## [Changelog](CHANGELOG.md)
41
+
42
+ ## [Impressum / Imprint](https://amabeth.github.io/#imprint)
43
+
44
+ ---
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Text, View, VirtualizedList } from "react-native";
5
+ import { LinearGradient } from "expo-linear-gradient";
6
+
7
+ /**
8
+ * Provides a wheel picker with repeating data that can be infinitely scrolled.
9
+ *
10
+ * @param properties configuration of the wheel picker
11
+ */
12
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
+ export function RepeatingWheelPicker(properties) {
14
+ // set defaults for all unprovided optional properties
15
+ const props = useMemo(() => withDefaults(properties), [properties]);
16
+ // to always have enough data to scroll through, define how often the input data should be multiplied
17
+ const dataMultiplier = useMemo(() => Math.max(Math.round(90 / props.data.length), 3), [props.data.length]);
18
+ // difference between centered index and top most visible index
19
+ const indexDiffTopToCentered = useMemo(() => Math.floor(props.itemDisplayCount / 2), [props.itemDisplayCount]);
20
+ // offsets for all list elements
21
+ const offsets = useMemo(() => getOffsets(props.data.length, props.itemDisplayCount, props.itemHeight, dataMultiplier), [props.data.length, props.itemDisplayCount, props.itemHeight, dataMultiplier, props.containerVerticalPadding]);
22
+
23
+ // current selected item (centered one)
24
+ const [current, setCurrent] = useState(() => props.initialIndex + props.data.length * Math.floor(dataMultiplier / 2));
25
+ const listRef = useRef(null);
26
+
27
+ // call "setSelected" when the current top item or the data changed
28
+ useEffect(() => {
29
+ const selectedElement = props.data[current % props.data.length]; // centered element
30
+
31
+ if (selectedElement !== undefined) {
32
+ props.setSelected(selectedElement);
33
+ }
34
+ }, [current, props.data]);
35
+ return /*#__PURE__*/_jsxs(View, {
36
+ onLayout: props.containerOnLayout,
37
+ style: {
38
+ ...props.containerStyle,
39
+ height: props.itemHeight * props.itemDisplayCount + props.containerVerticalPadding * 2
40
+ },
41
+ children: [/*#__PURE__*/_jsx(VirtualizedList, {
42
+ ref: listRef,
43
+ scrollEnabled: props.enabled,
44
+ getItemCount: () => props.data.length * dataMultiplier,
45
+ initialScrollIndex: current - indexDiffTopToCentered,
46
+ initialNumToRender: props.data.length * dataMultiplier,
47
+ windowSize: props.data.length * dataMultiplier,
48
+ renderItem: ({
49
+ item,
50
+ index
51
+ }) => /*#__PURE__*/_jsx(Item, {
52
+ item: item,
53
+ props: props
54
+ }, index),
55
+ getItem: (_, index) => props.data[index % props.data.length],
56
+ getItemLayout: (_, index) => ({
57
+ length: props.itemHeight,
58
+ offset: offsets[index],
59
+ index: index
60
+ }),
61
+ keyExtractor: (_, index) => `${index}`
62
+
63
+ // disableIntervalMomentum={true}
64
+ ,
65
+ decelerationRate: "fast",
66
+ snapToOffsets: offsets,
67
+ onMomentumScrollEnd: event => onMomentumScrollEnd(event.nativeEvent.contentOffset.y, setCurrent, props.data.length, props.itemHeight, dataMultiplier, indexDiffTopToCentered, props.containerVerticalPadding, listRef),
68
+ showsVerticalScrollIndicator: false,
69
+ style: {
70
+ flex: 1,
71
+ width: "100%",
72
+ borderRadius: props.containerStyle.borderRadius,
73
+ paddingVertical: props.containerVerticalPadding,
74
+ paddingHorizontal: props.containerHorizontalPadding
75
+ }
76
+ }), props.enableGradient && /*#__PURE__*/_jsx(View, {
77
+ style: {
78
+ backgroundColor: "transparent",
79
+ position: "absolute",
80
+ height: "100%",
81
+ width: "100%"
82
+ },
83
+ children: /*#__PURE__*/_jsx(FrontGradient, {
84
+ gradientFadeColor: props.gradientFadeColor,
85
+ borderRadius: props.containerStyle.borderRadius
86
+ })
87
+ })]
88
+ });
89
+ }
90
+ function Item({
91
+ item,
92
+ props
93
+ }) {
94
+ return /*#__PURE__*/_jsx(View, {
95
+ style: {
96
+ justifyContent: "center",
97
+ alignContent: "center",
98
+ backgroundColor: "transparent",
99
+ ...props.itemContainerStyle,
100
+ paddingVertical: 0,
101
+ height: props.itemHeight
102
+ },
103
+ children: /*#__PURE__*/_jsx(Text, {
104
+ style: {
105
+ textAlign: "center",
106
+ ...props.itemTextStyle
107
+ },
108
+ children: props.getLabel(item)
109
+ })
110
+ });
111
+ }
112
+ function FrontGradient({
113
+ gradientFadeColor,
114
+ borderRadius
115
+ }) {
116
+ return /*#__PURE__*/_jsx(LinearGradient, {
117
+ colors: [gradientFadeColor, "transparent", gradientFadeColor],
118
+ style: {
119
+ height: "100%",
120
+ width: "100%",
121
+ backgroundColor: "transparent",
122
+ borderRadius: borderRadius
123
+ }
124
+ });
125
+ }
126
+ function getOffsets(dataLength, itemDisplayCount, itemHeight, dataMultiplier) {
127
+ let offsets = [];
128
+
129
+ // calculate offset for all items
130
+ for (let i = 0; i < dataLength * dataMultiplier; i++) {
131
+ offsets[i] = itemOffset(i, itemDisplayCount, itemHeight);
132
+ }
133
+ return offsets;
134
+ }
135
+ function itemOffset(index, itemDisplayCount, itemHeight) {
136
+ return (index + (itemDisplayCount % 2 === 0 ? 0.5 : 0)) * itemHeight;
137
+ }
138
+ function onMomentumScrollEnd(offset, setCurrent, dataLength, itemHeight, dataMultiplier, indexDiffTopToCentered, verticalPadding, ref) {
139
+ // offset excluding padding
140
+ const innerOffset = offset - verticalPadding;
141
+ // get index of top most completely visible item
142
+ const currentTopIndex = Math.round(innerOffset / itemHeight);
143
+
144
+ // get current section within whole extended data (data * dataMultiplier)
145
+ // section 0 = [0, data.length)
146
+ // section 1 = [data.length, data.length * 2)
147
+ // ...
148
+ const currentSection = Math.floor(innerOffset / (dataLength * itemHeight));
149
+ // target section is always the middle one, so user can scroll seemingly infinitely
150
+ const targetSection = Math.floor(dataMultiplier / 2);
151
+
152
+ // get corresponding index of current top index in target section
153
+ const targetTopIndex = currentTopIndex + (targetSection - currentSection) * dataLength;
154
+ // set current index to centered one, if `targetTopIndex`was at the top
155
+ setCurrent(targetTopIndex + indexDiffTopToCentered);
156
+ if (currentSection === targetSection) {
157
+ // if target section is current section, stay in this section
158
+ return;
159
+ }
160
+
161
+ // if target section is different from current section, scroll to target
162
+ const targetOffset = offset + (targetTopIndex - currentTopIndex) * itemHeight;
163
+ ref.current?.scrollToOffset({
164
+ animated: false,
165
+ offset: targetOffset
166
+ });
167
+ }
168
+
169
+ // props
170
+
171
+ function withDefaults(props) {
172
+ const defaultBackgroundColor = "black";
173
+ const defaultTextColor = "white";
174
+ const defaultTextSize = 18;
175
+ validateProps(props);
176
+ return {
177
+ ...props,
178
+ // optional
179
+ containerOnLayout: props.containerOnLayout ?? (() => {}),
180
+ enabled: props.enabled ?? true,
181
+ getLabel: props.getLabel ?? (t => `${t}`),
182
+ itemHeight: props.itemHeight ?? (props.itemTextStyle?.fontSize ?? defaultTextSize) + 15,
183
+ itemDisplayCount: props.itemDisplayCount ?? 3,
184
+ containerVerticalPadding: props.containerVerticalPadding ?? 0,
185
+ containerHorizontalPadding: props.containerHorizontalPadding ?? 10,
186
+ containerStyle: {
187
+ ...props.containerStyle,
188
+ backgroundColor: props.containerStyle?.backgroundColor ?? defaultBackgroundColor,
189
+ padding: 0,
190
+ paddingHorizontal: 0,
191
+ paddingVertical: 0,
192
+ paddingTop: 0,
193
+ paddingBottom: 0,
194
+ paddingLeft: 0,
195
+ paddingRight: 0
196
+ },
197
+ itemContainerStyle: {
198
+ ...props.itemContainerStyle,
199
+ backgroundColor: props.itemContainerStyle?.backgroundColor ?? "transparent",
200
+ justifyContent: props.itemContainerStyle?.justifyContent ?? "center"
201
+ },
202
+ itemTextStyle: {
203
+ ...props.itemTextStyle,
204
+ fontSize: props.itemTextStyle?.fontSize ?? defaultTextSize,
205
+ color: props.itemTextStyle?.color ?? defaultTextColor
206
+ },
207
+ enableGradient: props.enableGradient ?? true,
208
+ gradientFadeColor: props.gradientFadeColor ?? props.containerStyle?.backgroundColor ?? defaultBackgroundColor
209
+ };
210
+ }
211
+ function validateProps(props) {
212
+ if (props.initialIndex < 0 || props.initialIndex >= props.data.length) {
213
+ throw InvalidPropertiesError("initialIndex", String(props.initialIndex), "has to be in range [0, data.length)");
214
+ }
215
+ if (props.data.length < 2) {
216
+ throw InvalidPropertiesError("data.length", String(props.data.length), "has to be larger than 1");
217
+ }
218
+ if (props.itemDisplayCount !== undefined && props.itemDisplayCount < 1) {
219
+ throw InvalidPropertiesError("itemDisplayCount", String(props.itemDisplayCount), "has to be larger than 0");
220
+ }
221
+ if (props.itemDisplayCount !== undefined && !Number.isInteger(props.itemDisplayCount)) {
222
+ throw InvalidPropertiesError("itemDisplayCount", String(props.itemDisplayCount), "has to be an integer");
223
+ }
224
+ if (props.itemHeight !== undefined && props.itemHeight < 1) {
225
+ throw InvalidPropertiesError("itemHeight", String(props.itemHeight), "has to be larger than 0");
226
+ }
227
+ }
228
+ function InvalidPropertiesError(propertyName, propertyValue, violatedConstraint) {
229
+ return Error(`Value "${propertyValue}" is invalid for property "${propertyName}": ${violatedConstraint}`);
230
+ }
231
+
232
+ /**
233
+ *
234
+ */
235
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["useEffect","useMemo","useRef","useState","Text","View","VirtualizedList","LinearGradient","jsx","_jsx","jsxs","_jsxs","RepeatingWheelPicker","properties","props","withDefaults","dataMultiplier","Math","max","round","data","length","indexDiffTopToCentered","floor","itemDisplayCount","offsets","getOffsets","itemHeight","containerVerticalPadding","current","setCurrent","initialIndex","listRef","selectedElement","undefined","setSelected","onLayout","containerOnLayout","style","containerStyle","height","children","ref","scrollEnabled","enabled","getItemCount","initialScrollIndex","initialNumToRender","windowSize","renderItem","item","index","Item","getItem","_","getItemLayout","offset","keyExtractor","decelerationRate","snapToOffsets","onMomentumScrollEnd","event","nativeEvent","contentOffset","y","showsVerticalScrollIndicator","flex","width","borderRadius","paddingVertical","paddingHorizontal","containerHorizontalPadding","enableGradient","backgroundColor","position","FrontGradient","gradientFadeColor","justifyContent","alignContent","itemContainerStyle","textAlign","itemTextStyle","getLabel","colors","dataLength","i","itemOffset","verticalPadding","innerOffset","currentTopIndex","currentSection","targetSection","targetTopIndex","targetOffset","scrollToOffset","animated","defaultBackgroundColor","defaultTextColor","defaultTextSize","validateProps","t","fontSize","padding","paddingTop","paddingBottom","paddingLeft","paddingRight","color","InvalidPropertiesError","String","Number","isInteger","propertyName","propertyValue","violatedConstraint","Error"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,157 @@
1
+ import { type ColorValue, type LayoutChangeEvent, type TextStyle, type ViewStyle } from "react-native";
2
+ /**
3
+ * Provides a wheel picker with repeating data that can be infinitely scrolled.
4
+ *
5
+ * @param properties configuration of the wheel picker
6
+ */
7
+ export declare function RepeatingWheelPicker<T>(properties: RepeatingWheelPickerProps<T>): import("react/jsx-runtime").JSX.Element;
8
+ /**
9
+ *
10
+ */
11
+ export type RepeatingWheelPickerProps<T> = {
12
+ /**
13
+ * Function to set currently selected element and use it in your application.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const [selected, setSelected] = useState(0);
18
+ *
19
+ * return (
20
+ * <RepeatingWheelPicker
21
+ * setSelected={setSelected}
22
+ * //...
23
+ * />
24
+ * );
25
+ * ```
26
+ *
27
+ * @param t currently selected element
28
+ */
29
+ setSelected: (t: T) => void;
30
+ /**
31
+ * Index to initially center.
32
+ */
33
+ initialIndex: number;
34
+ /**
35
+ * Data to display.
36
+ */
37
+ data: T[];
38
+ /**
39
+ * Function to retrieve the text to display for an element as a label.
40
+ *
41
+ * @defaultValue
42
+ * ```ts
43
+ * (t: T) => `${t}`
44
+ * ```
45
+ *
46
+ * @param t element to retrieve label for
47
+ */
48
+ getLabel?: (t: T) => string;
49
+ /**
50
+ * Function called when the layout of the container changes.
51
+ *
52
+ * _Example usage for monitoring the container's height:_
53
+ * ```ts
54
+ * const [pickerHeight, setPickerHeight] = useState<number>(0);
55
+ *
56
+ * const onLayout = useCallback((event: LayoutChangeEvent) => {
57
+ * const { height } = event.nativeEvent.layout;
58
+ * setPickerHeight(height);
59
+ * }, []);
60
+ *
61
+ * return (
62
+ * <View style={{flexDirection: "row"}}>
63
+ * <View style={{height: height}}>
64
+ * <Text>Picker label</Text>
65
+ * </View>
66
+ * <RepeatingWheelPicker
67
+ * //...
68
+ * containerOnLayout={onLayout}
69
+ * />
70
+ * </View>
71
+ * );
72
+ * ```
73
+ *
74
+ * @defaultValue () => {}
75
+ *
76
+ * @param event layout change event that triggered `onLayout`
77
+ */
78
+ containerOnLayout?: (event: LayoutChangeEvent) => void;
79
+ /**
80
+ * Enables / disables scrolling of the wheel picker.
81
+ *
82
+ * @defaultValue true
83
+ */
84
+ enabled?: boolean;
85
+ /**
86
+ * Height per displayed item.
87
+ *
88
+ * @defaultValue itemTextStyle.fontSize + 15
89
+ */
90
+ itemHeight?: number;
91
+ /**
92
+ * Number of items to display.
93
+ *
94
+ * @defaultValue 3
95
+ */
96
+ itemDisplayCount?: number;
97
+ /**
98
+ * Vertical padding for the container of the wheel picker.
99
+ *
100
+ * @defaultValue 0
101
+ */
102
+ containerVerticalPadding?: number;
103
+ /**
104
+ * Horizontal padding for the container of the wheel picker.
105
+ *
106
+ * @defaultValue 10
107
+ */
108
+ containerHorizontalPadding?: number;
109
+ /**
110
+ * Styling for the container of the wheel picker.
111
+ *
112
+ * @defaultValue
113
+ * ```ts
114
+ * {
115
+ * backgroundColor: "black"
116
+ * }
117
+ * ```
118
+ */
119
+ containerStyle?: ViewStyle;
120
+ /**
121
+ * Styling for the container of each element.
122
+ *
123
+ * @defaultValue
124
+ * ```ts
125
+ * {
126
+ * backgroundColor: "transparent",
127
+ * justifyContent: "center"
128
+ * }
129
+ * ```
130
+ */
131
+ itemContainerStyle?: ViewStyle;
132
+ /**
133
+ * Styling for the text of the elements.
134
+ *
135
+ * @defaultValue
136
+ * ```ts
137
+ * {
138
+ * fontSize: "18",
139
+ * color: "white"
140
+ * }
141
+ * ```
142
+ */
143
+ itemTextStyle?: TextStyle;
144
+ /**
145
+ * If enabled, will show a gradient fade towards the top and bottom of the wheel picker.
146
+ *
147
+ * @defaultValue true
148
+ */
149
+ enableGradient?: boolean;
150
+ /**
151
+ * Color the gradient should fade to at the top and bottom.
152
+ *
153
+ * @defaultValue containerStyle.backgroundColor
154
+ */
155
+ gradientFadeColor?: ColorValue;
156
+ };
157
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,iBAAiB,EAEtB,KAAK,SAAS,EAEd,KAAK,SAAS,EAEf,MAAM,cAAc,CAAC;AAGtB;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,UAAU,EAAE,yBAAyB,CAAC,CAAC,CAAC,2CAgHzC;AA+ND;;GAEG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IACzC;;;;;;;;;;;;;;;;OAgBG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,IAAI,CAAC;IAC5B;;OAEG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;OAEG;IACH,IAAI,EAAE,CAAC,EAAE,CAAC;IAEV;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAC;IAC5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACvD;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;;OAIG;IACH,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC;;;;OAIG;IACH,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,SAAS,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,SAAS,CAAC;IAC/B;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,SAAS,CAAC;IAE1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,UAAU,CAAC;CAChC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,177 @@
1
+ {
2
+ "name": "@amabeth/repeating-wheel-picker",
3
+ "version": "1.0.0",
4
+ "description": "A React Native wheel picker that allows endless scrolling through repeating content",
5
+ "main": "./lib/module/index.js",
6
+ "types": "./lib/typescript/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "source": "./src/index.tsx",
10
+ "types": "./lib/typescript/src/index.d.ts",
11
+ "default": "./lib/module/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "lib",
18
+ "android",
19
+ "ios",
20
+ "cpp",
21
+ "*.podspec",
22
+ "react-native.config.js",
23
+ "!ios/build",
24
+ "!android/build",
25
+ "!android/gradle",
26
+ "!android/gradlew",
27
+ "!android/gradlew.bat",
28
+ "!android/local.properties",
29
+ "!**/__tests__",
30
+ "!**/__fixtures__",
31
+ "!**/__mocks__",
32
+ "!**/.*"
33
+ ],
34
+ "scripts": {
35
+ "example": "yarn workspace repeating-wheel-picker-example",
36
+ "test": "jest",
37
+ "typecheck": "tsc",
38
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
39
+ "clean": "del-cli lib",
40
+ "prepare": "bob build & husky",
41
+ "docs": "typedoc",
42
+ "release": "release-it --only-version"
43
+ },
44
+ "keywords": [
45
+ "react-native",
46
+ "ios",
47
+ "android"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/amabeth/repeating-wheel-picker.git"
52
+ },
53
+ "author": "amabeth <amabeth.dev@gmail.com> (https://github.com/amabeth)",
54
+ "license": "MIT",
55
+ "bugs": {
56
+ "url": "https://github.com/amabeth/repeating-wheel-picker/issues"
57
+ },
58
+ "homepage": "https://github.com/amabeth/repeating-wheel-picker#readme",
59
+ "publishConfig": {
60
+ "registry": "https://registry.npmjs.org/"
61
+ },
62
+ "devDependencies": {
63
+ "@commitlint/config-conventional": "^19.6.0",
64
+ "@eslint/compat": "^1.3.1",
65
+ "@eslint/eslintrc": "^3.3.1",
66
+ "@eslint/js": "^9.30.1",
67
+ "@evilmartians/lefthook": "^1.11.16",
68
+ "@react-native/babel-preset": "^0.80.1",
69
+ "@react-native/eslint-config": "^0.80.1",
70
+ "@release-it/conventional-changelog": "^10.0.1",
71
+ "@types/jest": "^30.0.0",
72
+ "@types/react": "^19.0.14",
73
+ "commitlint": "^19.8.1",
74
+ "del-cli": "^6.0.0",
75
+ "eslint": "^9.30.1",
76
+ "eslint-config-prettier": "^10.1.5",
77
+ "eslint-plugin-prettier": "^5.5.1",
78
+ "husky": "^9.1.7",
79
+ "jest": "^30.0.4",
80
+ "prettier": "^3.6.2",
81
+ "react-native-builder-bob": "^0.40.12",
82
+ "release-it": "^19.0.3",
83
+ "typedoc-github-theme": "^0.3.0",
84
+ "typedoc-plugin-coverage": "^4.0.1",
85
+ "typescript": "^5.8.3"
86
+ },
87
+ "peerDependencies": {
88
+ "expo-linear-gradient": "^14.1.5",
89
+ "react": "19.0.0",
90
+ "react-native": "0.79.5"
91
+ },
92
+ "overrides": {
93
+ "@react-native/eslint-config": {
94
+ "eslint": "$eslint"
95
+ }
96
+ },
97
+ "workspaces": [
98
+ "example"
99
+ ],
100
+ "packageManager": "yarn@3.6.1",
101
+ "jest": {
102
+ "preset": "react-native",
103
+ "modulePathIgnorePatterns": [
104
+ "<rootDir>/example/node_modules",
105
+ "<rootDir>/lib/"
106
+ ]
107
+ },
108
+ "commitlint": {
109
+ "extends": [
110
+ "@commitlint/config-conventional"
111
+ ]
112
+ },
113
+ "release-it": {
114
+ "git": {
115
+ "commitMessage": "chore: release ${version}",
116
+ "tagName": "v${version}"
117
+ },
118
+ "npm": {
119
+ "publish": true
120
+ },
121
+ "github": {
122
+ "release": true
123
+ },
124
+ "plugins": {
125
+ "@release-it/conventional-changelog": {
126
+ "preset": {
127
+ "name": "angular"
128
+ }
129
+ }
130
+ }
131
+ },
132
+ "prettier": {
133
+ "quoteProps": "consistent",
134
+ "singleQuote": false,
135
+ "tabWidth": 2,
136
+ "trailingComma": "es5",
137
+ "useTabs": false
138
+ },
139
+ "react-native-builder-bob": {
140
+ "source": "src",
141
+ "output": "lib",
142
+ "targets": [
143
+ [
144
+ "module",
145
+ {
146
+ "esm": true
147
+ }
148
+ ],
149
+ [
150
+ "typescript",
151
+ {
152
+ "project": "tsconfig.build.json"
153
+ }
154
+ ]
155
+ ]
156
+ },
157
+ "create-react-native-library": {
158
+ "languages": "js",
159
+ "type": "library",
160
+ "version": "0.51.1"
161
+ },
162
+ "typedocOptions": {
163
+ "entryPoints": [
164
+ "src/index.tsx"
165
+ ],
166
+ "projectDocuments": [
167
+ "*.md"
168
+ ],
169
+ "sort": [
170
+ "source-order"
171
+ ],
172
+ "plugin": [
173
+ "typedoc-plugin-coverage",
174
+ "typedoc-github-theme"
175
+ ]
176
+ }
177
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,507 @@
1
+ import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ type AnimatableNumericValue,
4
+ type ColorValue,
5
+ type LayoutChangeEvent,
6
+ Text,
7
+ type TextStyle,
8
+ View,
9
+ type ViewStyle,
10
+ VirtualizedList,
11
+ } from "react-native";
12
+ import { LinearGradient } from "expo-linear-gradient";
13
+
14
+ /**
15
+ * Provides a wheel picker with repeating data that can be infinitely scrolled.
16
+ *
17
+ * @param properties configuration of the wheel picker
18
+ */
19
+ export function RepeatingWheelPicker<T>(
20
+ properties: RepeatingWheelPickerProps<T>
21
+ ) {
22
+ // set defaults for all unprovided optional properties
23
+ const props = useMemo(() => withDefaults(properties), [properties]);
24
+ // to always have enough data to scroll through, define how often the input data should be multiplied
25
+ const dataMultiplier = useMemo(
26
+ () => Math.max(Math.round(90 / props.data.length), 3),
27
+ [props.data.length]
28
+ );
29
+ // difference between centered index and top most visible index
30
+ const indexDiffTopToCentered = useMemo(
31
+ () => Math.floor(props.itemDisplayCount / 2),
32
+ [props.itemDisplayCount]
33
+ );
34
+ // offsets for all list elements
35
+ const offsets = useMemo((): number[] =>
36
+ getOffsets(
37
+ props.data.length,
38
+ props.itemDisplayCount,
39
+ props.itemHeight,
40
+ dataMultiplier),
41
+ [props.data.length, props.itemDisplayCount, props.itemHeight, dataMultiplier, props.containerVerticalPadding]
42
+ )
43
+
44
+ // current selected item (centered one)
45
+ const [current, setCurrent] = useState(() =>
46
+ props.initialIndex + props.data.length * Math.floor(dataMultiplier / 2)
47
+ );
48
+ const listRef = useRef<VirtualizedList<T>>(null);
49
+
50
+ // call "setSelected" when the current top item or the data changed
51
+ useEffect(() => {
52
+ const selectedElement =
53
+ props.data[current % props.data.length]; // centered element
54
+
55
+ if (selectedElement !== undefined) {
56
+ props.setSelected(selectedElement);
57
+ }
58
+ }, [current, props.data]);
59
+
60
+ return (
61
+ <View
62
+ onLayout={props.containerOnLayout}
63
+ style={{
64
+ ...props.containerStyle,
65
+ height:
66
+ props.itemHeight * props.itemDisplayCount + props.containerVerticalPadding * 2
67
+ }}
68
+ >
69
+ <VirtualizedList<T>
70
+ ref={listRef}
71
+ scrollEnabled={props.enabled}
72
+ getItemCount={() => props.data.length * dataMultiplier}
73
+
74
+ initialScrollIndex={current - indexDiffTopToCentered}
75
+ initialNumToRender={props.data.length * dataMultiplier}
76
+ windowSize={props.data.length * dataMultiplier}
77
+
78
+ renderItem={({ item, index }) => (
79
+ <Item item={item} props={props} key={index} />
80
+ )}
81
+ getItem={(_, index) =>
82
+ props.data[index % props.data.length]!
83
+ }
84
+ getItemLayout={(_, index) => ({
85
+ length: props.itemHeight,
86
+ offset: offsets[index]!,
87
+ index: index,
88
+ })}
89
+ keyExtractor={(_, index) => `${index}`}
90
+
91
+ // disableIntervalMomentum={true}
92
+ decelerationRate="fast"
93
+ snapToOffsets={offsets}
94
+
95
+ onMomentumScrollEnd={(event) =>
96
+ onMomentumScrollEnd(
97
+ event.nativeEvent.contentOffset.y,
98
+ setCurrent,
99
+ props.data.length,
100
+ props.itemHeight,
101
+ dataMultiplier,
102
+ indexDiffTopToCentered,
103
+ props.containerVerticalPadding,
104
+ listRef
105
+ )
106
+ }
107
+
108
+ showsVerticalScrollIndicator={false}
109
+ style={{
110
+ flex: 1,
111
+ width: "100%",
112
+ borderRadius: props.containerStyle.borderRadius,
113
+ paddingVertical: props.containerVerticalPadding,
114
+ paddingHorizontal: props.containerHorizontalPadding
115
+ }}
116
+ />
117
+
118
+ { props.enableGradient &&
119
+ <View
120
+ style={{
121
+ backgroundColor: "transparent",
122
+ position: "absolute",
123
+ height: "100%",
124
+ width: "100%"
125
+ }}
126
+ >
127
+ <FrontGradient gradientFadeColor={props.gradientFadeColor} borderRadius={props.containerStyle.borderRadius} />
128
+ </View>
129
+ }
130
+ </View>
131
+ );
132
+ }
133
+
134
+ function Item<T>({
135
+ item,
136
+ props,
137
+ }: {
138
+ item: T;
139
+ props: RepeatingWheelPickerPropsWithDefaults<T>;
140
+ }) {
141
+
142
+ return (
143
+ <View
144
+ style={{
145
+ justifyContent: "center",
146
+ alignContent: "center",
147
+ backgroundColor: "transparent",
148
+ ...props.itemContainerStyle,
149
+ paddingVertical: 0,
150
+ height: props.itemHeight,
151
+ }}
152
+ >
153
+ <Text style={{ textAlign: "center", ...props.itemTextStyle }}>
154
+ {props.getLabel(item)}
155
+ </Text>
156
+ </View>
157
+ );
158
+ }
159
+
160
+ function FrontGradient({ gradientFadeColor, borderRadius }: { gradientFadeColor: ColorValue, borderRadius: string | AnimatableNumericValue | undefined }) {
161
+
162
+ return (
163
+ <LinearGradient
164
+ colors={[gradientFadeColor, "transparent", gradientFadeColor]}
165
+ style={{
166
+ height: "100%",
167
+ width: "100%",
168
+ backgroundColor: "transparent",
169
+ borderRadius: borderRadius
170
+ }}
171
+ />
172
+ );
173
+ }
174
+
175
+ function getOffsets(
176
+ dataLength: number,
177
+ itemDisplayCount: number,
178
+ itemHeight: number,
179
+ dataMultiplier: number
180
+ ) {
181
+ let offsets = [];
182
+
183
+ // calculate offset for all items
184
+ for (let i = 0; i < dataLength * dataMultiplier; i++) {
185
+ offsets[i] = itemOffset(
186
+ i,
187
+ itemDisplayCount,
188
+ itemHeight
189
+ );
190
+ }
191
+
192
+ return offsets;
193
+ }
194
+
195
+ function itemOffset(
196
+ index: number,
197
+ itemDisplayCount: number,
198
+ itemHeight: number
199
+ ) {
200
+
201
+ return (index + (itemDisplayCount % 2 === 0 ? 0.5 : 0)) * itemHeight;
202
+ }
203
+
204
+ function onMomentumScrollEnd<T>(
205
+ offset: number,
206
+ setCurrent: (n: number) => void,
207
+ dataLength: number,
208
+ itemHeight: number,
209
+ dataMultiplier: number,
210
+ indexDiffTopToCentered: number,
211
+ verticalPadding: number,
212
+ ref: RefObject<VirtualizedList<T> | null>
213
+ ) {
214
+ // offset excluding padding
215
+ const innerOffset = offset - verticalPadding;
216
+ // get index of top most completely visible item
217
+ const currentTopIndex = Math.round(innerOffset / itemHeight);
218
+
219
+ // get current section within whole extended data (data * dataMultiplier)
220
+ // section 0 = [0, data.length)
221
+ // section 1 = [data.length, data.length * 2)
222
+ // ...
223
+ const currentSection = Math.floor(innerOffset / (dataLength * itemHeight));
224
+ // target section is always the middle one, so user can scroll seemingly infinitely
225
+ const targetSection = Math.floor(dataMultiplier / 2);
226
+
227
+ // get corresponding index of current top index in target section
228
+ const targetTopIndex =
229
+ currentTopIndex + (targetSection - currentSection) * dataLength;
230
+ // set current index to centered one, if `targetTopIndex`was at the top
231
+ setCurrent(targetTopIndex + indexDiffTopToCentered);
232
+
233
+ if (currentSection === targetSection) {
234
+ // if target section is current section, stay in this section
235
+ return;
236
+ }
237
+
238
+ // if target section is different from current section, scroll to target
239
+ const targetOffset = offset + (targetTopIndex - currentTopIndex) * itemHeight;
240
+ ref.current?.scrollToOffset({ animated: false, offset: targetOffset });
241
+ }
242
+
243
+ // props
244
+
245
+ function withDefaults<T>(
246
+ props: RepeatingWheelPickerProps<T>
247
+ ): RepeatingWheelPickerPropsWithDefaults<T> {
248
+ const defaultBackgroundColor = "black";
249
+ const defaultTextColor = "white";
250
+ const defaultTextSize = 18;
251
+
252
+ validateProps(props);
253
+
254
+ return {
255
+ ...props,
256
+
257
+ // optional
258
+ containerOnLayout: props.containerOnLayout ?? (() => {}),
259
+ enabled: props.enabled ?? true,
260
+
261
+ getLabel: props.getLabel ?? ((t: T) => `${t}`),
262
+
263
+ itemHeight: props.itemHeight ?? (props.itemTextStyle?.fontSize ?? defaultTextSize) + 15,
264
+ itemDisplayCount: props.itemDisplayCount ?? 3,
265
+
266
+ containerVerticalPadding: props.containerVerticalPadding ?? 0,
267
+ containerHorizontalPadding: props.containerHorizontalPadding ?? 10,
268
+ containerStyle: {
269
+ ...props.containerStyle,
270
+ backgroundColor:
271
+ props.containerStyle?.backgroundColor ?? defaultBackgroundColor,
272
+ padding: 0,
273
+ paddingHorizontal: 0,
274
+ paddingVertical: 0,
275
+ paddingTop: 0,
276
+ paddingBottom: 0,
277
+ paddingLeft: 0,
278
+ paddingRight: 0
279
+ },
280
+ itemContainerStyle: {
281
+ ...props.itemContainerStyle,
282
+ backgroundColor:
283
+ props.itemContainerStyle?.backgroundColor ?? "transparent",
284
+ justifyContent: props.itemContainerStyle?.justifyContent ?? "center"
285
+ },
286
+ itemTextStyle: {
287
+ ...props.itemTextStyle,
288
+ fontSize: props.itemTextStyle?.fontSize ?? defaultTextSize,
289
+ color: props.itemTextStyle?.color ?? defaultTextColor,
290
+ },
291
+
292
+ enableGradient: props.enableGradient ?? true,
293
+ gradientFadeColor: props.gradientFadeColor ?? (props.containerStyle?.backgroundColor ?? defaultBackgroundColor)
294
+ };
295
+ }
296
+
297
+ function validateProps<T>(props: RepeatingWheelPickerProps<T>) {
298
+ if (props.initialIndex < 0 || props.initialIndex >= props.data.length) {
299
+ throw InvalidPropertiesError(
300
+ "initialIndex",
301
+ String(props.initialIndex),
302
+ "has to be in range [0, data.length)"
303
+ );
304
+ }
305
+
306
+ if (props.data.length < 2) {
307
+ throw InvalidPropertiesError(
308
+ "data.length",
309
+ String(props.data.length),
310
+ "has to be larger than 1"
311
+ );
312
+ }
313
+
314
+ if (props.itemDisplayCount !== undefined && props.itemDisplayCount < 1) {
315
+ throw InvalidPropertiesError(
316
+ "itemDisplayCount",
317
+ String(props.itemDisplayCount),
318
+ "has to be larger than 0"
319
+ );
320
+ }
321
+ if (props.itemDisplayCount !== undefined && !Number.isInteger(props.itemDisplayCount)) {
322
+ throw InvalidPropertiesError(
323
+ "itemDisplayCount",
324
+ String(props.itemDisplayCount),
325
+ "has to be an integer"
326
+ );
327
+ }
328
+
329
+ if (props.itemHeight !== undefined && props.itemHeight < 1) {
330
+ throw InvalidPropertiesError(
331
+ "itemHeight",
332
+ String(props.itemHeight),
333
+ "has to be larger than 0"
334
+ );
335
+ }
336
+ }
337
+
338
+ function InvalidPropertiesError(
339
+ propertyName: string,
340
+ propertyValue: string,
341
+ violatedConstraint: string
342
+ ) {
343
+ return Error(
344
+ `Value "${propertyValue}" is invalid for property "${propertyName}": ${violatedConstraint}`
345
+ ) as InvalidPropertiesError;
346
+ }
347
+
348
+ interface InvalidPropertiesError extends Error {
349
+ name: "InvalidPropertiesError";
350
+ }
351
+
352
+ type RepeatingWheelPickerPropsWithDefaults<T> = RepeatingWheelPickerProps<T> &
353
+ Required<Omit<RepeatingWheelPickerProps<T>, "containerRef">>;
354
+
355
+ /**
356
+ *
357
+ */
358
+ export type RepeatingWheelPickerProps<T> = {
359
+ /**
360
+ * Function to set currently selected element and use it in your application.
361
+ *
362
+ * @example
363
+ * ```ts
364
+ * const [selected, setSelected] = useState(0);
365
+ *
366
+ * return (
367
+ * <RepeatingWheelPicker
368
+ * setSelected={setSelected}
369
+ * //...
370
+ * />
371
+ * );
372
+ * ```
373
+ *
374
+ * @param t currently selected element
375
+ */
376
+ setSelected: (t: T) => void;
377
+ /**
378
+ * Index to initially center.
379
+ */
380
+ initialIndex: number;
381
+ /**
382
+ * Data to display.
383
+ */
384
+ data: T[];
385
+
386
+ /**
387
+ * Function to retrieve the text to display for an element as a label.
388
+ *
389
+ * @defaultValue
390
+ * ```ts
391
+ * (t: T) => `${t}`
392
+ * ```
393
+ *
394
+ * @param t element to retrieve label for
395
+ */
396
+ getLabel?: (t: T) => string;
397
+ /**
398
+ * Function called when the layout of the container changes.
399
+ *
400
+ * _Example usage for monitoring the container's height:_
401
+ * ```ts
402
+ * const [pickerHeight, setPickerHeight] = useState<number>(0);
403
+ *
404
+ * const onLayout = useCallback((event: LayoutChangeEvent) => {
405
+ * const { height } = event.nativeEvent.layout;
406
+ * setPickerHeight(height);
407
+ * }, []);
408
+ *
409
+ * return (
410
+ * <View style={{flexDirection: "row"}}>
411
+ * <View style={{height: height}}>
412
+ * <Text>Picker label</Text>
413
+ * </View>
414
+ * <RepeatingWheelPicker
415
+ * //...
416
+ * containerOnLayout={onLayout}
417
+ * />
418
+ * </View>
419
+ * );
420
+ * ```
421
+ *
422
+ * @defaultValue () => {}
423
+ *
424
+ * @param event layout change event that triggered `onLayout`
425
+ */
426
+ containerOnLayout?: (event: LayoutChangeEvent) => void;
427
+ /**
428
+ * Enables / disables scrolling of the wheel picker.
429
+ *
430
+ * @defaultValue true
431
+ */
432
+ enabled?: boolean;
433
+
434
+ /**
435
+ * Height per displayed item.
436
+ *
437
+ * @defaultValue itemTextStyle.fontSize + 15
438
+ */
439
+ itemHeight?: number;
440
+ /**
441
+ * Number of items to display.
442
+ *
443
+ * @defaultValue 3
444
+ */
445
+ itemDisplayCount?: number;
446
+
447
+ /**
448
+ * Vertical padding for the container of the wheel picker.
449
+ *
450
+ * @defaultValue 0
451
+ */
452
+ containerVerticalPadding?: number;
453
+ /**
454
+ * Horizontal padding for the container of the wheel picker.
455
+ *
456
+ * @defaultValue 10
457
+ */
458
+ containerHorizontalPadding?: number;
459
+ /**
460
+ * Styling for the container of the wheel picker.
461
+ *
462
+ * @defaultValue
463
+ * ```ts
464
+ * {
465
+ * backgroundColor: "black"
466
+ * }
467
+ * ```
468
+ */
469
+ containerStyle?: ViewStyle;
470
+ /**
471
+ * Styling for the container of each element.
472
+ *
473
+ * @defaultValue
474
+ * ```ts
475
+ * {
476
+ * backgroundColor: "transparent",
477
+ * justifyContent: "center"
478
+ * }
479
+ * ```
480
+ */
481
+ itemContainerStyle?: ViewStyle;
482
+ /**
483
+ * Styling for the text of the elements.
484
+ *
485
+ * @defaultValue
486
+ * ```ts
487
+ * {
488
+ * fontSize: "18",
489
+ * color: "white"
490
+ * }
491
+ * ```
492
+ */
493
+ itemTextStyle?: TextStyle;
494
+
495
+ /**
496
+ * If enabled, will show a gradient fade towards the top and bottom of the wheel picker.
497
+ *
498
+ * @defaultValue true
499
+ */
500
+ enableGradient?: boolean;
501
+ /**
502
+ * Color the gradient should fade to at the top and bottom.
503
+ *
504
+ * @defaultValue containerStyle.backgroundColor
505
+ */
506
+ gradientFadeColor?: ColorValue;
507
+ };