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
@@ -12,11 +12,14 @@ import androidx.collection.LruCache
12
12
  import androidx.dynamicanimation.animation.DynamicAnimation
13
13
  import androidx.dynamicanimation.animation.SpringAnimation
14
14
  import androidx.dynamicanimation.animation.SpringForce
15
+ import com.facebook.react.uimanager.PointerEvents
15
16
  import com.facebook.react.uimanager.events.NativeGestureUtil
17
+ import com.facebook.react.views.view.ReactViewGroup
16
18
  import expo.modules.kotlin.AppContext
17
19
  import expo.modules.kotlin.viewevent.EventDispatcher
18
20
  import expo.modules.kotlin.views.ExpoView
19
21
  import java.lang.ref.WeakReference
22
+ import java.util.WeakHashMap
20
23
  import kotlin.math.abs
21
24
 
22
25
  class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
@@ -26,7 +29,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
26
29
  private const val GESTURE_TIMEOUT_MS = 5000L
27
30
 
28
31
  /** Thread-safe LRU cache for open state - prevents unbounded memory growth */
29
- private val openStateCache = LruCache<String, Boolean>(MAX_CACHE_SIZE)
32
+ private val openStateCache = LruCache<String, SwipeSide>(MAX_CACHE_SIZE)
30
33
 
31
34
  /** View registry for static method access by recyclingKey */
32
35
  private val viewRegistry = mutableMapOf<String, WeakReference<SwipeableView>>()
@@ -34,11 +37,25 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
34
37
  /** Lock for thread-safe registry access */
35
38
  private val registryLock = Any()
36
39
 
37
- // Thread-safe accessors (LruCache is already synchronized internally)
38
- private fun getOpenState(key: String): Boolean = openStateCache[key] ?: false
39
-
40
- private fun setOpenState(key: String, isOpen: Boolean) {
41
- openStateCache.put(key, isOpen)
40
+ /**
41
+ * Close ordering (guarded by registryLock). Each close takes the next sequence
42
+ * number. An open intent records the sequence it saw, so a close issued after it
43
+ * wins in every binding order.
44
+ */
45
+ private var closeSequence = 0L
46
+
47
+ /**
48
+ * Every live view, registered or not (guarded by registryLock). Only a view that
49
+ * holds a key can persist that key's open state, so a close marks the views that
50
+ * hold the key, not the key. Memory stays per live view.
51
+ */
52
+ private val liveViews = WeakHashMap<SwipeableView, Unit>()
53
+
54
+ // LruCache synchronizes access internally; absence represents closed.
55
+ private fun getOpenState(key: String): Boolean = openStateCache[key] != null
56
+
57
+ private fun setOpenState(key: String, side: SwipeSide?) {
58
+ if (side == null) openStateCache.remove(key) else openStateCache.put(key, side)
42
59
  }
43
60
 
44
61
  private fun getView(key: String): SwipeableView? = synchronized(registryLock) {
@@ -49,44 +66,117 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
49
66
  viewRegistry[key] = WeakReference(view)
50
67
  }
51
68
 
52
- private fun unregisterView(key: String) = synchronized(registryLock) {
53
- viewRegistry.remove(key)
69
+ private fun unregisterView(view: SwipeableView, key: String) = synchronized(registryLock) {
70
+ if (viewRegistry[key]?.get() === view) viewRegistry.remove(key)
54
71
  }
55
72
 
56
73
  private fun getAllViews(): List<SwipeableView> = synchronized(registryLock) {
57
74
  viewRegistry.values.mapNotNull { it.get() }
58
75
  }
59
76
 
60
- // Static methods (by recyclingKey)
61
- fun openByKey(key: String) {
62
- getView(key)?.let { view ->
63
- view.post { view.open() }
77
+ private fun currentIntent(): Long = synchronized(registryLock) { closeSequence }
78
+
79
+ private fun trackLiveView(view: SwipeableView) = synchronized(registryLock) {
80
+ liveViews[view] = Unit
81
+ }
82
+
83
+ // Call while holding registryLock.
84
+ private fun markHolders(sequence: Long, holds: (SwipeableView) -> Boolean) {
85
+ liveViews.keys.forEach { if (holds(it)) it.closedAtSequence = sequence }
86
+ }
87
+
88
+ /** The cached side and the intent it holds, read in one step. */
89
+ private fun readRestore(key: String): Pair<SwipeSide?, Long> = synchronized(registryLock) {
90
+ openStateCache[key] to closeSequence
91
+ }
92
+
93
+ /**
94
+ * Moves a view to its new key and reads that key's restore in one step. The view
95
+ * no longer holds the old key, so the old key's mark ends. An intent stamped
96
+ * before the bind belongs to an earlier binding and cannot persist the new key.
97
+ */
98
+ private fun bindRestore(view: SwipeableView, key: String?): Pair<SwipeSide?, Long>? =
99
+ synchronized(registryLock) {
100
+ if (view.heldKey != key) {
101
+ view.heldKey = key
102
+ view.closedAtSequence = closeSequence
103
+ }
104
+ key?.let(::readRestore)
105
+ }
106
+
107
+ /** Test seam: runs between a keyed open's registry read and its post. */
108
+ internal var afterOpenCapture: (() -> Unit)? = null
109
+
110
+ // The intent is read with the target and its binding, so a later close marks that
111
+ // target and a rebind before the post drops the open like any queued command.
112
+ private fun postOpen(key: String, side: SwipeSide?) {
113
+ val (view, generation, intent) = synchronized(registryLock) {
114
+ val view = getView(key)
115
+ Triple(view, view?.bindingGeneration, closeSequence)
64
116
  }
117
+ if (view == null || generation == null) return
118
+ afterOpenCapture?.invoke()
119
+ view.postCommand(generation, key) { view.openCommand(side, intent) }
120
+ }
121
+
122
+ // Keep the existing command signatures unchanged.
123
+ fun openByKey(key: String) = postOpen(key, null)
124
+
125
+ fun openByKeyOnSide(key: String, side: SwipeSide?) {
126
+ if (side == null) return
127
+ postOpen(key, side)
65
128
  }
66
129
 
130
+ fun getOpenSideByKey(key: String): String? = openStateCache[key]?.value
131
+
132
+ /**
133
+ * Records a keyed close intent and reads the bound view in one step. Mark every
134
+ * view that holds the key, the registered one and any older one, and clear the
135
+ * cache: a view that leaves the key cannot persist an earlier open, and a view
136
+ * that takes it reads closed.
137
+ */
138
+ private fun captureCloseTarget(key: String): Pair<SwipeableView?, Long> =
139
+ synchronized(registryLock) {
140
+ val sequence = ++closeSequence
141
+ markHolders(sequence) { it.heldKey == key }
142
+ setOpenState(key, null)
143
+ getView(key) to sequence
144
+ }
145
+
67
146
  fun closeByKey(key: String, animated: Boolean = true) {
68
- getView(key)?.let { view ->
69
- view.post { view.close(animated) }
147
+ val (view, sequence) = captureCloseTarget(key)
148
+ if (view == null) return
149
+ view.postCommand {
150
+ // An open issued after this command is the latest intent and wins.
151
+ if (view.recyclingKey == key && view.openIntent < sequence) view.close(animated)
70
152
  }
71
153
  }
72
154
 
155
+ /** A keyed cancel clears the cached open intent like a keyed close, mounted or not. */
73
156
  fun cancelByKey(key: String) {
74
- getView(key)?.let { view ->
75
- view.post { view.cancelGesture() }
157
+ val view = captureCloseTarget(key).first ?: return
158
+ view.postCommand {
159
+ if (view.recyclingKey == key) view.cancelGesture()
76
160
  }
77
161
  }
78
162
 
79
- /** Close all swipeables. When animated=true, only closes open views. When animated=false, resets all views. */
163
+ /** Include pending interactions and closing continuations, not only settled open rows. */
80
164
  fun closeAll(animated: Boolean = true) {
165
+ val sequence = synchronized(registryLock) {
166
+ // Like closeByKey for every key, bound or not, mounted or not.
167
+ val sequence = ++closeSequence
168
+ markHolders(sequence) { true }
169
+ openStateCache.evictAll()
170
+ sequence
171
+ }
81
172
  getAllViews().forEach { view ->
82
- if (animated) {
83
- // Only close open views when animated
84
- if (view.isOpen) {
85
- view.post { view.close(animated = true) }
86
- }
87
- } else {
88
- // Reset all views when not animated
89
- view.post { view.close(animated = false) }
173
+ view.postCommand {
174
+ // An open issued after this command is the latest intent and wins.
175
+ if (view.openIntent >= sequence) return@postCommand
176
+ // Earlier commands for this binding may still be queued.
177
+ val activeInteraction = (view.isAnimating || view.isDragging ||
178
+ view.directGesturePending || view.restorePending)
179
+ if (!animated || view.isOpen || activeInteraction) view.close(animated)
90
180
  }
91
181
  }
92
182
  }
@@ -102,30 +192,31 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
102
192
 
103
193
  private val density = context.resources.displayMetrics.density
104
194
 
105
- var actionsWidth: Float = 0f
195
+ private val actionsWidthPx: Float
196
+ get() = activeSide?.let(::widthFor) ?: 0f
197
+
198
+ var leftActionsWidth: Float = 0f
106
199
  set(value) {
107
- val validated = maxOf(0f, value)
108
- val previous = field
109
- if (previous == validated) return
110
- field = validated
111
- if (shouldSyncOpenLayoutForPropChange(previous, validated)) {
112
- syncOpenStateToCurrentWidth()
113
- }
114
- requestLayout()
200
+ val width = validWidth(value)
201
+ if (field == width) return
202
+ field = width
203
+ leftWidthPx = width * density
204
+ configurationChanged()
115
205
  }
116
206
 
117
- private val actionsWidthPx: Float
118
- get() = actionsWidth * density
119
-
120
- var actionsPosition: String = "right"
207
+ var rightActionsWidth: Float = 0f
121
208
  set(value) {
122
- val validated = if (value == "left" || value == "right") value else "right"
123
- if (field == validated) return
124
- field = validated
125
- requestLayout()
209
+ val width = validWidth(value)
210
+ if (field == width) return
211
+ field = width
212
+ rightWidthPx = width * density
213
+ configurationChanged()
126
214
  }
127
215
 
128
- val isLeading: Boolean get() = actionsPosition == "left"
216
+ private var leftWidthPx = 0f
217
+ private var rightWidthPx = 0f
218
+
219
+ val isLeading: Boolean get() = activeSide == SwipeSide.LEFT
129
220
 
130
221
  var friction: Float = 1.0f
131
222
  set(value) { field = value.coerceIn(0f, 1f) }
@@ -137,13 +228,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
137
228
  set(value) {
138
229
  if (field == value) return
139
230
  field = value
140
-
141
- if (!value) {
142
- abortActiveGesture()
143
- if (isOpen || currentTranslation != 0f) {
144
- close(animated = false)
145
- }
146
- }
231
+ pendingGestureDisable = !value
232
+ configurationChanged()
147
233
  }
148
234
 
149
235
  /**
@@ -155,27 +241,17 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
155
241
  * gesture activation (before any horizontal motion is detected).
156
242
  */
157
243
  fun cancelGesture() {
158
- abortActiveGesture()
159
244
  isCurrentTouchCancelled = true
160
-
161
- if (isOpen || currentTranslation != 0f) {
162
- close(animated = false)
163
- }
245
+ velocityTracker?.recycle()
246
+ velocityTracker = null
247
+ // Cancel even at zero: a side may be pending activation, restoration,
248
+ // or the first spring frame. Do not apply a pending restore first.
249
+ closeInternal(animated = false)
164
250
  }
165
251
 
166
- private fun abortActiveGesture() {
167
- isBlockingChildEvents = false
168
- isIntercepting = false
169
-
170
- if (isDragging || isGestureActivated) {
171
- isDragging = false
172
- isGestureActivated = false
173
- gestureStartTranslation = 0f
174
- cancelGestureTimeout()
175
- stopProgressUpdates()
176
- velocityTracker?.recycle()
177
- velocityTracker = null
178
- }
252
+ private fun suppressCancelledTouch(event: MotionEvent): Boolean {
253
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) isCurrentTouchCancelled = false
254
+ return isCurrentTouchCancelled
179
255
  }
180
256
 
181
257
  var dragOffsetFromEdge: Float = 0f
@@ -189,38 +265,41 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
189
265
  private val dragOffsetFromEdgePx: Float
190
266
  get() = dragOffsetFromEdge * density
191
267
 
192
- /** Key for recycling detection - when this changes, view saves/restores state from cache */
268
+ /** A rebind invalidates old work before restoring from the complete prop transaction. */
193
269
  var recyclingKey: String? = null
194
270
  set(value) {
195
271
  val oldValue = field
196
272
  if (oldValue == value) return
197
- field = value
198
-
199
- // Thread-safe read of new key's state
200
- val shouldOpen = value?.let { getOpenState(it) } ?: false
201
- pendingShouldOpen = shouldOpen
202
-
203
- // Thread-safe write of old key's state and unregister
273
+ val cancelled = takeGestureCancellation()
274
+ bindingGeneration++
204
275
  if (oldValue != null) {
205
- setOpenState(oldValue, isOpen)
206
- unregisterView(oldValue)
207
- }
208
-
209
- // Thread-safe register with new key
210
- if (value != null) {
211
- registerView(this, value)
212
- }
213
-
214
- close(animated = false)
215
-
216
- // Apply using local shouldOpen (pendingShouldOpen was cleared by close).
217
- // Set recycleSnapPending so onViewAdded can position actions immediately
218
- // when React renders them (isOpenByKey initializes hasActionsRendered=true
219
- // before snapToOpen runs on the next frame).
220
- recycleSnapPending = shouldOpen
221
- if (shouldOpen) {
222
- post { snapToOpen() }
276
+ // An abandoned auto-close wait cannot resume, so its transient open must not restore.
277
+ persistOpenSide(if (isOpen && autoCloseDeadline == null) openSide else null, oldValue)
278
+ unregisterView(this, oldValue)
223
279
  }
280
+ // The view stops holding the old key: its mark moves with the cache read.
281
+ val restored = bindRestore(this, value)
282
+ // A rebind consumes even a not-yet-activated physical sequence.
283
+ // cancelActiveWork clears activation, so suppress before clearing it.
284
+ isCurrentTouchCancelled = true
285
+ velocityTracker?.recycle()
286
+ velocityTracker = null
287
+ cancelActiveWork()
288
+ isOpen = false
289
+ openSide = null
290
+ animationSide = null
291
+ resetPosition()
292
+ requestedActions.clear()
293
+ observedSlotCounts.clear()
294
+ field = value
295
+ value?.let { registerView(this, it) }
296
+ pendingRestoreSide = restored?.first
297
+ pendingRestoreIntent = restored?.second ?: 0L
298
+ restorePending = true
299
+ configurationChanged()
300
+ // Finish registry bookkeeping before a synchronous callback can issue
301
+ // another command; the payload still belongs to the pre-rebind start.
302
+ if (cancelled != null) onGestureCancelled(cancelled)
224
303
  }
225
304
 
226
305
  private var currentTranslation: Float = 0f
@@ -230,26 +309,50 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
230
309
  private var isAnimating: Boolean = false
231
310
  private var gestureStartTranslation: Float = 0f
232
311
  private var isGestureActivated: Boolean = false
312
+ // Release ends touch activation, not ownership of the eventual completion.
313
+ private var gestureCompletionPending = false
314
+ private var gestureCompletionMetadata: Map<String, Any>? = null
233
315
  private var lastEmittedProgress: Float = -1f
234
- private var pendingShouldOpen: Boolean = false
235
- /** Tracks pending snapToOpen from recyclingKey setter. Unlike pendingShouldOpen,
236
- * this survives close(animated:false) so onViewAdded can position actions correctly. */
237
- private var recycleSnapPending: Boolean = false
238
316
  private var lastEmittedWillEndState: String? = null
239
317
 
240
- // Track if open animation was explicitly interrupted by close() for autoClose
241
- private var openAnimationInterruptedByClose: Boolean = false
242
-
243
- // Override onSwipeEnd state when autoClose triggers close() (emit "open" instead of "closed")
244
- private var autoCloseEndEventOverride: Boolean = false
318
+ private var openSide: SwipeSide? = null
319
+ private var gestureSide: SwipeSide? = null
320
+ private var animationSide: SwipeSide? = null
321
+ private var directGesturePending = false
322
+ private var workGeneration = 0
323
+ // Commands in the same binding execute in order, even as they cancel animations.
324
+ @Volatile private var bindingGeneration = 0L
325
+ private var configurationDirty = false
326
+ private var pendingGestureDisable = false
327
+ private var pendingRestoreSide: SwipeSide? = null
328
+ private var pendingRestoreIntent = 0L
329
+ // The close sequence this view's open intent saw. A later keyed close makes it stale.
330
+ private var openIntent = 0L
331
+ // The key this view holds, and the last close or bind that makes an earlier open
332
+ // intent stale for it (guarded by registryLock).
333
+ private var heldKey: String? = null
334
+ private var closedAtSequence = 0L
335
+ private var restorePending = false
336
+ private var restoreCacheOnAttach = false
337
+ private val requestedActions = mutableSetOf<SwipeSide>()
338
+ private val observedSlotCounts = mutableMapOf<SwipeSide, Int>()
339
+ private val configurationRunnable = Runnable { applyPendingConfiguration() }
340
+
341
+ private val activeSide: SwipeSide?
342
+ get() = gestureSide ?: animationSide ?: openSide ?: defaultOpenSide()
245
343
 
246
344
  private var contentView: View? = null
247
- private var actionsView: View? = null
345
+ private var leftActionsView: View? = null
346
+ private var rightActionsView: View? = null
347
+ private val actionsView: View?
348
+ get() = railFor(activeSide)
248
349
 
249
350
  private val onSwipeProgress by EventDispatcher<Map<String, Any>>()
250
351
  private val onSwipeStart by EventDispatcher<Map<String, Any>>()
251
352
  private val onSwipeStateChange by EventDispatcher<Map<String, Any>>()
252
353
  private val onSwipeEnd by EventDispatcher<Map<String, Any>>()
354
+ private val onActionsNeeded by EventDispatcher<Map<String, Any>>()
355
+ private val onGestureCancelled by EventDispatcher<Map<String, Any>>()
253
356
 
254
357
  private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
255
358
  private var velocityTracker: VelocityTracker? = null
@@ -273,6 +376,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
273
376
 
274
377
  // Auto-close runnable for tracking and cancellation
275
378
  private var autoCloseRunnable: Runnable? = null
379
+ private var autoCloseDeadline: Long? = null
380
+ private var autoCloseClosingSide: SwipeSide? = null
276
381
 
277
382
  // Track when a child (e.g. RNGH gesture handler) claims the touch
278
383
  private var childRequestedDisallowIntercept: Boolean = false
@@ -313,6 +418,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
313
418
  }
314
419
 
315
420
  init {
421
+ trackLiveView(this)
316
422
  clipChildren = false
317
423
  clipToPadding = false
318
424
  setWillNotDraw(false)
@@ -334,67 +440,30 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
334
440
  override fun onViewAdded(child: View?) {
335
441
  super.onViewAdded(child)
336
442
  child ?: return
337
-
338
- val wasActionsNil = actionsView == null
339
- val wasContentNil = contentView == null
340
-
341
- // With lazy Actions rendering:
342
- // - When Actions not mounted: only content at index 0
343
- // - When Actions mounted: actions at index 0, content at index 1
344
- // Always re-evaluate all children since React may add them in any order
345
- if (childCount == 1) {
346
- // Only one child - it's content (Actions not mounted yet)
347
- contentView = getChildAt(0)
348
- actionsView = null
349
- } else if (childCount >= 2) {
350
- // Always check both positions - React may have reordered children
351
- actionsView = getChildAt(0)
352
- contentView = getChildAt(1)
353
- }
354
-
355
- if (wasContentNil && contentView != null && (isOpen || currentTranslation != 0f)) {
356
- contentView?.translationX = currentTranslation
357
- }
358
-
359
- if (wasActionsNil && actionsView != null) {
360
- // Prevent child clipping: Yoga lays out children for absoluteFill (full parent width)
361
- // but our onLayout overrides the actionsView bounds to actionsWidth. Without this,
362
- // children positioned by Yoga's flex-end would be clipped beyond the narrower bounds.
363
- (actionsView as? ViewGroup)?.clipChildren = false
364
- (actionsView as? ViewGroup)?.clipToPadding = false
365
-
366
- if (isOpen || isAnimating) {
367
- actionsView?.visibility = View.VISIBLE
368
- actionsView?.translationX = 0f
369
- }
370
- else if (isDragging) {
371
- actionsView?.visibility = View.VISIBLE
372
- updateActionsTransform()
373
- }
374
- else if (pendingShouldOpen) {
375
- pendingShouldOpen = false
376
- post { snapToOpen() }
377
- }
378
- else if (recycleSnapPending) {
379
- // Actions were rendered by React (via isOpenByKey) before native snapToOpen ran.
380
- // Position them immediately so they're visible when UIAutomator checks.
381
- recycleSnapPending = false
382
- actionsView?.visibility = View.VISIBLE
383
- actionsView?.translationX = 0f
384
- }
385
- }
443
+ reconcileSideChildren()
386
444
  }
387
445
 
388
446
  override fun onViewRemoved(child: View?) {
389
447
  super.onViewRemoved(child)
390
- if (child === actionsView) {
391
- actionsView = null
392
- } else if (child === contentView) {
448
+ if (child === leftActionsView || child === rightActionsView) {
449
+ (child as? ReactViewGroup)?.pointerEvents = PointerEvents.AUTO
450
+ }
451
+ if (child === contentView) {
393
452
  contentView = null
453
+ isCurrentTouchCancelled = true
454
+ if (!cancelActiveWork()) return
455
+ // The drag offset left with the removed content. Replacement content
456
+ // must match the logical state, not the abandoned gesture.
457
+ val side = openSide
458
+ currentTranslation = if (isOpen && side != null) side.sign * widthFor(side) else 0f
459
+ startOffset = 0f
394
460
  }
461
+ reconcileSideChildren()
395
462
  }
396
463
 
397
464
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
465
+ applyPendingConfiguration()
466
+ reconcileSideChildren()
398
467
  val width = right - left
399
468
  val height = bottom - top
400
469
 
@@ -408,7 +477,9 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
408
477
  child.layout(0, 0, width, height)
409
478
  }
410
479
 
411
- if (!isDragging && !isAnimating && currentTranslation == 0f && actionsWidthPx > 0f && !recycleSnapPending) {
480
+ updateActionsTransform()
481
+
482
+ if (!isDragging && !isAnimating && currentTranslation == 0f && actionsWidthPx > 0f) {
412
483
  val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
413
484
  actionsView?.translationX = actionsOffset
414
485
  }
@@ -417,7 +488,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
417
488
  // prop reconciliation after a list reorder. The post {} ensures we run after
418
489
  // Fabric finishes applying all mutations in the current frame.
419
490
  if (isOpen && currentTranslation != 0f && !isDragging && !isAnimating) {
420
- post {
491
+ postCurrent {
421
492
  if (isOpen && currentTranslation != 0f && !isDragging && !isAnimating) {
422
493
  contentView?.translationX = currentTranslation
423
494
  updateActionsTransform()
@@ -444,23 +515,12 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
444
515
  * - UP: If blocking → consume (children never see UP, no onPress)
445
516
  */
446
517
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
447
- if (!gestureEnabled || actionsWidth <= 0f) {
518
+ // Every native touch entry point shares the same cancellation boundary.
519
+ if (suppressCancelledTouch(event)) return true
520
+ if (!gestureEnabled || !hasEnabledSide()) {
448
521
  return super.dispatchTouchEvent(event)
449
522
  }
450
523
 
451
- // After cancelGesture(), swallow the rest of the touch sequence so the user
452
- // can't reactivate the swipe by continuing to drag the same finger down.
453
- if (isCurrentTouchCancelled) {
454
- when (event.actionMasked) {
455
- MotionEvent.ACTION_DOWN -> isCurrentTouchCancelled = false
456
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
457
- isCurrentTouchCancelled = false
458
- return true
459
- }
460
- else -> return true
461
- }
462
- }
463
-
464
524
  when (event.actionMasked) {
465
525
  MotionEvent.ACTION_DOWN -> {
466
526
  dispatchTouchStartX = event.rawX
@@ -475,7 +535,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
475
535
  velocityTracker = VelocityTracker.obtain()
476
536
  velocityTracker?.addMovement(event)
477
537
 
478
- // Handle in onTouchEvent for animation interruption
538
+ // Track the touch start in onTouchEvent. Only a drag interrupts settling work.
479
539
  onTouchEvent(event)
480
540
 
481
541
  // Forward DOWN to children
@@ -502,13 +562,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
502
562
  }
503
563
 
504
564
  val horizontalDelta = event.rawX - dispatchTouchStartX
505
- val isCorrectDirection = if (isOpen) {
506
- true
507
- } else {
508
- if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
509
- }
565
+ val isCorrectDirection = selectGestureSide(horizontalDelta) != null
510
566
 
511
- if (isCorrectDirection) {
567
+ val meetsOffset = dragOffsetFromEdgePx <= 0f || dx >= dragOffsetFromEdgePx
568
+ if (isCorrectDirection && meetsOffset && !childRequestedDisallowIntercept) {
512
569
  // Swipe detected - cancel React touches, start blocking
513
570
  isBlockingChildEvents = true
514
571
  cancelReactTouches(event)
@@ -547,7 +604,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
547
604
  }
548
605
 
549
606
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
550
- if (!gestureEnabled || actionsWidth <= 0f) return false
607
+ if (suppressCancelledTouch(event)) return false
608
+ if (!gestureEnabled || !hasEnabledSide()) return false
551
609
 
552
610
  when (event.actionMasked) {
553
611
  MotionEvent.ACTION_DOWN -> {
@@ -555,11 +613,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
555
613
  touchStartY = event.rawY
556
614
  isIntercepting = false
557
615
 
558
- if (isAnimating) {
559
- contentSpringAnimation?.cancel()
560
- isAnimating = false
561
- }
562
-
616
+ // Like iOS, only drag activation interrupts settling work. A tap never does.
563
617
  velocityTracker?.recycle()
564
618
  velocityTracker = VelocityTracker.obtain()
565
619
  velocityTracker?.addMovement(event)
@@ -570,14 +624,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
570
624
  val verticalDelta = event.rawY - touchStartY
571
625
 
572
626
  if (abs(horizontalDelta) > touchSlop && abs(horizontalDelta) > abs(verticalDelta)) {
573
- val isCorrectDirection = if (isOpen) {
574
- true
575
- } else {
576
- if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
577
- }
627
+ val isCorrectDirection = selectGestureSide(horizontalDelta) != null
578
628
  val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
579
629
 
580
- if (isCorrectDirection && meetsOffset) {
630
+ if (isCorrectDirection && meetsOffset && !childRequestedDisallowIntercept) {
581
631
  isIntercepting = true
582
632
  parent?.requestDisallowInterceptTouchEvent(true)
583
633
  return true
@@ -593,7 +643,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
593
643
  }
594
644
 
595
645
  override fun onTouchEvent(event: MotionEvent): Boolean {
596
- if (!gestureEnabled || actionsWidth <= 0f) return false
646
+ if (suppressCancelledTouch(event)) return true
647
+ if (!gestureEnabled || !hasEnabledSide()) return false
597
648
 
598
649
  velocityTracker?.addMovement(event)
599
650
 
@@ -608,7 +659,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
608
659
 
609
660
  if (!isGestureActivated) {
610
661
  val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
611
- if (meetsOffset) {
662
+ val meetsActivation = (abs(horizontalDelta) > touchSlop && abs(horizontalDelta) > abs(event.rawY - touchStartY))
663
+ if (meetsOffset && meetsActivation) {
612
664
  activateGesture(horizontalDelta)
613
665
  } else {
614
666
  return true
@@ -621,7 +673,11 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
621
673
  if (isGestureActivated) {
622
674
  velocityTracker?.computeCurrentVelocity(1000)
623
675
  val horizontalDelta = event.rawX - touchStartX
624
- handlePanEnd(horizontalDelta, velocityTracker?.xVelocity ?: 0f)
676
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
677
+ cancelTouchGesture()
678
+ } else {
679
+ handlePanEnd(horizontalDelta, velocityTracker?.xVelocity ?: 0f)
680
+ }
625
681
  }
626
682
  velocityTracker?.recycle()
627
683
  velocityTracker = null
@@ -632,41 +688,83 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
632
688
  }
633
689
 
634
690
  private fun activateGesture(initialTranslation: Float) {
635
- // Cancel any running animations and capture current position
636
- contentSpringAnimation?.let { anim ->
637
- if (anim.isRunning) {
638
- currentTranslation = contentView?.translationX ?: currentTranslation
639
- anim.cancel()
640
- }
641
- }
642
- actionsSpringAnimation?.cancel()
643
- isAnimating = false
644
-
691
+ if (isCurrentTouchCancelled) return
692
+ applyPendingConfiguration()
693
+ if (isCurrentTouchCancelled) return
694
+ val side = selectGestureSide(initialTranslation) ?: return
695
+ val blockingChildren = isBlockingChildEvents
696
+ val intercepting = isIntercepting
697
+ if (!cancelActiveWork()) return
698
+ isBlockingChildEvents = blockingChildren
699
+ isIntercepting = intercepting
700
+ gestureSide = side
701
+ animationSide = null
645
702
  isGestureActivated = true
646
703
  isDragging = true
647
704
  startOffset = currentTranslation
648
705
  gestureStartTranslation = initialTranslation
649
706
  lastEmittedWillEndState = if (isOpen) "open" else "closed"
650
-
707
+ val generation = workGeneration
651
708
  actionsView?.visibility = View.VISIBLE
652
- parent?.requestDisallowInterceptTouchEvent(true) // Prevent parent from stealing gesture
653
-
654
- onSwipeStart(emptyMap())
709
+ requestActions(side, retryEmptySlot = true)
710
+ if (generation != workGeneration || !isGestureActivated) return
711
+ refreshRailVisibility()
712
+ parent?.requestDisallowInterceptTouchEvent(true)
713
+ gestureCompletionPending = true
714
+ gestureCompletionMetadata = sideMetadata(side)
715
+ onSwipeStart(gestureCompletionMetadata!!)
716
+ if (generation != workGeneration || !isGestureActivated) return
655
717
  startProgressUpdates()
656
718
  startGestureTimeout()
657
719
  }
658
720
 
659
721
  private fun startGestureTimeout() {
660
722
  cancelGestureTimeout()
723
+ val generation = workGeneration
661
724
  gestureTimeoutRunnable = Runnable {
662
- if (isGestureActivated) {
663
- // Force gesture end if stuck
664
- handlePanEnd(0f, 0f)
725
+ if (generation == workGeneration && isGestureActivated) {
726
+ // A stuck gesture ends like an OS cancel, not a release. The finger can
727
+ // still be down, so the rest of this touch sequence stays consumed.
728
+ cancelTouchGesture()
665
729
  }
666
730
  }
667
731
  postDelayed(gestureTimeoutRunnable, GESTURE_TIMEOUT_MS)
668
732
  }
669
733
 
734
+ /**
735
+ * An OS or watchdog cancel returns the row to its logical state. Progress and the
736
+ * intended state then report that position, so JS UIs do not stay mid-drag.
737
+ * A cancel never completes the swipe, so it emits no onSwipeEnd.
738
+ */
739
+ private fun cancelTouchGesture() {
740
+ val side = gestureSide
741
+ val reportedState = lastEmittedWillEndState
742
+ isCurrentTouchCancelled = true
743
+ velocityTracker?.recycle()
744
+ velocityTracker = null
745
+ // The drag cleared the auto-close wait. A row that settles open waits again,
746
+ // like a release that reopens it, and the auto-close completes the swipe.
747
+ val deadline = if (autoClose && isOpen) {
748
+ SystemClock.uptimeMillis() + maxOf(0L, autoCloseTimeout.toLong())
749
+ } else {
750
+ null
751
+ }
752
+ if (!reconcileLogicalState(deadline = deadline)) return
753
+ val generation = workGeneration
754
+ val settledSide = openSide ?: side
755
+ val settledState = if (isOpen) "open" else "closed"
756
+ emitFinalProgress(if (isOpen) 1f else 0f, currentTranslation, settledSide)
757
+ if (generation != workGeneration || reportedState == settledState) return
758
+ onSwipeStateChange(stateEvent(settledState, settledSide))
759
+ }
760
+
761
+ /** The watchdog measures inactivity: each move of the live gesture restarts it. */
762
+ private fun restartGestureTimeout() {
763
+ val runnable = gestureTimeoutRunnable ?: return
764
+ removeCallbacks(runnable)
765
+ postDelayed(runnable, GESTURE_TIMEOUT_MS)
766
+ }
767
+
670
768
  private fun cancelGestureTimeout() {
671
769
  gestureTimeoutRunnable?.let { removeCallbacks(it) }
672
770
  gestureTimeoutRunnable = null
@@ -675,29 +773,34 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
675
773
  private fun cancelAutoClose() {
676
774
  autoCloseRunnable?.let { removeCallbacks(it) }
677
775
  autoCloseRunnable = null
776
+ autoCloseDeadline = null
678
777
  }
679
778
 
680
- private fun scheduleAutoClose() {
779
+ private fun scheduleAutoClose(deadline: Long = SystemClock.uptimeMillis() + maxOf(0L, autoCloseTimeout.toLong())) {
681
780
  cancelAutoClose()
682
- val delay = if (autoCloseTimeout > 0) autoCloseTimeout.toLong() else 0L
781
+ autoCloseDeadline = deadline
782
+ val generation = workGeneration
783
+ val key = recyclingKey
784
+ val side = openSide
683
785
  autoCloseRunnable = Runnable {
684
786
  autoCloseRunnable = null
685
- // Emit "open" state when autoClose completes - user's swipe intent was achieved
686
- autoCloseEndEventOverride = true
687
- close()
787
+ autoCloseDeadline = null
788
+ if (generation == workGeneration && key == recyclingKey && isOpen && openSide == side && autoClose) {
789
+ closeInternal(animated = true, autoCloseCompletion = true)
790
+ }
688
791
  }
689
- postDelayed(autoCloseRunnable, delay)
792
+ postDelayed(autoCloseRunnable, maxOf(0L, deadline - SystemClock.uptimeMillis()))
690
793
  }
691
794
 
692
795
  private fun handlePanUpdate(translationX: Float) {
693
- if (!isGestureActivated || actionsWidth <= 0f) return
796
+ if (!isGestureActivated || actionsWidthPx <= 0f) return
797
+ restartGestureTimeout()
694
798
 
695
799
  val effectiveTranslation = translationX - gestureStartTranslation
696
800
 
697
- val rawTranslation = startOffset + (effectiveTranslation * friction)
698
- // Always clamp: never allow translation past closed position (0)
699
- val clampedTranslation = if (isLeading) maxOf(0f, rawTranslation) else minOf(0f, rawTranslation)
700
- val targetTranslation = SwipePhysics.applyRubberBand(clampedTranslation, actionsWidthPx, isLeading)
801
+ val targetTranslation = SwipePhysics.applyLatchedTranslation(
802
+ startOffset, effectiveTranslation, friction, actionsWidthPx, gestureSide ?: return
803
+ )
701
804
 
702
805
  currentTranslation = targetTranslation
703
806
  contentView?.translationX = targetTranslation
@@ -705,17 +808,19 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
705
808
 
706
809
  // Emit onSwipeStateChange when threshold crossing changes intended state
707
810
  val thresholdDistance = actionsWidthPx * threshold
708
- val wouldOpen = SwipePhysics.shouldSnapToOpen(targetTranslation, thresholdDistance, isOpen, isLeading)
811
+ val decisionTranslation = effectiveTranslation * friction
812
+ val wouldOpen = SwipePhysics.shouldSnapToOpen(decisionTranslation, thresholdDistance, isOpen, isLeading)
709
813
  val intendedState = if (wouldOpen) "open" else "closed"
710
814
  if (lastEmittedWillEndState != intendedState) {
711
815
  lastEmittedWillEndState = intendedState
712
- onSwipeStateChange(mapOf("state" to intendedState))
816
+ onSwipeStateChange(stateEvent(intendedState))
713
817
  }
714
818
  }
715
819
 
716
820
  private fun handlePanEnd(translationX: Float, velocityX: Float) {
717
821
  if (!isGestureActivated) return
718
822
 
823
+ val generation = workGeneration
719
824
  val effectiveTranslation = translationX - gestureStartTranslation
720
825
 
721
826
  isGestureActivated = false
@@ -733,9 +838,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
733
838
  val intendedState = if (shouldOpen) "open" else "closed"
734
839
  if (lastEmittedWillEndState != intendedState) {
735
840
  lastEmittedWillEndState = intendedState
736
- onSwipeStateChange(mapOf("state" to intendedState))
841
+ onSwipeStateChange(stateEvent(intendedState))
737
842
  }
738
843
 
844
+ if (generation != workGeneration || isCurrentTouchCancelled) return
739
845
  if (shouldOpen) {
740
846
  // Only use velocity if it's in the opening direction (prevents backward bounce)
741
847
  val openingVelocity = if (isLeading) {
@@ -743,9 +849,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
743
849
  } else {
744
850
  if (velocityX < 0) velocityX else 0f // Trailing: negative velocity opens
745
851
  }
746
- open(openingVelocity)
852
+ // A release settles its latch; an argumentless command uses the configured default.
853
+ openInternal(gestureSide ?: return, openingVelocity, preserveGestureCompletion = true)
747
854
  // Schedule auto-close if enabled
748
- if (autoClose) {
855
+ if (autoClose && isOpen && !isCurrentTouchCancelled) {
749
856
  scheduleAutoClose()
750
857
  }
751
858
  } else {
@@ -755,48 +862,69 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
755
862
  } else {
756
863
  if (velocityX > 0) velocityX else 0f // Trailing: positive velocity closes
757
864
  }
758
- close(velocity = closingVelocity)
865
+ applyPendingConfiguration()
866
+ closeInternal(animated = true, velocity = closingVelocity, preserveGestureCompletion = true)
759
867
  }
760
868
  }
761
869
 
762
870
  private fun updateActionsTransform() {
763
- val actions = actionsView ?: return
764
- if (actionsWidthPx <= 0f) return
765
-
766
- val progress = minOf(abs(currentTranslation) / actionsWidthPx, 1f)
767
- val offset = if (isLeading) -actionsWidthPx else actionsWidthPx
768
- val actionsTranslateX = offset * (1f - progress)
769
-
770
- actions.translationX = actionsTranslateX
871
+ for (side in SwipeSide.values()) {
872
+ val rail = railFor(side) ?: continue
873
+ val width = widthFor(side)
874
+ val progress = if (side == activeSide && width > 0f) minOf(abs(currentTranslation) / width, 1f) else 0f
875
+ rail.translationX = -side.sign * width * (1f - progress)
876
+ }
877
+ refreshRailVisibility()
771
878
  }
772
879
 
773
- private fun shouldSyncOpenLayoutForPropChange(oldValue: Float, newValue: Float): Boolean {
774
- if (oldValue == newValue) return false
775
- if (!isOpen || isDragging || isAnimating || isGestureActivated) return false
776
- return newValue > 0f
880
+ fun open(velocity: Float = 0f) {
881
+ applyPendingConfiguration()
882
+ val side = defaultOpenSide() ?: return
883
+ open(side, velocity)
777
884
  }
778
885
 
779
- private fun syncOpenStateToCurrentWidth() {
780
- val targetX = if (isLeading) actionsWidthPx else -actionsWidthPx
781
- currentTranslation = targetX
782
- contentView?.translationX = targetX
783
-
784
- if (actionsView != null) {
785
- actionsView?.visibility = View.VISIBLE
786
- actionsView?.translationX = 0f
787
- } else {
788
- onSwipeStart(emptyMap())
789
- }
886
+ fun open(side: SwipeSide, velocity: Float = 0f) {
887
+ openInternal(side, velocity)
888
+ }
790
889
 
791
- startLayoutGuard()
792
- emitFinalProgress(1f, targetX)
890
+ /** A keyed open keeps the intent of its command time, not of the main-thread turn. */
891
+ private fun openCommand(side: SwipeSide?, intent: Long) {
892
+ applyPendingConfiguration()
893
+ openInternal(side ?: defaultOpenSide() ?: return, 0f, intent = intent)
793
894
  }
794
895
 
795
- fun open(velocity: Float = 0f) {
896
+ private fun openInternal(
897
+ side: SwipeSide,
898
+ velocity: Float,
899
+ preserveGestureCompletion: Boolean = false,
900
+ intent: Long = currentIntent()
901
+ ) {
902
+ applyPendingConfiguration()
903
+ if (!isSideEnabled(side)) return
904
+ if (!preserveGestureCompletion && (isGestureActivated || directGesturePending)) {
905
+ isCurrentTouchCancelled = true
906
+ }
907
+ val previousSide = gestureSide ?: animationSide ?: openSide
908
+ if (previousSide != null && previousSide != side &&
909
+ (isOpen || currentTranslation != 0f || isAnimating || gestureCompletionPending)) {
910
+ // The side switch carries this intent through its closing leg.
911
+ openIntent = intent
912
+ closeInternal(animated = true, afterClose = { openInternal(side, velocity, intent = intent) })
913
+ return
914
+ }
915
+ // An ordinary open can deliver the pending end. An imperative auto-close
916
+ // open neither schedules a timer nor emits an end, so it abandons ownership.
917
+ if (!cancelActiveWork(preserveGestureCompletion || !autoClose)) return
918
+ animationSide = side
919
+ openSide = side
920
+ openIntent = intent
921
+ val generation = workGeneration
922
+ requestActions(side, retryEmptySlot = true)
923
+ if (generation != workGeneration) return
796
924
  isOpen = true
797
925
  isAnimating = true
798
926
  // Thread-safe persist to static cache for FlashList recycling
799
- recyclingKey?.let { key -> setOpenState(key, true) }
927
+ persistOpenSide(side)
800
928
  // Note: actionsView visibility is already set during drag phase
801
929
  if (actionsView?.visibility != View.VISIBLE) {
802
930
  actionsView?.visibility = View.VISIBLE
@@ -808,18 +936,16 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
808
936
  // Start progress updates to emit progress during animation
809
937
  startProgressUpdates()
810
938
 
811
- // Reset the flag - close() will set it to true if it interrupts us
812
- openAnimationInterruptedByClose = false
813
- contentSpringAnimation?.cancel()
814
- actionsSpringAnimation?.cancel()
815
-
816
939
  val content = contentView
817
- val actions = actionsView
818
940
 
819
941
  if (content == null) {
820
942
  currentTranslation = targetX
821
943
  isAnimating = false
822
944
  emitFinalProgress(1f, targetX)
945
+ if (generation != workGeneration) return
946
+ if (!autoClose) emitSwipeEnd("open", side)
947
+ if (generation != workGeneration) return
948
+ refreshRailVisibility()
823
949
  return
824
950
  }
825
951
 
@@ -849,20 +975,19 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
849
975
  setMaxValue(maxBound)
850
976
  setStartVelocity(effectiveVelocity)
851
977
  addUpdateListener { _, value, _ ->
978
+ if (generation != workGeneration) return@addUpdateListener
852
979
  currentTranslation = value
853
980
  updateActionsTransform()
854
981
  }
855
- addEndListener { _, _, _, _ ->
856
- if (openAnimationInterruptedByClose) {
857
- // Animation was interrupted by close() (e.g., autoClose)
858
- return@addEndListener
859
- }
982
+ addEndListener { _, canceled, _, _ ->
983
+ if (canceled || generation != workGeneration) return@addEndListener
860
984
  isAnimating = false
861
985
  startLayoutGuard()
862
986
  emitFinalProgress(1f, targetX)
987
+ if (generation != workGeneration) return@addEndListener
863
988
  // With autoClose, let close() emit onSwipeEnd("open") instead
864
989
  if (!autoClose) {
865
- onSwipeEnd(mapOf("state" to "open"))
990
+ emitSwipeEnd("open", side)
866
991
  }
867
992
  }
868
993
  start()
@@ -870,168 +995,153 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
870
995
  // Actions are now driven by updateActionsTransform() called from content's update listener
871
996
  }
872
997
 
873
- /** Instantly snap to open state without animation (for recycling) */
998
+ /** Snaps to the current edge, or the canonical default enabled edge. */
874
999
  fun snapToOpen() {
875
- contentSpringAnimation?.cancel()
876
- actionsSpringAnimation?.cancel()
1000
+ applyPendingConfiguration()
1001
+ val side = openSide ?: defaultOpenSide() ?: return
1002
+ snapToOpen(side)
1003
+ }
877
1004
 
1005
+ private fun snapToOpen(side: SwipeSide, intent: Long = currentIntent()) {
1006
+ if (!isSideEnabled(side)) return
1007
+ if (isGestureActivated || directGesturePending) isCurrentTouchCancelled = true
1008
+ if (!cancelActiveWork()) return
878
1009
  isOpen = true
879
- isAnimating = false
880
- val targetX = if (isLeading) actionsWidthPx else -actionsWidthPx
881
- currentTranslation = targetX
882
-
883
- // Set content translation immediately
884
- contentView?.translationX = targetX
885
-
1010
+ openSide = side
1011
+ openIntent = intent
1012
+ animationSide = side
1013
+ currentTranslation = side.sign * widthFor(side)
1014
+ contentView?.translationX = currentTranslation
1015
+ val generation = workGeneration
1016
+ requestActions(side, retryEmptySlot = true)
1017
+ if (generation != workGeneration) return
886
1018
  if (actionsView != null) {
887
- // Actions already mounted - position them
888
1019
  actionsView?.visibility = View.VISIBLE
889
1020
  actionsView?.translationX = 0f
890
- } else {
891
- // Actions not mounted yet (React lazy rendering)
892
- // Trigger onSwipeStart so React renders SwipeableActions
893
- // onViewAdded will handle positioning when actions are added
894
- onSwipeStart(emptyMap())
895
1021
  }
896
-
1022
+ if (generation != workGeneration) return
1023
+ updateActionsTransform()
897
1024
  startLayoutGuard()
898
-
899
- // Emit final progress
900
- emitFinalProgress(1f, targetX)
1025
+ emitFinalProgress(1f, currentTranslation)
901
1026
  }
902
1027
 
903
1028
  fun close(animated: Boolean = true, velocity: Float = 0f) {
904
- cancelAutoClose()
905
- stopProgressUpdates()
906
- stopLayoutGuard()
907
-
908
- // Cancel any running animations
909
- openAnimationInterruptedByClose = true
910
- contentSpringAnimation?.cancel()
911
- actionsSpringAnimation?.cancel()
912
-
913
- // Clear gesture state (unified from reset)
914
- isDragging = false
915
- isGestureActivated = false
916
- gestureStartTranslation = 0f
917
- pendingShouldOpen = false
1029
+ applyPendingConfiguration()
1030
+ closeInternal(animated, velocity)
1031
+ }
918
1032
 
1033
+ private fun closeInternal(
1034
+ animated: Boolean,
1035
+ velocity: Float = 0f,
1036
+ autoCloseCompletion: Boolean = false,
1037
+ preserveGestureCompletion: Boolean = false,
1038
+ afterClose: (() -> Unit)? = null
1039
+ ) {
1040
+ // Closing an already-closed row reports no side, like iOS.
1041
+ val endSide = gestureSide ?: animationSide ?: openSide
1042
+ val side = activeSide ?: SwipeSide.RIGHT
1043
+ val width = widthFor(side)
1044
+ if (isGestureActivated || directGesturePending) {
1045
+ isCurrentTouchCancelled = true
1046
+ }
1047
+ // An animated close takes over the pending gesture's eventual end.
1048
+ // Keep ownership until that end or a later silent interruption.
1049
+ if (!cancelActiveWork(animated || preserveGestureCompletion || autoCloseCompletion)) return
1050
+ val generation = workGeneration
1051
+ animationSide = side
1052
+ autoCloseClosingSide = if (autoCloseCompletion) side else null
919
1053
  isOpen = false
920
- // Only persist to cache when animated (user action), not when resetting (recycling)
921
- if (animated) {
922
- recyclingKey?.let { key -> setOpenState(key, false) }
1054
+ openSide = null
1055
+ restorePending = false
1056
+ pendingRestoreSide = null
1057
+ persistOpenSide(null)
1058
+
1059
+ if (!animated) {
1060
+ animationSide = null
1061
+ resetPosition()
1062
+ return
1063
+ }
1064
+ isAnimating = true
1065
+ startProgressUpdates()
1066
+ val content = contentView
1067
+ if (content == null) {
1068
+ currentTranslation = 0f
1069
+ actionsView?.translationX = -side.sign * width
1070
+ isAnimating = false
1071
+ emitFinalProgress(0f, 0f)
1072
+ if (generation != workGeneration) return
1073
+ autoCloseClosingSide = null
1074
+ emitSwipeEnd(if (autoCloseCompletion) "open" else "closed", endSide)
1075
+ if (generation != workGeneration) return
1076
+ animationSide = null
1077
+ refreshRailVisibility()
1078
+ if (generation == workGeneration) afterClose?.invoke()
1079
+ return
923
1080
  }
924
1081
 
925
- val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
926
-
927
- if (animated) {
928
- // Animated close with spring physics
929
- isAnimating = true
930
- startProgressUpdates()
931
-
932
- val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
933
- val content = contentView
934
-
935
- if (content == null) {
936
- currentTranslation = 0f
937
- actionsView?.translationX = actionsOffset
938
- isAnimating = false
939
- openAnimationInterruptedByClose = false
940
- emitFinalProgress(0f, 0f)
941
- return
1082
+ val minBound = if (side == SwipeSide.LEFT) 0f else -width
1083
+ val maxBound = if (side == SwipeSide.LEFT) width else 0f
1084
+ val clampedStart = content.translationX.coerceIn(minBound, maxBound)
1085
+ val wasClamped = content.translationX != clampedStart
1086
+ if (wasClamped) {
1087
+ content.translationX = clampedStart
1088
+ currentTranslation = clampedStart
1089
+ }
1090
+ val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
1091
+ val effectiveVelocity = if (wasClamped && clampedStart == 0f) 0f else springVelocity
1092
+ contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, 0f).apply {
1093
+ spring = SpringForce(0f).apply {
1094
+ stiffness = SwipeConstants.CLOSE_STIFFNESS
1095
+ dampingRatio = SwipeConstants.CLOSE_DAMPING_RATIO
942
1096
  }
943
-
944
- // Define bounds and clamp starting position
945
- val minBound = if (isLeading) 0f else -actionsWidthPx
946
- val maxBound = if (isLeading) actionsWidthPx else 0f
947
-
948
- val clampedStart = content.translationX.coerceIn(minBound, maxBound)
949
- val wasClamped = content.translationX != clampedStart
950
- if (wasClamped) {
951
- content.translationX = clampedStart
952
- currentTranslation = clampedStart
1097
+ setMinValue(minBound)
1098
+ setMaxValue(maxBound)
1099
+ setStartVelocity(effectiveVelocity)
1100
+ addUpdateListener { _, value, _ ->
1101
+ if (generation != workGeneration) return@addUpdateListener
1102
+ currentTranslation = value
1103
+ updateActionsTransform()
953
1104
  }
954
-
955
- val effectiveVelocity = if (wasClamped && clampedStart == 0f) 0f else springVelocity
956
-
957
- contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, 0f).apply {
958
- spring = SpringForce(0f).apply {
959
- stiffness = SwipeConstants.CLOSE_STIFFNESS
960
- dampingRatio = SwipeConstants.CLOSE_DAMPING_RATIO
961
- }
962
- setMinValue(minBound)
963
- setMaxValue(maxBound)
964
- setStartVelocity(effectiveVelocity)
965
- addUpdateListener { _, value, _ ->
966
- currentTranslation = value
967
- updateActionsTransform()
968
- }
969
- addEndListener { _, _, _, _ ->
970
- isAnimating = false
971
- openAnimationInterruptedByClose = false
972
- actionsView?.visibility = View.INVISIBLE
973
- emitFinalProgress(0f, 0f)
974
- // Emit "open" when autoClose completes - user's swipe intent was achieved
975
- val endState = if (autoCloseEndEventOverride) "open" else "closed"
976
- autoCloseEndEventOverride = false
977
- onSwipeEnd(mapOf("state" to endState))
978
- }
979
- start()
1105
+ addEndListener { _, canceled, _, _ ->
1106
+ if (canceled || generation != workGeneration) return@addEndListener
1107
+ isAnimating = false
1108
+ currentTranslation = 0f
1109
+ actionsView?.visibility = View.INVISIBLE
1110
+ emitFinalProgress(0f, 0f)
1111
+ if (generation != workGeneration) return@addEndListener
1112
+ autoCloseClosingSide = null
1113
+ emitSwipeEnd(if (autoCloseCompletion) "open" else "closed", endSide)
1114
+ if (generation != workGeneration) return@addEndListener
1115
+ animationSide = null
1116
+ refreshRailVisibility()
1117
+ if (generation == workGeneration) afterClose?.invoke()
980
1118
  }
981
- } else {
982
- // Instant close (formerly reset)
983
- isAnimating = false
984
- openAnimationInterruptedByClose = false
985
- currentTranslation = 0f
986
- startOffset = 0f
987
- lastEmittedProgress = -1f
988
-
989
- contentView?.translationX = 0f
990
- actionsView?.translationX = actionsOffset
991
- actionsView?.visibility = View.INVISIBLE
1119
+ start()
992
1120
  }
993
1121
  }
994
1122
 
995
1123
  fun handleGestureStart() {
996
- if (!gestureEnabled || actionsWidth <= 0f) return
997
-
998
- isDragging = true
999
- startOffset = currentTranslation
1000
- gestureStartTranslation = 0f
1001
- isGestureActivated = true
1002
- lastEmittedWillEndState = if (isOpen) "open" else "closed"
1003
- onSwipeStart(emptyMap())
1004
- startProgressUpdates()
1124
+ applyPendingConfiguration()
1125
+ if (!gestureEnabled || !hasEnabledSide()) return
1126
+ // The direct API start is the equivalent of a fresh ACTION_DOWN.
1127
+ isCurrentTouchCancelled = false
1128
+ if (!cancelActiveWork()) return
1129
+ directGesturePending = true
1005
1130
  }
1006
1131
 
1007
1132
  fun handleGestureUpdate(translationX: Float, velocityX: Float) {
1008
- if (actionsWidth <= 0f || !isGestureActivated) return
1009
-
1010
- val rawTranslation = startOffset + (translationX * friction)
1011
- val targetTranslation = SwipePhysics.applyRubberBand(rawTranslation, actionsWidthPx, isLeading)
1012
-
1013
- currentTranslation = targetTranslation
1014
- contentView?.translationX = targetTranslation
1015
- updateActionsTransform()
1133
+ if (isCurrentTouchCancelled) return
1134
+ if (directGesturePending && abs(translationX) > touchSlop && abs(translationX) >= dragOffsetFromEdgePx) {
1135
+ activateGesture(translationX)
1136
+ if (isGestureActivated) gestureStartTranslation = 0f
1137
+ }
1138
+ handlePanUpdate(translationX)
1016
1139
  }
1017
1140
 
1018
1141
  fun handleGestureEnd(translationX: Float, velocityX: Float) {
1019
- if (!isGestureActivated) return
1020
-
1021
- isGestureActivated = false
1022
- gestureStartTranslation = 0f
1023
- isDragging = false
1024
- stopProgressUpdates()
1025
-
1026
- val projectedX = SwipePhysics.projectFinalPosition(translationX, velocityX, friction)
1027
- val thresholdDistance = actionsWidthPx * threshold
1028
- val shouldOpen = SwipePhysics.shouldSnapToOpen(projectedX, thresholdDistance, isOpen, isLeading)
1029
-
1030
- if (shouldOpen) {
1031
- open(velocityX)
1032
- } else {
1033
- close(velocity = velocityX)
1034
- }
1142
+ if (isCurrentTouchCancelled) return
1143
+ directGesturePending = false
1144
+ handlePanEnd(translationX, velocityX)
1035
1145
  }
1036
1146
 
1037
1147
  private fun startProgressUpdates() {
@@ -1076,54 +1186,328 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
1076
1186
  onSwipeProgress(mapOf(
1077
1187
  "progress" to progress.toDouble(),
1078
1188
  "translationX" to translation.toDouble()
1079
- ))
1189
+ ) + sideMetadata())
1080
1190
  }
1081
1191
  }
1082
1192
 
1083
- private fun emitFinalProgress(progress: Float, translationX: Float) {
1193
+ private fun emitFinalProgress(progress: Float, translationX: Float, side: SwipeSide? = activeSide) {
1084
1194
  lastEmittedProgress = progress
1085
1195
  // Use fresh map for each emission
1086
1196
  onSwipeProgress(mapOf(
1087
1197
  "progress" to progress.toDouble(),
1088
1198
  "translationX" to translationX.toDouble()
1089
- ))
1199
+ ) + sideMetadata(side))
1090
1200
  }
1091
1201
 
1092
1202
  override fun onAttachedToWindow() {
1093
1203
  super.onAttachedToWindow()
1094
1204
  recyclingKey?.let { registerView(this, it) }
1095
-
1205
+ if (restoreCacheOnAttach) {
1206
+ restoreCacheOnAttach = false
1207
+ recyclingKey?.let { key ->
1208
+ // Detached commands can change cached intent without a registered
1209
+ // view. The same instance must restore that intent too.
1210
+ val (side, intent) = readRestore(key)
1211
+ pendingRestoreSide = side
1212
+ pendingRestoreIntent = intent
1213
+ restorePending = true
1214
+ isOpen = false
1215
+ openSide = null
1216
+ animationSide = null
1217
+ configurationChanged()
1218
+ }
1219
+ }
1220
+ applyPendingConfiguration()
1221
+ reconcileSideChildren()
1222
+ if (isOpen) openSide?.let(::requestActions)
1096
1223
  if (isOpen && currentTranslation != 0f) {
1097
- post {
1224
+ postCurrent {
1098
1225
  if (isOpen && currentTranslation != 0f && !isDragging && !isAnimating) {
1099
1226
  contentView?.translationX = currentTranslation
1100
1227
  updateActionsTransform()
1101
1228
  actionsView?.visibility = View.VISIBLE
1102
1229
  }
1103
1230
  }
1104
-
1105
- // Restart autoClose timer if it was cancelled during detach
1106
- if (autoClose) {
1107
- scheduleAutoClose()
1108
- }
1109
1231
  }
1110
1232
  }
1111
1233
 
1112
1234
  override fun onDetachedFromWindow() {
1235
+ val cancelled = takeGestureCancellation()
1236
+ bindingGeneration++
1237
+ restoreCacheOnAttach = true
1238
+ isCurrentTouchCancelled = true
1113
1239
  super.onDetachedFromWindow()
1114
- stopProgressUpdates()
1115
- stopLayoutGuard()
1116
- cancelGestureTimeout()
1117
- cancelAutoClose()
1118
- contentSpringAnimation?.cancel()
1119
- actionsSpringAnimation?.cancel()
1240
+ removeCallbacks(configurationRunnable)
1120
1241
  velocityTracker?.recycle()
1121
1242
  velocityTracker = null
1122
- // Reset state
1243
+ requestedActions.clear()
1244
+ observedSlotCounts.clear()
1245
+ if (autoCloseDeadline != null) {
1246
+ // The abandoned auto-close cannot resume, so the transient open must not restore.
1247
+ isOpen = false
1248
+ openSide = null
1249
+ persistOpenSide(null)
1250
+ }
1251
+ recyclingKey?.let { unregisterView(this, it) }
1252
+ cancelActiveWork()
1253
+ reconcileLogicalState(restartLayoutGuard = false)
1254
+ if (cancelled != null) onGestureCancelled(cancelled)
1255
+ }
1256
+
1257
+ private fun validWidth(value: Float): Float = if (value.isFinite() && value > 0f) value else 0f
1258
+
1259
+ private fun widthFor(side: SwipeSide): Float =
1260
+ if (side == SwipeSide.LEFT) leftWidthPx else rightWidthPx
1261
+
1262
+ private fun isSideEnabled(side: SwipeSide): Boolean = widthFor(side) > 0f
1263
+
1264
+ private fun hasEnabledSide(): Boolean = leftWidthPx > 0f || rightWidthPx > 0f
1265
+
1266
+ private fun defaultOpenSide(): SwipeSide? =
1267
+ SwipePhysics.defaultOpenSide(leftWidthPx, rightWidthPx)
1268
+
1269
+ private fun selectGestureSide(translation: Float): SwipeSide? = SwipePhysics.selectSide(
1270
+ translation, openSide ?: animationSide, gestureSide, leftWidthPx, rightWidthPx
1271
+ )
1272
+
1273
+ private fun railFor(side: SwipeSide?): View? = when (side) {
1274
+ SwipeSide.LEFT -> leftActionsView
1275
+ SwipeSide.RIGHT -> rightActionsView
1276
+ null -> null
1277
+ }
1278
+
1279
+ private fun sideMetadata(side: SwipeSide? = activeSide): Map<String, Any> {
1280
+ val key = recyclingKey
1281
+ val keyMetadata = if (key != null) mapOf("recyclingKey" to key) else emptyMap()
1282
+ if (side == null) return keyMetadata
1283
+ return mapOf("side" to side.value) + keyMetadata
1284
+ }
1285
+
1286
+ private fun stateEvent(state: String, side: SwipeSide? = activeSide): Map<String, Any> =
1287
+ mapOf("state" to state) + sideMetadata(side)
1288
+
1289
+ private fun emitSwipeEnd(state: String, side: SwipeSide?) {
1290
+ gestureCompletionPending = false
1291
+ gestureCompletionMetadata = null
1292
+ onSwipeEnd(stateEvent(state, side))
1293
+ }
1294
+
1295
+ private fun requestActions(side: SwipeSide, retryEmptySlot: Boolean = false) {
1296
+ // A slot may be emptied without changing this view's own layout bounds.
1297
+ if (retryEmptySlot && (railFor(side) as? ViewGroup)?.childCount == 0) {
1298
+ requestedActions.remove(side)
1299
+ }
1300
+ if (isSideEnabled(side) && requestedActions.add(side)) {
1301
+ onActionsNeeded(mapOf("side" to side.value) + sideMetadata(side))
1302
+ }
1303
+ }
1304
+
1305
+ private fun configurationChanged() {
1306
+ // Invalidates callbacks immediately, but commits related widths/key together.
1307
+ workGeneration++
1308
+ configurationDirty = true
1309
+ removeCallbacks(configurationRunnable)
1310
+ post(configurationRunnable)
1311
+ requestLayout()
1312
+ }
1313
+
1314
+ internal fun applyPendingConfiguration() {
1315
+ if (!configurationDirty) return
1316
+ configurationDirty = false
1317
+ removeCallbacks(configurationRunnable)
1318
+ if (isGestureActivated || directGesturePending) {
1319
+ // Configuration changes consume the remainder of this physical/direct gesture.
1320
+ isCurrentTouchCancelled = true
1321
+ }
1322
+ reconcileSideChildren()
1323
+ if (pendingGestureDisable && !gestureEnabled) {
1324
+ if (!cancelActiveWork()) return
1325
+ isOpen = false
1326
+ openSide = null
1327
+ animationSide = null
1328
+ resetPosition()
1329
+ // A pending cached restore owns the cache: gestureEnabled turns off only the gesture.
1330
+ if (!restorePending) persistOpenSide(null)
1331
+ }
1332
+ pendingGestureDisable = false
1333
+ if (restorePending) {
1334
+ val restored = pendingRestoreSide
1335
+ val intent = pendingRestoreIntent
1336
+ restorePending = false
1337
+ pendingRestoreSide = null
1338
+ // A keyed close issued after the cache read wins over the read side.
1339
+ val key = recyclingKey
1340
+ if (restored != null && isSideEnabled(restored) && (key == null || isCurrentIntent(intent))) {
1341
+ snapToOpen(restored, intent)
1342
+ } else {
1343
+ persistOpenSide(null)
1344
+ reconcileLogicalState()
1345
+ }
1346
+ } else {
1347
+ reconcileLogicalState()
1348
+ }
1349
+ }
1350
+
1351
+ /** True when the row settled at its logical state now, not in later work. */
1352
+ private fun reconcileLogicalState(
1353
+ restartLayoutGuard: Boolean = true,
1354
+ deadline: Long? = autoCloseDeadline
1355
+ ): Boolean {
1356
+ val side = openSide
1357
+ val closingSide = autoCloseClosingSide
1358
+ val resumesCompletion = autoClose && (
1359
+ closingSide != null && isSideEnabled(closingSide) ||
1360
+ isOpen && side != null && isSideEnabled(side) && deadline != null
1361
+ )
1362
+ if (!cancelActiveWork(preserveGestureCompletion = resumesCompletion)) return false
1363
+ animationSide = null
1364
+ if (autoClose && closingSide != null && isSideEnabled(closingSide)) {
1365
+ // Continue an actual auto-close animation, preserving its original side/intent.
1366
+ animationSide = closingSide
1367
+ closeInternal(animated = true, autoCloseCompletion = true)
1368
+ return false
1369
+ }
1370
+ if (isOpen && side != null && isSideEnabled(side)) {
1371
+ openSide = side
1372
+ currentTranslation = openSide!!.sign * widthFor(openSide!!)
1373
+ contentView?.translationX = currentTranslation
1374
+ updateActionsTransform()
1375
+ if (restartLayoutGuard) startLayoutGuard()
1376
+ } else {
1377
+ isOpen = false
1378
+ openSide = null
1379
+ resetPosition()
1380
+ }
1381
+ persistOpenSide(openSide)
1382
+ if (autoClose && isOpen && deadline != null) scheduleAutoClose(deadline)
1383
+ return true
1384
+ }
1385
+
1386
+ private fun persistOpenSide(side: SwipeSide?, key: String? = recyclingKey) {
1387
+ if (key == null) return
1388
+ // One critical section with the close commands: an open that a later close
1389
+ // made stale persists closed, whether this binding ends first or last.
1390
+ synchronized(registryLock) {
1391
+ if (getView(key) === this) {
1392
+ setOpenState(key, side?.takeIf { heldKey == key && openIntent >= closedAtSequence })
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ /** False if a close issued after the intent marked this view. */
1398
+ private fun isCurrentIntent(intent: Long): Boolean = synchronized(registryLock) {
1399
+ intent >= closedAtSequence
1400
+ }
1401
+
1402
+ private fun resetPosition() {
1403
+ currentTranslation = 0f
1404
+ startOffset = 0f
1405
+ contentView?.translationX = 0f
1406
+ updateActionsTransform()
1407
+ }
1408
+
1409
+ private fun takeGestureCancellation(): Map<String, Any>? {
1410
+ val cancelled = if (gestureCompletionPending) gestureCompletionMetadata else null
1411
+ gestureCompletionPending = false
1412
+ gestureCompletionMetadata = null
1413
+ return cancelled
1414
+ }
1415
+
1416
+ private fun cancelActiveWork(preserveGestureCompletion: Boolean = false): Boolean {
1417
+ // Capture the original start's identity before any reset/rebind. Dispatch
1418
+ // after clearing ownership, but before the caller can emit a replacement start.
1419
+ val cancelled = if (!preserveGestureCompletion) takeGestureCancellation() else null
1420
+ workGeneration++
1421
+ val generation = workGeneration
1422
+ currentTranslation = contentView?.translationX ?: currentTranslation
1423
+ contentSpringAnimation?.cancel()
1424
+ contentSpringAnimation = null
1425
+ actionsSpringAnimation?.cancel()
1426
+ actionsSpringAnimation = null
1427
+ cancelAutoClose()
1428
+ autoCloseClosingSide = null
1429
+ cancelGestureTimeout()
1430
+ stopProgressUpdates()
1431
+ stopLayoutGuard()
1123
1432
  isAnimating = false
1124
1433
  isDragging = false
1125
1434
  isGestureActivated = false
1126
- // Thread-safe deregister from view registry
1127
- recyclingKey?.let { unregisterView(it) }
1435
+ directGesturePending = false
1436
+ gestureSide = null
1437
+ gestureStartTranslation = 0f
1438
+ lastEmittedWillEndState = null
1439
+ isBlockingChildEvents = false
1440
+ isIntercepting = false
1441
+ childRequestedDisallowIntercept = false
1442
+ parent?.requestDisallowInterceptTouchEvent(false)
1443
+ if (cancelled != null) onGestureCancelled(cancelled)
1444
+ // Event dispatch is normally asynchronous, but native callbacks can reenter.
1445
+ // In that case the newer work owns the view and the caller must stop.
1446
+ return generation == workGeneration
1128
1447
  }
1448
+
1449
+ private fun postCurrent(action: () -> Unit) {
1450
+ val generation = workGeneration
1451
+ val key = recyclingKey
1452
+ post { if (generation == workGeneration && key == recyclingKey) action() }
1453
+ }
1454
+
1455
+ internal fun postCommand(action: () -> Unit) = postCommand(bindingGeneration, recyclingKey, action)
1456
+
1457
+ /** Runs the action only if the binding read at command time still owns the view. */
1458
+ private fun postCommand(generation: Long, key: String?, action: () -> Unit) {
1459
+ post {
1460
+ if (generation == bindingGeneration && key == recyclingKey &&
1461
+ (key == null || getView(key) === this)) {
1462
+ action()
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ private fun reconcileSideChildren() {
1468
+ val previousContent = contentView
1469
+ val previousLeft = leftActionsView
1470
+ val previousRight = rightActionsView
1471
+ if (childCount == 3) {
1472
+ leftActionsView = getChildAt(0)
1473
+ rightActionsView = getChildAt(1)
1474
+ contentView = getChildAt(2)
1475
+ } else {
1476
+ // Retain only known identities. A rail must never become content mid-transaction.
1477
+ if (contentView?.parent !== this) contentView = null
1478
+ if (leftActionsView?.parent !== this) leftActionsView = null
1479
+ if (rightActionsView?.parent !== this) rightActionsView = null
1480
+ }
1481
+ for (side in SwipeSide.values()) {
1482
+ val rail = railFor(side)
1483
+ val previous = if (side == SwipeSide.LEFT) previousLeft else previousRight
1484
+ val count = (rail as? ViewGroup)?.childCount ?: 0
1485
+ if (rail !== previous || (observedSlotCounts[side] ?: 0) > 0 && count == 0) requestedActions.remove(side)
1486
+ observedSlotCounts[side] = count
1487
+ (rail as? ViewGroup)?.apply {
1488
+ clipChildren = false
1489
+ clipToPadding = false
1490
+ }
1491
+ }
1492
+ if (contentView !== previousContent) contentView?.translationX = currentTranslation
1493
+ if (isOpen || isDragging || isAnimating) activeSide?.let(::requestActions)
1494
+ updateActionsTransform()
1495
+ }
1496
+
1497
+ private fun refreshRailVisibility() {
1498
+ for (side in SwipeSide.values()) {
1499
+ val visible = childCount == 3 && isSideEnabled(side) && side == activeSide &&
1500
+ (isOpen || isDragging || isAnimating) && (currentTranslation != 0f || isOpen)
1501
+ railFor(side)?.apply {
1502
+ visibility = if (visible) View.VISIBLE else View.INVISIBLE
1503
+ // RN's TouchTargetHelper ignores Android visibility. An empty
1504
+ // zero-width rail still spans the row and otherwise steals taps
1505
+ // from the exposed opposite action, even with no descendants.
1506
+ (this as? ReactViewGroup)?.pointerEvents = if (visible) PointerEvents.AUTO else PointerEvents.NONE
1507
+ importantForAccessibility = if (visible) View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
1508
+ else View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
1509
+ }
1510
+ }
1511
+ }
1512
+
1129
1513
  }