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
@@ -5,11 +5,46 @@ import UIKit
5
5
  import React
6
6
  #endif
7
7
 
8
- // MARK: - WeakRef Helper
8
+ extension UIView: SwipeChildVisibility {}
9
9
 
10
- private class WeakRef<T: AnyObject> {
11
- weak var value: T?
12
- init(_ value: T) { self.value = value }
10
+ private final class SwipeablePanGestureRecognizer: UIPanGestureRecognizer {
11
+ private struct WeakTouch {
12
+ weak var value: UITouch?
13
+ }
14
+
15
+ var captureBinding: (() -> SwipeCommandTarget<SwipeableView>?)?
16
+ private var binding = SwipeTouchBinding<SwipeableView>()
17
+ private var trackedTouches: [ObjectIdentifier: WeakTouch] = [:]
18
+
19
+ func isCurrentBinding(_ current: SwipeCommandTarget<SwipeableView>) -> Bool {
20
+ binding.isCurrent(current)
21
+ }
22
+
23
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
24
+ // A failed recognizer may stop receiving touch-end callbacks. Prune only
25
+ // physically finished contacts; resetting recognition is not a fresh down.
26
+ let active = trackedTouches.values.compactMap { $0.value }
27
+ .filter { $0.phase != .ended && $0.phase != .cancelled }
28
+ binding.prune(keeping: active)
29
+ trackedTouches = Dictionary(uniqueKeysWithValues: active.map {
30
+ (ObjectIdentifier($0), WeakTouch(value: $0))
31
+ })
32
+ binding.begin(Array(touches), target: captureBinding?())
33
+ for touch in touches { trackedTouches[ObjectIdentifier(touch)] = WeakTouch(value: touch) }
34
+ super.touchesBegan(touches, with: event)
35
+ }
36
+
37
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
38
+ super.touchesEnded(touches, with: event)
39
+ binding.end(Array(touches))
40
+ for touch in touches { trackedTouches.removeValue(forKey: ObjectIdentifier(touch)) }
41
+ }
42
+
43
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
44
+ super.touchesCancelled(touches, with: event)
45
+ binding.end(Array(touches))
46
+ for touch in touches { trackedTouches.removeValue(forKey: ObjectIdentifier(touch)) }
47
+ }
13
48
  }
14
49
 
