react-native-swipeable-actions 0.1.12 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/LICENSE +21 -0
  3. package/README.md +377 -207
  4. package/android/src/main/java/expo/modules/swipeable/SwipePhysics.kt +39 -0
  5. package/android/src/main/java/expo/modules/swipeable/SwipeableModule.kt +24 -10
  6. package/android/src/main/java/expo/modules/swipeable/SwipeableView.kt +804 -420
  7. package/build/Swipeable.d.ts +3 -8
  8. package/build/Swipeable.d.ts.map +1 -1
  9. package/build/Swipeable.js +87 -33
  10. package/build/Swipeable.js.map +1 -1
  11. package/build/Swipeable.types.d.ts +59 -9
  12. package/build/Swipeable.types.d.ts.map +1 -1
  13. package/build/SwipeableActions.d.ts +1 -1
  14. package/build/SwipeableActions.d.ts.map +1 -1
  15. package/build/SwipeableActions.js +4 -2
  16. package/build/SwipeableActions.js.map +1 -1
  17. package/build/SwipeableView.d.ts +11 -2
  18. package/build/SwipeableView.d.ts.map +1 -1
  19. package/build/SwipeableView.js.map +1 -1
  20. package/build/constants.d.ts +0 -1
  21. package/build/constants.d.ts.map +1 -1
  22. package/build/constants.js +0 -1
  23. package/build/constants.js.map +1 -1
  24. package/build/index.d.ts +1 -1
  25. package/build/index.d.ts.map +1 -1
  26. package/build/index.js.map +1 -1
  27. package/build/nativeModuleUtils.d.ts +3 -1
  28. package/build/nativeModuleUtils.d.ts.map +1 -1
  29. package/build/nativeModuleUtils.js +20 -9
  30. package/build/nativeModuleUtils.js.map +1 -1
  31. package/build/resolveSwipeableActions.d.ts +18 -0
  32. package/build/resolveSwipeableActions.d.ts.map +1 -0
  33. package/build/resolveSwipeableActions.js +26 -0
  34. package/build/resolveSwipeableActions.js.map +1 -0
  35. package/docs/migration-v1.md +191 -0
  36. package/docs/support.md +90 -0
  37. package/ios/SwipeAnimationConfig.swift +32 -0
  38. package/ios/SwipePhysics.swift +418 -0
  39. package/ios/SwipeableModule.swift +34 -12
  40. package/ios/SwipeableView.swift +1062 -613
  41. package/jestSetup.js +17 -5
  42. package/package.json +11 -5
  43. package/src/Swipeable.tsx +147 -54
  44. package/src/Swipeable.types.ts +50 -9
  45. package/src/SwipeableActions.tsx +6 -3
  46. package/src/SwipeableView.tsx +10 -3
  47. package/src/constants.ts +0 -1
  48. package/src/index.ts +7 -0
  49. package/src/nativeModuleUtils.ts +19 -8
  50. package/src/resolveSwipeableActions.ts +37 -0
