react-native-directional-toggle 0.1.1 → 0.1.3

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.
@@ -1,238 +1,312 @@
1
- import { useCallback, useEffect } from "react";
2
- import { type LayoutChangeEvent, Pressable, StyleSheet, View } from "react-native";
3
- import { Gesture, GestureDetector } from "react-native-gesture-handler";
1
+ import { useCallback, useEffect, useMemo } from 'react';
2
+ import {
3
+ type LayoutChangeEvent,
4
+ Pressable,
5
+ StyleSheet,
6
+ View,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ type TextStyle,
10
+ } from 'react-native';
11
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
12
+ import { scheduleOnRN } from 'react-native-worklets';
4
13
  import Animated, {
5
14
  interpolateColor,
6
- runOnJS,
7
15
  useAnimatedStyle,
8
16
  useSharedValue,
9
17
  withSpring,
10
18
  withTiming,
11
- } from "react-native-reanimated";
19
+ type WithSpringConfig,
20
+ type WithTimingConfig,
21
+ type SharedValue,
22
+ } from 'react-native-reanimated';
12
23
 
13
24
  type Option = {
14
25
  label: string;
15
26
  value: string | number;
16
27
  };
17
28
 
18
- type Props = {
29
+ export type AnimatedSwitchProps = {
19
30
  options: Option[];
20
31
  value: string | number;
21
32
  onChange: (value: string | number) => void;
22
- height?: number;
33
+ /**
34
+ * Direction of the switch.
35
+ * @default false (Horizontal)
36
+ */
23
37
  vertical?: boolean;
24
- colors?: {
25
- activeText: string;
26
- inactiveText: string;
27
- bgFront: string;
28
- bgBack: string;
29
- };
30
- animationConfig?: {
31
- duration?: number;
32
- damping?: number;
33
- stiffness?: number;
34
- };
38
+ /**
39
+ * Container style. Use this to set width/height.
40
+ */
41
+ style?: StyleProp<ViewStyle>;
42
+ /**
43
+ * Style for the moving thumb.
44
+ */
45
+ thumbStyle?: StyleProp<ViewStyle>;
46
+ /**
47
+ * Base style for option text.
48
+ */
49
+ textStyle?: StyleProp<TextStyle>;
50
+ /**
51
+ * Style for the text when active.
52
+ */
53
+ activeTextStyle?: StyleProp<TextStyle>;
54
+ /**
55
+ * Style for the text when inactive.
56
+ */
57
+ inactiveTextStyle?: StyleProp<TextStyle>;
58
+ /**
59
+ * Animation configuration.
60
+ */
61
+ animationConfig?: WithTimingConfig | WithSpringConfig;
62
+ /**
63
+ * When true, the switch does not respond to interactions.
64
+ * @default false
65
+ */
66
+ disabled?: boolean;
35
67
  };
36
68
 
37
- const THUMB_INSET = 4;
38
- const VERTICAL_WIDTH = 128;
69
+ const DEFAULT_ANIMATION = {
70
+ duration: 150,
71
+ damping: 5,
72
+ stiffness: 100,
73
+ };
39
74
 
40
75
  export function AnimatedSwitch({
41
76
  options,
42
77
  value,
43
78
  onChange,
44
- height = 36,
45
79
  vertical = false,
46
- colors = {
47
- activeText: "#373737",
48
- inactiveText: "#dededeff",
49
- bgFront: "#d4d4d4",
50
- bgBack: "#9a9a9a",
51
- },
52
- animationConfig = {
53
- duration: 100,
54
- damping: 50,
55
- stiffness: 200,
56
- },
57
- }: Props) {
58
-
59
- /** ===== SharedValues ===== */
80
+ style,
81
+ thumbStyle,
82
+ textStyle,
83
+ activeTextStyle,
84
+ inactiveTextStyle,
85
+ animationConfig = DEFAULT_ANIMATION,
86
+ disabled = false,
87
+ }: AnimatedSwitchProps) {
88
+ console.log('disabled ---->', disabled);
60
89
  const itemSizeSV = useSharedValue(0);
61
90
  const translate = useSharedValue(0);
62
91
  const indexSV = useSharedValue(0);
63
92
 
64
93
  const currentIndex = options.findIndex((o) => o.value === value);
65
94
 
66
- /** * 1. 监听外部 value 变化
67
- * 当外部修改 value 时,滑块应动画移动到新位置
68
- */
95
+ // Measure container layout
96
+ const onLayout = useCallback(
97
+ (e: LayoutChangeEvent) => {
98
+ const { width, height } = e.nativeEvent.layout;
99
+
100
+ const totalSize = vertical ? height - 4 : width - 4;
101
+ const itemSize = totalSize / options.length;
102
+ itemSizeSV.value = itemSize;
103
+
104
+ // Fix initial position on layout
105
+ if (currentIndex >= 0 && itemSize > 0) {
106
+ translate.value = currentIndex * itemSize;
107
+ }
108
+ },
109
+ [vertical, options.length, currentIndex, itemSizeSV, translate]
110
+ );
111
+
112
+ // Sync with external value changes
69
113
  useEffect(() => {
70
- if (currentIndex >= 0) {
114
+ if (currentIndex >= 0 && itemSizeSV.value > 0) {
71
115
  indexSV.value = currentIndex;
72
- // 只有当 itemSizeSV 已经有值(即 Layout 已完成)时才执行动画
73
- if (itemSizeSV.value > 0) {
74
- translate.value = withTiming(currentIndex * itemSizeSV.value, {
75
- duration: animationConfig.duration,
76
- });
77
- }
116
+ translate.value = withTiming(
117
+ currentIndex * itemSizeSV.value,
118
+ animationConfig as WithTimingConfig
119
+ );
78
120
  }
79
- }, [currentIndex, animationConfig.duration]);
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, [currentIndex, animationConfig]);
80
123
 
81
- /** ===== JS 回调 ===== */
82
124
  const emitChange = useCallback(
83
125
  (index: number) => {
84
- if (options.length > 0 && index >= 0 && index < options.length) {
85
- onChange(options[index]?.value || "");
126
+ // Clamp index to safe bounds
127
+ const safeIndex = Math.max(0, Math.min(index, options.length - 1));
128
+ if (options[safeIndex]) {
129
+ onChange(options[safeIndex].value);
86
130
  }
87
131
  },
88
- [onChange, options],
132
+ [onChange, options]
89
133
  );
90
134
 
91
- /** ===== Tap 点击切换 ===== */
92
135
  const handlePress = (index: number) => {
93
- indexSV.value = index;
94
- translate.value = withTiming(index * itemSizeSV.value, {
95
- duration: animationConfig.duration ?? 150
96
- });
136
+ if (disabled || itemSizeSV.value === 0) return;
137
+ translate.value = withTiming(
138
+ index * itemSizeSV.value,
139
+ animationConfig as WithTimingConfig
140
+ );
97
141
  emitChange(index);
98
142
  };
99
143
 
100
- /** ===== Drag 手势 ===== */
101
- const panGesture = Gesture.Pan()
102
- .onUpdate((e) => {
103
- const delta = vertical ? e.translationY : e.translationX;
104
- // 基于手势开始时的位置进行偏移计算
105
- const pos = delta + indexSV.value * itemSizeSV.value;
106
- const max = (options.length - 1) * itemSizeSV.value;
107
-
108
- translate.value = Math.min(Math.max(0, pos), max);
109
- })
110
- .onEnd(() => {
111
- const index = Math.round(translate.value / itemSizeSV.value);
112
- indexSV.value = index;
113
-
114
- translate.value = withSpring(index * itemSizeSV.value, {
115
- damping: animationConfig.damping ?? 20,
116
- stiffness: animationConfig.stiffness ?? 200,
117
- });
118
-
119
- runOnJS(emitChange)(index);
120
- });
121
-
122
- /** ===== Thumb 动画样式 ===== */
144
+ const panGesture = useMemo(
145
+ () =>
146
+ Gesture.Pan()
147
+ .enabled(!disabled)
148
+ .onUpdate((e) => {
149
+ if (itemSizeSV.value === 0) return;
150
+ const term = vertical ? e.translationY : e.translationX;
151
+ const startPos = indexSV.value * itemSizeSV.value;
152
+ const pos = startPos + term;
153
+ const max = (options.length - 1) * itemSizeSV.value;
154
+ translate.value = Math.min(Math.max(0, pos), max);
155
+ })
156
+ .onEnd(() => {
157
+ if (itemSizeSV.value === 0) return;
158
+ const index = Math.round(translate.value / itemSizeSV.value);
159
+ indexSV.value = index;
160
+ translate.value = withSpring(
161
+ index * itemSizeSV.value,
162
+ animationConfig as WithSpringConfig
163
+ );
164
+ scheduleOnRN(emitChange, index);
165
+ }),
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ [disabled]
168
+ );
169
+
123
170
  const animatedThumbStyle = useAnimatedStyle(() => {
124
- if (vertical) {
125
- return {
126
- height: itemSizeSV.value - THUMB_INSET * 2,
127
- top: THUMB_INSET,
128
- left: THUMB_INSET,
129
- right: THUMB_INSET,
130
- transform: [{ translateY: translate.value }],
131
- };
132
- }
171
+ // If layout not ready, hide thumb or show nothing
172
+ if (itemSizeSV.value === 0) return { opacity: 0 };
173
+
174
+ const transform = vertical
175
+ ? [{ translateY: translate.value }]
176
+ : [{ translateX: translate.value }];
177
+
178
+ const sizeStyle = vertical
179
+ ? { height: itemSizeSV.value, width: '100%' }
180
+ : { width: itemSizeSV.value, height: '100%' };
181
+
133
182
  return {
134
- width: itemSizeSV.value - THUMB_INSET * 2,
135
- left: THUMB_INSET,
136
- top: THUMB_INSET,
137
- bottom: THUMB_INSET,
138
- transform: [{ translateX: translate.value }],
183
+ position: 'absolute',
184
+ left: 2,
185
+ top: 2,
186
+ ...sizeStyle,
187
+ transform,
188
+ opacity: 1,
139
189
  };
140
190
  });
141
191
 
142
- /** ===== Label 动画样式 ===== */
143
- const getLabelAnimatedStyle = (index: number) =>
144
- useAnimatedStyle(() => {
145
- const center = index * itemSizeSV.value;
146
- const color = interpolateColor(
147
- translate.value,
148
- [center - itemSizeSV.value, center, center + itemSizeSV.value],
149
- [colors.inactiveText, colors.activeText, colors.inactiveText],
150
- );
151
- return { color };
152
- });
192
+ const content = (
193
+ <View
194
+ onLayout={onLayout}
195
+ style={[
196
+ styles.container,
197
+ vertical ? styles.vertical : styles.horizontal,
198
+ disabled && styles.disabled,
199
+ style,
200
+ ]}
201
+ >
202
+ <Animated.View style={[styles.thumb, thumbStyle, animatedThumbStyle]} />
153
203
 
154
- /** * 2. Layout 初始化
155
- * 这是修复初始显示问题的关键
156
- */
157
- const onLayout = (e: LayoutChangeEvent) => {
158
- const size = vertical
159
- ? e.nativeEvent.layout.height
160
- : e.nativeEvent.layout.width;
204
+ {options.map((opt, index) => (
205
+ <OptionItem
206
+ key={String(opt.value)}
207
+ label={opt.label}
208
+ onPress={() => handlePress(index)}
209
+ index={index}
210
+ translate={translate}
211
+ itemSizeSV={itemSizeSV}
212
+ textStyle={textStyle}
213
+ activeTextStyle={activeTextStyle}
214
+ inactiveTextStyle={inactiveTextStyle}
215
+ disabled={disabled}
216
+ />
217
+ ))}
218
+ </View>
219
+ );
161
220
 
162
- const newItemSize = size / options.length;
163
- itemSizeSV.value = newItemSize;
221
+ // disabled 时不挂载 GestureDetector
222
+ return disabled ? (
223
+ content
224
+ ) : (
225
+ <GestureDetector gesture={panGesture}>{content}</GestureDetector>
226
+ );
227
+ }
164
228
 
165
- // 核心修复:在获取到尺寸的第一时间,根据 currentIndex 强行同步 translate 的值
166
- if (currentIndex >= 0) {
167
- translate.value = currentIndex * newItemSize;
168
- }
169
- };
229
+ // Sub-component for individual options to isolate animated styles
230
+ const OptionItem = ({
231
+ label,
232
+ onPress,
233
+ index,
234
+ translate,
235
+ itemSizeSV,
236
+ textStyle,
237
+ activeTextStyle,
238
+ inactiveTextStyle,
239
+ disabled,
240
+ }: {
241
+ label: string;
242
+ onPress: () => void;
243
+ index: number;
244
+ translate: SharedValue<number>;
245
+ itemSizeSV: SharedValue<number>;
246
+ textStyle?: StyleProp<TextStyle>;
247
+ activeTextStyle?: StyleProp<TextStyle>;
248
+ inactiveTextStyle?: StyleProp<TextStyle>;
249
+ disabled?: boolean;
250
+ }) => {
251
+ const activeColor =
252
+ (StyleSheet.flatten(activeTextStyle)?.color as string) ?? '#000';
253
+ const inactiveColor =
254
+ (StyleSheet.flatten(inactiveTextStyle)?.color as string) ?? '#999';
255
+
256
+ const textAnimatedStyle = useAnimatedStyle(() => {
257
+ const center = index * itemSizeSV.value;
258
+ const color = interpolateColor(
259
+ translate.value,
260
+ [center - itemSizeSV.value, center, center + itemSizeSV.value],
261
+ [inactiveColor, activeColor, inactiveColor]
262
+ );
263
+ return { color };
264
+ });
170
265
 
171
266
  return (
172
- <GestureDetector gesture={panGesture}>
173
- <View
174
- onLayout={onLayout}
175
- style={[
176
- styles.container,
177
- { backgroundColor: colors.bgBack },
178
- vertical
179
- ? {
180
- height: height * options.length,
181
- width: VERTICAL_WIDTH,
182
- flexDirection: "column",
183
- }
184
- : { height, flexDirection: "row" },
185
- ]}
267
+ <Pressable style={styles.option} onPress={onPress} disabled={disabled}>
268
+ <Animated.Text
269
+ style={[styles.text, textStyle, textAnimatedStyle]}
270
+ numberOfLines={1}
186
271
  >
187
- {/* 滑块背景 */}
188
- <Animated.View
189
- pointerEvents="none"
190
- style={[styles.thumbBase, { backgroundColor: colors.bgFront }, animatedThumbStyle]}
191
- />
192
-
193
- {/* 选项列表 */}
194
- {options.map((opt, index) => {
195
- const labelStyle = getLabelAnimatedStyle(index);
196
- return (
197
- <Pressable
198
- key={opt.value}
199
- style={styles.item}
200
- onPress={() => handlePress(index)}
201
- >
202
- <Animated.Text style={[styles.label, labelStyle]}>
203
- {opt.label}
204
- </Animated.Text>
205
- </Pressable>
206
- );
207
- })}
208
- </View>
209
- </GestureDetector>
272
+ {label}
273
+ </Animated.Text>
274
+ </Pressable>
210
275
  );
211
- }
276
+ };
212
277
 
213
278
  const styles = StyleSheet.create({
214
279
  container: {
215
280
  borderRadius: 16,
216
- overflow: "hidden",
217
- flex: 1,
281
+ overflow: 'hidden',
282
+ padding: 2,
283
+ position: 'relative',
284
+ },
285
+ disabled: {
286
+ opacity: 0.5,
218
287
  },
219
- thumbBase: {
220
- position: "absolute",
221
- borderRadius: 12,
288
+ horizontal: {
289
+ flexDirection: 'row',
290
+ },
291
+ vertical: {
292
+ flexDirection: 'column',
293
+ },
294
+ thumb: {
295
+ borderRadius: 16,
222
296
  shadowColor: '#000',
223
- shadowOpacity: 0.3,
224
297
  shadowOffset: { width: 0, height: 1 },
298
+ shadowOpacity: 0.1,
225
299
  shadowRadius: 1,
226
- elevation: 2,
300
+ elevation: 1,
227
301
  },
228
- item: {
302
+ option: {
229
303
  flex: 1,
230
- alignItems: "center",
231
- justifyContent: "center",
232
- zIndex: 1, // 确保文字在滑块上方
304
+ alignItems: 'center',
305
+ justifyContent: 'center',
306
+ // zIndex: 1
233
307
  },
234
- label: {
235
- fontSize: 13,
236
- fontWeight: "600",
308
+ text: {
309
+ fontSize: 14,
310
+ fontWeight: '500',
237
311
  },
238
312
  });
package/src/index.tsx CHANGED
@@ -1,3 +1,4 @@
1
- import { AnimatedSwitch } from "./AnimatedSwitch";
1
+ import { AnimatedSwitch, type AnimatedSwitchProps } from './AnimatedSwitch';
2
2
 
3
3
  export default AnimatedSwitch;
4
+ export { AnimatedSwitch, type AnimatedSwitchProps };
@@ -1,25 +0,0 @@
1
- type Option = {
2
- label: string;
3
- value: string | number;
4
- };
5
- type Props = {
6
- options: Option[];
7
- value: string | number;
8
- onChange: (value: string | number) => void;
9
- height?: number;
10
- vertical?: boolean;
11
- colors?: {
12
- activeText: string;
13
- inactiveText: string;
14
- bgFront: string;
15
- bgBack: string;
16
- };
17
- animationConfig?: {
18
- duration?: number;
19
- damping?: number;
20
- stiffness?: number;
21
- };
22
- };
23
- export declare function AnimatedSwitch({ options, value, onChange, height, vertical, colors, animationConfig, }: Props): import("react/jsx-runtime").JSX.Element;
24
- export {};
25
- //# sourceMappingURL=AnimatedSwitch.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"AnimatedSwitch.d.ts","sourceRoot":"","sources":["../../../src/AnimatedSwitch.tsx"],"names":[],"mappings":"AAYA,KAAK,MAAM,GAAG;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,KAAK,GAAG;IACX,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,KAAK,IAAI,CAAC;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE;QACP,UAAU,EAAE,MAAM,CAAC;QACnB,YAAY,EAAE,MAAM,CAAC;QACrB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,eAAe,CAAC,EAAE;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;CACH,CAAC;AAKF,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,KAAK,EACL,QAAQ,EACR,MAAW,EACX,QAAgB,EAChB,MAKC,EACD,eAIC,GACF,EAAE,KAAK,2CA0JP"}
@@ -1,3 +0,0 @@
1
- import { AnimatedSwitch } from "./AnimatedSwitch";
2
- export default AnimatedSwitch;
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAElD,eAAe,cAAc,CAAC"}