expo-horizontal-picker 0.1.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/.lintstagedrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "*.{js,ts,jsx,tsx}": ["biome format", "biome lint"]
3
+ }
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ v22.11.0
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # expo-horizontal-picker
2
+
3
+ horizontal picker
4
+
5
+ # API documentation
6
+
7
+ - [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/horizontal-picker/)
8
+ - [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/horizontal-picker/)
9
+
10
+ # Installation in managed Expo projects
11
+
12
+ For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
13
+
14
+ # Installation in bare React Native projects
15
+
16
+ For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
17
+
18
+ ### Add the package to your npm dependencies
19
+
20
+ ```
21
+ npm install expo-horizontal-picker
22
+ ```
package/biome.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3
+ "formatter": {
4
+ "indentWidth": 2,
5
+ "lineWidth": 120,
6
+ "indentStyle": "space",
7
+ "enabled": true,
8
+ "bracketSpacing": true,
9
+ "formatWithErrors": false
10
+ },
11
+ "javascript": {
12
+ "formatter": {
13
+ "quoteStyle": "single",
14
+ "trailingCommas": "all",
15
+ "semicolons": "always",
16
+ "bracketSameLine": false
17
+ }
18
+ },
19
+ "linter": {
20
+ "enabled": true,
21
+ "rules": {
22
+ "recommended": true,
23
+ "a11y": {
24
+ "useButtonType": "off"
25
+ },
26
+ "complexity": {
27
+ "noVoid": "error",
28
+ "noBannedTypes": "error",
29
+ "noExtraBooleanCast": "error",
30
+ "noExcessiveCognitiveComplexity": "error"
31
+ },
32
+ "correctness": {
33
+ "noUnusedImports": "error",
34
+ "noUnusedVariables": "error",
35
+ "useHookAtTopLevel": "error"
36
+ },
37
+ "performance": {
38
+ "noBarrelFile": "error",
39
+ "noReExportAll": "error",
40
+ "noDelete": "error"
41
+ },
42
+ "security": {
43
+ "noDangerouslySetInnerHtml": "error"
44
+ },
45
+ "style": {
46
+ "noUselessElse": "error",
47
+ "useCollapsedElseIf": "error",
48
+ "noNonNullAssertion": "error",
49
+ "useBlockStatements": "error",
50
+ "noNamespace": "error",
51
+ "noYodaExpression": "error",
52
+ "useConsistentBuiltinInstantiation": "error",
53
+ "useDefaultSwitchClause": "error",
54
+ "useFragmentSyntax": "error",
55
+ "useThrowNewError": "error",
56
+ "useThrowOnlyError": "error",
57
+ "useFilenamingConvention": {
58
+ "level": "error",
59
+ "options": {
60
+ "filenameCases": ["camelCase", "PascalCase"]
61
+ }
62
+ }
63
+ },
64
+ "suspicious": {
65
+ "noArrayIndexKey": "error",
66
+ "noAssignInExpressions": "error",
67
+ "noConfusingVoidType": "error",
68
+ "noConsole": "error",
69
+ "noDebugger": "error",
70
+ "noExplicitAny": "error",
71
+ "noRedeclare": "error",
72
+ "noEvolvingTypes": "error",
73
+ "useNumberToFixedDigitsArgument": "error"
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,20 @@
1
+ import { type StyleProp, type TextStyle, type ViewStyle } from 'react-native';
2
+ import { type AnimatedScrollViewProps } from 'react-native-reanimated';
3
+ interface PickerOption {
4
+ label: string;
5
+ value: string | number;
6
+ }
7
+ interface Props extends Omit<AnimatedScrollViewProps, 'style'> {
8
+ data: PickerOption[];
9
+ initialIndex?: number;
10
+ visibleItemCount?: number;
11
+ onChange?: (value: PickerOption['value'], index: number) => void;
12
+ onHapticFeedback?: () => void;
13
+ containerStyle?: AnimatedScrollViewProps['style'];
14
+ itemContainerStyle?: StyleProp<ViewStyle>;
15
+ itemTextStyle?: StyleProp<TextStyle>;
16
+ selectedItemTextStyle?: StyleProp<TextStyle>;
17
+ }
18
+ export declare function HorizontalPicker({ data, initialIndex, visibleItemCount, onChange, onHapticFeedback, containerStyle, itemContainerStyle, itemTextStyle, selectedItemTextStyle, ...props }: Props): import("react").JSX.Element;
19
+ export {};
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,SAAS,EAGd,KAAK,SAAS,EAEd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AACtB,OAAiB,EACf,KAAK,uBAAuB,EAM7B,MAAM,yBAAyB,CAAC;AAEjC,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED,UAAU,KAAM,SAAQ,IAAI,CAAC,uBAAuB,EAAE,OAAO,CAAC;IAC5D,IAAI,EAAE,YAAY,EAAE,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjE,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B,cAAc,CAAC,EAAE,uBAAuB,CAAC,OAAO,CAAC,CAAC;IAClD,kBAAkB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC1C,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,qBAAqB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9C;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,IAAI,EACJ,YAAgB,EAChB,gBAAoB,EACpB,QAAQ,EACR,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,qBAAqB,EACrB,GAAG,KAAK,EACT,EAAE,KAAK,+BAoHP"}
package/build/index.js ADDED
@@ -0,0 +1,105 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { PixelRatio, Platform, Pressable, StyleSheet, Text, View, } from 'react-native';
3
+ import Animated, { runOnJS, useAnimatedScrollHandler, useDerivedValue, useSharedValue, } from 'react-native-reanimated';
4
+ export function HorizontalPicker({ data, initialIndex = 0, visibleItemCount = 7, onChange, onHapticFeedback, containerStyle, itemContainerStyle, itemTextStyle, selectedItemTextStyle, ...props }) {
5
+ const scrollViewRef = useRef(null);
6
+ const lastHapticIndexRef = useRef(-1);
7
+ const [scrollViewWidth, setScrollViewWidth] = useState(0);
8
+ const scrollX = useSharedValue(0);
9
+ const currentIndex = useSharedValue(initialIndex);
10
+ const { itemWidth, paddingSide } = useMemo(() => {
11
+ const width = PixelRatio.roundToNearestPixel(scrollViewWidth / visibleItemCount);
12
+ const padding = PixelRatio.roundToNearestPixel(scrollViewWidth / 2 - width / 2);
13
+ return {
14
+ itemWidth: width,
15
+ paddingSide: padding,
16
+ };
17
+ }, [scrollViewWidth, visibleItemCount]);
18
+ const snapOffsets = useMemo(() => {
19
+ return data.map((_, index) => PixelRatio.roundToNearestPixel(index * itemWidth));
20
+ }, [data, itemWidth]);
21
+ const handleOnLayout = useCallback((e) => {
22
+ const layoutWidth = e.nativeEvent.layout.width;
23
+ setScrollViewWidth(layoutWidth);
24
+ const safeIndex = Math.max(0, Math.min(data.length - 1, initialIndex));
25
+ const rawItemWidth = layoutWidth / visibleItemCount;
26
+ const x = PixelRatio.roundToNearestPixel(safeIndex * rawItemWidth);
27
+ setTimeout(() => {
28
+ scrollViewRef.current?.scrollTo({ x, y: 0, animated: true });
29
+ }, 0);
30
+ }, [initialIndex, data.length, visibleItemCount]);
31
+ const handleOnScroll = useAnimatedScrollHandler({
32
+ onScroll: (event) => {
33
+ scrollX.value = event.contentOffset.x;
34
+ },
35
+ });
36
+ const handleOnPress = useCallback((newIndex) => {
37
+ const safeIndex = Math.max(0, Math.min(data.length - 1, newIndex));
38
+ const x = PixelRatio.roundToNearestPixel(safeIndex * itemWidth);
39
+ scrollViewRef.current?.scrollTo({ x, y: 0, animated: true });
40
+ }, [data.length, itemWidth]);
41
+ const handleOnChange = useCallback((index) => {
42
+ const item = data[index];
43
+ if (item) {
44
+ onChange?.(item.value, index);
45
+ }
46
+ }, [onChange, data]);
47
+ const handleOnHapticFeedback = useCallback((index) => {
48
+ if (index !== lastHapticIndexRef.current && index >= 0 && index < data.length) {
49
+ lastHapticIndexRef.current = index;
50
+ onHapticFeedback?.();
51
+ }
52
+ }, [data.length, onHapticFeedback]);
53
+ useDerivedValue(() => {
54
+ if (scrollViewWidth === 0) {
55
+ return;
56
+ }
57
+ const newIndex = Math.round(scrollX.value / itemWidth);
58
+ const safeIndex = Math.max(0, Math.min(data.length - 1, newIndex));
59
+ if (safeIndex !== currentIndex.value) {
60
+ currentIndex.value = safeIndex;
61
+ runOnJS(handleOnChange)(safeIndex);
62
+ runOnJS(handleOnHapticFeedback)(safeIndex);
63
+ }
64
+ }, [itemWidth, data.length]);
65
+ return (<Animated.ScrollView ref={scrollViewRef} horizontal onLayout={handleOnLayout} onScroll={handleOnScroll} showsHorizontalScrollIndicator={false} scrollEventThrottle={Platform.select({ ios: 16, android: 2 })} decelerationRate={Platform.select({ ios: undefined, android: 'fast' })} snapToOffsets={snapOffsets} contentContainerStyle={{ paddingHorizontal: paddingSide }} style={[styles.container, containerStyle]} {...props}>
66
+ {data.map((item, index) => (<PickerItem key={`picker-item-${item.value}`} label={item.label} index={index} currentIndex={currentIndex} itemWidth={itemWidth} onPress={() => handleOnPress(index)} itemContainerStyle={itemContainerStyle} itemTextStyle={itemTextStyle} selectedItemTextStyle={selectedItemTextStyle}/>))}
67
+ </Animated.ScrollView>);
68
+ }
69
+ function PickerItem({ label, index, currentIndex, itemWidth, onPress, itemContainerStyle, itemTextStyle, selectedItemTextStyle, }) {
70
+ const [isFocused, setIsFocused] = useState(false);
71
+ useDerivedValue(() => {
72
+ const shouldBeFocused = currentIndex.value === index;
73
+ if (shouldBeFocused !== isFocused) {
74
+ runOnJS(setIsFocused)(shouldBeFocused);
75
+ }
76
+ });
77
+ return (<Pressable onPress={onPress}>
78
+ <View style={[{ width: itemWidth }, styles.itemContainer, itemContainerStyle]}>
79
+ <Text style={isFocused ? [styles.itemTextSelected, selectedItemTextStyle] : [styles.itemText, itemTextStyle]}>
80
+ {label}
81
+ </Text>
82
+ </View>
83
+ </Pressable>);
84
+ }
85
+ const styles = StyleSheet.create({
86
+ container: {
87
+ width: '100%',
88
+ },
89
+ itemContainer: {
90
+ justifyContent: 'center',
91
+ alignItems: 'center',
92
+ height: 60,
93
+ },
94
+ itemText: {
95
+ fontSize: 13,
96
+ fontWeight: '700',
97
+ color: '#C9CED9',
98
+ },
99
+ itemTextSelected: {
100
+ fontSize: 15,
101
+ fontWeight: '800',
102
+ color: '#000000',
103
+ },
104
+ });
105
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo, useRef, useState } from 'react';\nimport {\n type LayoutChangeEvent,\n PixelRatio,\n Platform,\n Pressable,\n type StyleProp,\n StyleSheet,\n Text,\n type TextStyle,\n View,\n type ViewStyle,\n} from 'react-native';\nimport Animated, {\n type AnimatedScrollViewProps,\n runOnJS,\n type SharedValue,\n useAnimatedScrollHandler,\n useDerivedValue,\n useSharedValue,\n} from 'react-native-reanimated';\n\ninterface PickerOption {\n label: string;\n value: string | number;\n}\n\ninterface Props extends Omit<AnimatedScrollViewProps, 'style'> {\n data: PickerOption[];\n initialIndex?: number;\n visibleItemCount?: number;\n onChange?: (value: PickerOption['value'], index: number) => void;\n onHapticFeedback?: () => void;\n containerStyle?: AnimatedScrollViewProps['style'];\n itemContainerStyle?: StyleProp<ViewStyle>;\n itemTextStyle?: StyleProp<TextStyle>;\n selectedItemTextStyle?: StyleProp<TextStyle>;\n}\n\nexport function HorizontalPicker({\n data,\n initialIndex = 0,\n visibleItemCount = 7,\n onChange,\n onHapticFeedback,\n containerStyle,\n itemContainerStyle,\n itemTextStyle,\n selectedItemTextStyle,\n ...props\n}: Props) {\n const scrollViewRef = useRef<Animated.ScrollView>(null);\n const lastHapticIndexRef = useRef<number>(-1);\n const [scrollViewWidth, setScrollViewWidth] = useState<number>(0);\n\n const scrollX = useSharedValue<number>(0);\n const currentIndex = useSharedValue<number>(initialIndex);\n\n const { itemWidth, paddingSide } = useMemo(() => {\n const width = PixelRatio.roundToNearestPixel(scrollViewWidth / visibleItemCount);\n const padding = PixelRatio.roundToNearestPixel(scrollViewWidth / 2 - width / 2);\n return {\n itemWidth: width,\n paddingSide: padding,\n };\n }, [scrollViewWidth, visibleItemCount]);\n\n const snapOffsets = useMemo(() => {\n return data.map((_, index) => PixelRatio.roundToNearestPixel(index * itemWidth));\n }, [data, itemWidth]);\n\n const handleOnLayout = useCallback(\n (e: LayoutChangeEvent) => {\n const layoutWidth = e.nativeEvent.layout.width;\n setScrollViewWidth(layoutWidth);\n\n const safeIndex = Math.max(0, Math.min(data.length - 1, initialIndex));\n const rawItemWidth = layoutWidth / visibleItemCount;\n const x = PixelRatio.roundToNearestPixel(safeIndex * rawItemWidth);\n\n setTimeout(() => {\n scrollViewRef.current?.scrollTo({ x, y: 0, animated: true });\n }, 0);\n },\n [initialIndex, data.length, visibleItemCount],\n );\n\n const handleOnScroll = useAnimatedScrollHandler({\n onScroll: (event) => {\n scrollX.value = event.contentOffset.x;\n },\n });\n\n const handleOnPress = useCallback(\n (newIndex: number) => {\n const safeIndex = Math.max(0, Math.min(data.length - 1, newIndex));\n const x = PixelRatio.roundToNearestPixel(safeIndex * itemWidth);\n scrollViewRef.current?.scrollTo({ x, y: 0, animated: true });\n },\n [data.length, itemWidth],\n );\n\n const handleOnChange = useCallback(\n (index: number) => {\n const item = data[index];\n if (item) {\n onChange?.(item.value, index);\n }\n },\n [onChange, data],\n );\n\n const handleOnHapticFeedback = useCallback(\n (index: number) => {\n if (index !== lastHapticIndexRef.current && index >= 0 && index < data.length) {\n lastHapticIndexRef.current = index;\n onHapticFeedback?.();\n }\n },\n [data.length, onHapticFeedback],\n );\n\n useDerivedValue(() => {\n if (scrollViewWidth === 0) {\n return;\n }\n\n const newIndex = Math.round(scrollX.value / itemWidth);\n const safeIndex = Math.max(0, Math.min(data.length - 1, newIndex));\n\n if (safeIndex !== currentIndex.value) {\n currentIndex.value = safeIndex;\n runOnJS(handleOnChange)(safeIndex);\n runOnJS(handleOnHapticFeedback)(safeIndex);\n }\n }, [itemWidth, data.length]);\n\n return (\n <Animated.ScrollView\n ref={scrollViewRef}\n horizontal\n onLayout={handleOnLayout}\n onScroll={handleOnScroll}\n showsHorizontalScrollIndicator={false}\n scrollEventThrottle={Platform.select({ ios: 16, android: 2 })}\n decelerationRate={Platform.select({ ios: undefined, android: 'fast' })}\n snapToOffsets={snapOffsets}\n contentContainerStyle={{ paddingHorizontal: paddingSide }}\n style={[styles.container, containerStyle]}\n {...props}\n >\n {data.map((item, index) => (\n <PickerItem\n key={`picker-item-${item.value}`}\n label={item.label}\n index={index}\n currentIndex={currentIndex}\n itemWidth={itemWidth}\n onPress={() => handleOnPress(index)}\n itemContainerStyle={itemContainerStyle}\n itemTextStyle={itemTextStyle}\n selectedItemTextStyle={selectedItemTextStyle}\n />\n ))}\n </Animated.ScrollView>\n );\n}\n\nfunction PickerItem({\n label,\n index,\n currentIndex,\n itemWidth,\n onPress,\n itemContainerStyle,\n itemTextStyle,\n selectedItemTextStyle,\n}: {\n label: PickerOption['label'];\n index: number;\n currentIndex: SharedValue<number>;\n itemWidth: number;\n onPress: () => void;\n itemContainerStyle?: StyleProp<ViewStyle>;\n itemTextStyle?: StyleProp<TextStyle>;\n selectedItemTextStyle?: StyleProp<TextStyle>;\n}) {\n const [isFocused, setIsFocused] = useState(false);\n\n useDerivedValue(() => {\n const shouldBeFocused = currentIndex.value === index;\n if (shouldBeFocused !== isFocused) {\n runOnJS(setIsFocused)(shouldBeFocused);\n }\n });\n\n return (\n <Pressable onPress={onPress}>\n <View style={[{ width: itemWidth }, styles.itemContainer, itemContainerStyle]}>\n <Text style={isFocused ? [styles.itemTextSelected, selectedItemTextStyle] : [styles.itemText, itemTextStyle]}>\n {label}\n </Text>\n </View>\n </Pressable>\n );\n}\n\nconst styles = StyleSheet.create({\n container: {\n width: '100%',\n },\n itemContainer: {\n justifyContent: 'center',\n alignItems: 'center',\n height: 60,\n },\n itemText: {\n fontSize: 13,\n fontWeight: '700',\n color: '#C9CED9',\n },\n itemTextSelected: {\n fontSize: 15,\n fontWeight: '800',\n color: '#000000',\n },\n});\n"]}
@@ -0,0 +1 @@
1
+ module.exports = { extends: ['@commitlint/config-conventional'] };
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android", "web"],
3
+ "apple": {
4
+ "modules": ["ExpoHorizontalPickerModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.horizontalpicker.ExpoHorizontalPickerModule"]
8
+ }
9
+ }
package/knip.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://unpkg.com/knip@5/schema.json",
3
+ "workspaces": {
4
+ ".": {
5
+ "ignore": ["packlint.config.mjs"]
6
+ }
7
+ }
8
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "expo-horizontal-picker",
3
+ "version": "0.1.0",
4
+ "description": "expo-horizontal-picker",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build",
9
+ "changeset": "changeset",
10
+ "changeset:publish": "yarn build && changeset publish",
11
+ "changeset:version": "changeset version && pnpm i --lockfile-only",
12
+ "clean": "expo-module clean",
13
+ "lint": "expo-module lint",
14
+ "test": "expo-module test",
15
+ "prepublishOnly": "expo-module prepublishOnly",
16
+ "expo-module": "expo-module",
17
+ "open:ios": "xed example/ios",
18
+ "open:android": "open -a \"Android Studio\" example/android"
19
+ },
20
+ "keywords": [
21
+ "react-native",
22
+ "expo",
23
+ "expo-horizontal-picker",
24
+ "ExpoHorizontalPicker",
25
+ "react-native-horizontal-picker"
26
+ ],
27
+ "repository": "https://github.com/fe-dudu/expo-horizontal-picker",
28
+ "bugs": {
29
+ "url": "https://github.com/fe-dudu/expo-horizontal-picker/issues"
30
+ },
31
+ "author": "fe-dudu <ehehwhdwhd@naver.com> (https://github.com/fe-dudu)",
32
+ "license": "MIT",
33
+ "homepage": "https://github.com/fe-dudu/expo-horizontal-picker#readme",
34
+ "devDependencies": {
35
+ "@biomejs/biome": "^2.0.6",
36
+ "@changesets/changelog-github": "^0.5.1",
37
+ "@changesets/cli": "^2.29.5",
38
+ "@commitlint/cli": "^19.8.1",
39
+ "@commitlint/config-conventional": "^19.8.1",
40
+ "@types/react": "~19.0.0",
41
+ "expo": "~53.0.0",
42
+ "expo-module-scripts": "^4.1.6",
43
+ "husky": "^9.1.7",
44
+ "knip": "^5.61.3",
45
+ "lint-staged": "^16.1.2",
46
+ "packlint": "^0.2.4",
47
+ "react": "^19.1.0",
48
+ "react-native": "0.79.1",
49
+ "typescript": "^5.8.3",
50
+ "react-native-reanimated": "^3.18.0"
51
+ },
52
+ "peerDependencies": {
53
+ "expo": "*",
54
+ "react": "*",
55
+ "react-native": "*",
56
+ "react-native-reanimated": ">=2.10.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "packageManager": "yarn@1.22.22"
62
+ }
@@ -0,0 +1,3 @@
1
+ export default {
2
+ files: ['./package.json'],
3
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,227 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ type LayoutChangeEvent,
4
+ PixelRatio,
5
+ Platform,
6
+ Pressable,
7
+ type StyleProp,
8
+ StyleSheet,
9
+ Text,
10
+ type TextStyle,
11
+ View,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+ import Animated, {
15
+ type AnimatedScrollViewProps,
16
+ runOnJS,
17
+ type SharedValue,
18
+ useAnimatedScrollHandler,
19
+ useDerivedValue,
20
+ useSharedValue,
21
+ } from 'react-native-reanimated';
22
+
23
+ interface PickerOption {
24
+ label: string;
25
+ value: string | number;
26
+ }
27
+
28
+ interface Props extends Omit<AnimatedScrollViewProps, 'style'> {
29
+ data: PickerOption[];
30
+ initialIndex?: number;
31
+ visibleItemCount?: number;
32
+ onChange?: (value: PickerOption['value'], index: number) => void;
33
+ onHapticFeedback?: () => void;
34
+ containerStyle?: AnimatedScrollViewProps['style'];
35
+ itemContainerStyle?: StyleProp<ViewStyle>;
36
+ itemTextStyle?: StyleProp<TextStyle>;
37
+ selectedItemTextStyle?: StyleProp<TextStyle>;
38
+ }
39
+
40
+ export function HorizontalPicker({
41
+ data,
42
+ initialIndex = 0,
43
+ visibleItemCount = 7,
44
+ onChange,
45
+ onHapticFeedback,
46
+ containerStyle,
47
+ itemContainerStyle,
48
+ itemTextStyle,
49
+ selectedItemTextStyle,
50
+ ...props
51
+ }: Props) {
52
+ const scrollViewRef = useRef<Animated.ScrollView>(null);
53
+ const lastHapticIndexRef = useRef<number>(-1);
54
+ const [scrollViewWidth, setScrollViewWidth] = useState<number>(0);
55
+
56
+ const scrollX = useSharedValue<number>(0);
57
+ const currentIndex = useSharedValue<number>(initialIndex);
58
+
59
+ const { itemWidth, paddingSide } = useMemo(() => {
60
+ const width = PixelRatio.roundToNearestPixel(scrollViewWidth / visibleItemCount);
61
+ const padding = PixelRatio.roundToNearestPixel(scrollViewWidth / 2 - width / 2);
62
+ return {
63
+ itemWidth: width,
64
+ paddingSide: padding,
65
+ };
66
+ }, [scrollViewWidth, visibleItemCount]);
67
+
68
+ const snapOffsets = useMemo(() => {
69
+ return data.map((_, index) => PixelRatio.roundToNearestPixel(index * itemWidth));
70
+ }, [data, itemWidth]);
71
+
72
+ const handleOnLayout = useCallback(
73
+ (e: LayoutChangeEvent) => {
74
+ const layoutWidth = e.nativeEvent.layout.width;
75
+ setScrollViewWidth(layoutWidth);
76
+
77
+ const safeIndex = Math.max(0, Math.min(data.length - 1, initialIndex));
78
+ const rawItemWidth = layoutWidth / visibleItemCount;
79
+ const x = PixelRatio.roundToNearestPixel(safeIndex * rawItemWidth);
80
+
81
+ setTimeout(() => {
82
+ scrollViewRef.current?.scrollTo({ x, y: 0, animated: true });
83
+ }, 0);
84
+ },
85
+ [initialIndex, data.length, visibleItemCount],
86
+ );
87
+
88
+ const handleOnScroll = useAnimatedScrollHandler({
89
+ onScroll: (event) => {
90
+ scrollX.value = event.contentOffset.x;
91
+ },
92
+ });
93
+
94
+ const handleOnPress = useCallback(
95
+ (newIndex: number) => {
96
+ const safeIndex = Math.max(0, Math.min(data.length - 1, newIndex));
97
+ const x = PixelRatio.roundToNearestPixel(safeIndex * itemWidth);
98
+ scrollViewRef.current?.scrollTo({ x, y: 0, animated: true });
99
+ },
100
+ [data.length, itemWidth],
101
+ );
102
+
103
+ const handleOnChange = useCallback(
104
+ (index: number) => {
105
+ const item = data[index];
106
+ if (item) {
107
+ onChange?.(item.value, index);
108
+ }
109
+ },
110
+ [onChange, data],
111
+ );
112
+
113
+ const handleOnHapticFeedback = useCallback(
114
+ (index: number) => {
115
+ if (index !== lastHapticIndexRef.current && index >= 0 && index < data.length) {
116
+ lastHapticIndexRef.current = index;
117
+ onHapticFeedback?.();
118
+ }
119
+ },
120
+ [data.length, onHapticFeedback],
121
+ );
122
+
123
+ useDerivedValue(() => {
124
+ if (scrollViewWidth === 0) {
125
+ return;
126
+ }
127
+
128
+ const newIndex = Math.round(scrollX.value / itemWidth);
129
+ const safeIndex = Math.max(0, Math.min(data.length - 1, newIndex));
130
+
131
+ if (safeIndex !== currentIndex.value) {
132
+ currentIndex.value = safeIndex;
133
+ runOnJS(handleOnChange)(safeIndex);
134
+ runOnJS(handleOnHapticFeedback)(safeIndex);
135
+ }
136
+ }, [itemWidth, data.length]);
137
+
138
+ return (
139
+ <Animated.ScrollView
140
+ ref={scrollViewRef}
141
+ horizontal
142
+ onLayout={handleOnLayout}
143
+ onScroll={handleOnScroll}
144
+ showsHorizontalScrollIndicator={false}
145
+ scrollEventThrottle={Platform.select({ ios: 16, android: 2 })}
146
+ decelerationRate={Platform.select({ ios: undefined, android: 'fast' })}
147
+ snapToOffsets={snapOffsets}
148
+ contentContainerStyle={{ paddingHorizontal: paddingSide }}
149
+ style={[styles.container, containerStyle]}
150
+ {...props}
151
+ >
152
+ {data.map((item, index) => (
153
+ <PickerItem
154
+ key={`picker-item-${item.value}`}
155
+ label={item.label}
156
+ index={index}
157
+ currentIndex={currentIndex}
158
+ itemWidth={itemWidth}
159
+ onPress={() => handleOnPress(index)}
160
+ itemContainerStyle={itemContainerStyle}
161
+ itemTextStyle={itemTextStyle}
162
+ selectedItemTextStyle={selectedItemTextStyle}
163
+ />
164
+ ))}
165
+ </Animated.ScrollView>
166
+ );
167
+ }
168
+
169
+ function PickerItem({
170
+ label,
171
+ index,
172
+ currentIndex,
173
+ itemWidth,
174
+ onPress,
175
+ itemContainerStyle,
176
+ itemTextStyle,
177
+ selectedItemTextStyle,
178
+ }: {
179
+ label: PickerOption['label'];
180
+ index: number;
181
+ currentIndex: SharedValue<number>;
182
+ itemWidth: number;
183
+ onPress: () => void;
184
+ itemContainerStyle?: StyleProp<ViewStyle>;
185
+ itemTextStyle?: StyleProp<TextStyle>;
186
+ selectedItemTextStyle?: StyleProp<TextStyle>;
187
+ }) {
188
+ const [isFocused, setIsFocused] = useState(false);
189
+
190
+ useDerivedValue(() => {
191
+ const shouldBeFocused = currentIndex.value === index;
192
+ if (shouldBeFocused !== isFocused) {
193
+ runOnJS(setIsFocused)(shouldBeFocused);
194
+ }
195
+ });
196
+
197
+ return (
198
+ <Pressable onPress={onPress}>
199
+ <View style={[{ width: itemWidth }, styles.itemContainer, itemContainerStyle]}>
200
+ <Text style={isFocused ? [styles.itemTextSelected, selectedItemTextStyle] : [styles.itemText, itemTextStyle]}>
201
+ {label}
202
+ </Text>
203
+ </View>
204
+ </Pressable>
205
+ );
206
+ }
207
+
208
+ const styles = StyleSheet.create({
209
+ container: {
210
+ width: '100%',
211
+ },
212
+ itemContainer: {
213
+ justifyContent: 'center',
214
+ alignItems: 'center',
215
+ height: 60,
216
+ },
217
+ itemText: {
218
+ fontSize: 13,
219
+ fontWeight: '700',
220
+ color: '#C9CED9',
221
+ },
222
+ itemTextSelected: {
223
+ fontSize: 15,
224
+ fontWeight: '800',
225
+ color: '#000000',
226
+ },
227
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }