react-native-directional-toggle 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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alan Suhe
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.en.md ADDED
@@ -0,0 +1,65 @@
1
+ # Description
2
+
3
+ This is a React Native / Expo component library.
4
+
5
+ For Chinese version, please click: [中文](README.md).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add react-native-directional-toggle
11
+
12
+ yarn add react-native-directional-toggle
13
+ ```
14
+
15
+ ## Peer Dependencies
16
+
17
+ ```bash
18
+ pnpm add react-native-gesture-handler react-native-reanimated react-native-worklets --save-peer
19
+ ```
20
+
21
+ Dependencies required:
22
+ - react-native-gesture-handler
23
+ - react-native-reanimated
24
+ - react-native-worklets
25
+
26
+ Note: Only works with `expo run` after prebuild, not supported by `expo go`.
27
+
28
+ ## Usage
29
+
30
+ > Refer to the [example project](example/).
31
+
32
+ ### Import and use
33
+
34
+ ```tsx
35
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
36
+ import Switcher from 'react-native-directional-toggle';
37
+
38
+ const options = [
39
+ {
40
+ label: 'Option 1',
41
+ value: 'Option 1',
42
+ },
43
+ {
44
+ label: 'Option 2',
45
+ value: 'Option 2',
46
+ },
47
+ {
48
+ label: 'Option 3',
49
+ value: 'Option 3',
50
+ },
51
+ ];
52
+
53
+ ...
54
+
55
+ <GestureHandlerRootView>
56
+ <Switcher
57
+ options={options}
58
+ value={'Option 2'}
59
+ height={36}
60
+ onChange={value => console.log(value)}
61
+ />
62
+ </GestureHandlerRootView>
63
+
64
+ ```
65
+ Note: You need to wrap the outer layer of your App (such as _layout.tsx, etc.) with `GestureHandlerRootView`.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # 说明
2
+
3
+ 这是一个 React Native / Expo 组件库。
4
+
5
+ For English version, please click: [English](README.en.md)。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pnpm add react-native-directional-toggle
11
+
12
+ yarn add react-native-directional-toggle
13
+ ```
14
+
15
+ ## 依赖包
16
+
17
+ ```bash
18
+ pnpm add react-native-gesture-handler react-native-reanimated react-native-worklets --save-peer
19
+ ```
20
+
21
+ 需要安装的依赖包:
22
+ - react-native-gesture-handler
23
+ - react-native-reanimated
24
+ - react-native-worklets
25
+
26
+ 注意:只能在prebuild后正常用`expo run`的方式运行,不支持expo go。
27
+
28
+ ## 使用
29
+
30
+ > 参考[示例项目](example/)。
31
+
32
+ ### 导入组件使用
33
+
34
+ ```tsx
35
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
36
+ import Switcher from 'react-native-directional-toggle';
37
+
38
+ const options = [
39
+ {
40
+ label: 'Option 1',
41
+ value: 'Option 1',
42
+ },
43
+ {
44
+ label: 'Option 2',
45
+ value: 'Option 2',
46
+ },
47
+ {
48
+ label: 'Option 3',
49
+ value: 'Option 3',
50
+ },
51
+ ];
52
+
53
+ ...
54
+
55
+ <GestureHandlerRootView>
56
+ <Switcher
57
+ options={options}
58
+ value={'Option 2'}
59
+ height={36}
60
+ onChange={value => console.log(value)}
61
+ />
62
+ </GestureHandlerRootView>
63
+
64
+ ```
65
+ 注意: App中需要在外层(如_layout.tsx等)包裹GestureHandlerRootView。
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+
3
+ import { useCallback, useEffect } from "react";
4
+ import { Pressable, StyleSheet, View } from "react-native";
5
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
6
+ import Animated, { interpolateColor, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
7
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ const THUMB_INSET = 4;
9
+ const VERTICAL_WIDTH = 128;
10
+ export function AnimatedSwitch({
11
+ options,
12
+ value,
13
+ onChange,
14
+ height = 40,
15
+ vertical = false,
16
+ colors = {
17
+ activeText: "#373737",
18
+ inactiveText: "#dededeff",
19
+ bgFront: "#d4d4d4",
20
+ bgBack: "#798393ff"
21
+ },
22
+ animationConfig = {
23
+ duration: 100,
24
+ damping: 50,
25
+ stiffness: 200
26
+ }
27
+ }) {
28
+ /** ===== SharedValues ===== */
29
+ const itemSizeSV = useSharedValue(0);
30
+ const translate = useSharedValue(0);
31
+ const indexSV = useSharedValue(0);
32
+ const currentIndex = options.findIndex(o => o.value === value);
33
+
34
+ /** * 1. 监听外部 value 变化
35
+ * 当外部修改 value 时,滑块应动画移动到新位置
36
+ */
37
+ useEffect(() => {
38
+ if (currentIndex >= 0) {
39
+ indexSV.value = currentIndex;
40
+ // 只有当 itemSizeSV 已经有值(即 Layout 已完成)时才执行动画
41
+ if (itemSizeSV.value > 0) {
42
+ translate.value = withTiming(currentIndex * itemSizeSV.value, {
43
+ duration: animationConfig.duration
44
+ });
45
+ }
46
+ }
47
+ }, [currentIndex, animationConfig.duration]);
48
+
49
+ /** ===== JS 回调 ===== */
50
+ const emitChange = useCallback(index => {
51
+ if (options.length > 0 && index >= 0 && index < options.length) {
52
+ onChange(options[index]?.value || "");
53
+ }
54
+ }, [onChange, options]);
55
+
56
+ /** ===== Tap 点击切换 ===== */
57
+ const handlePress = index => {
58
+ indexSV.value = index;
59
+ translate.value = withTiming(index * itemSizeSV.value, {
60
+ duration: animationConfig.duration ?? 150
61
+ });
62
+ emitChange(index);
63
+ };
64
+
65
+ /** ===== Drag 手势 ===== */
66
+ const panGesture = Gesture.Pan().onUpdate(e => {
67
+ const delta = vertical ? e.translationY : e.translationX;
68
+ // 基于手势开始时的位置进行偏移计算
69
+ const pos = delta + indexSV.value * itemSizeSV.value;
70
+ const max = (options.length - 1) * itemSizeSV.value;
71
+ translate.value = Math.min(Math.max(0, pos), max);
72
+ }).onEnd(() => {
73
+ const index = Math.round(translate.value / itemSizeSV.value);
74
+ indexSV.value = index;
75
+ translate.value = withSpring(index * itemSizeSV.value, {
76
+ damping: animationConfig.damping ?? 20,
77
+ stiffness: animationConfig.stiffness ?? 200
78
+ });
79
+ runOnJS(emitChange)(index);
80
+ });
81
+
82
+ /** ===== Thumb 动画样式 ===== */
83
+ const animatedThumbStyle = useAnimatedStyle(() => {
84
+ if (vertical) {
85
+ return {
86
+ height: itemSizeSV.value - THUMB_INSET * 2,
87
+ top: THUMB_INSET,
88
+ left: THUMB_INSET,
89
+ right: THUMB_INSET,
90
+ transform: [{
91
+ translateY: translate.value
92
+ }]
93
+ };
94
+ }
95
+ return {
96
+ width: itemSizeSV.value - THUMB_INSET * 2,
97
+ left: THUMB_INSET,
98
+ top: THUMB_INSET,
99
+ bottom: THUMB_INSET,
100
+ transform: [{
101
+ translateX: translate.value
102
+ }]
103
+ };
104
+ });
105
+
106
+ /** ===== Label 动画样式 ===== */
107
+ const getLabelAnimatedStyle = index => useAnimatedStyle(() => {
108
+ const center = index * itemSizeSV.value;
109
+ const color = interpolateColor(translate.value, [center - itemSizeSV.value, center, center + itemSizeSV.value], [colors.inactiveText, colors.activeText, colors.inactiveText]);
110
+ return {
111
+ color
112
+ };
113
+ });
114
+
115
+ /** * 2. Layout 初始化
116
+ * 这是修复初始显示问题的关键
117
+ */
118
+ const onLayout = e => {
119
+ const size = vertical ? e.nativeEvent.layout.height : e.nativeEvent.layout.width;
120
+ const newItemSize = size / options.length;
121
+ itemSizeSV.value = newItemSize;
122
+
123
+ // 核心修复:在获取到尺寸的第一时间,根据 currentIndex 强行同步 translate 的值
124
+ if (currentIndex >= 0) {
125
+ translate.value = currentIndex * newItemSize;
126
+ }
127
+ };
128
+ return /*#__PURE__*/_jsx(GestureDetector, {
129
+ gesture: panGesture,
130
+ children: /*#__PURE__*/_jsxs(View, {
131
+ onLayout: onLayout,
132
+ style: [styles.container, {
133
+ backgroundColor: colors.bgBack
134
+ }, vertical ? {
135
+ height: height * options.length,
136
+ width: VERTICAL_WIDTH,
137
+ flexDirection: "column"
138
+ } : {
139
+ height,
140
+ flexDirection: "row"
141
+ }],
142
+ children: [/*#__PURE__*/_jsx(Animated.View, {
143
+ pointerEvents: "none",
144
+ style: [styles.thumbBase, {
145
+ backgroundColor: colors.bgFront
146
+ }, animatedThumbStyle]
147
+ }), options.map((opt, index) => {
148
+ const labelStyle = getLabelAnimatedStyle(index);
149
+ return /*#__PURE__*/_jsx(Pressable, {
150
+ style: styles.item,
151
+ onPress: () => handlePress(index),
152
+ children: /*#__PURE__*/_jsx(Animated.Text, {
153
+ style: [styles.label, labelStyle],
154
+ children: opt.label
155
+ })
156
+ }, opt.value);
157
+ })]
158
+ })
159
+ });
160
+ }
161
+ const styles = StyleSheet.create({
162
+ container: {
163
+ borderRadius: 16,
164
+ overflow: "hidden",
165
+ flex: 1
166
+ },
167
+ thumbBase: {
168
+ position: "absolute",
169
+ borderRadius: 12,
170
+ shadowColor: '#000',
171
+ shadowOpacity: 0.3,
172
+ shadowOffset: {
173
+ width: 0,
174
+ height: 1
175
+ },
176
+ shadowRadius: 2,
177
+ elevation: 2
178
+ },
179
+ item: {
180
+ flex: 1,
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ zIndex: 1 // 确保文字在滑块上方
184
+ },
185
+ label: {
186
+ fontSize: 14,
187
+ fontWeight: "600"
188
+ }
189
+ });
190
+ //# sourceMappingURL=AnimatedSwitch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["useCallback","useEffect","Pressable","StyleSheet","View","Gesture","GestureDetector","Animated","interpolateColor","runOnJS","useAnimatedStyle","useSharedValue","withSpring","withTiming","jsx","_jsx","jsxs","_jsxs","THUMB_INSET","VERTICAL_WIDTH","AnimatedSwitch","options","value","onChange","height","vertical","colors","activeText","inactiveText","bgFront","bgBack","animationConfig","duration","damping","stiffness","itemSizeSV","translate","indexSV","currentIndex","findIndex","o","emitChange","index","length","handlePress","panGesture","Pan","onUpdate","e","delta","translationY","translationX","pos","max","Math","min","onEnd","round","animatedThumbStyle","top","left","right","transform","translateY","width","bottom","translateX","getLabelAnimatedStyle","center","color","onLayout","size","nativeEvent","layout","newItemSize","gesture","children","style","styles","container","backgroundColor","flexDirection","pointerEvents","thumbBase","map","opt","labelStyle","item","onPress","Text","label","create","borderRadius","overflow","flex","position","shadowColor","shadowOpacity","shadowOffset","shadowRadius","elevation","alignItems","justifyContent","zIndex","fontSize","fontWeight"],"sourceRoot":"../../src","sources":["AnimatedSwitch.tsx"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ import { AnimatedSwitch } from "./AnimatedSwitch.js";
4
+ export default AnimatedSwitch;
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["AnimatedSwitch"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,cAAc,QAAQ,qBAAkB;AAEjD,eAAeA,cAAc","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,25 @@
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
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,3 @@
1
+ import { AnimatedSwitch } from "./AnimatedSwitch";
2
+ export default AnimatedSwitch;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
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"}
package/package.json ADDED
@@ -0,0 +1,161 @@
1
+ {
2
+ "name": "react-native-directional-toggle",
3
+ "version": "0.1.0",
4
+ "description": "Multi-element toggle component for React Native and Expo with support for vertical and horizontal layouts and animations.",
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
+ "keywords": [
35
+ "react-native",
36
+ "ios",
37
+ "android"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/alansuhe/react-native-directional-toggle.git"
42
+ },
43
+ "author": "Alan Suhe <tosuhe@gmail.com> (https://github.com/alansuhe)",
44
+ "license": "MIT",
45
+ "bugs": {
46
+ "url": "https://github.com/alansuhe/react-native-directional-toggle/issues"
47
+ },
48
+ "homepage": "https://github.com/alansuhe/react-native-directional-toggle#readme",
49
+ "publishConfig": {
50
+ "registry": "https://registry.npmjs.org/"
51
+ },
52
+ "devDependencies": {
53
+ "@commitlint/config-conventional": "^19.8.1",
54
+ "@eslint/compat": "^1.3.2",
55
+ "@eslint/eslintrc": "^3.3.1",
56
+ "@eslint/js": "^9.35.0",
57
+ "@react-native/babel-preset": "0.83.0",
58
+ "@react-native/eslint-config": "0.83.0",
59
+ "@release-it/conventional-changelog": "^10.0.1",
60
+ "@types/jest": "^29.5.14",
61
+ "@types/react": "^19.1.12",
62
+ "commitlint": "^19.8.1",
63
+ "del-cli": "^6.0.0",
64
+ "eslint": "^9.35.0",
65
+ "eslint-config-prettier": "^10.1.8",
66
+ "eslint-plugin-prettier": "^5.5.4",
67
+ "jest": "^29.7.0",
68
+ "lefthook": "^2.0.3",
69
+ "prettier": "^2.8.8",
70
+ "react": "19.1.0",
71
+ "react-native": "0.81.5",
72
+ "react-native-builder-bob": "^0.40.17",
73
+ "release-it": "^19.0.4",
74
+ "typescript": "^5.9.2"
75
+ },
76
+ "peerDependencies": {
77
+ "react": "*",
78
+ "react-native": "*",
79
+ "react-native-gesture-handler": "^2.30.0",
80
+ "react-native-reanimated": "^4.2.1",
81
+ "react-native-worklets": "0.7.1"
82
+ },
83
+ "workspaces": [
84
+ "example"
85
+ ],
86
+ "react-native-builder-bob": {
87
+ "source": "src",
88
+ "output": "lib",
89
+ "targets": [
90
+ [
91
+ "module",
92
+ {
93
+ "esm": true
94
+ }
95
+ ],
96
+ [
97
+ "typescript",
98
+ {
99
+ "project": "tsconfig.build.json"
100
+ }
101
+ ]
102
+ ]
103
+ },
104
+ "prettier": {
105
+ "quoteProps": "consistent",
106
+ "singleQuote": true,
107
+ "tabWidth": 2,
108
+ "trailingComma": "es5",
109
+ "useTabs": false
110
+ },
111
+ "jest": {
112
+ "preset": "react-native",
113
+ "modulePathIgnorePatterns": [
114
+ "<rootDir>/example/node_modules",
115
+ "<rootDir>/lib/"
116
+ ]
117
+ },
118
+ "commitlint": {
119
+ "extends": [
120
+ "@commitlint/config-conventional"
121
+ ]
122
+ },
123
+ "release-it": {
124
+ "git": {
125
+ "commitMessage": "chore: release ${version}",
126
+ "tagName": "v${version}"
127
+ },
128
+ "npm": {
129
+ "publish": true
130
+ },
131
+ "github": {
132
+ "release": true
133
+ },
134
+ "plugins": {
135
+ "@release-it/conventional-changelog": {
136
+ "preset": {
137
+ "name": "angular"
138
+ }
139
+ }
140
+ }
141
+ },
142
+ "create-react-native-library": {
143
+ "type": "library",
144
+ "languages": "js",
145
+ "tools": [
146
+ "eslint",
147
+ "jest",
148
+ "lefthook",
149
+ "release-it"
150
+ ],
151
+ "version": "0.56.1"
152
+ },
153
+ "scripts": {
154
+ "example": "pnpm --filter react-native-directional-toggle-example run start",
155
+ "clean": "del-cli lib",
156
+ "typecheck": "tsc",
157
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
158
+ "test": "jest",
159
+ "release": "release-it --only-version"
160
+ }
161
+ }
@@ -0,0 +1,238 @@
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";
4
+ import Animated, {
5
+ interpolateColor,
6
+ runOnJS,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
+ withSpring,
10
+ withTiming,
11
+ } from "react-native-reanimated";
12
+
13
+ type Option = {
14
+ label: string;
15
+ value: string | number;
16
+ };
17
+
18
+ type Props = {
19
+ options: Option[];
20
+ value: string | number;
21
+ onChange: (value: string | number) => void;
22
+ height?: number;
23
+ 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
+ };
35
+ };
36
+
37
+ const THUMB_INSET = 4;
38
+ const VERTICAL_WIDTH = 128;
39
+
40
+ export function AnimatedSwitch({
41
+ options,
42
+ value,
43
+ onChange,
44
+ height = 40,
45
+ vertical = false,
46
+ colors = {
47
+ activeText: "#373737",
48
+ inactiveText: "#dededeff",
49
+ bgFront: "#d4d4d4",
50
+ bgBack: "#798393ff",
51
+ },
52
+ animationConfig = {
53
+ duration: 100,
54
+ damping: 50,
55
+ stiffness: 200,
56
+ },
57
+ }: Props) {
58
+
59
+ /** ===== SharedValues ===== */
60
+ const itemSizeSV = useSharedValue(0);
61
+ const translate = useSharedValue(0);
62
+ const indexSV = useSharedValue(0);
63
+
64
+ const currentIndex = options.findIndex((o) => o.value === value);
65
+
66
+ /** * 1. 监听外部 value 变化
67
+ * 当外部修改 value 时,滑块应动画移动到新位置
68
+ */
69
+ useEffect(() => {
70
+ if (currentIndex >= 0) {
71
+ indexSV.value = currentIndex;
72
+ // 只有当 itemSizeSV 已经有值(即 Layout 已完成)时才执行动画
73
+ if (itemSizeSV.value > 0) {
74
+ translate.value = withTiming(currentIndex * itemSizeSV.value, {
75
+ duration: animationConfig.duration,
76
+ });
77
+ }
78
+ }
79
+ }, [currentIndex, animationConfig.duration]);
80
+
81
+ /** ===== JS 回调 ===== */
82
+ const emitChange = useCallback(
83
+ (index: number) => {
84
+ if (options.length > 0 && index >= 0 && index < options.length) {
85
+ onChange(options[index]?.value || "");
86
+ }
87
+ },
88
+ [onChange, options],
89
+ );
90
+
91
+ /** ===== Tap 点击切换 ===== */
92
+ const handlePress = (index: number) => {
93
+ indexSV.value = index;
94
+ translate.value = withTiming(index * itemSizeSV.value, {
95
+ duration: animationConfig.duration ?? 150
96
+ });
97
+ emitChange(index);
98
+ };
99
+
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 动画样式 ===== */
123
+ 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
+ }
133
+ 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 }],
139
+ };
140
+ });
141
+
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
+ });
153
+
154
+ /** * 2. Layout 初始化
155
+ * 这是修复初始显示问题的关键
156
+ */
157
+ const onLayout = (e: LayoutChangeEvent) => {
158
+ const size = vertical
159
+ ? e.nativeEvent.layout.height
160
+ : e.nativeEvent.layout.width;
161
+
162
+ const newItemSize = size / options.length;
163
+ itemSizeSV.value = newItemSize;
164
+
165
+ // 核心修复:在获取到尺寸的第一时间,根据 currentIndex 强行同步 translate 的值
166
+ if (currentIndex >= 0) {
167
+ translate.value = currentIndex * newItemSize;
168
+ }
169
+ };
170
+
171
+ 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
+ ]}
186
+ >
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>
210
+ );
211
+ }
212
+
213
+ const styles = StyleSheet.create({
214
+ container: {
215
+ borderRadius: 16,
216
+ overflow: "hidden",
217
+ flex: 1,
218
+ },
219
+ thumbBase: {
220
+ position: "absolute",
221
+ borderRadius: 12,
222
+ shadowColor: '#000',
223
+ shadowOpacity: 0.3,
224
+ shadowOffset: { width: 0, height: 1 },
225
+ shadowRadius: 2,
226
+ elevation: 2,
227
+ },
228
+ item: {
229
+ flex: 1,
230
+ alignItems: "center",
231
+ justifyContent: "center",
232
+ zIndex: 1, // 确保文字在滑块上方
233
+ },
234
+ label: {
235
+ fontSize: 14,
236
+ fontWeight: "600",
237
+ },
238
+ });
package/src/index.tsx ADDED
@@ -0,0 +1,3 @@
1
+ import { AnimatedSwitch } from "./AnimatedSwitch";
2
+
3
+ export default AnimatedSwitch;