@harya72/react-native-ruler 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/src/Ruler.tsx ADDED
@@ -0,0 +1,457 @@
1
+ import {
2
+ View,
3
+ Dimensions,
4
+ StyleSheet,
5
+ Platform,
6
+ TextInput,
7
+ } from 'react-native';
8
+ import React, {
9
+ useCallback,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ memo,
14
+ } from 'react';
15
+ import Animated, {
16
+ runOnJS,
17
+ useAnimatedProps,
18
+ useAnimatedScrollHandler,
19
+ useSharedValue,
20
+ } from 'react-native-reanimated';
21
+ import type {
22
+ RulerProps,
23
+ AnimatedTextInputProps,
24
+ AnimatedGradientTextProps,
25
+ BarProps,
26
+ } from './types';
27
+
28
+ let LinearGradient: any = null;
29
+ try {
30
+ // @ts-ignore
31
+ LinearGradient = require('expo-linear-gradient').LinearGradient;
32
+ } catch (e) {
33
+ // expo-linear-gradient not installed, fade gradients will be disabled
34
+ }
35
+
36
+ const { width: SCREEN_WIDTH } = Dimensions.get('window');
37
+ const { width: screenWidth } = Dimensions.get('screen');
38
+
39
+ const FADE_CONFIG = {
40
+ fadeWidth: 0.25,
41
+ fadeOpacity: 0.1,
42
+ };
43
+
44
+ const STEP = 1;
45
+ const LONG_BAR_WIDTH = 6;
46
+ const LONG_BAR_HEIGHT = 50;
47
+ const SHORT_BAR_WIDTH = 3;
48
+ const SHORT_BAR_HEIGHT = 30;
49
+ const LONG_BAR_COLOR = 'white';
50
+ const SHORT_BAR_COLOR = '#B5B5B5';
51
+ const GAP = 10;
52
+ const BORDER_RADIUS = 50;
53
+
54
+ const Bar = memo(
55
+ ({
56
+ index,
57
+ longBarHeight,
58
+ longBarWidth,
59
+ shortBarHeight,
60
+ shortBarWidth,
61
+ longBarColor,
62
+ shortBarColor,
63
+ borderRadius,
64
+ }: BarProps) => {
65
+ const isLong = index % 10 === 0;
66
+ const barWidth = isLong ? longBarWidth : shortBarWidth;
67
+ const barHeight = isLong ? longBarHeight : shortBarHeight;
68
+
69
+ const margin = (longBarWidth - barWidth) / 2;
70
+
71
+ return (
72
+ <View
73
+ style={{
74
+ height: barHeight,
75
+ backgroundColor: isLong ? longBarColor : shortBarColor,
76
+ width: barWidth,
77
+ borderRadius: borderRadius,
78
+ marginHorizontal: margin,
79
+ }}
80
+ />
81
+ );
82
+ }
83
+ );
84
+
85
+ Bar.displayName = 'Bar';
86
+
87
+ const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
88
+
89
+ const AnimatedText = memo(({
90
+ text,
91
+ fontSize = 96,
92
+ style,
93
+ }: AnimatedGradientTextProps) => {
94
+ const animatedProps = useAnimatedProps(() => {
95
+ return {
96
+ text: text.value,
97
+ defaultValue: text.value,
98
+ } as Partial<AnimatedTextInputProps>;
99
+ });
100
+
101
+ const textStyle = [
102
+ style,
103
+ {
104
+ fontSize: fontSize,
105
+ padding: 0,
106
+ margin: 0,
107
+ textAlign: 'center' as const,
108
+ textAlignVertical: 'center' as const,
109
+ },
110
+ ];
111
+
112
+ return (
113
+ <AnimatedTextInput
114
+ animatedProps={animatedProps}
115
+ editable={false}
116
+ underlineColorAndroid="transparent"
117
+ style={[textStyle, { opacity: 1 }]}
118
+ multiline={false}
119
+ />
120
+ );
121
+ });
122
+
123
+ AnimatedText.displayName = 'AnimatedText';
124
+
125
+ /**
126
+ * A highly customizable, performant ruler component for React Native
127
+ *
128
+ * @example
129
+ * ```tsx
130
+ * <Ruler
131
+ * min={0}
132
+ * max={200}
133
+ * step={1}
134
+ * height={100}
135
+ * initialValue={50}
136
+ * onChange={(value) => console.log('Selected:', value)}
137
+ * supportLinearGradient={true}
138
+ * />
139
+ * ```
140
+ */
141
+ const Ruler = ({
142
+ onChange,
143
+ max,
144
+ min,
145
+ fadeWidth = FADE_CONFIG.fadeWidth,
146
+ fadeOpacity = FADE_CONFIG.fadeOpacity,
147
+ decelerationRate = 'normal',
148
+ ...rest
149
+ }: RulerProps) => {
150
+ // Prop validation
151
+ if (__DEV__) {
152
+ if (min >= max) {
153
+ console.error('Ruler: min must be less than max');
154
+ }
155
+ if (rest?.step !== undefined && rest.step <= 0) {
156
+ console.error('Ruler: step must be greater than 0');
157
+ }
158
+ }
159
+
160
+ const MAX = max;
161
+ const MIN = min;
162
+ const step = rest?.step ?? STEP;
163
+ const longBarWidth = rest?.longBarWidth ?? LONG_BAR_WIDTH;
164
+ const shortBarWidth = rest?.shortBarWidth ?? SHORT_BAR_WIDTH;
165
+ const longBarHeight = rest?.longBarHeight ?? LONG_BAR_HEIGHT;
166
+ const shortBarHeight = rest?.shortBarHeight ?? SHORT_BAR_HEIGHT;
167
+ const longBarColor = rest?.longBarColor ?? LONG_BAR_COLOR;
168
+ const shortBarColor = rest?.shortBarColor ?? SHORT_BAR_COLOR;
169
+ const gap = rest?.gapBetweenSteps ?? GAP;
170
+ const borderRadius = rest?.borderRadius ?? BORDER_RADIUS;
171
+
172
+ // Calculate fraction digits based on step precision
173
+ const fractionDigits = useMemo(() => {
174
+ if (step >= 1) return 0;
175
+ const decimals = step.toString().split('.')[1]?.length || 0;
176
+ return Math.min(decimals, 10);
177
+ }, [step]);
178
+
179
+ const formatValue = useCallback(
180
+ (value: number) => value.toFixed(fractionDigits),
181
+ [fractionDigits]
182
+ );
183
+
184
+ const getInitialValue = useCallback(() => {
185
+ const initialVal =
186
+ rest?.initialValue !== undefined ? rest.initialValue : MIN;
187
+ return Math.max(MIN, Math.min(initialVal, MAX));
188
+ }, [rest?.initialValue, MIN, MAX]);
189
+
190
+ const prevInitialValue = useRef(getInitialValue());
191
+ const prevMomentumValue = useSharedValue<string>(
192
+ formatValue(getInitialValue())
193
+ );
194
+ const isUserInteractingRef = useRef(false);
195
+ const isUserInteracting = useSharedValue(false);
196
+
197
+ const setIsUserInteracting = useCallback((value: boolean) => {
198
+ isUserInteractingRef.current = value;
199
+ }, []);
200
+
201
+ const currentValue = useSharedValue<string>(formatValue(getInitialValue()));
202
+
203
+ const listRef = useRef<Animated.FlatList<any>>(null);
204
+ const isInitialMount = useRef(true);
205
+
206
+ const data = useMemo(() => {
207
+ // Prevent division by zero
208
+ if (step <= 0) return [];
209
+
210
+ const itemAmount = (MAX - MIN) / step;
211
+
212
+ // Warn about large ranges
213
+ if (__DEV__ && itemAmount > 1000) {
214
+ console.warn(
215
+ `Ruler: Large range (${Math.round(itemAmount)} items). Consider increasing step size for better performance.`
216
+ );
217
+ }
218
+
219
+ return Array.from({ length: itemAmount + 1 }, (_, i) => MIN + i * step);
220
+ }, [MIN, MAX, step]);
221
+
222
+ const snapOffsets = useMemo(() => {
223
+ return data.map((_, index) => index * (longBarWidth + gap));
224
+ }, [data, longBarWidth, gap]);
225
+
226
+ useEffect(() => {
227
+ let isMounted = true;
228
+
229
+ if (isInitialMount.current || isUserInteractingRef.current) {
230
+ return;
231
+ }
232
+ const clampedValue = getInitialValue();
233
+ const prevValue = prevInitialValue.current;
234
+
235
+ if (Math.abs(clampedValue - prevValue) <= step) {
236
+ return;
237
+ }
238
+
239
+ prevInitialValue.current = clampedValue;
240
+ currentValue.value = formatValue(clampedValue);
241
+
242
+ const index = Math.round((clampedValue - MIN) / step);
243
+
244
+ if (isMounted) {
245
+ listRef.current?.scrollToOffset({
246
+ offset: index * (longBarWidth + gap),
247
+ animated: true,
248
+ });
249
+ }
250
+
251
+ return () => {
252
+ isMounted = false;
253
+ };
254
+ }, [rest?.initialValue, MIN, MAX, step, longBarWidth, gap, formatValue, getInitialValue]);
255
+
256
+ const scrollHandler = useAnimatedScrollHandler({
257
+ onBeginDrag: () => {
258
+ 'worklet';
259
+ isUserInteracting.value = true;
260
+ runOnJS(setIsUserInteracting)(true);
261
+ },
262
+ onScroll: (event) => {
263
+ if (!isUserInteracting.value) return;
264
+ const scrollX = event.contentOffset.x;
265
+ const index = Math.round(scrollX / (longBarWidth + gap));
266
+ const value = MIN + index * step;
267
+
268
+ if (value >= MIN && value <= MAX) {
269
+ currentValue.value = value.toFixed(fractionDigits);
270
+ }
271
+ },
272
+ onMomentumEnd: (event) => {
273
+ const scrollX = event.contentOffset.x;
274
+ const index = Math.round(scrollX / (longBarWidth + gap));
275
+ const value = MIN + index * step;
276
+
277
+ const formattedValue = value.toFixed(fractionDigits);
278
+ if (value >= MIN && value <= MAX) {
279
+ if (prevMomentumValue.value !== formattedValue) {
280
+ if (isUserInteracting.value) {
281
+ runOnJS(onChange)(parseFloat(formattedValue));
282
+ }
283
+ }
284
+ }
285
+
286
+ prevMomentumValue.value = formattedValue;
287
+ isUserInteracting.value = false;
288
+ runOnJS(setIsUserInteracting)(false);
289
+ },
290
+ });
291
+
292
+ const Spacer = useCallback(
293
+ () => <View style={{ width: SCREEN_WIDTH / 2 - longBarWidth / 2 }} />,
294
+ [longBarWidth]
295
+ );
296
+
297
+ const onContentSizeChange = useCallback(() => {
298
+ if (isInitialMount.current) {
299
+ isInitialMount.current = false;
300
+ const initialIndex = Math.round((getInitialValue() - MIN) / step);
301
+ listRef.current?.scrollToOffset({
302
+ offset: initialIndex * (longBarWidth + gap),
303
+ animated: false,
304
+ });
305
+ }
306
+ }, [getInitialValue, MIN, step, longBarWidth, gap]);
307
+
308
+ const renderItem = useCallback(
309
+ ({ index }: { index: number }) => (
310
+ <Bar
311
+ index={index}
312
+ longBarColor={longBarColor}
313
+ longBarHeight={longBarHeight}
314
+ longBarWidth={longBarWidth}
315
+ shortBarColor={shortBarColor}
316
+ shortBarHeight={shortBarHeight}
317
+ shortBarWidth={shortBarWidth}
318
+ borderRadius={borderRadius}
319
+ />
320
+ ),
321
+ [
322
+ longBarColor,
323
+ longBarHeight,
324
+ longBarWidth,
325
+ shortBarColor,
326
+ shortBarHeight,
327
+ shortBarWidth,
328
+ borderRadius,
329
+ ]
330
+ );
331
+
332
+ const renderSeparator = useCallback(
333
+ () => <View style={{ width: gap }} />,
334
+ [gap]
335
+ );
336
+
337
+ const getItemLayout = useCallback(
338
+ (_data: any, index: number) => ({
339
+ length: longBarWidth + gap,
340
+ offset: (longBarWidth + gap) * index,
341
+ index,
342
+ }),
343
+ [longBarWidth, gap]
344
+ );
345
+
346
+ // Check if linear gradient is supported
347
+ const shouldShowGradient = rest?.supportLinearGradient && LinearGradient;
348
+
349
+ return (
350
+ <View style={{ width: screenWidth }}>
351
+ {!rest?.textComponent ? (
352
+ <AnimatedText
353
+ text={currentValue}
354
+ style={[styles.valueText, rest?.textStyle]}
355
+ />
356
+ ) : (
357
+ rest.textComponent(currentValue)
358
+ )}
359
+
360
+ <View style={{ justifyContent: 'center', maxHeight: rest?.height }}>
361
+ <Animated.FlatList
362
+ ref={listRef}
363
+ data={data}
364
+ renderItem={renderItem}
365
+ keyExtractor={(_, i) => i.toString()}
366
+ horizontal
367
+ showsHorizontalScrollIndicator={false}
368
+ ListHeaderComponent={Spacer}
369
+ ListFooterComponent={Spacer}
370
+ ItemSeparatorComponent={renderSeparator}
371
+ contentContainerStyle={{
372
+ alignItems: 'center',
373
+ }}
374
+ scrollEventThrottle={16}
375
+ onScroll={scrollHandler}
376
+ overScrollMode="never"
377
+ snapToOffsets={snapOffsets}
378
+ snapToAlignment="start"
379
+ onContentSizeChange={onContentSizeChange}
380
+ decelerationRate={decelerationRate}
381
+ maxToRenderPerBatch={10}
382
+ initialNumToRender={20}
383
+ windowSize={21}
384
+ getItemLayout={getItemLayout}
385
+ removeClippedSubviews={Platform.OS === 'android'}
386
+ />
387
+ {shouldShowGradient && (
388
+ <LinearGradient
389
+ colors={['rgba(0,0,0,0.9)', `rgba(0,0,0,${fadeOpacity})`] as const}
390
+ start={{ x: 0, y: 0 }}
391
+ end={{ x: 1, y: 0 }}
392
+ style={[
393
+ styles.fadeOverlay,
394
+ {
395
+ left: 0,
396
+ width: SCREEN_WIDTH * fadeWidth,
397
+ },
398
+ ]}
399
+ pointerEvents="none"
400
+ />
401
+ )}
402
+ {shouldShowGradient && (
403
+ <LinearGradient
404
+ colors={[`rgba(0,0,0,${fadeOpacity})`, 'rgba(0,0,0,0.9)'] as const}
405
+ start={{ x: 0, y: 0 }}
406
+ end={{ x: 1, y: 0 }}
407
+ style={[
408
+ styles.fadeOverlay,
409
+ {
410
+ right: 0,
411
+ width: SCREEN_WIDTH * fadeWidth,
412
+ },
413
+ ]}
414
+ pointerEvents="none"
415
+ />
416
+ )}
417
+ <View
418
+ style={{
419
+ position: 'absolute',
420
+ alignSelf: 'center',
421
+ zIndex: 1,
422
+ }}
423
+ pointerEvents="none"
424
+ >
425
+ {rest?.barComponent ?? (
426
+ <View
427
+ style={{
428
+ height: rest?.indicatorHeight ?? 100,
429
+ width: rest?.indicatorWidth ?? 10,
430
+ backgroundColor: rest?.indicatorColor ?? 'white',
431
+ borderRadius: rest?.borderRadius ?? BORDER_RADIUS,
432
+ }}
433
+ />
434
+ )}
435
+ </View>
436
+ </View>
437
+ </View>
438
+ );
439
+ };
440
+
441
+ export default Ruler;
442
+
443
+ const styles = StyleSheet.create({
444
+ container: { justifyContent: 'center', alignItems: 'center' },
445
+ valueText: {
446
+ fontSize: 28,
447
+ color: 'white',
448
+ marginBottom: 16,
449
+ textAlign: 'center',
450
+ },
451
+ fadeOverlay: {
452
+ position: 'absolute',
453
+ top: 0,
454
+ bottom: 0,
455
+ zIndex: 2,
456
+ },
457
+ });
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { default as Ruler } from './Ruler';
2
+ export { default } from './Ruler';
3
+ export type {
4
+ RulerProps,
5
+ AnimatedTextInputProps,
6
+ AnimatedGradientTextProps,
7
+ BarProps,
8
+ } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { ReactNode } from 'react';
2
+ import { StyleProp, ViewStyle, TextStyle, TextInputProps } from 'react-native';
3
+ import { SharedValue } from 'react-native-reanimated';
4
+
5
+ /**
6
+ * Props for the Ruler component
7
+ */
8
+ export interface RulerProps {
9
+ /**
10
+ * Callback function called when the ruler value changes
11
+ * @param value - The current selected value
12
+ */
13
+ onChange: (value: number) => void;
14
+
15
+ /**
16
+ * Maximum value on the ruler
17
+ */
18
+ max: number;
19
+
20
+ /**
21
+ * Minimum value on the ruler
22
+ */
23
+ min: number;
24
+
25
+ /**
26
+ * Step increment between values
27
+ * @default 1
28
+ */
29
+ step?: number;
30
+
31
+ /**
32
+ * Width of the fade gradient as a percentage of screen width (0-1)
33
+ * @default 0.25
34
+ */
35
+ fadeWidth?: number;
36
+
37
+ /**
38
+ * Opacity of the fade gradient (0-1)
39
+ * @default 0.1
40
+ */
41
+ fadeOpacity?: number;
42
+
43
+ /**
44
+ * Style for the ruler container
45
+ */
46
+ style?: StyleProp<ViewStyle>;
47
+
48
+ /**
49
+ * Style for the value text
50
+ */
51
+ textStyle?: StyleProp<TextStyle>;
52
+
53
+ /**
54
+ * Color of the center indicator
55
+ * @default "white"
56
+ */
57
+ indicatorColor?: string;
58
+
59
+ /**
60
+ * Height of the center indicator
61
+ * @default 100
62
+ */
63
+ indicatorHeight?: number;
64
+
65
+ /**
66
+ * Width of the center indicator
67
+ * @default 10
68
+ */
69
+ indicatorWidth?: number;
70
+
71
+ /**
72
+ * Height of long bars (every 10th step)
73
+ * @default 50
74
+ */
75
+ longBarHeight?: number;
76
+
77
+ /**
78
+ * Height of short bars
79
+ * @default 30
80
+ */
81
+ shortBarHeight?: number;
82
+
83
+ /**
84
+ * Width of long bars
85
+ * @default 6
86
+ */
87
+ longBarWidth?: number;
88
+
89
+ /**
90
+ * Width of short bars
91
+ * @default 3
92
+ */
93
+ shortBarWidth?: number;
94
+
95
+ /**
96
+ * Color of long bars
97
+ * @default "white"
98
+ */
99
+ longBarColor?: string;
100
+
101
+ /**
102
+ * Color of short bars
103
+ * @default "#B5B5B5"
104
+ */
105
+ shortBarColor?: string;
106
+
107
+ /**
108
+ * Gap between ruler steps
109
+ * @default 10
110
+ */
111
+ gapBetweenSteps?: number;
112
+
113
+ /**
114
+ * Border radius for bars and indicator
115
+ * @default 50
116
+ */
117
+ borderRadius?: number;
118
+
119
+ /**
120
+ * Custom component to render as the center indicator bar
121
+ */
122
+ barComponent?: ReactNode;
123
+
124
+ /**
125
+ * Enable linear gradient fade on edges (requires expo-linear-gradient)
126
+ * @default false
127
+ */
128
+ supportLinearGradient?: boolean;
129
+
130
+ /**
131
+ * Initial value to display
132
+ * @default min
133
+ */
134
+ initialValue?: number;
135
+
136
+ /**
137
+ * Scroll deceleration rate
138
+ * @default "normal"
139
+ */
140
+ decelerationRate?: 'fast' | 'normal';
141
+
142
+ /**
143
+ * Height of the ruler component
144
+ */
145
+ height: number;
146
+
147
+ /**
148
+ * Custom text component to display the current value
149
+ * @param currentValue - Shared value containing the current ruler value as a string
150
+ */
151
+ textComponent?: (currentValue: SharedValue<string>) => ReactNode;
152
+ }
153
+
154
+ /**
155
+ * Internal props for AnimatedTextInput
156
+ */
157
+ export interface AnimatedTextInputProps extends TextInputProps {
158
+ text?: string;
159
+ }
160
+
161
+ /**
162
+ * Props for the AnimatedText component
163
+ */
164
+ export interface AnimatedGradientTextProps {
165
+ text: SharedValue<string>;
166
+ fontSize?: number;
167
+ style?: any;
168
+ }
169
+
170
+ /**
171
+ * Props for the Bar component
172
+ */
173
+ export interface BarProps {
174
+ index: number;
175
+ longBarHeight: number;
176
+ longBarWidth: number;
177
+ shortBarHeight: number;
178
+ shortBarWidth: number;
179
+ longBarColor: string;
180
+ shortBarColor: string;
181
+ borderRadius?: number;
182
+ }