package/jestSetup.js CHANGED
@@ -7,16 +7,21 @@ jest.mock('./src/SwipeableView.tsx', () => {
7
7
  closeByKey: jest.fn(),
8
8
  cancelByKey: jest.fn(),
9
9
  closeAll: jest.fn(),
10
- isOpenByKey: jest.fn().mockReturnValue(true)
10
+ isOpenByKey: jest.fn().mockReturnValue(true),
11
+ openByKeyOnSide: jest.fn(),
12
+ getOpenSideByKey: jest.fn(() => null)
11
13
  }
12
14
 
13
15
  const SwipeableView = React.forwardRef((props, ref) => {
16
+ // The native event props (onSwipeStart, onSwipeEnd and the others) stay on the
17
+ // host View, so a test can fire native-shaped events with fireEvent.
14
18
  const {
15
19
  children,
16
- onSwipeStart,
20
+ onActionsNeeded,
17
21
  testID,
18
22
  ...rest
19
23
  } = props
24
+ const { recyclingKey } = props
20
25
 
21
26
  React.useImperativeHandle(ref, () => ({
22
27
  close: jest.fn(),
@@ -25,8 +30,15 @@ jest.mock('./src/SwipeableView.tsx', () => {
25
30
  }), [])
26
31
 
27
32
  React.useEffect(() => {
28
- onSwipeStart?.()
29
- }, [onSwipeStart])
33
+ // Match the native event shape: Swipeable reads the binding key from nativeEvent.
34
+ const nativeEvent = (extra) => ({
35
+ nativeEvent: { ...(recyclingKey != null ? { recyclingKey } : {}), ...extra }
36
+ })
37
+ // Mount both action rails so tests can find action content. This internal
38
+ // request calls no consumer callback. A mount never starts a swipe.
39
+ onActionsNeeded?.(nativeEvent({ side: 'left' }))
40
+ onActionsNeeded?.(nativeEvent({ side: 'right' }))
41
+ }, [onActionsNeeded, recyclingKey])
30
42
 
31
43
  return React.createElement(
32
44
  View,
@@ -44,4 +56,4 @@ jest.mock('./src/SwipeableView.tsx', () => {
44
56
  SwipeableModule,
45
57
  SwipeableView
46
58
  }
47
- })
59
+ })
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "react-native-swipeable-actions",
3
- "version": "0.1.12",
3
+ "version": "1.0.0",
4
4
  "description": "High-performance native swipeable row component for React Native using Expo Modules",
5
5
  "main": "src/index.ts",
6
6
  "types": "build/index.d.ts",
7
7
  "files": [
8
+ "CHANGELOG.md",
9
+ "docs/migration-v1.md",
10
+ "docs/support.md",
8
11
  "build",
9
12
  "src",
10
13
  "!src/__tests__",
11
14
  "!src/__mocks__",
15
+ "!src/__type_tests__",
12
16
  "ios",
13
17
  "!ios/Tests",
14
18
  "!ios/.build",
19
+ "!ios/build",
15
20
  "android/src/main",
16
21
  "android/build.gradle",
17
22
  "expo-module.config.json",
@@ -33,6 +38,8 @@
33
38
  "format:check": "biome format src example e2e",
34
39
  "check": "biome check src example e2e",
35
40
  "typecheck": "tsc --noEmit",
41
+ "typecheck:fixtures": "tsc -p tsconfig.type-tests.json",
42
+ "test:package": "node --test scripts/package-artifact.test.mjs",
36
43
  "android": "expo run:android",
37
44
  "ios": "expo run:ios",
38
45
  "e2e": "cd e2e && npm run test",
@@ -59,10 +66,9 @@
59
66
  "author": "chocky335",
60
67
  "license": "MIT",
61
68
  "peerDependencies": {
62
- "expo": ">=50.0.0",
63
- "expo-modules-core": ">=1.0.0",
64
- "react": ">=18.0.0",
65
- "react-native": ">=0.73.0"
69
+ "expo": ">=54.0.0",
70
+ "react": ">=19.1.0",
71
+ "react-native": ">=0.81.0"
66
72
  },
67
73
  "devDependencies": {
68
74
  "@biomejs/biome": "2.4.9",
package/src/Swipeable.tsx CHANGED
@@ -1,12 +1,35 @@
1
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
1
+ import {
2
+ Fragment,
3
+ type ForwardRefExoticComponent,
4
+ type RefAttributes,
5
+ forwardRef,
6
+ useCallback,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useMemo,
10
+ useRef,
11
+ useState
12
+ } from 'react'
2
13
  import { Platform, StyleSheet, View } from 'react-native'
3
14
  import { SWIPE_DEFAULTS } from './constants'
4
- import { cancelByKey, closeAll, closeByKey, isOpenByKey, openByKey } from './nativeModuleUtils'
15
+ import {
16
+ cancelByKey,
17
+ closeAll,
18
+ closeByKey,
19
+ getOpenSideByKey,
20
+ openByKey,
21
+ openByKeyOnSide
22
+ } from './nativeModuleUtils'
23
+ import { resolveSwipeableActions } from './resolveSwipeableActions'
5
24
  import type {
25
+ ActionsNeededEvent,
6
26
  NativeSwipeableRef,
27
+ SwipeSide,
28
+ SwipeableComponentProps,
7
29
  SwipeableMethods,
8
- SwipeableProps,
30
+ SwipeableStatic,
9
31
  SwipeProgressEvent,
32
+ SwipeStartEvent,
10
33
  SwipeStateEvent
11
34
  } from './Swipeable.types'
12
35
  import { SwipeableActions } from './SwipeableActions'
@@ -23,12 +46,17 @@ function normalizeRecyclingKey(key: string | number | undefined): string | undef
23
46
  return undefined
24
47
  }
25
48
 
26
- const Swipeable = forwardRef<SwipeableMethods, SwipeableProps>(function Swipeable(
49
+ const Swipeable = forwardRef<SwipeableMethods, SwipeableComponentProps>(function Swipeable(
27
50
  {
28
51
  children,
29
- actions,
30
- actionsWidth,
31
- actionsPosition = SWIPE_DEFAULTS.ACTIONS_POSITION,
52
+ leftActions,
53
+ leftActionsWidth,
54
+ rightActions,
55
+ rightActionsWidth,
56
+ onLeftSwipeEnd,
57
+ onRightSwipeEnd,
58
+ appearance = 'default',
59
+ hapticFeedback,
32
60
  friction = SWIPE_DEFAULTS.FRICTION,
33
61
  threshold = SWIPE_DEFAULTS.THRESHOLD,
34
62
  gestureEnabled = SWIPE_DEFAULTS.GESTURE_ENABLED,
@@ -46,28 +74,48 @@ const Swipeable = forwardRef<SwipeableMethods, SwipeableProps>(function Swipeabl
46
74
  ref
47
75
  ) {
48
76
  const nativeRef = useRef<NativeSwipeableRef>(null)
77
+ const resolved = useMemo(
78
+ () =>
79
+ resolveSwipeableActions({
80
+ leftActions,
81
+ ...(leftActionsWidth !== undefined ? { leftActionsWidth } : {}),
82
+ rightActions,
83
+ ...(rightActionsWidth !== undefined ? { rightActionsWidth } : {})
84
+ }),
85
+ [leftActions, leftActionsWidth, rightActions, rightActionsWidth]
86
+ )
49
87
 
50
88
  // Normalize recyclingKey before hooks that depend on it
51
89
  const normalizedKey = normalizeRecyclingKey(recyclingKey)
52
90
  const recyclingKeyRef = useRef<string | undefined>(undefined)
53
91
  recyclingKeyRef.current = normalizedKey
54
92
 
55
- // Initialize from native cache: if this recyclingKey was open, render actions immediately.
56
- // This handles FlatList remounting components during data reorder - React state is lost
57
- // but native cache preserves the open state.
58
- const [hasActionsRendered, setHasActionsRendered] = useState(() => {
59
- return normalizedKey != null && isOpenByKey(normalizedKey)
60
- })
93
+ // Hydrate the cached physical edge, including after a keyed list remount.
61
94
  const swipeStartedRef = useRef(false)
95
+ const cachedSide = useMemo(
96
+ () => (normalizedKey != null ? getOpenSideByKey(normalizedKey) : null),
97
+ [normalizedKey]
98
+ )
99
+ const initialSides = useMemo(
100
+ () => ({
101
+ key: normalizedKey,
102
+ left: cachedSide === 'left',
103
+ right: cachedSide === 'right'
104
+ }),
105
+ [normalizedKey, cachedSide]
106
+ )
107
+ const [renderedSides, setRenderedSides] = useState(initialSides)
108
+ const sides = renderedSides.key === normalizedKey ? renderedSides : initialSides
62
109
 
63
- // When recyclingKey changes (FlatList reorder/recycle), sync hasActionsRendered
64
- // with the native cached open state. FlatList reuses component instances without
65
- // remounting, so useState initializer doesn't re-run.
66
110
  useEffect(() => {
67
- if (!normalizedKey) return
68
- const cachedOpen = isOpenByKey(normalizedKey)
69
- setHasActionsRendered(cachedOpen)
70
- }, [normalizedKey])
111
+ setRenderedSides((previous) => (previous.key === normalizedKey ? previous : initialSides))
112
+ swipeStartedRef.current = false
113
+ }, [initialSides, normalizedKey])
114
+
115
+ const warningText = resolved.warnings.join('\n')
116
+ useEffect(() => {
117
+ if (__DEV__ && warningText) console.warn(`[Swipeable] ${warningText}`)
118
+ }, [warningText])
71
119
 
72
120
  // Development-time validation
73
121
  if (__DEV__) {
@@ -90,10 +138,11 @@ const Swipeable = forwardRef<SwipeableMethods, SwipeableProps>(function Swipeabl
90
138
  console.warn('[Swipeable] close() called without recyclingKey - method has no effect')
91
139
  }
92
140
  },
93
- open: () => {
141
+ open: (side?: SwipeSide) => {
94
142
  const key = recyclingKeyRef.current
95
143
  if (key) {
96
- openByKey(key)
144
+ if (side === undefined) openByKey(key)
145
+ else openByKeyOnSide(key, side)
97
146
  } else if (__DEV__) {
98
147
  console.warn('[Swipeable] open() called without recyclingKey - method has no effect')
99
148
  }
@@ -114,47 +163,80 @@ const Swipeable = forwardRef<SwipeableMethods, SwipeableProps>(function Swipeabl
114
163
  []
115
164
  )
116
165
 
117
- const handleSwipeStart = useCallback(() => {
118
- if (!hasActionsRendered) {
119
- setHasActionsRendered(true)
120
- }
166
+ const handleSwipeStart = useCallback(
167
+ (event: SwipeStartEvent) => {
168
+ // Missing metadata identifies the unkeyed native binding. It must not
169
+ // bypass identity checks after recycling into a keyed row.
170
+ if (event.nativeEvent.recyclingKey !== (normalizedKey || undefined)) return
171
+ if (!swipeStartedRef.current) {
172
+ swipeStartedRef.current = true
173
+ onSwipeStart?.()
174
+ }
175
+ },
176
+ [normalizedKey, onSwipeStart]
177
+ )
121
178
 
122
- if (!swipeStartedRef.current) {
123
- swipeStartedRef.current = true
124
- onSwipeStart?.()
125
- }
126
- }, [hasActionsRendered, onSwipeStart])
179
+ const handleGestureCancelled = useCallback(
180
+ (event: { nativeEvent: { recyclingKey?: string } }) => {
181
+ if (event.nativeEvent.recyclingKey !== (normalizedKey || undefined)) return
182
+ swipeStartedRef.current = false
183
+ },
184
+ [normalizedKey]
185
+ )
186
+
187
+ const handleActionsNeeded = useCallback(
188
+ (event: ActionsNeededEvent) => {
189
+ const side = event.nativeEvent.side
190
+ if (event.nativeEvent.recyclingKey !== (normalizedKey || undefined)) return
191
+ if ((side !== 'left' && side !== 'right') || resolved[side].width <= 0) return
192
+ setRenderedSides((previous) => {
193
+ const current = previous.key === normalizedKey ? previous : initialSides
194
+ return current[side] ? current : { ...current, [side]: true }
195
+ })
196
+ },
197
+ [initialSides, normalizedKey, resolved]
198
+ )
127
199
 
128
200
  const handleSwipeProgress = useCallback(
129
201
  (event: SwipeProgressEvent) => {
202
+ if (event.nativeEvent.recyclingKey !== (normalizedKey || undefined)) return
130
203
  const newProgress = event.nativeEvent.progress
131
204
  const clamped = Math.max(0, Math.min(1, newProgress))
132
205
  onProgress?.(clamped)
133
206
  },
134
- [onProgress]
207
+ [normalizedKey, onProgress]
135
208
  )
136
209
 
137
210
  const handleSwipeStateChange = useCallback(
138
211
  (event: SwipeStateEvent) => {
212
+ if (event.nativeEvent.recyclingKey !== (normalizedKey || undefined)) return
139
213
  onSwipeStateChange?.(event.nativeEvent.state)
140
214
  },
141
- [onSwipeStateChange]
215
+ [normalizedKey, onSwipeStateChange]
142
216
  )
143
217
 
144
218
  const handleSwipeEnd = useCallback(
145
219
  (event: SwipeStateEvent) => {
220
+ if (event.nativeEvent.recyclingKey !== (normalizedKey || undefined)) return
146
221
  swipeStartedRef.current = false
147
222
  onSwipeEnd?.(event.nativeEvent.state)
223
+ const side = event.nativeEvent.side
224
+ if (side === 'left') onLeftSwipeEnd?.(event.nativeEvent.state)
225
+ if (side === 'right') onRightSwipeEnd?.(event.nativeEvent.state)
148
226
  },
149
- [onSwipeEnd]
227
+ [normalizedKey, onLeftSwipeEnd, onRightSwipeEnd, onSwipeEnd]
150
228
  )
151
229
 
152
230
  return (
153
231
  <SwipeableView
154
232
  ref={nativeRef}
155
233
  style={[styles.container, style]}
156
- actionsWidth={actionsWidth}
157
- actionsPosition={actionsPosition}
234
+ leftActionsWidth={resolved.left.width}
235
+ rightActionsWidth={resolved.right.width}
236
+ onActionsNeeded={handleActionsNeeded}
237
+ {...(Platform.OS === 'ios'
238
+ ? { appearance, hapticFeedback: hapticFeedback ?? appearance === 'ios26' }
239
+ : {})}
158
240
  friction={friction}
159
241
  threshold={threshold}
160
242
  gestureEnabled={gestureEnabled}
@@ -164,20 +246,32 @@ const Swipeable = forwardRef<SwipeableMethods, SwipeableProps>(function Swipeabl
164
246
  {...(normalizedKey ? { recyclingKey: normalizedKey } : {})}
165
247
  {...(testID ? { testID } : {})}
166
248
  onSwipeStart={handleSwipeStart}
249
+ onGestureCancelled={handleGestureCancelled}
167
250
  onSwipeProgress={handleSwipeProgress}
168
251
  onSwipeStateChange={handleSwipeStateChange}
169
252
  onSwipeEnd={handleSwipeEnd}
170
253
  >
171
- {hasActionsRendered && (
172
- <SwipeableActions
173
- actionsPosition={actionsPosition}
174
- {...(testID ? { testID: `${testID}-actions` } : {})}
175
- >
176
- {actions}
177
- </SwipeableActions>
178
- )}
179
-
180
- <View style={styles.content}>{children}</View>
254
+ <SwipeableActions
255
+ key='left-slot'
256
+ actionsPosition='left'
257
+ {...(testID ? { testID: `${testID}-left-actions` } : {})}
258
+ >
259
+ <Fragment key={normalizedKey ?? ''}>
260
+ {sides.left && resolved.left.width > 0 ? resolved.left.node : null}
261
+ </Fragment>
262
+ </SwipeableActions>
263
+ <SwipeableActions
264
+ key='right-slot'
265
+ actionsPosition='right'
266
+ {...(testID ? { testID: `${testID}-right-actions` } : {})}
267
+ >
268
+ <Fragment key={normalizedKey ?? ''}>
269
+ {sides.right && resolved.right.width > 0 ? resolved.right.node : null}
270
+ </Fragment>
271
+ </SwipeableActions>
272
+ <View key='content' style={styles.content} collapsable={false}>
273
+ {children}
274
+ </View>
181
275
  </SwipeableView>
182
276
  )
183
277
  })
@@ -196,21 +290,20 @@ const styles = StyleSheet.create({
196
290
  Swipeable.displayName = 'Swipeable'
197
291
 
198
292
  // Static methods for controlling Swipeables by recyclingKey
199
- type SwipeableWithStatic = typeof Swipeable & {
200
- open: (recyclingKey: string) => void
201
- close: (recyclingKey: string, animated?: boolean) => void
202
- cancel: (recyclingKey: string) => void
203
- closeAll: (animated?: boolean) => void
204
- }
293
+ type SwipeableWithStatic = ForwardRefExoticComponent<
294
+ SwipeableComponentProps & RefAttributes<SwipeableMethods>
295
+ > &
296
+ SwipeableStatic
205
297
 
206
298
  const SwipeableExport = Swipeable as SwipeableWithStatic
207
299
 
208
- SwipeableExport.open = (recyclingKey: string) => {
300
+ SwipeableExport.open = (recyclingKey: string, side?: SwipeSide) => {
209
301
  if (__DEV__ && (!recyclingKey || typeof recyclingKey !== 'string')) {
210
302
  console.warn('[Swipeable.open] recyclingKey must be a non-empty string')
211
303
  return
212
304
  }
213
- openByKey(recyclingKey)
305
+ if (side === undefined) openByKey(recyclingKey)
306
+ else openByKeyOnSide(recyclingKey, side)
214
307
  }
215
308
 
216
309
  SwipeableExport.close = (recyclingKey: string, animated?: boolean) => {
@@ -1,16 +1,32 @@
1
1
  import type { ReactNode, Ref } from 'react'
2
2
  import type { StyleProp, ViewProps, ViewStyle } from 'react-native'
3
3
 
4
+ export type SwipeSide = 'left' | 'right'
5
+ export type SwipeAppearance = 'default' | 'ios26'
6
+
7
+ /** Internal native identity; the consumer's onSwipeStart remains zero-argument. */
8
+ export interface SwipeStartEvent {
9
+ nativeEvent: { recyclingKey?: string; side?: SwipeSide }
10
+ }
11
+
12
+ export interface ActionsNeededEvent {
13
+ nativeEvent: { side: SwipeSide; recyclingKey?: string }
14
+ }
15
+
4
16
  export interface SwipeProgressEvent {
5
17
  nativeEvent: {
6
18
  progress: number
7
19
  translationX: number
20
+ side?: SwipeSide
21
+ recyclingKey?: string
8
22
  }
9
23
  }
10
24
 
11
25
  export interface SwipeStateEvent {
12
26
  nativeEvent: {
13
27
  state: 'open' | 'closed'
28
+ side?: SwipeSide
29
+ recyclingKey?: string
14
30
  }
15
31
  }
16
32
 
@@ -20,16 +36,20 @@ export interface NativeSwipeableRef {
20
36
  cancel: () => Promise<void>
21
37
  }
22
38
 
39
+ /** Native child order is always left slot, right slot, then content. */
23
40
  export interface SwipeableViewProps extends ViewProps {
24
- actionsWidth: number
25
- actionsPosition?: 'left' | 'right'
41
+ leftActionsWidth?: number
42
+ rightActionsWidth?: number
43
+ onActionsNeeded?: (event: ActionsNeededEvent) => void
44
+ appearance?: SwipeAppearance
45
+ hapticFeedback?: boolean
26
46
  friction?: number
27
47
  threshold?: number
28
48
  gestureEnabled?: boolean
29
49
  dragOffsetFromEdge?: number
30
50
  recyclingKey?: string
31
51
  onSwipeProgress?: (event: SwipeProgressEvent) => void
32
- onSwipeStart?: () => void
52
+ onSwipeStart?: (event: SwipeStartEvent) => void
33
53
  onSwipeStateChange?: (event: SwipeStateEvent) => void
34
54
  onSwipeEnd?: (event: SwipeStateEvent) => void
35
55
  autoClose?: boolean
@@ -40,11 +60,13 @@ export interface SwipeableViewProps extends ViewProps {
40
60
  testID?: string
41
61
  }
42
62
 
43
- export interface SwipeableProps {
63
+ /** Props that every side configuration shares. Wrappers that emit declarations name it. */
64
+ export interface SwipeableCommonProps {
44
65
  children: ReactNode
45
- actions: ReactNode
46
- actionsWidth: number
47
- actionsPosition?: 'left' | 'right'
66
+ onLeftSwipeEnd?: (state: 'open' | 'closed') => void
67
+ onRightSwipeEnd?: (state: 'open' | 'closed') => void
68
+ appearance?: SwipeAppearance
69
+ hapticFeedback?: boolean
48
70
  friction?: number
49
71
  threshold?: number
50
72
  gestureEnabled?: boolean
@@ -63,13 +85,32 @@ export interface SwipeableProps {
63
85
 
64
86
  export interface SwipeableMethods {
65
87
  close: (animated?: boolean) => void
66
- open: () => void
88
+ /** Open the requested physical edge; without a side, prefer enabled right, then left. */
89
+ open: (side?: SwipeSide) => void
67
90
  cancel: () => void
68
91
  }
69
92
 
70
93
  export interface SwipeableStatic {
71
- open: (recyclingKey: string) => void
94
+ open: (recyclingKey: string, side?: SwipeSide) => void
72
95
  close: (recyclingKey: string, animated?: boolean) => void
73
96
  cancel: (recyclingKey: string) => void
74
97
  closeAll: (animated?: boolean) => void
75
98
  }
99
+
100
+ type LeftActionsConfiguration =
101
+ | { leftActions: ReactNode; leftActionsWidth: number }
102
+ | { leftActions?: null | false | undefined; leftActionsWidth?: never }
103
+
104
+ type RightActionsConfiguration =
105
+ | { rightActions: ReactNode; rightActionsWidth: number }
106
+ | { rightActions?: null | false | undefined; rightActionsWidth?: never }
107
+
108
+ /** The sole action API: explicit physical-edge pairs, including single-sided rows. */
109
+ export type SwipeableProps = SwipeableCommonProps &
110
+ LeftActionsConfiguration &
111
+ RightActionsConfiguration &
112
+ ({ leftActions: ReactNode } | { rightActions: ReactNode })
113
+
114
+ /** Descriptive alias for the same explicit-side API, not a separate implementation. */
115
+ export type TwoSidedSwipeableProps = SwipeableProps
116
+ export type SwipeableComponentProps = SwipeableProps
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react'
2
- import { StyleSheet, View } from 'react-native'
2
+ import { I18nManager, StyleSheet, View } from 'react-native'
3
3
 
4
4
  /**
5
5
  * Props for SwipeableActions component
@@ -16,14 +16,17 @@ export interface SwipeableActionsProps {
16
16
  /**
17
17
  * SwipeableActions - Internal layout component for swipeable actions
18
18
  *
19
- * Handles entrance animation when children mount.
19
+ * Provides a stable physical-edge slot while action content mounts lazily.
20
20
  */
21
21
  export function SwipeableActions({ children, actionsPosition, testID }: SwipeableActionsProps) {
22
+ // Side names are physical edges; flex row start/end reverse in RTL.
23
+ const leading = I18nManager.isRTL ? actionsPosition === 'right' : actionsPosition === 'left'
22
24
  return (
23
25
  <View
24
- style={actionsPosition === 'left' ? styles.actionsLeading : styles.actionsTrailing}
26
+ style={leading ? styles.actionsLeading : styles.actionsTrailing}
25
27
  pointerEvents={children ? 'auto' : 'none'}
26
28
  testID={testID}
29
+ collapsable={false}
27
30
  >
28
31
  {children}
29
32
  </View>
@@ -1,8 +1,13 @@
1
1
  import { requireNativeModule, requireNativeViewManager } from 'expo-modules-core'
2
2
  import { type ComponentProps, createElement, forwardRef } from 'react'
3
- import type { NativeSwipeableRef, SwipeableViewProps } from './Swipeable.types'
3
+ import type { NativeSwipeableRef, SwipeableViewProps, SwipeSide } from './Swipeable.types'
4
4
 
5
- const NativeView = requireNativeViewManager<SwipeableViewProps>('Swipeable')
5
+ type NativeViewProps = SwipeableViewProps & {
6
+ /** Internal lifecycle notification; never forwarded as a consumer completion. */
7
+ onGestureCancelled?: (event: { nativeEvent: { recyclingKey?: string } }) => void
8
+ }
9
+
10
+ const NativeView = requireNativeViewManager<NativeViewProps>('Swipeable')
6
11
 
7
12
  // Module-level functions interface
8
13
  interface SwipeableNativeModule {
@@ -11,11 +16,13 @@ interface SwipeableNativeModule {
11
16
  cancelByKey: (key: string) => void
12
17
  closeAll: (animated?: boolean) => void
13
18
  isOpenByKey: (key: string) => boolean
19
+ openByKeyOnSide: (key: string, side: SwipeSide) => void
20
+ getOpenSideByKey: (key: string) => SwipeSide | null
14
21
  }
15
22
 
16
23
  export const SwipeableModule = requireNativeModule<SwipeableNativeModule>('Swipeable')
17
24
 
18
- export const SwipeableView = forwardRef<NativeSwipeableRef, SwipeableViewProps>(
25
+ export const SwipeableView = forwardRef<NativeSwipeableRef, NativeViewProps>(
19
26
  function SwipeableView(props, ref) {
20
27
  return createElement(NativeView, { ...props, ref } as ComponentProps<typeof NativeView>)
21
28
  }
package/src/constants.ts CHANGED
@@ -7,6 +7,5 @@ export const SWIPE_DEFAULTS = {
7
7
  FRICTION: 1,
8
8
  THRESHOLD: 0.4,
9
9
  DRAG_OFFSET_FROM_EDGE: 0,
10
- ACTIONS_POSITION: 'right',
11
10
  GESTURE_ENABLED: true
12
11
  } as const
package/src/index.ts CHANGED
@@ -4,12 +4,19 @@ export type { NativeModuleError, SwipeableErrorHandler } from './nativeModuleUti
4
4
  export { setGlobalErrorHandler } from './nativeModuleUtils'
5
5
  export { default as Swipeable } from './Swipeable'
6
6
  export type {
7
+ ActionsNeededEvent,
7
8
  NativeSwipeableRef,
9
+ SwipeAppearance,
10
+ SwipeSide,
11
+ SwipeableCommonProps,
12
+ SwipeableComponentProps,
13
+ TwoSidedSwipeableProps,
8
14
  SwipeableMethods,
9
15
  SwipeableProps,
10
16
  SwipeableStatic,
11
17
  SwipeableViewProps,
12
18
  SwipeProgressEvent,
19
+ SwipeStartEvent,
13
20
  SwipeStateEvent
14
21
  } from './Swipeable.types'
15
22
  export { SwipeableView } from './SwipeableView'
@@ -1,4 +1,5 @@
1
1
  import { SwipeableModule } from './SwipeableView'
2
+ import type { SwipeSide } from './Swipeable.types'
2
3
 
3
4
  export interface NativeModuleError {
4
5
  code: string
@@ -40,6 +41,24 @@ export function openByKey(key: string): void {
40
41
  }
41
42
  }
42
43
 
44
+ export function openByKeyOnSide(key: string, side: SwipeSide): void {
45
+ try {
46
+ SwipeableModule.openByKeyOnSide(key, side)
47
+ } catch (error) {
48
+ handleNativeError(error, 'openByKeyOnSide', key)
49
+ }
50
+ }
51
+
52
+ export function getOpenSideByKey(key: string): SwipeSide | null {
53
+ try {
54
+ const side = SwipeableModule.getOpenSideByKey(key)
55
+ return side === 'left' || side === 'right' ? side : null
56
+ } catch (error) {
57
+ handleNativeError(error, 'getOpenSideByKey', key)
58
+ return null
59
+ }
60
+ }
61
+
43
62
  export function closeByKey(key: string, animated?: boolean): void {
44
63
  try {
45
64
  SwipeableModule.closeByKey(key, animated)
@@ -63,11 +82,3 @@ export function closeAll(animated?: boolean): void {
63
82
  handleNativeError(error, 'closeAll')
64
83
  }
65
84
  }
66
-
67
- export function isOpenByKey(key: string): boolean {
68
- try {
69
- return SwipeableModule.isOpenByKey(key)
70
- } catch {
71
- return false
72
- }
73
- }
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { SwipeSide } from './Swipeable.types'
3
+
4
+ interface ActionsInput {
5
+ leftActions?: ReactNode
6
+ leftActionsWidth?: number
7
+ rightActions?: ReactNode
8
+ rightActionsWidth?: number
9
+ }
10
+
11
+ export interface ResolvedActions {
12
+ node: ReactNode
13
+ width: number
14
+ }
15
+
16
+ const isContent = (node: ReactNode) => node != null && typeof node !== 'boolean'
17
+ const isWidth = (width: number | undefined): width is number =>
18
+ width !== undefined && Number.isFinite(width) && width > 0
19
+
20
+ export function resolveSwipeableActions(input: ActionsInput) {
21
+ const warnings: string[] = []
22
+ const resolve = (side: SwipeSide, node: ReactNode, width?: number): ResolvedActions => {
23
+ if (node !== undefined) {
24
+ if (!isContent(node)) return { node: null, width: 0 }
25
+ if (isWidth(width)) return { node, width }
26
+ warnings.push(side + 'Actions requires a finite, positive ' + side + 'ActionsWidth.')
27
+ return { node: null, width: 0 }
28
+ }
29
+ if (width !== undefined) {
30
+ warnings.push(side + 'ActionsWidth is ignored without explicit ' + side + 'Actions.')
31
+ }
32
+ return { node: null, width: 0 }
33
+ }
34
+ const left = resolve('left', input.leftActions, input.leftActionsWidth)
35
+ const right = resolve('right', input.rightActions, input.rightActionsWidth)
36
+ return { left, right, warnings }
37
+ }