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
@@ -0,0 +1,951 @@
1
+ package expo.modules.swipeable
2
+
3
+ import android.content.Context
4
+ import android.os.SystemClock
5
+ import android.view.Choreographer
6
+ import android.view.MotionEvent
7
+ import android.view.VelocityTracker
8
+ import android.view.View
9
+ import android.view.ViewConfiguration
10
+ import android.view.ViewGroup
11
+ import androidx.collection.LruCache
12
+ import androidx.dynamicanimation.animation.DynamicAnimation
13
+ import androidx.dynamicanimation.animation.SpringAnimation
14
+ import androidx.dynamicanimation.animation.SpringForce
15
+ import expo.modules.kotlin.AppContext
16
+ import expo.modules.kotlin.viewevent.EventDispatcher
17
+ import expo.modules.kotlin.views.ExpoView
18
+ import java.lang.ref.WeakReference
19
+ import kotlin.math.abs
20
+
21
+ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
22
+
23
+ companion object {
24
+ private const val MAX_CACHE_SIZE = 1000
25
+ private const val GESTURE_TIMEOUT_MS = 5000L
26
+
27
+ /** Thread-safe LRU cache for open state - prevents unbounded memory growth */
28
+ private val openStateCache = LruCache<String, Boolean>(MAX_CACHE_SIZE)
29
+
30
+ /** View registry for static method access by recyclingKey */
31
+ private val viewRegistry = mutableMapOf<String, WeakReference<SwipeableView>>()
32
+
33
+ /** Lock for thread-safe registry access */
34
+ private val registryLock = Any()
35
+
36
+ // Thread-safe accessors (LruCache is already synchronized internally)
37
+ private fun getOpenState(key: String): Boolean = openStateCache[key] ?: false
38
+
39
+ private fun setOpenState(key: String, isOpen: Boolean) {
40
+ openStateCache.put(key, isOpen)
41
+ }
42
+
43
+ private fun getView(key: String): SwipeableView? = synchronized(registryLock) {
44
+ viewRegistry[key]?.get()
45
+ }
46
+
47
+ private fun registerView(view: SwipeableView, key: String) = synchronized(registryLock) {
48
+ viewRegistry[key] = WeakReference(view)
49
+ }
50
+
51
+ private fun unregisterView(key: String) = synchronized(registryLock) {
52
+ viewRegistry.remove(key)
53
+ }
54
+
55
+ private fun getAllViews(): List<SwipeableView> = synchronized(registryLock) {
56
+ viewRegistry.values.mapNotNull { it.get() }
57
+ }
58
+
59
+ // Static methods (by recyclingKey)
60
+ fun openByKey(key: String) {
61
+ getView(key)?.let { view ->
62
+ view.post { view.open() }
63
+ }
64
+ }
65
+
66
+ fun closeByKey(key: String, animated: Boolean = true) {
67
+ getView(key)?.let { view ->
68
+ view.post { view.close(animated) }
69
+ }
70
+ }
71
+
72
+ /** Close all swipeables. When animated=true, only closes open views. When animated=false, resets all views. */
73
+ fun closeAll(animated: Boolean = true) {
74
+ getAllViews().forEach { view ->
75
+ if (animated) {
76
+ // Only close open views when animated
77
+ if (view.isOpen) {
78
+ view.post { view.close(animated = true) }
79
+ }
80
+ } else {
81
+ // Reset all views when not animated
82
+ view.post { view.close(animated = false) }
83
+ }
84
+ }
85
+ }
86
+
87
+ /** Clear all cached state - useful for app reset scenarios */
88
+ fun clearCache() {
89
+ openStateCache.evictAll()
90
+ }
91
+ }
92
+
93
+ private val density = context.resources.displayMetrics.density
94
+
95
+ var actionsWidth: Float = 0f
96
+ set(value) {
97
+ val validated = maxOf(0f, value)
98
+ if (field == validated) return
99
+ field = validated
100
+ requestLayout()
101
+ }
102
+
103
+ private val actionsWidthPx: Float
104
+ get() = actionsWidth * density
105
+
106
+ var actionsPosition: String = "right"
107
+ set(value) {
108
+ val validated = if (value == "left" || value == "right") value else "right"
109
+ if (field == validated) return
110
+ field = validated
111
+ requestLayout()
112
+ }
113
+
114
+ val isLeading: Boolean get() = actionsPosition == "left"
115
+
116
+ var friction: Float = 1.0f
117
+ set(value) { field = value.coerceIn(0f, 1f) }
118
+
119
+ var threshold: Float = 0.4f
120
+ set(value) { field = value.coerceIn(0f, 1f) }
121
+
122
+ var dragOffsetFromEdge: Float = 0f
123
+ set(value) { field = maxOf(0f, value) }
124
+
125
+ var autoClose: Boolean = false
126
+
127
+ var autoCloseTimeout: Float = 0f // milliseconds
128
+ set(value) { field = maxOf(0f, value) }
129
+
130
+ private val dragOffsetFromEdgePx: Float
131
+ get() = dragOffsetFromEdge * density
132
+
133
+ /** Key for recycling detection - when this changes, view saves/restores state from cache */
134
+ var recyclingKey: String? = null
135
+ set(value) {
136
+ val oldValue = field
137
+ if (oldValue == value) return
138
+ field = value
139
+
140
+ // Thread-safe read of new key's state
141
+ val shouldOpen = value?.let { getOpenState(it) } ?: false
142
+ pendingShouldOpen = shouldOpen
143
+
144
+ // Thread-safe write of old key's state and unregister
145
+ if (oldValue != null) {
146
+ setOpenState(oldValue, isOpen)
147
+ unregisterView(oldValue)
148
+ }
149
+
150
+ // Thread-safe register with new key
151
+ if (value != null) {
152
+ registerView(this, value)
153
+ }
154
+
155
+ close(animated = false)
156
+
157
+ // Apply using local shouldOpen (pendingShouldOpen was cleared by close)
158
+ if (shouldOpen) {
159
+ post { snapToOpen() }
160
+ }
161
+ }
162
+
163
+ private var currentTranslation: Float = 0f
164
+ private var isOpen: Boolean = false
165
+ private var startOffset: Float = 0f
166
+ private var isDragging: Boolean = false
167
+ private var isAnimating: Boolean = false
168
+ private var gestureStartTranslation: Float = 0f
169
+ private var isGestureActivated: Boolean = false
170
+ private var lastEmittedProgress: Float = -1f
171
+ private var pendingShouldOpen: Boolean = false
172
+ private var lastEmittedWillEndState: String? = null
173
+
174
+ // Track if open animation was explicitly interrupted by close() for autoClose
175
+ private var openAnimationInterruptedByClose: Boolean = false
176
+
177
+ // Override onSwipeEnd state when autoClose triggers close() (emit "open" instead of "closed")
178
+ private var autoCloseEndEventOverride: Boolean = false
179
+
180
+ private var contentView: View? = null
181
+ private var actionsView: View? = null
182
+
183
+ private val onSwipeProgress by EventDispatcher<Map<String, Any>>()
184
+ private val onSwipeStart by EventDispatcher<Map<String, Any>>()
185
+ private val onSwipeStateChange by EventDispatcher<Map<String, Any>>()
186
+ private val onSwipeEnd by EventDispatcher<Map<String, Any>>()
187
+
188
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
189
+ private var velocityTracker: VelocityTracker? = null
190
+ private var touchStartX: Float = 0f
191
+ private var touchStartY: Float = 0f
192
+ private var isIntercepting: Boolean = false
193
+
194
+ // Touch interception for swipe detection
195
+ private var dispatchTouchStartX: Float = 0f
196
+ private var dispatchTouchStartY: Float = 0f
197
+ private var isBlockingChildEvents: Boolean = false
198
+
199
+ private var contentSpringAnimation: SpringAnimation? = null
200
+ private var actionsSpringAnimation: SpringAnimation? = null
201
+
202
+ private var frameCallback: Choreographer.FrameCallback? = null
203
+
204
+ // Gesture timeout for stuck gesture recovery
205
+ private var gestureTimeoutRunnable: Runnable? = null
206
+
207
+ // Auto-close runnable for tracking and cancellation
208
+ private var autoCloseRunnable: Runnable? = null
209
+
210
+ init {
211
+ clipChildren = false
212
+ clipToPadding = false
213
+ setWillNotDraw(false)
214
+ }
215
+
216
+ override fun setClipChildren(clipChildren: Boolean) {
217
+ super.setClipChildren(false)
218
+ }
219
+
220
+ override fun setClipToPadding(clipToPadding: Boolean) {
221
+ super.setClipToPadding(false)
222
+ }
223
+
224
+ override fun onViewAdded(child: View?) {
225
+ super.onViewAdded(child)
226
+ child ?: return
227
+
228
+ val wasActionsNil = actionsView == null
229
+ val wasContentNil = contentView == null
230
+
231
+ // With lazy Actions rendering:
232
+ // - When Actions not mounted: only content at index 0
233
+ // - When Actions mounted: actions at index 0, content at index 1
234
+ // Always re-evaluate all children since React may add them in any order
235
+ if (childCount == 1) {
236
+ // Only one child - it's content (Actions not mounted yet)
237
+ contentView = getChildAt(0)
238
+ actionsView = null
239
+ } else if (childCount >= 2) {
240
+ // Always check both positions - React may have reordered children
241
+ actionsView = getChildAt(0)
242
+ contentView = getChildAt(1)
243
+ }
244
+
245
+ if (wasContentNil && contentView != null && (isOpen || currentTranslation != 0f)) {
246
+ contentView?.translationX = currentTranslation
247
+ }
248
+
249
+ if (wasActionsNil && actionsView != null) {
250
+ if (isOpen || isAnimating) {
251
+ actionsView?.visibility = View.VISIBLE
252
+ actionsView?.translationX = 0f
253
+ }
254
+ else if (isDragging) {
255
+ actionsView?.visibility = View.VISIBLE
256
+ updateActionsTransform()
257
+ }
258
+ else if (pendingShouldOpen) {
259
+ pendingShouldOpen = false
260
+ post { snapToOpen() }
261
+ }
262
+ }
263
+ }
264
+
265
+ override fun onViewRemoved(child: View?) {
266
+ super.onViewRemoved(child)
267
+ if (child === actionsView) {
268
+ actionsView = null
269
+ } else if (child === contentView) {
270
+ contentView = null
271
+ }
272
+ }
273
+
274
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
275
+ val width = right - left
276
+ val height = bottom - top
277
+
278
+ for (i in 0 until childCount) {
279
+ val child = getChildAt(i)
280
+ if (child === actionsView && actionsWidthPx > 0f) {
281
+ val actionsWidthInt = actionsWidthPx.toInt()
282
+ if (isLeading) {
283
+ child.layout(0, 0, actionsWidthInt, height)
284
+ } else {
285
+ child.layout(width - actionsWidthInt, 0, width, height)
286
+ }
287
+ } else {
288
+ child.layout(0, 0, width, height)
289
+ }
290
+ }
291
+
292
+ if (!isDragging && !isAnimating && currentTranslation == 0f && actionsWidthPx > 0f) {
293
+ val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
294
+ actionsView?.translationX = actionsOffset
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Cancels React Native's JS touch handling using the official API.
300
+ * Calls onChildStartedNativeGesture() which properly notifies
301
+ * JSTouchDispatcher and JSPointerDispatcher to cancel tracking.
302
+ */
303
+ private fun cancelReactTouches(event: MotionEvent) {
304
+ // Find ReactRootView by walking up hierarchy and checking for the method
305
+ var currentView: ViewGroup? = parent as? ViewGroup
306
+ while (currentView != null) {
307
+ try {
308
+ // Try to find onChildStartedNativeGesture method
309
+ val method = currentView.javaClass.getMethod(
310
+ "onChildStartedNativeGesture",
311
+ View::class.java,
312
+ MotionEvent::class.java
313
+ )
314
+ method.invoke(currentView, this, event)
315
+ return
316
+ } catch (e: NoSuchMethodException) {
317
+ // Method not found, continue up the hierarchy
318
+ } catch (e: Exception) {
319
+ // Method found but invocation failed
320
+ break
321
+ }
322
+ currentView = currentView.parent as? ViewGroup
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Simple touch interception: forward DOWN, send CANCEL when swipe detected.
328
+ *
329
+ * Flow:
330
+ * - DOWN: Forward to children (they start tracking), init our tracking
331
+ * - MOVE: If horizontal swipe → send CANCEL to children, block further events
332
+ * - UP: If blocking → consume (children never see UP, no onPress)
333
+ */
334
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
335
+ if (actionsWidth <= 0f) {
336
+ return super.dispatchTouchEvent(event)
337
+ }
338
+
339
+ when (event.actionMasked) {
340
+ MotionEvent.ACTION_DOWN -> {
341
+ dispatchTouchStartX = event.rawX
342
+ dispatchTouchStartY = event.rawY
343
+ isBlockingChildEvents = false
344
+
345
+ // Initialize tracking
346
+ touchStartX = event.rawX
347
+ touchStartY = event.rawY
348
+ velocityTracker?.recycle()
349
+ velocityTracker = VelocityTracker.obtain()
350
+ velocityTracker?.addMovement(event)
351
+
352
+ // Handle in onTouchEvent for animation interruption
353
+ onTouchEvent(event)
354
+
355
+ // Forward DOWN to children
356
+ return super.dispatchTouchEvent(event)
357
+ }
358
+
359
+ MotionEvent.ACTION_MOVE -> {
360
+ velocityTracker?.addMovement(event)
361
+
362
+ // Already blocking - consume all MOVE events
363
+ if (isBlockingChildEvents) {
364
+ onTouchEvent(event)
365
+ return true
366
+ }
367
+
368
+ val dx = abs(event.rawX - dispatchTouchStartX)
369
+ val dy = abs(event.rawY - dispatchTouchStartY)
370
+
371
+ // Detect horizontal swipe
372
+ if (dx > touchSlop && dx > dy) {
373
+ val horizontalDelta = event.rawX - dispatchTouchStartX
374
+ val isCorrectDirection = if (isOpen) {
375
+ true
376
+ } else {
377
+ if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
378
+ }
379
+
380
+ if (isCorrectDirection) {
381
+ // Swipe detected - cancel React touches, start blocking
382
+ isBlockingChildEvents = true
383
+ cancelReactTouches(event)
384
+ parent?.requestDisallowInterceptTouchEvent(true)
385
+ onTouchEvent(event)
386
+ return true
387
+ }
388
+ }
389
+
390
+ // Not a swipe - forward to children
391
+ return super.dispatchTouchEvent(event)
392
+ }
393
+
394
+ MotionEvent.ACTION_UP -> {
395
+ velocityTracker?.addMovement(event)
396
+
397
+ if (isBlockingChildEvents) {
398
+ // Swipe ended - consume UP (children already got CANCEL)
399
+ onTouchEvent(event)
400
+ isBlockingChildEvents = false
401
+ return true
402
+ }
403
+
404
+ // Not blocking - forward to children (tap)
405
+ return super.dispatchTouchEvent(event)
406
+ }
407
+
408
+ MotionEvent.ACTION_CANCEL -> {
409
+ isBlockingChildEvents = false
410
+ onTouchEvent(event)
411
+ return super.dispatchTouchEvent(event)
412
+ }
413
+ }
414
+
415
+ return super.dispatchTouchEvent(event)
416
+ }
417
+
418
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
419
+ if (actionsWidth <= 0f) return false
420
+
421
+ when (event.actionMasked) {
422
+ MotionEvent.ACTION_DOWN -> {
423
+ touchStartX = event.rawX
424
+ touchStartY = event.rawY
425
+ isIntercepting = false
426
+
427
+ if (isAnimating) {
428
+ contentSpringAnimation?.cancel()
429
+ isAnimating = false
430
+ }
431
+
432
+ velocityTracker?.recycle()
433
+ velocityTracker = VelocityTracker.obtain()
434
+ velocityTracker?.addMovement(event)
435
+ }
436
+ MotionEvent.ACTION_MOVE -> {
437
+ velocityTracker?.addMovement(event)
438
+ val horizontalDelta = event.rawX - touchStartX
439
+ val verticalDelta = event.rawY - touchStartY
440
+
441
+ if (abs(horizontalDelta) > touchSlop && abs(horizontalDelta) > abs(verticalDelta)) {
442
+ val isCorrectDirection = if (isOpen) {
443
+ true
444
+ } else {
445
+ if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
446
+ }
447
+ val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
448
+
449
+ if (isCorrectDirection && meetsOffset) {
450
+ isIntercepting = true
451
+ parent?.requestDisallowInterceptTouchEvent(true)
452
+ return true
453
+ }
454
+ }
455
+ }
456
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
457
+ isIntercepting = false
458
+ parent?.requestDisallowInterceptTouchEvent(false)
459
+ }
460
+ }
461
+ return false
462
+ }
463
+
464
+ override fun onTouchEvent(event: MotionEvent): Boolean {
465
+ if (actionsWidth <= 0f) return false
466
+
467
+ velocityTracker?.addMovement(event)
468
+
469
+ when (event.actionMasked) {
470
+ MotionEvent.ACTION_DOWN -> {
471
+ touchStartX = event.rawX
472
+ touchStartY = event.rawY
473
+ return true
474
+ }
475
+ MotionEvent.ACTION_MOVE -> {
476
+ val horizontalDelta = event.rawX - touchStartX
477
+
478
+ if (!isGestureActivated) {
479
+ val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
480
+ if (meetsOffset) {
481
+ activateGesture(horizontalDelta)
482
+ } else {
483
+ return true
484
+ }
485
+ }
486
+
487
+ handlePanUpdate(horizontalDelta)
488
+ }
489
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
490
+ if (isGestureActivated) {
491
+ velocityTracker?.computeCurrentVelocity(1000)
492
+ val horizontalDelta = event.rawX - touchStartX
493
+ handlePanEnd(horizontalDelta, velocityTracker?.xVelocity ?: 0f)
494
+ }
495
+ velocityTracker?.recycle()
496
+ velocityTracker = null
497
+ isIntercepting = false
498
+ }
499
+ }
500
+ return isGestureActivated || isIntercepting
501
+ }
502
+
503
+ private fun activateGesture(initialTranslation: Float) {
504
+ // Cancel any running animations and capture current position
505
+ contentSpringAnimation?.let { anim ->
506
+ if (anim.isRunning) {
507
+ currentTranslation = contentView?.translationX ?: currentTranslation
508
+ anim.cancel()
509
+ }
510
+ }
511
+ actionsSpringAnimation?.cancel()
512
+ isAnimating = false
513
+
514
+ isGestureActivated = true
515
+ isDragging = true
516
+ startOffset = currentTranslation
517
+ gestureStartTranslation = initialTranslation
518
+ lastEmittedWillEndState = if (isOpen) "open" else "closed"
519
+
520
+ actionsView?.visibility = View.VISIBLE
521
+ parent?.requestDisallowInterceptTouchEvent(true) // Prevent parent from stealing gesture
522
+
523
+ onSwipeStart(emptyMap())
524
+ startProgressUpdates()
525
+ startGestureTimeout()
526
+ }
527
+
528
+ private fun startGestureTimeout() {
529
+ cancelGestureTimeout()
530
+ gestureTimeoutRunnable = Runnable {
531
+ if (isGestureActivated) {
532
+ // Force gesture end if stuck
533
+ handlePanEnd(0f, 0f)
534
+ }
535
+ }
536
+ postDelayed(gestureTimeoutRunnable, GESTURE_TIMEOUT_MS)
537
+ }
538
+
539
+ private fun cancelGestureTimeout() {
540
+ gestureTimeoutRunnable?.let { removeCallbacks(it) }
541
+ gestureTimeoutRunnable = null
542
+ }
543
+
544
+ private fun cancelAutoClose() {
545
+ autoCloseRunnable?.let { removeCallbacks(it) }
546
+ autoCloseRunnable = null
547
+ }
548
+
549
+ private fun scheduleAutoClose() {
550
+ cancelAutoClose()
551
+ val delay = if (autoCloseTimeout > 0) autoCloseTimeout.toLong() else 0L
552
+ autoCloseRunnable = Runnable {
553
+ autoCloseRunnable = null
554
+ // Emit "open" state when autoClose completes - user's swipe intent was achieved
555
+ autoCloseEndEventOverride = true
556
+ close()
557
+ }
558
+ postDelayed(autoCloseRunnable, delay)
559
+ }
560
+
561
+ private fun handlePanUpdate(translationX: Float) {
562
+ if (!isGestureActivated || actionsWidth <= 0f) return
563
+
564
+ val effectiveTranslation = translationX - gestureStartTranslation
565
+
566
+ val rawTranslation = startOffset + (effectiveTranslation * friction)
567
+ // Always clamp: never allow translation past closed position (0)
568
+ val clampedTranslation = if (isLeading) maxOf(0f, rawTranslation) else minOf(0f, rawTranslation)
569
+ val targetTranslation = SwipePhysics.applyRubberBand(clampedTranslation, actionsWidthPx, isLeading)
570
+
571
+ currentTranslation = targetTranslation
572
+ contentView?.translationX = targetTranslation
573
+ updateActionsTransform()
574
+
575
+ // Emit onSwipeStateChange when threshold crossing changes intended state
576
+ val thresholdDistance = actionsWidthPx * threshold
577
+ val wouldOpen = SwipePhysics.shouldSnapToOpen(targetTranslation, thresholdDistance, isOpen, isLeading)
578
+ val intendedState = if (wouldOpen) "open" else "closed"
579
+ if (lastEmittedWillEndState != intendedState) {
580
+ lastEmittedWillEndState = intendedState
581
+ onSwipeStateChange(mapOf("state" to intendedState))
582
+ }
583
+ }
584
+
585
+ private fun handlePanEnd(translationX: Float, velocityX: Float) {
586
+ if (!isGestureActivated) return
587
+
588
+ val effectiveTranslation = translationX - gestureStartTranslation
589
+
590
+ isGestureActivated = false
591
+ gestureStartTranslation = 0f
592
+ isDragging = false
593
+ stopProgressUpdates()
594
+ cancelGestureTimeout()
595
+
596
+ val projectedX = SwipePhysics.projectFinalPosition(effectiveTranslation, velocityX, friction)
597
+ val thresholdDistance = actionsWidthPx * threshold
598
+ val shouldOpen = SwipePhysics.shouldSnapToOpen(projectedX, thresholdDistance, isOpen, isLeading)
599
+
600
+ // Only emit onSwipeStateChange if state changed from what was emitted during drag
601
+ // (velocity projection may result in different final state)
602
+ val intendedState = if (shouldOpen) "open" else "closed"
603
+ if (lastEmittedWillEndState != intendedState) {
604
+ lastEmittedWillEndState = intendedState
605
+ onSwipeStateChange(mapOf("state" to intendedState))
606
+ }
607
+
608
+ if (shouldOpen) {
609
+ // Only use velocity if it's in the opening direction (prevents backward bounce)
610
+ val openingVelocity = if (isLeading) {
611
+ if (velocityX > 0) velocityX else 0f // Leading: positive velocity opens
612
+ } else {
613
+ if (velocityX < 0) velocityX else 0f // Trailing: negative velocity opens
614
+ }
615
+ open(openingVelocity)
616
+ // Schedule auto-close if enabled
617
+ if (autoClose) {
618
+ scheduleAutoClose()
619
+ }
620
+ } else {
621
+ // Only use velocity if it's in the closing direction (prevents backward bounce)
622
+ val closingVelocity = if (isLeading) {
623
+ if (velocityX < 0) velocityX else 0f // Leading: negative velocity closes
624
+ } else {
625
+ if (velocityX > 0) velocityX else 0f // Trailing: positive velocity closes
626
+ }
627
+ close(velocity = closingVelocity)
628
+ }
629
+ }
630
+
631
+ private fun updateActionsTransform() {
632
+ val actions = actionsView ?: return
633
+ if (actionsWidthPx <= 0f) return
634
+
635
+ val progress = minOf(abs(currentTranslation) / actionsWidthPx, 1f)
636
+ val offset = if (isLeading) -actionsWidthPx else actionsWidthPx
637
+ val actionsTranslateX = offset * (1f - progress)
638
+
639
+ actions.translationX = actionsTranslateX
640
+ }
641
+
642
+ fun open(velocity: Float = 0f) {
643
+ isOpen = true
644
+ isAnimating = true
645
+ // Thread-safe persist to static cache for FlashList recycling
646
+ recyclingKey?.let { key -> setOpenState(key, true) }
647
+ // Note: actionsView visibility is already set during drag phase
648
+ if (actionsView?.visibility != View.VISIBLE) {
649
+ actionsView?.visibility = View.VISIBLE
650
+ }
651
+ val targetX = if (isLeading) actionsWidthPx else -actionsWidthPx
652
+
653
+ val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
654
+
655
+ // Start progress updates to emit progress during animation
656
+ startProgressUpdates()
657
+
658
+ // Reset the flag - close() will set it to true if it interrupts us
659
+ openAnimationInterruptedByClose = false
660
+ contentSpringAnimation?.cancel()
661
+ actionsSpringAnimation?.cancel()
662
+
663
+ val content = contentView
664
+ val actions = actionsView
665
+
666
+ if (content == null) {
667
+ currentTranslation = targetX
668
+ isAnimating = false
669
+ emitFinalProgress(1f, targetX)
670
+ return
671
+ }
672
+
673
+ // Define bounds and clamp starting position to prevent IllegalArgumentException
674
+ val minBound = if (isLeading) 0f else -actionsWidthPx
675
+ val maxBound = if (isLeading) actionsWidthPx else 0f
676
+
677
+ // Clamp content's current position to within bounds before starting animation
678
+ // (user may have overswept beyond the target)
679
+ val clampedStart = content.translationX.coerceIn(minBound, maxBound)
680
+ val wasClamped = content.translationX != clampedStart
681
+ if (wasClamped) {
682
+ content.translationX = clampedStart
683
+ currentTranslation = clampedStart
684
+ }
685
+
686
+ // If we clamped to the target, use zero velocity to prevent spring bounce
687
+ val effectiveVelocity = if (wasClamped && clampedStart == targetX) 0f else springVelocity
688
+
689
+ contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, targetX).apply {
690
+ spring = SpringForce(targetX).apply {
691
+ stiffness = SwipeConstants.OPEN_STIFFNESS
692
+ dampingRatio = SwipeConstants.OPEN_DAMPING_RATIO
693
+ }
694
+ // Clamp animation bounds to prevent spring overshoot from affecting UI
695
+ setMinValue(minBound)
696
+ setMaxValue(maxBound)
697
+ setStartVelocity(effectiveVelocity)
698
+ addUpdateListener { _, value, _ ->
699
+ currentTranslation = value
700
+ updateActionsTransform()
701
+ }
702
+ addEndListener { _, _, _, _ ->
703
+ if (openAnimationInterruptedByClose) {
704
+ // Animation was interrupted by close() (e.g., autoClose)
705
+ return@addEndListener
706
+ }
707
+ isAnimating = false
708
+ emitFinalProgress(1f, targetX)
709
+ // With autoClose, let close() emit onSwipeEnd("open") instead
710
+ if (!autoClose) {
711
+ onSwipeEnd(mapOf("state" to "open"))
712
+ }
713
+ }
714
+ start()
715
+ }
716
+ // Actions are now driven by updateActionsTransform() called from content's update listener
717
+ }
718
+
719
+ /** Instantly snap to open state without animation (for recycling) */
720
+ fun snapToOpen() {
721
+ contentSpringAnimation?.cancel()
722
+ actionsSpringAnimation?.cancel()
723
+
724
+ isOpen = true
725
+ isAnimating = false
726
+ val targetX = if (isLeading) actionsWidthPx else -actionsWidthPx
727
+ currentTranslation = targetX
728
+
729
+ // Set content translation immediately
730
+ contentView?.translationX = targetX
731
+
732
+ if (actionsView != null) {
733
+ // Actions already mounted - position them
734
+ actionsView?.visibility = View.VISIBLE
735
+ actionsView?.translationX = 0f
736
+ } else {
737
+ // Actions not mounted yet (React lazy rendering)
738
+ // Trigger onSwipeStart so React renders SwipeableActions
739
+ // onViewAdded will handle positioning when actions are added
740
+ onSwipeStart(emptyMap())
741
+ }
742
+
743
+ // Emit final progress
744
+ emitFinalProgress(1f, targetX)
745
+ }
746
+
747
+ fun close(animated: Boolean = true, velocity: Float = 0f) {
748
+ cancelAutoClose()
749
+ stopProgressUpdates()
750
+
751
+ // Cancel any running animations
752
+ openAnimationInterruptedByClose = true
753
+ contentSpringAnimation?.cancel()
754
+ actionsSpringAnimation?.cancel()
755
+
756
+ // Clear gesture state (unified from reset)
757
+ isDragging = false
758
+ isGestureActivated = false
759
+ gestureStartTranslation = 0f
760
+ pendingShouldOpen = false
761
+
762
+ isOpen = false
763
+ // Only persist to cache when animated (user action), not when resetting (recycling)
764
+ if (animated) {
765
+ recyclingKey?.let { key -> setOpenState(key, false) }
766
+ }
767
+
768
+ val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
769
+
770
+ if (animated) {
771
+ // Animated close with spring physics
772
+ isAnimating = true
773
+ startProgressUpdates()
774
+
775
+ val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
776
+ val content = contentView
777
+
778
+ if (content == null) {
779
+ currentTranslation = 0f
780
+ actionsView?.translationX = actionsOffset
781
+ isAnimating = false
782
+ openAnimationInterruptedByClose = false
783
+ emitFinalProgress(0f, 0f)
784
+ return
785
+ }
786
+
787
+ // Define bounds and clamp starting position
788
+ val minBound = if (isLeading) 0f else -actionsWidthPx
789
+ val maxBound = if (isLeading) actionsWidthPx else 0f
790
+
791
+ val clampedStart = content.translationX.coerceIn(minBound, maxBound)
792
+ val wasClamped = content.translationX != clampedStart
793
+ if (wasClamped) {
794
+ content.translationX = clampedStart
795
+ currentTranslation = clampedStart
796
+ }
797
+
798
+ val effectiveVelocity = if (wasClamped && clampedStart == 0f) 0f else springVelocity
799
+
800
+ contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, 0f).apply {
801
+ spring = SpringForce(0f).apply {
802
+ stiffness = SwipeConstants.CLOSE_STIFFNESS
803
+ dampingRatio = SwipeConstants.CLOSE_DAMPING_RATIO
804
+ }
805
+ setMinValue(minBound)
806
+ setMaxValue(maxBound)
807
+ setStartVelocity(effectiveVelocity)
808
+ addUpdateListener { _, value, _ ->
809
+ currentTranslation = value
810
+ updateActionsTransform()
811
+ }
812
+ addEndListener { _, _, _, _ ->
813
+ isAnimating = false
814
+ openAnimationInterruptedByClose = false
815
+ actionsView?.visibility = View.INVISIBLE
816
+ emitFinalProgress(0f, 0f)
817
+ // Emit "open" when autoClose completes - user's swipe intent was achieved
818
+ val endState = if (autoCloseEndEventOverride) "open" else "closed"
819
+ autoCloseEndEventOverride = false
820
+ onSwipeEnd(mapOf("state" to endState))
821
+ }
822
+ start()
823
+ }
824
+ } else {
825
+ // Instant close (formerly reset)
826
+ isAnimating = false
827
+ openAnimationInterruptedByClose = false
828
+ currentTranslation = 0f
829
+ startOffset = 0f
830
+ lastEmittedProgress = -1f
831
+
832
+ contentView?.translationX = 0f
833
+ actionsView?.translationX = actionsOffset
834
+ actionsView?.visibility = View.INVISIBLE
835
+ }
836
+ }
837
+
838
+ fun handleGestureStart() {
839
+ if (actionsWidth <= 0f) return
840
+
841
+ isDragging = true
842
+ startOffset = currentTranslation
843
+ gestureStartTranslation = 0f
844
+ isGestureActivated = true
845
+ lastEmittedWillEndState = if (isOpen) "open" else "closed"
846
+ onSwipeStart(emptyMap())
847
+ startProgressUpdates()
848
+ }
849
+
850
+ fun handleGestureUpdate(translationX: Float, velocityX: Float) {
851
+ if (actionsWidth <= 0f || !isGestureActivated) return
852
+
853
+ val rawTranslation = startOffset + (translationX * friction)
854
+ val targetTranslation = SwipePhysics.applyRubberBand(rawTranslation, actionsWidthPx, isLeading)
855
+
856
+ currentTranslation = targetTranslation
857
+ contentView?.translationX = targetTranslation
858
+ updateActionsTransform()
859
+ }
860
+
861
+ fun handleGestureEnd(translationX: Float, velocityX: Float) {
862
+ if (!isGestureActivated) return
863
+
864
+ isGestureActivated = false
865
+ gestureStartTranslation = 0f
866
+ isDragging = false
867
+ stopProgressUpdates()
868
+
869
+ val projectedX = SwipePhysics.projectFinalPosition(translationX, velocityX, friction)
870
+ val thresholdDistance = actionsWidthPx * threshold
871
+ val shouldOpen = SwipePhysics.shouldSnapToOpen(projectedX, thresholdDistance, isOpen, isLeading)
872
+
873
+ if (shouldOpen) {
874
+ open(velocityX)
875
+ } else {
876
+ close(velocity = velocityX)
877
+ }
878
+ }
879
+
880
+ private fun startProgressUpdates() {
881
+ if (frameCallback != null) return
882
+
883
+ frameCallback = Choreographer.FrameCallback {
884
+ emitProgressIfChanged()
885
+ if (isDragging || isAnimating) {
886
+ frameCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
887
+ } else {
888
+ // Callback stopped naturally - clear reference so startProgressUpdates() can restart it
889
+ frameCallback = null
890
+ }
891
+ }
892
+ frameCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
893
+ }
894
+
895
+ private fun stopProgressUpdates() {
896
+ frameCallback?.let {
897
+ Choreographer.getInstance().removeFrameCallback(it)
898
+ }
899
+ frameCallback = null
900
+ lastEmittedProgress = -1f
901
+ }
902
+
903
+ private fun emitProgressIfChanged() {
904
+ if (actionsWidthPx <= 0f) return
905
+
906
+ // During animation, read from view's actual translationX to get real animated value
907
+ // (SpringAnimation updates view.translationX directly, but currentTranslation may lag
908
+ // due to Choreographer callback ordering - our callback may run before SpringAnimation's)
909
+ val translation = if (isAnimating) {
910
+ contentView?.translationX ?: currentTranslation
911
+ } else {
912
+ currentTranslation
913
+ }
914
+
915
+ val progress = (abs(translation) / actionsWidthPx).coerceIn(0f, 1f)
916
+
917
+ if (abs(progress - lastEmittedProgress) > SwipeConstants.PROGRESS_CHANGE_THRESHOLD) {
918
+ lastEmittedProgress = progress
919
+ onSwipeProgress(mapOf(
920
+ "progress" to progress.toDouble(),
921
+ "translationX" to translation.toDouble()
922
+ ))
923
+ }
924
+ }
925
+
926
+ private fun emitFinalProgress(progress: Float, translationX: Float) {
927
+ lastEmittedProgress = progress
928
+ // Use fresh map for each emission
929
+ onSwipeProgress(mapOf(
930
+ "progress" to progress.toDouble(),
931
+ "translationX" to translationX.toDouble()
932
+ ))
933
+ }
934
+
935
+ override fun onDetachedFromWindow() {
936
+ super.onDetachedFromWindow()
937
+ stopProgressUpdates()
938
+ cancelGestureTimeout()
939
+ cancelAutoClose()
940
+ contentSpringAnimation?.cancel()
941
+ actionsSpringAnimation?.cancel()
942
+ velocityTracker?.recycle()
943
+ velocityTracker = null
944
+ // Reset state
945
+ isAnimating = false
946
+ isDragging = false
947
+ isGestureActivated = false
948
+ // Thread-safe deregister from view registry
949
+ recyclingKey?.let { unregisterView(it) }
950
+ }
951
+ }