react-native-swipeable-actions 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.
Files changed (55) hide show
  1. package/README.md +250 -0
  2. package/android/build.gradle +99 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/expo/modules/swipeable/SwipeConstants.kt +29 -0
  5. package/android/src/main/java/expo/modules/swipeable/SwipePhysics.kt +66 -0
  6. package/android/src/main/java/expo/modules/swipeable/SwipeableModule.kt +69 -0
  7. package/android/src/main/java/expo/modules/swipeable/SwipeableView.kt +951 -0
  8. package/build/Swipeable.d.ts +10 -0
  9. package/build/Swipeable.d.ts.map +1 -0
  10. package/build/Swipeable.js +114 -0
  11. package/build/Swipeable.js.map +1 -0
  12. package/build/Swipeable.types.d.ts +64 -0
  13. package/build/Swipeable.types.d.ts.map +1 -0
  14. package/build/Swipeable.types.js +3 -0
  15. package/build/Swipeable.types.js.map +1 -0
  16. package/build/SwipeableActions.d.ts +17 -0
  17. package/build/SwipeableActions.d.ts.map +1 -0
  18. package/build/SwipeableActions.js +31 -0
  19. package/build/SwipeableActions.js.map +1 -0
  20. package/build/SwipeableView.d.ts +10 -0
  21. package/build/SwipeableView.d.ts.map +1 -0
  22. package/build/SwipeableView.js +11 -0
  23. package/build/SwipeableView.js.map +1 -0
  24. package/build/constants.d.ts +11 -0
  25. package/build/constants.d.ts.map +1 -0
  26. package/build/constants.js +14 -0
  27. package/build/constants.js.map +1 -0
  28. package/build/index.d.ts +9 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +21 -0
  31. package/build/index.js.map +1 -0
  32. package/build/nativeModuleUtils.d.ts +11 -0
  33. package/build/nativeModuleUtils.d.ts.map +1 -0
  34. package/build/nativeModuleUtils.js +49 -0
  35. package/build/nativeModuleUtils.js.map +1 -0
  36. package/build/swipeStateUtils.d.ts +16 -0
  37. package/build/swipeStateUtils.d.ts.map +1 -0
  38. package/build/swipeStateUtils.js +37 -0
  39. package/build/swipeStateUtils.js.map +1 -0
  40. package/expo-module.config.json +9 -0
  41. package/ios/Package.swift +28 -0
  42. package/ios/SwipeAnimationConfig.swift +27 -0
  43. package/ios/SwipePhysics.swift +66 -0
  44. package/ios/SwipeableModule.swift +84 -0
  45. package/ios/SwipeableView.swift +845 -0
  46. package/ios/react-native-swipeable-actions.podspec +22 -0
  47. package/package.json +85 -0
  48. package/src/Swipeable.tsx +195 -0
  49. package/src/Swipeable.types.ts +70 -0
  50. package/src/SwipeableActions.tsx +41 -0
  51. package/src/SwipeableView.tsx +20 -0
  52. package/src/constants.ts +11 -0
  53. package/src/index.ts +22 -0
  54. package/src/nativeModuleUtils.ts +57 -0
  55. package/src/swipeStateUtils.ts +55 -0
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # react-native-swipeable-actions
2
+
3
+ A high-performance native swipeable row component for React Native. Native alternative to `react-native-gesture-handler/Swipeable` - no `react-native-reanimated` required.
4
+
5
+ ## Features
6
+
7
+ - Pure native gestures - no JS thread blocking
8
+ - 60fps spring animations on both iOS and Android
9
+ - No `react-native-reanimated` dependency
10
+ - Built-in FlashList/virtualized list support via `recyclingKey`
11
+ - AutoClose pattern for chat-style swipe-to-reply
12
+ - Lazy action rendering for optimal performance
13
+
14
+ ## Performance
15
+
16
+ Benchmark comparison on Android 16, Pixel 8 Pro, GrapheneOS — List Demo with 100 items:
17
+
18
+ <table>
19
+ <tr>
20
+ <th>react-native-gesture-handler/Swipeable</th>
21
+ <th>react-native-swipeable-actions</th>
22
+ </tr>
23
+ <tr>
24
+ <td><video src="example/assets/rngh.mp4" controls width="300"></video></td>
25
+ <td><video src="example/assets/swipeable.mp4" controls width="300"></video></td>
26
+ </tr>
27
+ <tr>
28
+ <td align="center">Reanimated</td>
29
+ <td align="center">Pure native</td>
30
+ </tr>
31
+ <tr>
32
+ <td align="center"><b>Avg 60.9</b> FPS (Min: 56.7 / Max: 65.7)</td>
33
+ <td align="center"><b>Avg 116.8</b> FPS (Min: 109.9 / Max: 120.3)</td>
34
+ </tr>
35
+ </table>
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ npm install react-native-swipeable-actions
41
+ ```
42
+
43
+ ### Peer Dependencies
44
+
45
+ ```json
46
+ {
47
+ "expo": ">=50.0.0",
48
+ "expo-modules-core": ">=1.0.0",
49
+ "react": ">=18.0.0",
50
+ "react-native": ">=0.73.0"
51
+ }
52
+ ```
53
+
54
+ ## Basic Usage
55
+
56
+ ```tsx
57
+ import { Swipeable, SwipeableMethods } from 'react-native-swipeable-actions'
58
+ import { useRef } from 'react'
59
+ import { View, Text, TouchableOpacity } from 'react-native'
60
+
61
+ function DeleteAction({ onPress }: { onPress: () => void }) {
62
+ return (
63
+ <TouchableOpacity onPress={onPress} style={styles.deleteAction}>
64
+ <Text style={styles.actionText}>Delete</Text>
65
+ </TouchableOpacity>
66
+ )
67
+ }
68
+
69
+ function MyRow() {
70
+ const swipeableRef = useRef<SwipeableMethods>(null)
71
+
72
+ return (
73
+ <Swipeable
74
+ ref={swipeableRef}
75
+ actions={<DeleteAction onPress={() => console.log('Delete!')} />}
76
+ actionsWidth={80}
77
+ onSwipeEnd={(state) => console.log('Swipe ended:', state)}
78
+ >
79
+ <View style={styles.row}>
80
+ <Text>Swipe me left</Text>
81
+ </View>
82
+ </Swipeable>
83
+ )
84
+ }
85
+ ```
86
+
87
+ ## Props
88
+
89
+ | Prop | Type | Default | Description |
90
+ |------|------|---------|-------------|
91
+ | `children` | `ReactNode` | required | Row content |
92
+ | `actions` | `ReactNode` | required | Action buttons revealed on swipe |
93
+ | `actionsWidth` | `number` | required | Width of actions container in pixels |
94
+ | `actionsPosition` | `'left' \| 'right'` | `'right'` | Position of actions (`'left'` = swipe right to reveal) |
95
+ | `friction` | `number` | `1` | Drag damping factor (0-1). Lower = more resistance |
96
+ | `threshold` | `number` | `0.4` | Snap-to threshold as percentage of actionsWidth (0-1) |
97
+ | `dragOffsetFromEdge` | `number` | `0` | Minimum drag distance before gesture starts |
98
+ | `autoClose` | `boolean` | `false` | Auto-close after swipe release (for swipe-to-reply) |
99
+ | `autoCloseTimeout` | `number` | `0` | Delay in ms before auto-closing |
100
+ | `recyclingKey` | `string \| number` | - | Unique key for FlashList/recycling support |
101
+ | `style` | `StyleProp<ViewStyle>` | - | Container style |
102
+ | `testID` | `string` | - | Test ID for e2e testing |
103
+
104
+ ## Callbacks
105
+
106
+ | Callback | Type | Description |
107
+ |----------|------|-------------|
108
+ | `onSwipeStart` | `() => void` | Called when swipe gesture begins |
109
+ | `onSwipeStateChange` | `(state: 'open' \| 'closed') => void` | Called when gesture ends (before animation) |
110
+ | `onSwipeEnd` | `(state: 'open' \| 'closed') => void` | Called when animation completes |
111
+ | `onProgress` | `(progress: number) => void` | Called on each frame (0 = closed, 1 = fully open) |
112
+
113
+ ## Ref Methods
114
+
115
+ Access via ref:
116
+
117
+ ```tsx
118
+ const swipeableRef = useRef<SwipeableMethods>(null)
119
+
120
+ // Close the row (with optional animation)
121
+ swipeableRef.current?.close() // animated
122
+ swipeableRef.current?.close(false) // instant
123
+
124
+ // Open the row
125
+ swipeableRef.current?.open()
126
+ ```
127
+
128
+ ## Static Methods
129
+
130
+ Control swipeables globally by their `recyclingKey`:
131
+
132
+ ```tsx
133
+ import { Swipeable } from 'react-native-swipeable-actions'
134
+
135
+ // Open a specific row
136
+ Swipeable.open('row-1')
137
+
138
+ // Close a specific row
139
+ Swipeable.close('row-1') // animated
140
+ Swipeable.close('row-1', false) // instant
141
+
142
+ // Close all open rows
143
+ Swipeable.closeAll() // animated
144
+ Swipeable.closeAll(false) // instant
145
+ ```
146
+
147
+ ## AutoClose Pattern (Swipe-to-Reply)
148
+
149
+ For chat-style swipe-to-reply where the row should automatically close:
150
+
151
+ ```tsx
152
+ <Swipeable
153
+ actions={<ReplyIndicator />}
154
+ actionsWidth={60}
155
+ actionsPosition="right"
156
+ autoClose={true}
157
+ threshold={0.6}
158
+ onSwipeEnd={(state) => {
159
+ if (state === 'open') {
160
+ // Trigger reply action
161
+ onReply(message)
162
+ }
163
+ }}
164
+ >
165
+ <ChatMessage message={message} />
166
+ </Swipeable>
167
+ ```
168
+
169
+ ## FlashList Integration
170
+
171
+ For virtualized lists, use `recyclingKey` to persist swipe state across recycling:
172
+
173
+ ```tsx
174
+ import { FlashList } from '@shopify/flash-list'
175
+
176
+ function MessageList({ messages }) {
177
+ return (
178
+ <FlashList
179
+ data={messages}
180
+ renderItem={({ item }) => (
181
+ <Swipeable
182
+ recyclingKey={item.id}
183
+ actions={<DeleteAction />}
184
+ actionsWidth={80}
185
+ >
186
+ <MessageRow message={item} />
187
+ </Swipeable>
188
+ )}
189
+ estimatedItemSize={60}
190
+ />
191
+ )
192
+ }
193
+ ```
194
+
195
+ ## Architecture
196
+
197
+ ```
198
+ ┌─────────────────────────────────────────────────────────────┐
199
+ │ JS Layer (React) │
200
+ │ ┌─────────────────┐ ┌──────────────────────────────────┐ │
201
+ │ │ <Swipeable> │ │ Actions (ReactNode) │ │
202
+ │ │ - renderActions│──│ - Rendered lazily on first swipe│ │
203
+ │ │ - ref methods │ │ - Any React components │ │
204
+ │ └────────┬────────┘ └──────────────────────────────────┘ │
205
+ └───────────│─────────────────────────────────────────────────┘
206
+ │ Expo Modules Bridge
207
+ ┌───────────▼─────────────────────────────────────────────────┐
208
+ │ Native Layer (Kotlin/Swift) │
209
+ │ ┌─────────────────────────────────────────────────────────┐│
210
+ │ │ SwipeableView ││
211
+ │ │ - UIPanGestureRecognizer (iOS) / OnTouchListener (And) ││
212
+ │ │ - Spring animations (UIView.animate / DynamicAnimation)││
213
+ │ │ - Progress events @ 60fps (CADisplayLink / Choreograph)││
214
+ │ │ - LRU cache for recycling state persistence ││
215
+ │ └─────────────────────────────────────────────────────────┘│
216
+ └─────────────────────────────────────────────────────────────┘
217
+ ```
218
+
219
+ **Key design decisions:**
220
+
221
+ - Gestures handled entirely in native code for 60fps performance
222
+ - Actions rendered lazily on first swipe gesture (performance optimization)
223
+ - Recycling key enables state persistence in virtualized lists
224
+ - No SharedValue dependency - progress synced via native events
225
+
226
+ ## Platform Support
227
+
228
+ | Platform | Version |
229
+ |----------|---------|
230
+ | iOS | 12.0+ |
231
+ | Android | API 21+ |
232
+
233
+ ## TypeScript
234
+
235
+ Full TypeScript support with exported types:
236
+
237
+ ```tsx
238
+ import {
239
+ Swipeable,
240
+ SwipeableProps,
241
+ SwipeableMethods,
242
+ SwipeableStatic,
243
+ SwipeProgressEvent,
244
+ SwipeStateEvent,
245
+ } from 'react-native-swipeable-actions'
246
+ ```
247
+
248
+ ## License
249
+
250
+ MIT
@@ -0,0 +1,99 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+ apply plugin: 'maven-publish'
4
+
5
+ group = 'expo.modules.swipeable'
6
+ version = '0.1.0'
7
+
8
+ buildscript {
9
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
10
+ if (expoModulesCorePlugin.exists()) {
11
+ apply from: expoModulesCorePlugin
12
+ applyKotlinExpoModulesCorePlugin()
13
+ }
14
+
15
+ repositories {
16
+ mavenCentral()
17
+ }
18
+
19
+ dependencies {
20
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23")
21
+ }
22
+ }
23
+
24
+ afterEvaluate {
25
+ publishing {
26
+ publications {
27
+ release(MavenPublication) {
28
+ from components.release
29
+ }
30
+ }
31
+ repositories {
32
+ maven {
33
+ url = mavenLocal().url
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ android {
40
+ namespace "expo.modules.swipeable"
41
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
42
+
43
+ defaultConfig {
44
+ minSdkVersion safeExtGet("minSdkVersion", 23)
45
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
46
+ versionCode 1
47
+ versionName "0.1.0"
48
+ }
49
+
50
+ publishing {
51
+ singleVariant("release") {
52
+ withSourcesJar()
53
+ }
54
+ }
55
+
56
+ lintOptions {
57
+ abortOnError false
58
+ }
59
+
60
+ compileOptions {
61
+ sourceCompatibility JavaVersion.VERSION_17
62
+ targetCompatibility JavaVersion.VERSION_17
63
+ }
64
+
65
+ kotlinOptions {
66
+ jvmTarget = JavaVersion.VERSION_17.majorVersion
67
+ }
68
+
69
+ testOptions {
70
+ unitTests {
71
+ includeAndroidResources = true
72
+ }
73
+ }
74
+ }
75
+
76
+ repositories {
77
+ mavenCentral()
78
+ }
79
+
80
+ dependencies {
81
+ implementation project(':expo-modules-core')
82
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
83
+ implementation "androidx.dynamicanimation:dynamicanimation:1.0.0"
84
+
85
+ testImplementation "junit:junit:4.13.2"
86
+ testImplementation "io.mockk:mockk:1.13.8"
87
+ testImplementation "org.robolectric:robolectric:4.11.1"
88
+ }
89
+
90
+ def getKotlinVersion() {
91
+ def kotlin_version = rootProject.ext.has("kotlinVersion")
92
+ ? rootProject.ext.get("kotlinVersion")
93
+ : "1.9.23"
94
+ return kotlin_version
95
+ }
96
+
97
+ def safeExtGet(String prop, Integer defaultValue) {
98
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : defaultValue
99
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,29 @@
1
+ package expo.modules.swipeable
2
+
3
+ /**
4
+ * Physics and animation constants for swipeable behavior.
5
+ *
6
+ * Cross-platform constants (must match iOS SwipeAnimationConfig.swift):
7
+ * - RUBBER_BAND_DIVISOR, VELOCITY_NORMALIZER, MAX_NORMALIZED_VELOCITY
8
+ * - VELOCITY_PROJECTION_FACTOR, PROGRESS_CHANGE_THRESHOLD, *_DAMPING_RATIO
9
+ *
10
+ * Platform-specific: Android uses spring physics (stiffness), iOS uses duration-based animation.
11
+ * Both produce similar visual results with critical damping (no oscillation).
12
+ */
13
+ object SwipeConstants {
14
+ // Cross-platform constants - must match iOS
15
+ const val RUBBER_BAND_DIVISOR = 3f
16
+ const val VELOCITY_NORMALIZER = 500f
17
+ const val MAX_NORMALIZED_VELOCITY = 2.0f
18
+ const val VELOCITY_PROJECTION_FACTOR = 0.15f
19
+ const val PROGRESS_CHANGE_THRESHOLD = 0.001f
20
+
21
+ // Android-specific: velocity conversion
22
+ const val VELOCITY_SCALE = 1000f
23
+
24
+ // Android-specific: spring physics (iOS uses duration-based animation)
25
+ const val OPEN_STIFFNESS = 300f
26
+ const val OPEN_DAMPING_RATIO = 1.0f // Critical damping - no oscillation (matches iOS)
27
+ const val CLOSE_STIFFNESS = 400f
28
+ const val CLOSE_DAMPING_RATIO = 1.0f // Critical damping - no oscillation (matches iOS)
29
+ }
@@ -0,0 +1,66 @@
1
+ package expo.modules.swipeable
2
+
3
+ import kotlin.math.abs
4
+ import kotlin.math.min
5
+ import kotlin.math.sign
6
+
7
+ object SwipePhysics {
8
+
9
+ fun applyRubberBand(
10
+ translation: Float,
11
+ actionsWidth: Float,
12
+ isLeading: Boolean
13
+ ): Float {
14
+ var result = translation
15
+ if (isLeading) {
16
+ when {
17
+ result > actionsWidth -> {
18
+ result = actionsWidth + ((result - actionsWidth) / SwipeConstants.RUBBER_BAND_DIVISOR)
19
+ }
20
+ result < 0f -> {
21
+ result /= SwipeConstants.RUBBER_BAND_DIVISOR
22
+ }
23
+ }
24
+ } else {
25
+ when {
26
+ result < -actionsWidth -> {
27
+ result = -actionsWidth - ((abs(result) - actionsWidth) / SwipeConstants.RUBBER_BAND_DIVISOR)
28
+ }
29
+ result > 0f -> {
30
+ result /= SwipeConstants.RUBBER_BAND_DIVISOR
31
+ }
32
+ }
33
+ }
34
+ return result
35
+ }
36
+
37
+ fun shouldSnapToOpen(
38
+ projectedPosition: Float,
39
+ thresholdDistance: Float,
40
+ isOpen: Boolean,
41
+ isLeading: Boolean
42
+ ): Boolean {
43
+ return if (isLeading) {
44
+ if (isOpen) projectedPosition > -thresholdDistance else projectedPosition > thresholdDistance
45
+ } else {
46
+ if (isOpen) projectedPosition < thresholdDistance else projectedPosition < -thresholdDistance
47
+ }
48
+ }
49
+
50
+ fun projectFinalPosition(
51
+ translation: Float,
52
+ velocity: Float,
53
+ friction: Float
54
+ ): Float {
55
+ return (translation * friction) + (velocity * friction * SwipeConstants.VELOCITY_PROJECTION_FACTOR)
56
+ }
57
+
58
+ fun calculateSpringVelocity(gestureVelocity: Float): Float {
59
+ // Preserve the sign of the velocity - critical for correct spring animation direction
60
+ val magnitude = min(
61
+ abs(gestureVelocity) / SwipeConstants.VELOCITY_NORMALIZER,
62
+ SwipeConstants.MAX_NORMALIZED_VELOCITY
63
+ ) * SwipeConstants.VELOCITY_SCALE
64
+ return sign(gestureVelocity) * magnitude
65
+ }
66
+ }
@@ -0,0 +1,69 @@
1
+ package expo.modules.swipeable
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ class SwipeableModule : Module() {
7
+ override fun definition() = ModuleDefinition {
8
+ Name("Swipeable")
9
+
10
+ // Module-level functions (by recyclingKey)
11
+ Function("openByKey") { key: String ->
12
+ SwipeableView.openByKey(key)
13
+ }
14
+
15
+ Function("closeByKey") { key: String, animated: Boolean? ->
16
+ SwipeableView.closeByKey(key, animated ?: true)
17
+ }
18
+
19
+ Function("closeAll") { animated: Boolean? ->
20
+ SwipeableView.closeAll(animated ?: true)
21
+ }
22
+
23
+ View(SwipeableView::class) {
24
+ Prop("actionsWidth") { view: SwipeableView, width: Float ->
25
+ view.actionsWidth = width
26
+ }
27
+ Prop("actionsPosition") { view: SwipeableView, position: String ->
28
+ view.actionsPosition = position
29
+ }
30
+ Prop("friction") { view: SwipeableView, friction: Float ->
31
+ view.friction = friction.coerceIn(0f, 1f)
32
+ }
33
+ Prop("threshold") { view: SwipeableView, threshold: Float ->
34
+ view.threshold = threshold.coerceIn(0f, 1f)
35
+ }
36
+ Prop("dragOffsetFromEdge") { view: SwipeableView, offset: Float ->
37
+ view.dragOffsetFromEdge = offset
38
+ }
39
+ Prop("recyclingKey") { view: SwipeableView, key: String? ->
40
+ view.recyclingKey = key
41
+ }
42
+ Prop("autoClose") { view: SwipeableView, autoClose: Boolean ->
43
+ view.autoClose = autoClose
44
+ }
45
+ Prop("autoCloseTimeout") { view: SwipeableView, timeout: Float ->
46
+ view.autoCloseTimeout = timeout
47
+ }
48
+
49
+ Events("onSwipeProgress", "onSwipeStart", "onSwipeStateChange", "onSwipeEnd")
50
+
51
+ AsyncFunction("close") { view: SwipeableView, animated: Boolean? ->
52
+ view.post { view.close(animated ?: true) }
53
+ }
54
+ AsyncFunction("open") { view: SwipeableView ->
55
+ view.post { view.open() }
56
+ }
57
+
58
+ AsyncFunction("handleGestureStart") { view: SwipeableView ->
59
+ view.handleGestureStart()
60
+ }
61
+ AsyncFunction("handleGestureUpdate") { view: SwipeableView, translationX: Float, velocityX: Float ->
62
+ view.handleGestureUpdate(translationX, velocityX)
63
+ }
64
+ AsyncFunction("handleGestureEnd") { view: SwipeableView, translationX: Float, velocityX: Float ->
65
+ view.handleGestureEnd(translationX, velocityX)
66
+ }
67
+ }
68
+ }
69
+ }