15
50
  public class SwipeableView: ExpoView {
@@ -31,15 +66,12 @@ public class SwipeableView: ExpoView {
31
66
  return typeName.contains("SurfaceTouchHandler") || typeName.contains("RCTTouchHandler")
32
67
  }
33
68
 
34
- /// Walk from a hit-tested view up to (but not including) self, checking for gesture recognizers.
35
- /// Any child view with a gesture recognizer (RNGH, custom, etc.) takes priority over our pan.
69
+ /// Child recognizers (RNGH or otherwise) take priority over the row pan.
36
70
  private func hasChildGesture(from view: UIView) -> Bool {
37
71
  var current: UIView? = view
38
- while let v = current, v !== self {
39
- if !(v.gestureRecognizers ?? []).isEmpty {
40
- return true
41
- }
42
- current = v.superview
72
+ while let child = current, child !== self {
73
+ if !(child.gestureRecognizers ?? []).isEmpty { return true }
74
+ current = child.superview
43
75
  }
44
76
  return false
45
77
  }
@@ -47,252 +79,589 @@ public class SwipeableView: ExpoView {
47
79
  // MARK: - Thread-Safe Static Registry
48
80
 
49
81
  private static let registryQueue = DispatchQueue(label: "com.swipeable.registry", qos: .userInteractive)
50
- private static var _openStateCache: [String: Bool] = [:]
51
- private static var _viewRegistry: [String: WeakRef<SwipeableView>] = [:]
82
+ private static let maxCacheSize = 1000
83
+ private static var _openStateCache = SwipeOpenStateCache(capacity: maxCacheSize)
84
+ private static var _viewRegistry: [String: SwipeCommandTarget<SwipeableView>] = [:]
52
85
 
53
86
  static func getOpenState(for key: String) -> Bool {
54
- registryQueue.sync { _openStateCache[key] ?? false }
87
+ registryQueue.sync { _openStateCache.side(for: key) != nil }
55
88
  }
56
89
 
57
- private static func setOpenState(for key: String, isOpen: Bool) {
58
- registryQueue.async(flags: .barrier) { _openStateCache[key] = isOpen }
90
+ static func getOpenSide(for key: String) -> String? {
91
+ registryQueue.sync { _openStateCache.side(for: key)?.rawValue }
59
92
  }
60
93
 
61
- private static func getView(for key: String) -> SwipeableView? {
62
- registryQueue.sync { _viewRegistry[key]?.value }
94
+ private static func currentIntent() -> Int {
95
+ registryQueue.sync { _openStateCache.currentIntent }
96
+ }
97
+
98
+ private static func isCurrentIntent(_ intent: Int, for mark: SwipeCloseMark) -> Bool {
99
+ registryQueue.sync { _openStateCache.isCurrent(intent, for: mark) }
100
+ }
101
+
102
+ private static func bindRestore(_ mark: SwipeCloseMark, to key: String?) -> (side: SwipeSide?, intent: Int)? {
103
+ registryQueue.sync { _openStateCache.bind(mark, to: key) }
104
+ }
105
+
106
+ private static func readRestore(for key: String) -> (side: SwipeSide?, intent: Int) {
107
+ registryQueue.sync { _openStateCache.restore(for: key) }
108
+ }
109
+
110
+ private static func setOpenState(for key: String, side: SwipeSide?, owner: SwipeableView, intent: Int) {
111
+ let identity = ObjectIdentifier(owner)
112
+ let mark = owner.closeMark
113
+ registryQueue.async {
114
+ // A recycled/detached view must not overwrite its replacement's state.
115
+ if let current = _viewRegistry[key]?.value, ObjectIdentifier(current) != identity { return }
116
+ // One step with the close commands: an open that a later close made
117
+ // stale persists closed, whether this binding ends first or last.
118
+ _openStateCache.persist(side, for: key, from: mark, intent: intent)
119
+ }
63
120
  }
64
121
 
65
122
  private static func registerView(_ view: SwipeableView, for key: String) {
66
- registryQueue.async(flags: .barrier) { _viewRegistry[key] = WeakRef(view) }
123
+ // Registration creates a fresh binding identity, even for A -> B -> A on
124
+ // the same view or a detach/reattach. Open/close never change this identity.
125
+ let reference = SwipeCommandTarget(view)
126
+ registryQueue.async(flags: .barrier) { _viewRegistry[key] = reference }
67
127
  }
68
128
 
69
129
  private static func unregisterView(_ view: SwipeableView, for key: String) {
70
- // Identity-check: a newer SwipeableView may have already taken over this
71
- // key (FlashList remounts a new instance before the old one's deinit
72
- // fires). Without this guard the old view's deinit blindly removes the
73
- // key, leaving the new view orphaned in the registry — closeByKey for
74
- // that key then returns nil and the visible row can't be closed.
75
- //
76
- // Capture identity as a *value* (ObjectIdentifier), never reference
77
- // `view` inside the async closure. Callers include `deinit`, where
78
- // `self` is mid-deallocation; strongly capturing it in an escaping
79
- // closure produces a zombie reference and crashes (EXC_BAD_ACCESS) when
80
- // the barrier finally runs after the object has been freed.
130
+ // Do not retain a deinitializing view in asynchronous registry work.
81
131
  let identity = ObjectIdentifier(view)
82
- registryQueue.async(flags: .barrier) {
83
- if let registered = _viewRegistry[key]?.value,
84
- ObjectIdentifier(registered) == identity {
85
- _viewRegistry.removeValue(forKey: key)
86
- }
132
+ registryQueue.async {
133
+ guard _viewRegistry[key]?.identity == identity else { return }
134
+ _viewRegistry.removeValue(forKey: key)
87
135
  }
88
136
  }
89
137
 
90
- private static func clearOpenState(for key: String) {
91
- registryQueue.async(flags: .barrier) { _openStateCache.removeValue(forKey: key) }
138
+ /// The command gets the intent read with its target, so a later close marks that target.
139
+ private static func enqueueCommand(for key: String, _ command: @escaping (SwipeableView, Int) -> Void) {
140
+ let (target, intent) = registryQueue.sync { (_viewRegistry[key], _openStateCache.currentIntent) }
141
+ guard let target = target else { return }
142
+ DispatchQueue.main.async {
143
+ performCommand({ command($0, intent) }, for: key, target: target)
144
+ }
92
145
  }
93
146
 
94
- private static func getAllViews() -> [SwipeableView] {
95
- registryQueue.sync { _viewRegistry.values.compactMap { $0.value } }
147
+ private static func performCommand(
148
+ _ command: (SwipeableView) -> Void, for key: String, target: SwipeCommandTarget<SwipeableView>
149
+ ) {
150
+ let current = registryQueue.sync { _viewRegistry[key] }
151
+ target.perform(ifCurrent: current) { view in
152
+ guard view.recyclingKey == key, !view.isDetached else { return }
153
+ command(view)
154
+ }
155
+ }
156
+
157
+ // Raw view refs also need a binding guard, including refs without a key.
158
+ // Opening/closing must not invalidate other commands queued for this binding.
159
+ private lazy var commandTarget = SwipeCommandTarget(self)
160
+
161
+ func enqueueCommand(_ command: @escaping (SwipeableView) -> Void) {
162
+ let target = commandTarget
163
+ let key = recyclingKey
164
+ DispatchQueue.main.async { [weak self] in
165
+ guard let self = self, !self.isDetached, self.recyclingKey == key else { return }
166
+ target.perform(ifCurrent: self.commandTarget) { view in
167
+ if let key = key {
168
+ let current = Self.registryQueue.sync { Self._viewRegistry[key] }
169
+ guard current?.value === view else { return }
170
+ }
171
+ command(view)
172
+ }
173
+ }
96
174
  }
97
175
 
98
176
  // MARK: - Static Methods (by recyclingKey)
99
177
 
178
+ // A keyed open keeps the intent of its command time, not of the main-queue turn.
100
179
  static func openByKey(_ key: String) {
101
- DispatchQueue.main.async {
102
- getView(for: key)?.open()
103
- }
180
+ enqueueCommand(for: key) { view, intent in view.open(intent: intent) }
181
+ }
182
+
183
+ static func openByKeyOnSide(_ key: String, side: String) {
184
+ guard let side = SwipeSide(rawValue: side) else { return }
185
+ enqueueCommand(for: key) { view, intent in view.open(side: side, intent: intent) }
104
186
  }
105
187
 
106
188
  static func closeByKey(_ key: String, animated: Bool = true) {
189
+ let (target, sequence) = registryQueue.sync { () -> (SwipeCommandTarget<SwipeableView>?, Int) in
190
+ // Consume the cached intent before any binding can read the cached side.
191
+ let target = _openStateCache.captureCloseTarget(for: key, current: _viewRegistry[key])
192
+ return (target, _openStateCache.currentIntent)
193
+ }
194
+ guard let target = target else { return }
107
195
  DispatchQueue.main.async {
108
- if let view = getView(for: key) {
196
+ performCommand({ view in
197
+ guard SwipeOpenStateCache.closeApplies(sequence, toOpenIntent: view.openIntent) else { return }
109
198
  view.close(animated: animated)
110
- } else {
111
- // View was recycled away (or unregistered) before this close
112
- // could run. Update the cache directly so a future cell binding
113
- // to this key starts closed instead of inheriting a stale open
114
- // state — see recyclingKey setter for the matching invariant.
115
- setOpenState(for: key, isOpen: false)
116
- }
199
+ }, for: key, target: target)
117
200
  }
118
201
  }
119
202
 
203
+ /// A keyed cancel clears the cached open intent like a keyed close, mounted or not.
120
204
  static func cancelByKey(_ key: String) {
205
+ let target = registryQueue.sync {
206
+ _openStateCache.captureCloseTarget(for: key, current: _viewRegistry[key])
207
+ }
208
+ guard let target = target else { return }
121
209
  DispatchQueue.main.async {
122
- getView(for: key)?.cancelGesture()
210
+ performCommand({ $0.cancelGesture() }, for: key, target: target)
123
211
  }
124
212
  }
125
213
 
126
214
  /// Close all swipeables. When animated=true, only closes open views. When animated=false, resets all views.
127
215
  static func closeAll(animated: Bool = true) {
128
- let views = getAllViews()
216
+ let (targets, sequence) = registryQueue.sync {
217
+ () -> ([(key: String, target: SwipeCommandTarget<SwipeableView>)], Int) in
218
+ _openStateCache.closeAll()
219
+ return (_viewRegistry.map { (key: $0.key, target: $0.value) }, _openStateCache.currentIntent)
220
+ }
129
221
  DispatchQueue.main.async {
130
- if animated {
131
- // Only close open views when animated
132
- views.filter { $0.isOpen }.forEach { $0.close(animated: true) }
133
- } else {
134
- // Reset all views when not animated
135
- views.forEach { $0.close(animated: false) }
222
+ for entry in targets {
223
+ performCommand({ view in
224
+ guard SwipeOpenStateCache.closeApplies(sequence, toOpenIntent: view.openIntent) else { return }
225
+ if !animated || SwipePhysics.shouldCloseForCloseAll(
226
+ isOpen: view.isOpen, isAnimating: view.isAnimating,
227
+ hasPendingInteraction: view.isDragging || view.isGestureActivated ||
228
+ view.directGesturePending || view.pendingRestoreSide != nil
229
+ ) {
230
+ view.close(animated: animated)
231
+ }
232
+ }, for: entry.key, target: entry.target)
136
233
  }
137
234
  }
138
235
  }
139
236
 
140
237
  // MARK: - State
141
- private var actionsView: UIView?
238
+
239
+ private var leftActionsView: UIView?
240
+ private var rightActionsView: UIView?
142
241
  private var contentView: UIView?
143
- // Track React-managed children explicitly via Fabric's mount hooks.
144
- // Using UIView.subviews is unsafe because UIKit/Expo/RN may insert helper
145
- // views (clipping wrappers, focus indicators, etc.) that shift indices and
146
- // cause us to transform the wrong UIView.
147
- private var reactChildren: [UIView] = []
242
+ private let railVisibility = SwipeRailVisibility()
243
+ // Fabric mount order, not UIView.subviews (which can contain UIKit/Expo helpers).
244
+ private var reactChildren = SwipeReactChildren<UIView>()
245
+ private var isDetached = false
148
246
  private var currentTranslation: CGFloat = 0
149
- private var isOpen = false
247
+ private var openSide: SwipeSide?
248
+ private var gestureSide: SwipeSide?
249
+ private var animationSide: SwipeSide?
250
+ private var isOpen: Bool { openSide != nil }
150
251
  private var startOffset: CGFloat = 0
252
+ private var gestureStartedOpen = false
151
253
  private var isDragging = false
152
254
  private var isAnimating = false
153
255
  private var gestureStartTranslation: CGFloat = 0
154
256
  private var isGestureActivated = false
155
- private var pendingShouldOpen = false
257
+ private var gestureCompletion = SwipeGestureCompletion()
258
+ private var directGesturePending = false
259
+ private var ignoresPanUntilNextBegin = false
156
260
  private var lastEmittedWillEndState: String?
157
261
  private weak var savedFirstResponder: UIView?
158
262
 
263
+ // Every asynchronous operation belongs to one key/configuration/command generation.
264
+ private var workGeneration = 0
265
+ private var pendingRestoreSide: SwipeSide?
266
+ private var pendingRestoreIntent = 0
267
+ // The close sequence this view's open intent saw. A later keyed close makes it stale.
268
+ private var openIntent = 0
269
+ // The keyed close mark of the key this view holds. Only the registry queue uses it.
270
+ private let closeMark = SwipeCloseMark()
271
+ private var isCurrentRestore: Bool {
272
+ recyclingKey.map { _ in Self.isCurrentIntent(pendingRestoreIntent, for: closeMark) } ?? true
273
+ }
274
+ private var reconciliationWorkItem: DispatchWorkItem?
275
+ private var visualRestoreWorkItem: DispatchWorkItem?
276
+ private var requestedSides: Set<SwipeSide> = []
277
+ private var slotChildren: [SwipeSide: [ObjectIdentifier]] = [:]
278
+
279
+ private struct LayoutConfiguration: Equatable {
280
+ var leftWidth: CGFloat
281
+ var rightWidth: CGFloat
282
+ var enabled: Bool
283
+ var friction: CGFloat
284
+ var threshold: CGFloat
285
+ var dragOffset: CGFloat
286
+ var autoClose: Bool
287
+ var autoCloseTimeout: CGFloat
288
+ }
289
+ private var appliedConfiguration = LayoutConfiguration(
290
+ leftWidth: 0, rightWidth: 0, enabled: true,
291
+ friction: 1, threshold: 0.4, dragOffset: 0, autoClose: false, autoCloseTimeout: 0
292
+ )
293
+
159
294
  // MARK: - Props
160
295
 
161
- var actionsWidth: CGFloat = 0 {
296
+ var leftActionsWidth: CGFloat = 0 {
162
297
  didSet {
163
- let validated = max(0, actionsWidth)
164
- if validated != actionsWidth {
165
- actionsWidth = validated
166
- return
167
- }
168
- if shouldSyncOpenLayoutForPropChange(oldValue: oldValue, newValue: validated) {
169
- syncOpenStateToCurrentWidth()
170
- }
171
- setNeedsLayout()
298
+ leftActionsWidth = SwipePhysics.validWidth(leftActionsWidth)
299
+ if leftActionsWidth != oldValue { scheduleReconciliation() }
172
300
  }
173
301
  }
174
302
 
175
- var actionsPosition: String = "right" {
303
+ var rightActionsWidth: CGFloat = 0 {
176
304
  didSet {
177
- let validPositions = ["left", "right"]
178
- if !validPositions.contains(actionsPosition) {
179
- actionsPosition = "right"
180
- return
181
- }
182
- setNeedsLayout()
305
+ rightActionsWidth = SwipePhysics.validWidth(rightActionsWidth)
306
+ if rightActionsWidth != oldValue { scheduleReconciliation() }
183
307
  }
184
308
  }
185
309
 
186
- var isLeading: Bool { actionsPosition == "left" }
187
-
188
310
  var friction: CGFloat = 1.0 {
189
- didSet { friction = max(0.0, min(1.0, friction)) }
311
+ didSet {
312
+ friction = max(0.0, min(1.0, friction))
313
+ if friction != oldValue { scheduleReconciliation() }
314
+ }
190
315
  }
191
-
192
316
  var threshold: CGFloat = 0.4 {
193
- didSet { threshold = max(0.0, min(1.0, threshold)) }
317
+ didSet {
318
+ threshold = max(0.0, min(1.0, threshold))
319
+ if threshold != oldValue { scheduleReconciliation() }
320
+ }
194
321
  }
195
-
196
- var gestureEnabled: Bool = true {
322
+ var gestureEnabled = true {
197
323
  didSet {
198
324
  guard gestureEnabled != oldValue else { return }
199
-
200
- if !gestureEnabled {
201
- abortActiveGesture()
202
- panGesture.isEnabled = false
203
-
204
- // Close if open (instant reset, no animation)
205
- if isOpen || currentTranslation != 0 {
206
- close(animated: false)
207
- }
208
- } else {
209
- panGesture.isEnabled = true
210
- }
325
+ scheduleReconciliation()
211
326
  }
212
327
  }
213
-
214
- /// Cancels an in-flight pan gesture and snaps the view back to closed.
215
- /// Use from `onSwipeStart` to abort a swipe before it can complete.
328
+ /// Explicit cancellation consumes the rest of the pan and any pending reopen,
329
+ /// but leaves the binding identity intact for later commands in the same JS turn.
216
330
  func cancelGesture() {
217
- guard isGestureActivated || isDragging || currentTranslation != 0 else { return }
218
-
331
+ ignoresPanUntilNextBegin = true
219
332
  abortActiveGesture()
220
-
221
- // Toggle isEnabled to fire .cancelled synchronously on the recognizer,
222
- // then re-enable so the next gesture can begin.
223
333
  panGesture.isEnabled = false
224
- panGesture.isEnabled = true
225
-
226
- if isOpen || currentTranslation != 0 {
227
- close(animated: false)
228
- }
334
+ panGesture.isEnabled = gestureEnabled
335
+ performClose(animated: false)
229
336
  }
230
337
 
231
338
  private func abortActiveGesture() {
232
- guard isGestureActivated || isDragging else { return }
233
339
  isGestureActivated = false
340
+ directGesturePending = false
234
341
  isDragging = false
235
342
  gestureStartTranslation = 0
236
343
  stopProgressUpdates()
237
344
  }
238
345
 
239
346
  var dragOffsetFromEdge: CGFloat = 0 {
240
- didSet { dragOffsetFromEdge = max(0, dragOffsetFromEdge) }
347
+ didSet {
348
+ dragOffsetFromEdge = max(0, dragOffsetFromEdge)
349
+ if dragOffsetFromEdge != oldValue { scheduleReconciliation() }
350
+ }
351
+ }
352
+ var autoClose = false {
353
+ didSet {
354
+ if autoClose != oldValue {
355
+ cancelAutoClose()
356
+ scheduleReconciliation()
357
+ }
358
+ }
359
+ }
360
+ var autoCloseTimeout: CGFloat = 0 {
361
+ didSet {
362
+ autoCloseTimeout = max(0, autoCloseTimeout)
363
+ if autoCloseTimeout != oldValue { scheduleReconciliation() }
364
+ }
241
365
  }
242
366
 
243
- var autoClose: Bool = false
244
-
245
- var autoCloseTimeout: CGFloat = 0 { // milliseconds
246
- didSet { autoCloseTimeout = max(0, autoCloseTimeout) }
367
+ var appearance = "default" {
368
+ didSet {
369
+ if appearance != oldValue {
370
+ resetFeedback()
371
+ updateActionsTransform()
372
+ }
373
+ }
374
+ }
375
+ var hapticFeedback: Bool? {
376
+ didSet {
377
+ if hapticFeedback != oldValue {
378
+ resetFeedback()
379
+ updateActionsTransform()
380
+ }
381
+ }
247
382
  }
248
383
 
249
384
  var recyclingKey: String? {
250
385
  didSet {
251
386
  guard oldValue != recyclingKey else { return }
252
-
253
- // Thread-safe read of new key's state
254
- let shouldOpen = recyclingKey.map { Self.getOpenState(for: $0) } ?? false
255
- pendingShouldOpen = shouldOpen
256
-
257
- // Unregister from the old key. We intentionally do NOT write back
258
- // `isOpen` here: open() and close() already maintain the cache when
259
- // the user actually triggers the state change. Writing the view's
260
- // current `isOpen` at reassignment time captures stale state when a
261
- // pending async close hasn't run yet, leaving the old key cached as
262
- // open forever — the next cell binding to it would snap open
263
- // without any user intent.
387
+ let cancelled = gestureCompletion.cancel(recyclingKey: oldValue, side: currentSide)
388
+ commandTarget = SwipeCommandTarget(self)
389
+ // The view stops holding the old key: its mark moves with the cache read.
390
+ let restored = Self.bindRestore(closeMark, to: recyclingKey)
264
391
  if let old = oldValue {
392
+ // An abandoned auto-close wait cannot resume, so its transient open must not restore.
393
+ if autoCloseWorkItem != nil { Self.setOpenState(for: old, side: nil, owner: self, intent: openIntent) }
394
+ // Commands own cached intent. Rebinding must not resurrect the old
395
+ // view's stale state while a queued close is waiting to execute.
265
396
  Self.unregisterView(self, for: old)
266
397
  }
267
-
268
- // Thread-safe register with new key
269
- if let new = recyclingKey {
270
- Self.registerView(self, for: new)
271
- }
272
-
273
- close(animated: false)
274
-
275
- // Apply using local shouldOpen (pendingShouldOpen was cleared by close)
276
- if shouldOpen {
277
- DispatchQueue.main.async { [weak self] in self?.snapToOpen() }
278
- }
398
+ invalidateWork(preservePresentation: false)
399
+ openSide = nil
400
+ currentTranslation = 0
401
+ savedFirstResponder = nil
402
+ requestedSides.removeAll()
403
+ slotChildren.removeAll()
404
+ contentView?.transform = .identity
405
+ updateActionsTransform()
406
+ pendingRestoreSide = restored?.side
407
+ pendingRestoreIntent = restored?.intent ?? 0
408
+ if let key = recyclingKey, !isDetached { Self.registerView(self, for: key) }
409
+ // Widths may follow the key in the same Expo prop transaction.
410
+ scheduleReconciliation()
411
+ // Registry state is complete before native callback reentry. The
412
+ // cancellation retains the old start's identity across async delivery.
413
+ if let cancelled = cancelled { onGestureCancelled(cancelled) }
279
414
  }
280
415
  }
281
416
 
282
- // MARK: - Events
417
+ // MARK: - Events and animation
283
418
 
284
419
  let onSwipeProgress = EventDispatcher()
285
420
  let onSwipeStart = EventDispatcher()
286
421
  let onSwipeStateChange = EventDispatcher()
287
422
  let onSwipeEnd = EventDispatcher()
423
+ let onActionsNeeded = EventDispatcher()
424
+ let onGestureCancelled = EventDispatcher()
288
425
  private var displayLink: CADisplayLink?
289
426
  private var lastEmittedProgress: CGFloat = -1
290
-
291
- // Auto-close work item for tracking and cancellation
292
427
  private var autoCloseWorkItem: DispatchWorkItem?
293
-
294
- // UIViewPropertyAnimator for interruptible animations (2.1, 2.2)
428
+ private var autoCloseState: SwipeAutoCloseState?
429
+ private var autoCloseGeneration = 0
295
430
  private var currentAnimator: UIViewPropertyAnimator?
431
+ private var feedback = SwipeFeedbackState()
432
+ private var feedbackGenerator: UIImpactFeedbackGenerator?
433
+ private var popStartedAt: CFTimeInterval?
434
+ private var reduceMotion = UIAccessibility.isReduceMotionEnabled
435
+ private var usesAppearance: Bool { appearance == "ios26" }
436
+ private var usesHaptics: Bool { hapticFeedback ?? usesAppearance }
437
+ private var currentSide: SwipeSide? {
438
+ gestureSide ?? animationSide ?? openSide
439
+ }
440
+ private var actionsView: UIView? {
441
+ guard let side = currentSide else { return nil }
442
+ return actionsView(for: side)
443
+ }
444
+
445
+ private func width(for side: SwipeSide) -> CGFloat {
446
+ return side == .left ? leftActionsWidth : rightActionsWidth
447
+ }
448
+
449
+ private func actionsView(for side: SwipeSide) -> UIView? {
450
+ return side == .left ? leftActionsView : rightActionsView
451
+ }
452
+
453
+ private func defaultOpenSide() -> SwipeSide? {
454
+ if width(for: .right) > 0 { return .right }
455
+ if width(for: .left) > 0 { return .left }
456
+ return nil
457
+ }
458
+
459
+ private func payload(_ values: [String: Any], side: SwipeSide?) -> [String: Any] {
460
+ var result = values
461
+ if let side = side { result["side"] = side.rawValue }
462
+ // Capture identity before EventDispatcher queues delivery to JavaScript.
463
+ if let key = recyclingKey { result["recyclingKey"] = key }
464
+ return result
465
+ }
466
+
467
+ private func persistOpenSide() {
468
+ if let key = recyclingKey { Self.setOpenState(for: key, side: openSide, owner: self, intent: openIntent) }
469
+ }
470
+
471
+ private func isCurrent(_ generation: Int, key: String?) -> Bool {
472
+ !isDetached && workGeneration == generation && recyclingKey == key
473
+ }
474
+
475
+ private func requestActions(on side: SwipeSide) {
476
+ guard width(for: side) > 0 else { return }
477
+ guard requestedSides.insert(side).inserted else { return }
478
+ onActionsNeeded(payload(["side": side.rawValue], side: nil))
479
+ }
480
+
481
+ private func scheduleReconciliation() {
482
+ setNeedsLayout()
483
+ guard !isDetached else { return }
484
+ guard reconciliationWorkItem == nil else { return }
485
+ let item = DispatchWorkItem { [weak self] in
486
+ self?.reconciliationWorkItem = nil
487
+ self?.commitProps()
488
+ }
489
+ reconciliationWorkItem = item
490
+ DispatchQueue.main.async(execute: item)
491
+ }
492
+
493
+ // Called by Expo after all props, and on the next turn for direct native setters/children.
494
+ func commitProps() {
495
+ reconciliationWorkItem?.cancel()
496
+ reconciliationWorkItem = nil
497
+ guard !isDetached else { return }
498
+ let next = LayoutConfiguration(
499
+ leftWidth: leftActionsWidth, rightWidth: rightActionsWidth,
500
+ enabled: gestureEnabled, friction: friction, threshold: threshold,
501
+ dragOffset: dragOffsetFromEdge, autoClose: autoClose, autoCloseTimeout: autoCloseTimeout
502
+ )
503
+ let previous = appliedConfiguration
504
+ appliedConfiguration = next
505
+ var resumeAutoClose: SwipeAutoCloseState?
506
+ if next != previous {
507
+ // Like Android, the invalidation below consumes only a pan that already activated.
508
+ // A pan that has not activated keeps tracking and latches against this configuration.
509
+ let logicalSide = openSide
510
+ let reconciled = SwipePhysics.reconcileTwoSidedState(
511
+ openSide: logicalSide,
512
+ autoCloseState: autoCloseState,
513
+ leftWidth: width(for: .left), rightWidth: width(for: .right),
514
+ wasGestureEnabled: previous.enabled, gestureEnabled: gestureEnabled, autoClose: autoClose
515
+ )
516
+ let presentation = contentView?.layer.presentation()?.transform.m41 ?? currentTranslation
517
+ let preservesCompletion = reconciled.autoClose != nil
518
+ guard invalidateWork(
519
+ preservePresentation: false, clearRestore: false,
520
+ preserveGestureCompletion: preservesCompletion
521
+ ) else { return }
522
+ requestedSides.removeAll()
523
+ openSide = reconciled.openSide
524
+ resumeAutoClose = reconciled.autoClose
525
+ if case .closing = resumeAutoClose {
526
+ currentTranslation = presentation
527
+ } else {
528
+ currentTranslation = openSide.map { $0.sign * width(for: $0) } ?? 0
529
+ }
530
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
531
+ if pendingRestoreSide == nil { persistOpenSide() }
532
+ }
533
+ panGesture.isEnabled = gestureEnabled
534
+ reconcileChildRoles()
535
+ if let cachedSide = pendingRestoreSide {
536
+ let side = cachedSide
537
+ // gestureEnabled turns off only the gesture; cached intent still restores.
538
+ // A keyed close issued after the cache read wins over the read side.
539
+ if width(for: side) <= 0 || !isCurrentRestore {
540
+ pendingRestoreSide = nil
541
+ openSide = nil
542
+ currentTranslation = 0
543
+ persistOpenSide()
544
+ } else if contentView != nil {
545
+ pendingRestoreSide = nil
546
+ snapToOpen(side: side, intent: pendingRestoreIntent)
547
+ } else {
548
+ requestActions(on: side)
549
+ }
550
+ }
551
+ updateActionsTransform()
552
+ setNeedsLayout()
553
+ if let pending = resumeAutoClose {
554
+ switch pending {
555
+ case .waiting:
556
+ resumeAutoCloseTimer(pending)
557
+ case .closing(let side):
558
+ animationSide = side
559
+ performClose(animated: true, endState: "open")
560
+ }
561
+ }
562
+ }
563
+
564
+ @discardableResult
565
+ private func invalidateWork(
566
+ preservePresentation: Bool = true,
567
+ clearRestore: Bool = true,
568
+ resetAppearance: Bool = true,
569
+ preserveGestureCompletion: Bool = false
570
+ ) -> Bool {
571
+ let cancelled = preserveGestureCompletion ? nil :
572
+ gestureCompletion.cancel(recyclingKey: recyclingKey, side: currentSide)
573
+ // Like Android, interrupted work consumes only a gesture that already activated.
574
+ // A pan that has not activated keeps tracking and can activate after that work.
575
+ let panIsLive = panGesture.state == .began || panGesture.state == .changed
576
+ let gestureIsLive = isGestureActivated || directGesturePending
577
+ if gestureIsLive && panIsLive {
578
+ ignoresPanUntilNextBegin = true
579
+ }
580
+ workGeneration += 1
581
+ let generation = workGeneration
582
+ cancelAutoClose()
583
+ visualRestoreWorkItem?.cancel()
584
+ visualRestoreWorkItem = nil
585
+ if let animator = currentAnimator {
586
+ if preservePresentation {
587
+ animator.pauseAnimation()
588
+ if let presentation = contentView?.layer.presentation() {
589
+ currentTranslation = presentation.transform.m41
590
+ }
591
+ }
592
+ animator.stopAnimation(true)
593
+ }
594
+ currentAnimator = nil
595
+ isAnimating = false
596
+ stopProgressUpdates()
597
+ if preservePresentation {
598
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
599
+ }
600
+ isGestureActivated = false
601
+ directGesturePending = false
602
+ isDragging = false
603
+ // A pan that has not activated keeps its start, so its offset check stays exact.
604
+ if gestureIsLive || !panIsLive { gestureStartTranslation = 0 }
605
+ gestureSide = nil
606
+ animationSide = nil
607
+ startOffset = 0
608
+ if clearRestore { pendingRestoreSide = nil }
609
+ if resetAppearance { resetFeedback() }
610
+ // Queue the old cancellation before any replacement start. Ownership has
611
+ // already been cleared, so synchronous native reentry cannot cancel twice.
612
+ if let cancelled = cancelled { onGestureCancelled(cancelled) }
613
+ return generation == workGeneration
614
+ }
615
+
616
+ // MARK: - Appearance
617
+
618
+ private func resetFeedback() {
619
+ feedback.reset()
620
+ feedbackGenerator = nil
621
+ popStartedAt = nil
622
+ for view in [leftActionsView, rightActionsView].compactMap({ $0 }) {
623
+ view.transform = CGAffineTransform(translationX: view.transform.tx, y: view.transform.ty)
624
+ }
625
+ }
626
+
627
+ @objc private func reduceMotionChanged() {
628
+ reduceMotion = UIAccessibility.isReduceMotionEnabled
629
+ if reduceMotion { popStartedAt = nil }
630
+ updateActionsTransform()
631
+ }
632
+
633
+ private func beginFeedback(on side: SwipeSide) {
634
+ resetFeedback()
635
+ feedback.activate(isOpening: gestureStartedOpen)
636
+ if usesHaptics {
637
+ let generator = UIImpactFeedbackGenerator(style: .light)
638
+ generator.prepare()
639
+ feedbackGenerator = generator
640
+ }
641
+ }
642
+
643
+ private func updateFeedback(on side: SwipeSide, relativeTranslation: CGFloat) {
644
+ let sideWidth = width(for: side)
645
+ guard sideWidth > 0 else { return }
646
+ let isOpening = SwipePhysics.shouldSnapToOpen(
647
+ projectedTranslation: relativeTranslation * friction,
648
+ actionsWidth: sideWidth, threshold: threshold,
649
+ isLeading: side == .left, isCurrentlyOpen: gestureStartedOpen
650
+ )
651
+ guard feedback.update(isOpening: isOpening) else { return }
652
+ if usesHaptics { feedbackGenerator?.impactOccurred() }
653
+ if usesAppearance && !reduceMotion { popStartedAt = CACurrentMediaTime() }
654
+ }
655
+
656
+ private func railScale(progress: CGFloat) -> CGFloat {
657
+ guard usesAppearance else { return 1 }
658
+ return SwipeAnimationConfig.actionRailScale(
659
+ progress: progress,
660
+ popElapsed: popStartedAt.map { CACurrentMediaTime() - $0 },
661
+ hasPopped: feedback.hasCrossedThreshold,
662
+ reduceMotion: reduceMotion
663
+ )
664
+ }
296
665
 
297
666
  // MARK: - First Responder Helper
298
667
 
@@ -305,8 +674,9 @@ public class SwipeableView: ExpoView {
305
674
  return nil
306
675
  }
307
676
 
308
- private lazy var panGesture: UIPanGestureRecognizer = {
309
- let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
677
+ private lazy var panGesture: SwipeablePanGestureRecognizer = {
678
+ let gesture = SwipeablePanGestureRecognizer(target: self, action: #selector(handlePan))
679
+ gesture.captureBinding = { [weak self] in self?.commandTarget }
310
680
  gesture.delegate = self
311
681
  gesture.cancelsTouchesInView = false
312
682
  gesture.delaysTouchesBegan = true
@@ -314,11 +684,17 @@ public class SwipeableView: ExpoView {
314
684
  return gesture
315
685
  }()
316
686
 
317
- // MARK: - Init
687
+ // MARK: - Init and attachment
318
688
 
319
689
  public required init(appContext: AppContext? = nil) {
320
690
  super.init(appContext: appContext)
321
- setupViews()
691
+ clipsToBounds = true
692
+ isUserInteractionEnabled = true
693
+ addGestureRecognizer(panGesture)
694
+ NotificationCenter.default.addObserver(
695
+ self, selector: #selector(reduceMotionChanged),
696
+ name: UIAccessibility.reduceMotionStatusDidChangeNotification, object: nil
697
+ )
322
698
  }
323
699
 
324
700
  required init?(coder: NSCoder) {
@@ -326,77 +702,75 @@ public class SwipeableView: ExpoView {
326
702
  }
327
703
 
328
704
  deinit {
705
+ releaseChildRoles()
329
706
  stopProgressUpdates()
330
707
  cancelAutoClose()
331
- // Stop any running animations (2.2)
708
+ reconciliationWorkItem?.cancel()
709
+ visualRestoreWorkItem?.cancel()
332
710
  currentAnimator?.stopAnimation(true)
333
- currentAnimator = nil
334
- removeGestureRecognizer(panGesture)
335
- // Thread-safe deregister from view registry
711
+ NotificationCenter.default.removeObserver(self)
712
+ if let key = recyclingKey { Self.unregisterView(self, for: key) }
713
+ }
714
+
715
+ private var hasPendingInteraction: Bool {
716
+ isAnimating || isDragging || isGestureActivated || directGesturePending || autoCloseWorkItem != nil
717
+ }
718
+
719
+ private func suspendForDetach() {
720
+ guard !isDetached else { return }
721
+ let cancelled = gestureCompletion.cancel(recyclingKey: recyclingKey, side: currentSide)
722
+ let preservesCachedSide = SwipePhysics.shouldRestoreAfterDetach(
723
+ hasPendingInteraction: hasPendingInteraction
724
+ )
725
+ commandTarget = SwipeCommandTarget(self)
726
+ isDetached = true
727
+ reconciliationWorkItem?.cancel()
728
+ reconciliationWorkItem = nil
729
+ invalidateWork(preservePresentation: false)
730
+ savedFirstResponder = nil
731
+ openSide = nil
732
+ currentTranslation = 0
733
+ lastEmittedWillEndState = nil
734
+ requestedSides.removeAll()
735
+ releaseChildRoles()
336
736
  if let key = recyclingKey {
737
+ // Restore only settled cache intent, never unfinished interaction work.
738
+ if !preservesCachedSide { Self.setOpenState(for: key, side: nil, owner: self, intent: openIntent) }
337
739
  Self.unregisterView(self, for: key)
338
740
  }
741
+ if let cancelled = cancelled { onGestureCancelled(cancelled) }
339
742
  }
340
743
 
341
744
  public override func willMove(toSuperview newSuperview: UIView?) {
342
- if newSuperview == nil {
343
- // Stop all timers and animations
344
- stopProgressUpdates()
345
- cancelAutoClose()
346
- currentAnimator?.stopAnimation(true)
347
- currentAnimator = nil
348
- isAnimating = false
349
-
350
- // Reset internal gesture/translation state so a re-attached view
351
- // (FlashList recycle) does not re-apply a stale currentTranslation
352
- // in the next layoutSubviews pass.
353
- currentTranslation = 0
354
- isOpen = false
355
- pendingShouldOpen = false
356
- startOffset = 0
357
- lastEmittedProgress = -1
358
- isDragging = false
359
- isGestureActivated = false
360
- gestureStartTranslation = 0
361
-
362
- // Reset layer properties to prevent corruption
363
- contentView?.transform = .identity
364
- contentView?.layer.zPosition = 0
365
- actionsView?.transform = .identity
366
- actionsView?.layer.zPosition = 0
367
-
368
- // Unregister from static registry and clear cached state
369
- if let key = recyclingKey {
370
- Self.unregisterView(self, for: key)
371
- Self.clearOpenState(for: key)
372
- }
373
- }
745
+ if newSuperview == nil { suspendForDetach() }
374
746
  super.willMove(toSuperview: newSuperview)
375
747
  }
376
748
 
377
- private func setupViews() {
378
- clipsToBounds = true
379
- isUserInteractionEnabled = true
380
- addGestureRecognizer(panGesture)
381
- }
382
-
383
749
  public override func didMoveToWindow() {
384
750
  super.didMoveToWindow()
385
- guard window != nil else { return }
386
-
387
- // Re-register in the view registry after reattach (e.g. returning from navigation).
388
- // willMove(toSuperview: nil) unregisters the view, but the recyclingKey prop setter
389
- // short-circuits when the value hasn't changed, so the view is never re-registered
390
- // unless we do it here.
751
+ guard window != nil else {
752
+ suspendForDetach()
753
+ return
754
+ }
755
+ let wasDetached = isDetached
756
+ isDetached = false
757
+ reduceMotionChanged()
391
758
  if let key = recyclingKey {
392
759
  Self.registerView(self, for: key)
760
+ if wasDetached {
761
+ // Read again: a closeByKey while detached may have cleared the cache.
762
+ let restored = Self.readRestore(for: key)
763
+ pendingRestoreSide = restored.side
764
+ pendingRestoreIntent = restored.intent
765
+ }
393
766
  }
394
-
395
- // Ensure gesture is attached to this view (handles view hierarchy changes)
396
767
  if panGesture.view !== self {
397
768
  panGesture.view?.removeGestureRecognizer(panGesture)
398
769
  addGestureRecognizer(panGesture)
399
770
  }
771
+ commitProps()
772
+ scheduleVisualRestore()
773
+ // A detached timer/animation must not restart and fabricate an end-open event.
400
774
  }
401
775
 
402
776
  public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
@@ -428,7 +802,6 @@ public class SwipeableView: ExpoView {
428
802
  }
429
803
  }
430
804
 
431
-
432
805
  private func startProgressUpdates() {
433
806
  guard displayLink == nil else { return }
434
807
  displayLink = CADisplayLink(target: self, selector: #selector(emitProgressUpdate))
@@ -452,542 +825,612 @@ public class SwipeableView: ExpoView {
452
825
  }
453
826
 
454
827
  private func cancelAutoClose() {
828
+ autoCloseGeneration += 1
455
829
  autoCloseWorkItem?.cancel()
456
830
  autoCloseWorkItem = nil
831
+ autoCloseState = nil
457
832
  }
458
833
 
459
834
  private func scheduleAutoClose() {
460
835
  cancelAutoClose()
461
836
  let delay = autoCloseTimeout > 0 ? autoCloseTimeout / 1000.0 : 0
462
- let workItem = DispatchWorkItem { [weak self] in
463
- self?.tryAutoClose()
837
+ if let side = openSide {
838
+ autoCloseState = .waiting(side: side, deadline: ProcessInfo.processInfo.systemUptime + delay)
464
839
  }
465
- autoCloseWorkItem = workItem
466
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
840
+ enqueueAutoClose(
841
+ after: delay, generation: workGeneration, timerGeneration: autoCloseGeneration, key: recyclingKey
842
+ )
467
843
  }
468
844
 
469
- private func tryAutoClose() {
470
- autoCloseWorkItem = nil
845
+ private func resumeAutoCloseTimer(_ state: SwipeAutoCloseState) {
846
+ cancelAutoClose()
847
+ autoCloseState = state
848
+ enqueueAutoClose(
849
+ after: state.remainingDelay(at: ProcessInfo.processInfo.systemUptime),
850
+ generation: workGeneration, timerGeneration: autoCloseGeneration, key: recyclingKey
851
+ )
852
+ }
471
853
 
472
- // If animation is still running, keep retrying until it completes
473
- if isAnimating {
474
- let retryWorkItem = DispatchWorkItem { [weak self] in
475
- self?.tryAutoClose()
854
+ private func enqueueAutoClose(after delay: TimeInterval, generation: Int, timerGeneration: Int, key: String?) {
855
+ let item = DispatchWorkItem { [weak self] in
856
+ guard let self = self, self.isCurrent(generation, key: key),
857
+ self.autoCloseGeneration == timerGeneration,
858
+ self.autoClose else { return }
859
+ self.autoCloseWorkItem = nil
860
+ if self.isAnimating {
861
+ self.enqueueAutoClose(after: 0.1, generation: generation, timerGeneration: timerGeneration, key: key)
862
+ } else if self.isOpen {
863
+ // Keep the end-open quirk local to this close; cancellation cannot leak it.
864
+ self.performClose(animated: true, endState: "open")
476
865
  }
477
- autoCloseWorkItem = retryWorkItem
478
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: retryWorkItem)
479
- } else if isOpen {
480
- // Emit "open" state when autoClose completes - user's swipe intent was achieved
481
- close(autoCloseOverride: true)
482
866
  }
867
+ autoCloseWorkItem = item
868
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
483
869
  }
484
870
 
485
871
  @objc private func emitProgressUpdate() {
486
- guard actionsWidth > 0 else { return }
487
-
488
- // During animation, read from presentation layer to get actual animated value
489
- var translation = currentTranslation
490
- if isAnimating, let presentationLayer = contentView?.layer.presentation() {
491
- translation = presentationLayer.transform.m41 // tx component of transform
492
- // Update actions to match content position
493
- let progress = min(abs(translation) / actionsWidth, 1.0)
494
- let offset = isLeading ? -actionsWidth : actionsWidth
495
- let actionsTranslateX = offset * (1 - progress)
496
- actionsView?.transform = CGAffineTransform(translationX: actionsTranslateX, y: 0)
497
- }
498
-
499
- let progress = abs(translation) / actionsWidth
500
-
872
+ guard let side = currentSide, width(for: side) > 0 else { return }
873
+ let translation = isAnimating
874
+ ? (contentView?.layer.presentation()?.transform.m41 ?? currentTranslation)
875
+ : currentTranslation
876
+ updateActionsTransform(translation: translation)
877
+ let rawProgress = abs(translation) / width(for: side)
878
+ let progress = min(rawProgress, 1)
501
879
  if abs(progress - lastEmittedProgress) > SwipeAnimationConfig.progressChangeThreshold {
502
880
  lastEmittedProgress = progress
503
- onSwipeProgress([
504
- "progress": progress,
505
- "translationX": translation
506
- ])
881
+ onSwipeProgress(payload(["progress": progress, "translationX": translation], side: side))
507
882
  }
508
883
  }
509
884
 
510
- private func updateActionsTransform() {
511
- guard let actionsView = actionsView, actionsWidth > 0 else { return }
512
- let progress = min(abs(currentTranslation) / actionsWidth, 1.0)
513
- let offset = isLeading ? -actionsWidth : actionsWidth
514
- let actionsTranslateX = offset * (1 - progress)
515
- actionsView.transform = CGAffineTransform(translationX: actionsTranslateX, y: 0)
885
+ private func updateActionsTransform(translation: CGFloat? = nil) {
886
+ let translation = translation ?? (isAnimating
887
+ ? (contentView?.layer.presentation()?.transform.m41 ?? currentTranslation)
888
+ : currentTranslation)
889
+ let visibleSide = currentSide
890
+ for side in SwipeSide.allCases {
891
+ guard let rail = actionsView(for: side) else { continue }
892
+ let sideWidth = width(for: side)
893
+ let active = visibleSide == side
894
+ let progress = active && sideWidth > 0 ? min(abs(translation) / sideWidth, 1) : 0
895
+ let offset = -side.sign * sideWidth * (1 - progress)
896
+ let scale = active && (isDragging || isAnimating || isOpen) ? railScale(progress: progress) : 1
897
+ // Explicit tx anchors scaling at the actions' row edge and never scales row translation.
898
+ let tx = SwipeAnimationConfig.actionRailTranslation(
899
+ offset: offset, scale: scale, railWidth: rail.bounds.width, side: side
900
+ )
901
+ rail.transform = CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: tx, ty: 0)
902
+ let revealed = active && sideWidth > 0 && side.sign * translation > 0
903
+ railVisibility.setRevealed(revealed, for: rail)
904
+ }
516
905
  }
517
906
 
518
- private func shouldSyncOpenLayoutForPropChange(oldValue: CGFloat, newValue: CGFloat) -> Bool {
519
- guard oldValue != newValue else { return false }
520
- guard isOpen, !isDragging, !isAnimating, !isGestureActivated else { return false }
521
- return newValue > 0
522
- }
907
+ // MARK: - React child roles
523
908
 
524
- private func syncOpenStateToCurrentWidth() {
525
- let targetX = isLeading ? actionsWidth : -actionsWidth
526
- currentTranslation = targetX
527
- contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
909
+ private func releaseChild(_ view: UIView) {
910
+ railVisibility.release(view)
911
+ view.transform = .identity
912
+ view.layer.zPosition = 0
913
+ }
528
914
 
529
- if actionsView != nil {
530
- actionsView?.transform = .identity
531
- } else {
532
- onSwipeStart([:])
915
+ private func releaseChildRoles() {
916
+ for view in [leftActionsView, rightActionsView, contentView].compactMap({ $0 }) {
917
+ releaseChild(view)
533
918
  }
534
-
535
- onSwipeProgress(["progress": 1.0, "translationX": targetX])
919
+ railVisibility.releaseAll()
920
+ leftActionsView = nil
921
+ rightActionsView = nil
922
+ contentView = nil
923
+ slotChildren.removeAll()
536
924
  }
537
925
 
538
- // MARK: - View Lifecycle
926
+ private func reconcileChildRoles() {
927
+ guard !isDetached else { return }
928
+ let children = reactChildren.views
929
+ // On removal, preserve surviving identities instead of promoting a rail to content.
930
+ if let view = contentView, !children.contains(where: { $0 === view }) { releaseChild(view); contentView = nil }
931
+ if let view = leftActionsView, !children.contains(where: { $0 === view }) { releaseChild(view); leftActionsView = nil }
932
+ if let view = rightActionsView, !children.contains(where: { $0 === view }) { releaseChild(view); rightActionsView = nil }
933
+ guard let roles = SwipePhysics.childRoles(count: children.count) else { return }
934
+ let previousLeft = leftActionsView
935
+ let previousRight = rightActionsView
936
+ contentView = children[roles.content]
937
+ // A complete Fabric transaction can give a previously hidden rail the content role.
938
+ // Restore only flags that this view owned, for all side configurations.
939
+ if let contentView = contentView { railVisibility.release(contentView) }
940
+ leftActionsView = roles.left.map { children[$0] }
941
+ rightActionsView = roles.right.map { children[$0] }
942
+ if previousLeft !== leftActionsView { requestedSides.remove(.left) }
943
+ if previousRight !== rightActionsView { requestedSides.remove(.right) }
944
+ contentView?.layer.zPosition = 1
945
+ for side in SwipeSide.allCases {
946
+ guard let rail = actionsView(for: side) else { continue }
947
+ rail.layer.zPosition = -1
948
+ let children = rail.subviews.map { ObjectIdentifier($0) }
949
+ if let previous = slotChildren[side], !previous.isEmpty && previous != children {
950
+ requestedSides.remove(side)
951
+ }
952
+ slotChildren[side] = children
953
+ }
954
+ if let side = currentSide, isOpen || isDragging || isAnimating {
955
+ requestActions(on: side)
956
+ }
957
+ if !isAnimating {
958
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
959
+ }
960
+ }
539
961
 
540
- // Track React-managed children only. Fabric guarantees this fires solely
541
- // for views Fabric mounts (the JSX children) — never for UIKit helper
542
- // views that UIView.subviews would otherwise include.
543
962
  public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
544
963
  super.mountChildComponentView(childComponentView, index: index)
545
- // Incoming child may carry a stale transform/zPosition from Fabric recycling;
546
- // reset it (refreshChildAssignments re-applies the open transform when open).
547
- childComponentView.transform = .identity
548
- childComponentView.layer.zPosition = 0
549
- let clampedIndex = min(max(index, 0), reactChildren.count)
550
- reactChildren.insert(childComponentView, at: clampedIndex)
551
- refreshChildAssignments()
964
+ releaseChild(childComponentView)
965
+ reactChildren.mount(childComponentView, at: index)
966
+ // Fabric mounts slots individually; commit only a complete child protocol.
967
+ reconcileChildRoles()
968
+ scheduleReconciliation()
552
969
  }
553
970
 
554
971
  public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
555
- // Content recycled mid-animation: stop the animator before contentView is
556
- // nil'd below, else it keeps writing a transform onto the pooled view.
557
- // (Android skips this — RN zeroes translationX on recycle; port if needed.)
972
+ let cancelled = childComponentView === contentView
973
+ ? gestureCompletion.cancel(recyclingKey: recyclingKey, side: currentSide) : nil
558
974
  if childComponentView === contentView {
559
- currentAnimator?.stopAnimation(true)
560
- currentAnimator = nil
561
- isAnimating = false
562
- stopProgressUpdates()
563
- cancelAutoClose() // a pending timer would fire on the reused view
564
- }
565
-
566
- // Fabric recycles UIView instances. Before handing this child back, undo any
567
- // transform / zPosition we may have applied — otherwise the next React
568
- // component that receives this recycled UIView inherits our stale state and
569
- // appears translated.
570
- childComponentView.transform = .identity
571
- childComponentView.layer.zPosition = 0
572
-
573
- if index >= 0 && index < reactChildren.count && reactChildren[index] === childComponentView {
574
- reactChildren.remove(at: index)
575
- } else if let found = reactChildren.firstIndex(where: { $0 === childComponentView }) {
576
- reactChildren.remove(at: found)
975
+ let interrupted = hasPendingInteraction
976
+ // Like Android, content removal consumes the rest of the pan, activated or not.
977
+ if panGesture.state == .began || panGesture.state == .changed {
978
+ ignoresPanUntilNextBegin = true
979
+ } else if panGesture.state == .possible {
980
+ // A touch that has not begun would clear the flag at its own .began.
981
+ // Fail it instead; a failed recognizer sends no action.
982
+ panGesture.isEnabled = false
983
+ panGesture.isEnabled = gestureEnabled
984
+ }
985
+ // Invalidate completion/timer generations before returning the child to
986
+ // Fabric's pool, not merely the animator's reference to that child.
987
+ invalidateWork(preservePresentation: false, clearRestore: false)
988
+ savedFirstResponder = nil
989
+ if interrupted {
990
+ openSide = nil
991
+ pendingRestoreSide = nil
992
+ persistOpenSide()
993
+ }
994
+ currentTranslation = openSide.map { $0.sign * width(for: $0) } ?? 0
577
995
  }
578
-
996
+ // Return both transforms and weakly-owned visibility/accessibility flags.
997
+ releaseChild(childComponentView)
998
+ reactChildren.unmount(childComponentView, at: index)
579
999
  if childComponentView === contentView { contentView = nil }
580
- if childComponentView === actionsView { actionsView = nil }
581
-
1000
+ if childComponentView === leftActionsView { leftActionsView = nil; requestedSides.remove(.left) }
1001
+ if childComponentView === rightActionsView { rightActionsView = nil; requestedSides.remove(.right) }
582
1002
  super.unmountChildComponentView(childComponentView, index: index)
583
- refreshChildAssignments()
584
- }
585
-
586
- private func refreshChildAssignments() {
587
- let wasActionsNil = actionsView == nil
588
- let wasContentNil = contentView == nil
589
-
590
- switch reactChildren.count {
591
- case 0:
592
- contentView = nil
593
- actionsView = nil
594
- case 1:
595
- contentView = reactChildren[0]
596
- contentView?.layer.zPosition = 1
597
- actionsView = nil
598
- case 2:
599
- actionsView = reactChildren[0]
600
- actionsView?.layer.zPosition = -1
601
- contentView = reactChildren[1]
602
- contentView?.layer.zPosition = 1
603
- default:
604
- #if DEBUG
605
- print("[SwipeableView] Warning: Expected 1-2 React children, got \(reactChildren.count)")
606
- #endif
607
- actionsView = reactChildren[0]
608
- actionsView?.layer.zPosition = -1
609
- contentView = reactChildren[1]
610
- contentView?.layer.zPosition = 1
611
- }
612
-
613
- setNeedsLayout()
614
-
615
- if wasContentNil && contentView != nil && (isOpen || currentTranslation != 0) {
616
- contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
617
- }
1003
+ let generation = workGeneration
1004
+ if let cancelled = cancelled { onGestureCancelled(cancelled) }
1005
+ guard generation == workGeneration else { return }
1006
+ reconcileChildRoles()
1007
+ scheduleReconciliation()
1008
+ }
618
1009
 
619
- if wasActionsNil && actionsView != nil {
620
- if isOpen || isAnimating {
621
- actionsView?.transform = .identity
622
- } else if pendingShouldOpen {
623
- pendingShouldOpen = false
624
- DispatchQueue.main.async { [weak self] in
625
- self?.snapToOpen()
626
- }
627
- }
1010
+ private func scheduleVisualRestore() {
1011
+ guard !isDetached, visualRestoreWorkItem == nil else { return }
1012
+ let generation = workGeneration
1013
+ let key = recyclingKey
1014
+ let item = DispatchWorkItem { [weak self] in
1015
+ guard let self = self else { return }
1016
+ self.visualRestoreWorkItem = nil
1017
+ guard self.isCurrent(generation, key: key), !self.isDragging, !self.isAnimating else { return }
1018
+ self.reconcileChildRoles()
1019
+ self.contentView?.transform = CGAffineTransform(translationX: self.currentTranslation, y: 0)
1020
+ self.updateActionsTransform()
628
1021
  }
1022
+ visualRestoreWorkItem = item
1023
+ DispatchQueue.main.async(execute: item)
629
1024
  }
630
1025
 
631
1026
  public override func layoutSubviews() {
632
- guard !isDragging && !isAnimating else { return }
633
- super.layoutSubviews()
634
-
635
- if let actionsView = actionsView {
636
- var frame = actionsView.frame
637
- frame.size.height = bounds.height
638
- actionsView.frame = frame
639
-
640
- if currentTranslation == 0 && actionsWidth > 0 {
641
- let offset = isLeading ? -actionsWidth : actionsWidth
642
- actionsView.transform = CGAffineTransform(translationX: offset, y: 0)
643
- }
1027
+ guard !isDetached else { return }
1028
+ guard !isDragging && !isAnimating else {
1029
+ reconcileChildRoles(); updateActionsTransform()
1030
+ return
644
1031
  }
645
-
646
- if currentTranslation != 0 {
647
- contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
648
- updateActionsTransform()
1032
+ super.layoutSubviews()
1033
+ reconcileChildRoles()
1034
+ for side in SwipeSide.allCases {
1035
+ guard let rail = actionsView(for: side) else { continue }
1036
+ // React aligns the caller's actions inside full-bounds slot shells.
1037
+ rail.bounds.size = bounds.size
1038
+ rail.center = CGPoint(x: bounds.midX, y: bounds.midY)
649
1039
  }
1040
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
1041
+ updateActionsTransform()
1042
+ if currentTranslation != 0 { scheduleVisualRestore() }
650
1043
  }
651
1044
 
652
- // MARK: - Gesture Handling
1045
+ // MARK: - Gesture handling
653
1046
 
654
1047
  func handleGestureStart() {
655
- guard gestureEnabled, actionsWidth > 0 else { return }
656
-
657
- isDragging = true
1048
+ commitProps()
1049
+ guard gestureEnabled, defaultOpenSide() != nil else { return }
1050
+ let side = currentSide
1051
+ guard invalidateWork() else { return }
1052
+ gestureSide = side
1053
+ directGesturePending = true
658
1054
  startOffset = currentTranslation
659
- gestureStartTranslation = 0
660
- isGestureActivated = true
661
- onSwipeStart([:])
662
- startProgressUpdates()
1055
+ gestureStartedOpen = isOpen
1056
+ // Direct API has no direction at start. Latch when its first update arrives.
663
1057
  }
664
1058
 
665
1059
  func handleGestureUpdate(translationX: CGFloat, velocityX: CGFloat) {
666
- guard actionsWidth > 0 && isGestureActivated else { return }
667
-
668
- let rawTranslation = startOffset + (translationX * friction)
669
- let constrainedTranslation = SwipePhysics.applyRubberBand(
670
- translation: rawTranslation,
671
- actionsWidth: actionsWidth,
672
- isLeading: isLeading
673
- )
674
-
675
- currentTranslation = constrainedTranslation
676
- contentView?.transform = CGAffineTransform(translationX: constrainedTranslation, y: 0)
677
- updateActionsTransform()
1060
+ if directGesturePending && !isGestureActivated {
1061
+ guard abs(translationX) >= dragOffsetFromEdge,
1062
+ let side = gestureSide ?? SwipePhysics.latchedSide(
1063
+ openSide: openSide, translation: translationX,
1064
+ leftWidth: leftActionsWidth, rightWidth: rightActionsWidth
1065
+ ) else { return }
1066
+ gestureStartTranslation = dragOffsetFromEdge > 0 ? translationX : 0
1067
+ activateGesture(on: side)
1068
+ }
1069
+ guard isGestureActivated, let side = currentSide, width(for: side) > 0 else { return }
1070
+ let relative = translationX - gestureStartTranslation
1071
+ updateGesture(translation: relative, side: side)
678
1072
  }
679
1073
 
680
1074
  func handleGestureEnd(translationX: CGFloat, velocityX: CGFloat) {
681
- guard isGestureActivated else { return }
682
-
683
- isGestureActivated = false
684
- gestureStartTranslation = 0
685
- isDragging = false
686
- stopProgressUpdates()
687
-
688
- let projectedX = SwipePhysics.projectFinalPosition(
689
- translation: translationX,
690
- velocity: velocityX,
691
- friction: friction
692
- )
693
- let willOpen = SwipePhysics.shouldSnapToOpen(
694
- projectedTranslation: projectedX,
695
- actionsWidth: actionsWidth,
696
- threshold: threshold,
697
- isLeading: isLeading,
698
- isCurrentlyOpen: isOpen
699
- )
700
-
701
- if willOpen {
702
- open(velocity: velocityX)
703
- } else {
704
- close(velocity: velocityX)
1075
+ guard isGestureActivated, let side = currentSide else {
1076
+ // A command already consumed this gesture. Its trailing end cannot
1077
+ // invalidate the animator that inherited the completion.
1078
+ directGesturePending = false
1079
+ return
705
1080
  }
1081
+ let relative = translationX - gestureStartTranslation
1082
+ finishGesture(translation: relative, velocity: velocityX, side: side)
706
1083
  }
707
1084
 
708
1085
  @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
709
- guard actionsWidth > 0 else { return }
710
-
1086
+ guard gestureEnabled, defaultOpenSide() != nil else { return }
1087
+ // Key/detach rebinding invalidates even a touch that was still .possible.
1088
+ guard panGesture.isCurrentBinding(commandTarget) else {
1089
+ ignoresPanUntilNextBegin = true
1090
+ return
1091
+ }
1092
+ // A command/configuration cancellation consumes the rest of this physical pan.
1093
+ // It must not reactivate on a later changed event or switch to the opposite edge.
1094
+ if ignoresPanUntilNextBegin && gesture.state != .began {
1095
+ if gesture.state == .ended || gesture.state == .cancelled { savedFirstResponder = nil }
1096
+ return
1097
+ }
711
1098
  switch gesture.state {
712
1099
  case .began:
713
1100
  handlePanBegan(gesture)
714
1101
  case .changed:
715
1102
  handlePanChanged(gesture)
716
- case .ended, .cancelled:
1103
+ case .ended:
717
1104
  handlePanEnded(gesture)
1105
+ case .cancelled:
1106
+ guard isGestureActivated else {
1107
+ // A pan that never activated interrupted nothing, so all work stays as it is.
1108
+ gestureStartTranslation = 0
1109
+ savedFirstResponder = nil
1110
+ return
1111
+ }
1112
+ let settledSide = openSide
1113
+ let draggedSide = currentSide
1114
+ let reportedState = lastEmittedWillEndState
1115
+ // The drag cleared the auto-close wait. A row that settles open waits again,
1116
+ // like a release that reopens it, and the auto-close completes the swipe.
1117
+ let restartsAutoClose = autoClose && settledSide != nil
1118
+ guard invalidateWork(
1119
+ preservePresentation: false, preserveGestureCompletion: restartsAutoClose
1120
+ ) else { return }
1121
+ openSide = settledSide
1122
+ currentTranslation = settledSide.map { $0.sign * width(for: $0) } ?? 0
1123
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
1124
+ updateActionsTransform()
1125
+ savedFirstResponder = nil
1126
+ if restartsAutoClose { scheduleAutoClose() }
1127
+ reportCancelledGesture(side: settledSide ?? draggedSide, reportedState: reportedState)
718
1128
  default:
719
1129
  break
720
1130
  }
721
1131
  }
722
1132
 
1133
+ /// A cancel returns the row to its logical state. Progress and the intended state
1134
+ /// then report that position, so JS UIs do not stay mid-drag. A cancel never
1135
+ /// completes the swipe, so it emits no onSwipeEnd.
1136
+ private func reportCancelledGesture(side: SwipeSide?, reportedState: String?) {
1137
+ let generation = workGeneration
1138
+ let key = recyclingKey
1139
+ let settledState = isOpen ? "open" : "closed"
1140
+ let progress: CGFloat = isOpen ? 1 : 0
1141
+ onSwipeProgress(payload(["progress": progress, "translationX": currentTranslation], side: side))
1142
+ guard isCurrent(generation, key: key), reportedState != settledState else { return }
1143
+ onSwipeStateChange(payload(["state": settledState], side: side))
1144
+ }
1145
+
723
1146
  private func handlePanBegan(_ gesture: UIPanGestureRecognizer) {
724
- // Cancel RN touches immediately when pan begins to prevent press events during swipe
725
1147
  cancelFabricTouches()
1148
+ ignoresPanUntilNextBegin = false
1149
+ gestureStartTranslation = gesture.translation(in: self).x
1150
+ // Like Android, only activation interrupts settling work. A pan that ends or is
1151
+ // cancelled before it activates keeps a pending auto-close and an animation.
1152
+ guard dragOffsetFromEdge <= 0 else { return }
1153
+ let side = currentSide ?? SwipePhysics.latchedSide(
1154
+ openSide: openSide, translation: gesture.velocity(in: self).x,
1155
+ leftWidth: leftActionsWidth, rightWidth: rightActionsWidth
1156
+ )
1157
+ if let side = side { interruptWorkAndActivate(on: side, startTranslation: gestureStartTranslation) }
1158
+ }
726
1159
 
727
- // Capture animation position if interrupted using UIViewPropertyAnimator (2.2)
728
- if isAnimating, let animator = currentAnimator {
729
- // Pause to capture current position, then stop completely
730
- animator.pauseAnimation()
731
- if let presentationLayer = contentView?.layer.presentation() {
732
- currentTranslation = presentationLayer.transform.m41
733
- contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
734
- }
735
- if let actionsPresentation = actionsView?.layer.presentation() {
736
- actionsView?.transform = CGAffineTransform(translationX: actionsPresentation.transform.m41, y: 0)
737
- }
738
- animator.stopAnimation(true)
739
- currentAnimator = nil
740
- isAnimating = false
741
- }
742
-
743
- let translation = gesture.translation(in: self).x
744
- gestureStartTranslation = translation
745
- isGestureActivated = (dragOffsetFromEdge <= 0)
746
-
747
- if isGestureActivated {
748
- activateGesture()
749
- }
1160
+ /// Activation takes over the side of the work that it interrupts.
1161
+ @discardableResult
1162
+ private func interruptWorkAndActivate(on side: SwipeSide, startTranslation: CGFloat) -> Bool {
1163
+ guard invalidateWork() else { return false }
1164
+ // The invalidation is this pan's own, so it does not consume the pan.
1165
+ ignoresPanUntilNextBegin = false
1166
+ gestureStartTranslation = startTranslation
1167
+ activateGesture(on: side)
1168
+ return true
750
1169
  }
751
1170
 
752
1171
  private func handlePanChanged(_ gesture: UIPanGestureRecognizer) {
753
1172
  let translation = gesture.translation(in: self).x
754
-
755
1173
  if !isGestureActivated {
756
- let dragDistance = abs(translation - gestureStartTranslation)
757
- guard dragDistance >= dragOffsetFromEdge else { return }
758
-
759
- isGestureActivated = true
760
- gestureStartTranslation = translation
761
- activateGesture()
1174
+ let relative = translation - gestureStartTranslation
1175
+ guard abs(relative) >= dragOffsetFromEdge else { return }
1176
+ let side = currentSide ?? SwipePhysics.latchedSide(
1177
+ openSide: openSide, translation: relative,
1178
+ leftWidth: leftActionsWidth, rightWidth: rightActionsWidth
1179
+ )
1180
+ guard let selected = side else { return }
1181
+ guard interruptWorkAndActivate(on: selected, startTranslation: translation) else { return }
762
1182
  }
1183
+ guard let side = currentSide else { return }
1184
+ updateGesture(translation: translation - gestureStartTranslation, side: side)
1185
+ }
763
1186
 
764
- let effectiveTranslation = translation - gestureStartTranslation
765
- let rawTranslation = startOffset + (effectiveTranslation * friction)
766
- let clampedTranslation = isLeading ? max(0, rawTranslation) : min(0, rawTranslation)
767
- let constrainedTranslation = SwipePhysics.applyRubberBand(
768
- translation: clampedTranslation,
769
- actionsWidth: actionsWidth,
770
- isLeading: isLeading
1187
+ private func handlePanEnded(_ gesture: UIPanGestureRecognizer) {
1188
+ guard isGestureActivated, let side = currentSide else {
1189
+ gestureStartTranslation = 0
1190
+ savedFirstResponder = nil
1191
+ // A pan that never activated began no feedback and interrupted nothing.
1192
+ if isGestureActivated { resetFeedback() }
1193
+ return
1194
+ }
1195
+ finishGesture(
1196
+ translation: gesture.translation(in: self).x - gestureStartTranslation,
1197
+ velocity: gesture.velocity(in: self).x, side: side
771
1198
  )
1199
+ }
772
1200
 
773
- currentTranslation = constrainedTranslation
774
- contentView?.transform = CGAffineTransform(translationX: constrainedTranslation, y: 0)
775
- updateActionsTransform()
1201
+ private func activateGesture(on side: SwipeSide) {
1202
+ let generation = workGeneration
1203
+ let key = recyclingKey
1204
+ gestureSide = side
1205
+ isGestureActivated = true
1206
+ isDragging = true
1207
+ startOffset = currentTranslation
1208
+ gestureStartedOpen = isOpen
1209
+ lastEmittedWillEndState = isOpen ? "open" : "closed"
1210
+ beginFeedback(on: side)
1211
+ requestActions(on: side)
1212
+ guard isCurrent(generation, key: key), isGestureActivated else { return }
1213
+ gestureCompletion.begin(recyclingKey: key, side: side)
1214
+ onSwipeStart(payload([:], side: side))
1215
+ guard isCurrent(generation, key: key), isGestureActivated else { return }
1216
+ startProgressUpdates()
1217
+ }
776
1218
 
777
- // Emit onSwipeStateChange when threshold crossing changes intended state
1219
+ private func updateGesture(translation: CGFloat, side: SwipeSide) {
1220
+ let raw = startOffset + translation * friction
1221
+ let constrained = SwipePhysics.constrainToSide(
1222
+ translation: raw, actionsWidth: width(for: side), side: side
1223
+ )
1224
+ currentTranslation = constrained
1225
+ contentView?.transform = CGAffineTransform(translationX: constrained, y: 0)
1226
+ updateFeedback(on: side, relativeTranslation: translation)
1227
+ updateActionsTransform()
778
1228
  let wouldOpen = SwipePhysics.shouldSnapToOpen(
779
- projectedTranslation: constrainedTranslation,
780
- actionsWidth: actionsWidth,
781
- threshold: threshold,
782
- isLeading: isLeading,
783
- isCurrentlyOpen: isOpen
1229
+ projectedTranslation: translation * friction, actionsWidth: width(for: side), threshold: threshold,
1230
+ isLeading: side == .left, isCurrentlyOpen: gestureStartedOpen
784
1231
  )
785
- let intendedState = wouldOpen ? "open" : "closed"
786
- if lastEmittedWillEndState != intendedState {
787
- lastEmittedWillEndState = intendedState
788
- onSwipeStateChange(["state": intendedState])
789
- }
1232
+ emitIntendedState(wouldOpen, side: side)
790
1233
  }
791
1234
 
792
- private func handlePanEnded(_ gesture: UIPanGestureRecognizer) {
793
- guard isGestureActivated else {
794
- gestureStartTranslation = 0
795
- savedFirstResponder = nil // Clear saved responder if gesture wasn't activated
796
- return
1235
+ private func emitIntendedState(_ willOpen: Bool, side: SwipeSide) {
1236
+ let state = willOpen ? "open" : "closed"
1237
+ if lastEmittedWillEndState != state {
1238
+ lastEmittedWillEndState = state
1239
+ onSwipeStateChange(payload(["state": state], side: side))
797
1240
  }
1241
+ }
798
1242
 
799
- let translation = gesture.translation(in: self).x
800
- let velocity = gesture.velocity(in: self).x
801
- let effectiveTranslation = translation - gestureStartTranslation
802
-
1243
+ private func finishGesture(
1244
+ translation: CGFloat, velocity: CGFloat, side: SwipeSide
1245
+ ) {
1246
+ let generation = workGeneration
1247
+ let key = recyclingKey
803
1248
  isGestureActivated = false
1249
+ directGesturePending = false
804
1250
  gestureStartTranslation = 0
805
1251
  isDragging = false
806
1252
  stopProgressUpdates()
807
-
808
- let projectedX = SwipePhysics.projectFinalPosition(
809
- translation: effectiveTranslation,
810
- velocity: velocity,
811
- friction: friction
1253
+ let willOpen = SwipePhysics.shouldOpenOnSide(
1254
+ translation: translation, velocity: velocity, friction: friction,
1255
+ actionsWidth: width(for: side), threshold: threshold, side: side,
1256
+ isCurrentlyOpen: gestureStartedOpen
812
1257
  )
813
- let willOpen = SwipePhysics.shouldSnapToOpen(
814
- projectedTranslation: projectedX,
815
- actionsWidth: actionsWidth,
816
- threshold: threshold,
817
- isLeading: isLeading,
818
- isCurrentlyOpen: isOpen
819
- )
820
-
821
- // Only emit onSwipeStateChange if state changed from what was emitted during drag
822
- // (velocity projection may result in different final state)
823
- let intendedState = willOpen ? "open" : "closed"
824
- if lastEmittedWillEndState != intendedState {
825
- lastEmittedWillEndState = intendedState
826
- onSwipeStateChange(["state": intendedState])
827
- }
828
-
1258
+ emitIntendedState(willOpen, side: side)
1259
+ guard isCurrent(generation, key: key) else { return }
829
1260
  if willOpen {
830
- open(velocity: velocity)
831
- // Schedule auto-close if enabled
832
- if autoClose {
833
- scheduleAutoClose()
834
- }
1261
+ beginOpen(on: side, preserveFeedback: true, preserveGestureCompletion: true)
1262
+ if autoClose, gestureCompletion.isPending, isOpen { scheduleAutoClose() }
835
1263
  } else {
836
- close(velocity: velocity)
1264
+ performClose(animated: true, preserveGestureCompletion: true)
837
1265
  }
838
1266
  }
839
1267
 
840
- private func activateGesture() {
841
- isDragging = true
842
- startOffset = currentTranslation
843
- lastEmittedWillEndState = isOpen ? "open" : "closed"
844
- onSwipeStart([:])
845
- startProgressUpdates()
1268
+ // MARK: - Open/close
1269
+
1270
+ func open(velocity: CGFloat = 0, intent: Int? = nil) {
1271
+ commitProps()
1272
+ // gestureEnabled controls the recognizer, not imperative commands.
1273
+ guard let side = defaultOpenSide() else { return }
1274
+ openResolvedSide(side, intent: intent ?? Self.currentIntent())
846
1275
  }
847
1276
 
848
- // MARK: - Open/Close
1277
+ func open(side: SwipeSide, velocity: CGFloat = 0, intent: Int? = nil) {
1278
+ commitProps()
1279
+ // Keep explicit opens available with gestureEnabled=false, just like open().
1280
+ guard width(for: side) > 0 else { return }
1281
+ openResolvedSide(side, intent: intent ?? Self.currentIntent())
1282
+ }
849
1283
 
850
- func open(velocity: CGFloat = 0) {
851
- // Cancel any existing animation
852
- currentAnimator?.stopAnimation(true)
853
- currentAnimator = nil
1284
+ private func openResolvedSide(_ side: SwipeSide, intent: Int) {
1285
+ if let previous = currentSide, previous != side,
1286
+ isOpen || isAnimating || currentTranslation != 0 || gestureCompletion.isPending {
1287
+ // The side switch carries this intent through its closing leg.
1288
+ openIntent = intent
1289
+ performClose(animated: true) { [weak self] in
1290
+ guard let self = self, self.width(for: side) > 0 else { return }
1291
+ self.beginOpen(on: side, intent: intent)
1292
+ }
1293
+ } else {
1294
+ beginOpen(on: side, intent: intent)
1295
+ }
1296
+ }
854
1297
 
855
- isOpen = true
1298
+ private func beginOpen(
1299
+ on side: SwipeSide, preserveFeedback: Bool = false, preserveGestureCompletion: Bool = false,
1300
+ intent: Int? = nil
1301
+ ) {
1302
+ // Imperative auto-close opens have no timer/end; all other same-side opens
1303
+ // can take over the owed completion without resetting the JS start gate.
1304
+ guard invalidateWork(
1305
+ resetAppearance: !preserveFeedback,
1306
+ preserveGestureCompletion: preserveGestureCompletion || !autoClose
1307
+ ) else { return }
1308
+ let generation = workGeneration
1309
+ let key = recyclingKey
1310
+ openSide = side
1311
+ openIntent = intent ?? Self.currentIntent()
1312
+ animationSide = side
856
1313
  isAnimating = true
857
- if let key = recyclingKey { Self.setOpenState(for: key, isOpen: true) }
858
- let targetX = isLeading ? actionsWidth : -actionsWidth
859
-
860
- // Start displayLink to sync actions with content during animation
1314
+ persistOpenSide()
1315
+ requestActions(on: side)
1316
+ guard isCurrent(generation, key: key) else { return }
1317
+ let targetX = side.sign * width(for: side)
861
1318
  startProgressUpdates()
862
-
863
- // Create interruptible spring animator (2.1)
864
1319
  let animator = UIViewPropertyAnimator(
865
1320
  duration: SwipeAnimationConfig.openDuration,
866
1321
  dampingRatio: SwipeAnimationConfig.openDamping
867
1322
  ) {
868
- // Only animate content - actions are synced via displayLink
869
1323
  self.contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
870
1324
  }
871
-
872
1325
  animator.addCompletion { [weak self] position in
873
- guard let self = self, position == .end else { return }
874
-
1326
+ guard let self = self, position == .end, self.isCurrent(generation, key: key) else { return }
875
1327
  self.currentTranslation = targetX
876
1328
  self.isAnimating = false
877
1329
  self.currentAnimator = nil
1330
+ self.animationSide = nil
878
1331
  self.stopProgressUpdates()
879
- // Ensure final actions position
880
- self.actionsView?.transform = .identity
881
- self.onSwipeProgress(["progress": 1.0, "translationX": targetX])
882
- // With autoClose, let close() emit onSwipeEnd("open") instead
883
- if !self.autoClose {
884
- self.onSwipeEnd(["state": "open"])
885
- }
886
-
887
- // Restore first responder if it was lost during swipe (keyboard preservation)
888
- if let saved = self.savedFirstResponder, !saved.isFirstResponder {
889
- saved.becomeFirstResponder()
890
- }
891
- self.savedFirstResponder = nil
1332
+ self.resetFeedback()
1333
+ self.updateActionsTransform()
1334
+ self.onSwipeProgress(self.payload(["progress": 1.0, "translationX": targetX], side: side))
1335
+ guard self.isCurrent(generation, key: key) else { return }
1336
+ // Programmatic opens with autoClose historically neither schedule a close nor emit end.
1337
+ if !self.autoClose { self.emitSwipeEnd("open", side: side) }
1338
+ guard self.isCurrent(generation, key: key) else { return }
1339
+ self.restoreFirstResponder()
892
1340
  }
893
-
894
- // Store reference for interruption handling (2.2)
895
1341
  currentAnimator = animator
896
1342
  animator.startAnimation()
897
1343
  }
898
1344
 
899
1345
  func snapToOpen() {
900
- isOpen = true
901
- isAnimating = false
902
- let targetX = isLeading ? actionsWidth : -actionsWidth
903
- currentTranslation = targetX
904
-
905
- // Set content translation immediately
906
- contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
907
-
908
- if actionsView != nil {
909
- // Actions already mounted - position them
910
- actionsView?.transform = .identity
911
- } else {
912
- // Actions not mounted yet (React lazy rendering)
913
- // Trigger onSwipeStart so React renders SwipeableActions
914
- // didAddSubview will handle positioning when actions are added
915
- onSwipeStart([:])
916
- }
917
-
918
- onSwipeProgress(["progress": 1.0, "translationX": targetX])
1346
+ commitProps()
1347
+ guard let side = defaultOpenSide() else { return }
1348
+ snapToOpen(side: side)
919
1349
  }
920
1350
 
921
- // autoCloseOverride is a value, not a sticky flag: an interrupted close (which
922
- // never runs the completion) can't strand it and mislabel a later close as "open".
923
- func close(animated: Bool = true, velocity: CGFloat = 0, autoCloseOverride: Bool = false) {
924
- cancelAutoClose()
925
- stopProgressUpdates()
926
-
927
- // Cancel any existing animation (2.2)
928
- currentAnimator?.stopAnimation(true)
929
- currentAnimator = nil
1351
+ private func snapToOpen(side: SwipeSide, intent: Int? = nil) {
1352
+ guard invalidateWork() else { return }
1353
+ let generation = workGeneration
1354
+ let key = recyclingKey
1355
+ openSide = side
1356
+ openIntent = intent ?? Self.currentIntent()
1357
+ isAnimating = false
1358
+ currentTranslation = side.sign * width(for: side)
1359
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
1360
+ requestActions(on: side)
1361
+ guard isCurrent(generation, key: key) else { return }
1362
+ updateActionsTransform()
1363
+ onSwipeProgress(payload(["progress": 1.0, "translationX": currentTranslation], side: side))
1364
+ }
930
1365
 
931
- // Clear gesture state (unified from reset)
932
- isDragging = false
933
- isGestureActivated = false
934
- gestureStartTranslation = 0
935
- pendingShouldOpen = false
1366
+ func close(animated: Bool = true, velocity: CGFloat = 0) {
1367
+ performClose(animated: animated)
1368
+ }
936
1369
 
937
- isOpen = false
938
- // Only persist to cache when animated (user action), not when resetting (recycling)
939
- if animated {
940
- if let key = recyclingKey { Self.setOpenState(for: key, isOpen: false) }
1370
+ private func performClose(
1371
+ animated: Bool, endState: String = "closed", preserveGestureCompletion: Bool = false,
1372
+ completion: (() -> Void)? = nil
1373
+ ) {
1374
+ let side = currentSide
1375
+ // An animated close inherits pending completion ownership until it ends
1376
+ // or a later silent close/cancel sends the internal abandonment event.
1377
+ guard invalidateWork(
1378
+ preserveGestureCompletion: animated || preserveGestureCompletion || endState == "open"
1379
+ ) else { return }
1380
+ openSide = nil
1381
+ animationSide = side
1382
+ if endState == "open", let side = side {
1383
+ autoCloseState = .closing(side: side)
941
1384
  }
942
-
943
- let actionsOffset = isLeading ? -actionsWidth : actionsWidth
944
-
945
- if animated {
946
- // Animated close with spring physics using UIViewPropertyAnimator (2.1)
947
- isAnimating = true
948
- startProgressUpdates()
949
-
950
- let animator = UIViewPropertyAnimator(
951
- duration: SwipeAnimationConfig.closeDuration,
952
- dampingRatio: SwipeAnimationConfig.closeDamping
953
- ) {
954
- self.contentView?.transform = .identity
955
- }
956
-
957
- animator.addCompletion { [weak self] position in
958
- guard let self = self, position == .end else { return }
959
-
960
- self.currentTranslation = 0
961
- self.isAnimating = false
962
- self.currentAnimator = nil
963
- self.stopProgressUpdates()
964
- self.actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
965
- self.onSwipeProgress(["progress": 0.0, "translationX": 0.0])
966
- // Emit "open" when autoClose completes - user's swipe intent was achieved
967
- let endState = autoCloseOverride ? "open" : "closed"
968
- self.onSwipeEnd(["state": endState])
969
-
970
- // Restore first responder if it was lost during swipe (keyboard preservation)
971
- if let saved = self.savedFirstResponder, !saved.isFirstResponder {
972
- saved.becomeFirstResponder()
973
- }
974
- self.savedFirstResponder = nil
975
- }
976
-
977
- // Store reference for interruption handling (2.2)
978
- currentAnimator = animator
979
- animator.startAnimation()
980
- } else {
981
- // Instant close (formerly reset)
982
- isAnimating = false
1385
+ // Every explicit close clears cached intent, including instant resets.
1386
+ persistOpenSide()
1387
+ if !animated {
983
1388
  currentTranslation = 0
984
- startOffset = 0
985
- lastEmittedProgress = -1
1389
+ animationSide = nil
986
1390
  savedFirstResponder = nil
987
-
988
1391
  contentView?.transform = .identity
989
- actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
1392
+ updateActionsTransform()
1393
+ return
1394
+ }
1395
+ isAnimating = true
1396
+ let generation = workGeneration
1397
+ let key = recyclingKey
1398
+ startProgressUpdates()
1399
+ let animator = UIViewPropertyAnimator(
1400
+ duration: SwipeAnimationConfig.closeDuration,
1401
+ dampingRatio: SwipeAnimationConfig.closeDamping
1402
+ ) {
1403
+ self.contentView?.transform = .identity
990
1404
  }
1405
+ animator.addCompletion { [weak self] position in
1406
+ guard let self = self, position == .end, self.isCurrent(generation, key: key) else { return }
1407
+ self.currentTranslation = 0
1408
+ self.isAnimating = false
1409
+ self.currentAnimator = nil
1410
+ self.stopProgressUpdates()
1411
+ self.updateActionsTransform()
1412
+ self.onSwipeProgress(self.payload(["progress": 0.0, "translationX": 0.0], side: side))
1413
+ guard self.isCurrent(generation, key: key) else { return }
1414
+ self.autoCloseState = nil
1415
+ self.emitSwipeEnd(endState, side: side)
1416
+ guard self.isCurrent(generation, key: key) else { return }
1417
+ self.animationSide = nil
1418
+ self.resetFeedback()
1419
+ self.restoreFirstResponder()
1420
+ if self.isCurrent(generation, key: key) { completion?() }
1421
+ }
1422
+ currentAnimator = animator
1423
+ animator.startAnimation()
1424
+ }
1425
+
1426
+ private func emitSwipeEnd(_ state: String, side: SwipeSide?) {
1427
+ gestureCompletion.complete()
1428
+ onSwipeEnd(payload(["state": state], side: side))
1429
+ }
1430
+
1431
+ private func restoreFirstResponder() {
1432
+ if let saved = savedFirstResponder, !saved.isFirstResponder { saved.becomeFirstResponder() }
1433
+ savedFirstResponder = nil
991
1434
  }
992
1435
  }
993
1436
 
@@ -997,15 +1440,21 @@ extension SwipeableView: UIGestureRecognizerDelegate {
997
1440
 
998
1441
  public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
999
1442
  guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
1443
+ if pan === panGesture && !panGesture.isCurrentBinding(commandTarget) { return false }
1000
1444
  let velocity = pan.velocity(in: self)
1001
1445
  let isHorizontal = abs(velocity.x) > abs(velocity.y)
1002
1446
  guard isHorizontal else { return false }
1003
- let shouldBegin = isOpen || (isLeading ? velocity.x > 0 : velocity.x < 0)
1447
+ let shouldBegin: Bool
1448
+ guard gestureEnabled else { return false }
1449
+ let side = currentSide ?? SwipePhysics.latchedSide(
1450
+ openSide: openSide, translation: velocity.x,
1451
+ leftWidth: leftActionsWidth, rightWidth: rightActionsWidth
1452
+ )
1453
+ shouldBegin = side.map { width(for: $0) > 0 } ?? false
1004
1454
 
1005
1455
  if shouldBegin {
1006
- // Prevent our pan from starting when the touch is on a child view that has
1007
- // its own gesture recognizers (e.g. RNGH). Delegate-based conflict resolution
1008
- // doesn't work with RNGH (it overrides delegates), so we yield via hit-test.
1456
+ // Yield to any child-owned recognizer, including RNGH recognizers
1457
+ // whose delegates cannot participate in our failure ordering.
1009
1458
  let touchPoint = pan.location(in: self)
1010
1459
  if let hitView = super.hitTest(touchPoint, with: nil), hasChildGesture(from: hitView) {
1011
1460
  return false