react-native-swipeable-actions 0.1.11 → 1.0.0-rc.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 +78 -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 +1061 -600
  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,255 +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)
135
+ }
136
+ }
137
+
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)
87
144
  }
88
145
  }
89
146
 
90
- private static func clearOpenState(for key: String) {
91
- registryQueue.async(flags: .barrier) { _openStateCache.removeValue(forKey: key) }
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
+ }
92
155
  }
93
156
 
94
- private static func getAllViews() -> [SwipeableView] {
95
- registryQueue.sync { _viewRegistry.values.compactMap { $0.value } }
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?
428
+ private var autoCloseState: SwipeAutoCloseState?
429
+ private var autoCloseGeneration = 0
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
+ }
293
448
 
294
- // Override onSwipeEnd state when autoClose triggers close() (emit "open" instead of "closed")
295
- private var autoCloseEndEventOverride = false
449
+ private func actionsView(for side: SwipeSide) -> UIView? {
450
+ return side == .left ? leftActionsView : rightActionsView
451
+ }
296
452
 
297
- // UIViewPropertyAnimator for interruptible animations (2.1, 2.2)
298
- private var currentAnimator: UIViewPropertyAnimator?
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
+ }
299
665
 
300
666
  // MARK: - First Responder Helper
301
667
 
@@ -308,8 +674,9 @@ public class SwipeableView: ExpoView {
308
674
  return nil
309
675
  }
310
676
 
311
- private lazy var panGesture: UIPanGestureRecognizer = {
312
- 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 }
313
680
  gesture.delegate = self
314
681
  gesture.cancelsTouchesInView = false
315
682
  gesture.delaysTouchesBegan = true
@@ -317,11 +684,17 @@ public class SwipeableView: ExpoView {
317
684
  return gesture
318
685
  }()
319
686
 
320
- // MARK: - Init
687
+ // MARK: - Init and attachment
321
688
 
322
689
  public required init(appContext: AppContext? = nil) {
323
690
  super.init(appContext: appContext)
324
- 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
+ )
325
698
  }
326
699
 
327
700
  required init?(coder: NSCoder) {
@@ -329,77 +702,75 @@ public class SwipeableView: ExpoView {
329
702
  }
330
703
 
331
704
  deinit {
705
+ releaseChildRoles()
332
706
  stopProgressUpdates()
333
707
  cancelAutoClose()
334
- // Stop any running animations (2.2)
708
+ reconciliationWorkItem?.cancel()
709
+ visualRestoreWorkItem?.cancel()
335
710
  currentAnimator?.stopAnimation(true)
336
- currentAnimator = nil
337
- removeGestureRecognizer(panGesture)
338
- // 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()
339
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) }
340
739
  Self.unregisterView(self, for: key)
341
740
  }
741
+ if let cancelled = cancelled { onGestureCancelled(cancelled) }
342
742
  }
343
743
 
344
744
  public override func willMove(toSuperview newSuperview: UIView?) {
345
- if newSuperview == nil {
346
- // Stop all timers and animations
347
- stopProgressUpdates()
348
- cancelAutoClose()
349
- currentAnimator?.stopAnimation(true)
350
- currentAnimator = nil
351
- isAnimating = false
352
-
353
- // Reset internal gesture/translation state so a re-attached view
354
- // (FlashList recycle) does not re-apply a stale currentTranslation
355
- // in the next layoutSubviews pass.
356
- currentTranslation = 0
357
- isOpen = false
358
- pendingShouldOpen = false
359
- startOffset = 0
360
- lastEmittedProgress = -1
361
- isDragging = false
362
- isGestureActivated = false
363
- gestureStartTranslation = 0
364
-
365
- // Reset layer properties to prevent corruption
366
- contentView?.transform = .identity
367
- contentView?.layer.zPosition = 0
368
- actionsView?.transform = .identity
369
- actionsView?.layer.zPosition = 0
370
-
371
- // Unregister from static registry and clear cached state
372
- if let key = recyclingKey {
373
- Self.unregisterView(self, for: key)
374
- Self.clearOpenState(for: key)
375
- }
376
- }
745
+ if newSuperview == nil { suspendForDetach() }
377
746
  super.willMove(toSuperview: newSuperview)
378
747
  }
379
748
 
380
- private func setupViews() {
381
- clipsToBounds = true
382
- isUserInteractionEnabled = true
383
- addGestureRecognizer(panGesture)
384
- }
385
-
386
749
  public override func didMoveToWindow() {
387
750
  super.didMoveToWindow()
388
- guard window != nil else { return }
389
-
390
- // Re-register in the view registry after reattach (e.g. returning from navigation).
391
- // willMove(toSuperview: nil) unregisters the view, but the recyclingKey prop setter
392
- // short-circuits when the value hasn't changed, so the view is never re-registered
393
- // unless we do it here.
751
+ guard window != nil else {
752
+ suspendForDetach()
753
+ return
754
+ }
755
+ let wasDetached = isDetached
756
+ isDetached = false
757
+ reduceMotionChanged()
394
758
  if let key = recyclingKey {
395
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
+ }
396
766
  }
397
-
398
- // Ensure gesture is attached to this view (handles view hierarchy changes)
399
767
  if panGesture.view !== self {
400
768
  panGesture.view?.removeGestureRecognizer(panGesture)
401
769
  addGestureRecognizer(panGesture)
402
770
  }
771
+ commitProps()
772
+ scheduleVisualRestore()
773
+ // A detached timer/animation must not restart and fabricate an end-open event.
403
774
  }
404
775
 
405
776
  public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
@@ -431,7 +802,6 @@ public class SwipeableView: ExpoView {
431
802
  }
432
803
  }
433
804
 
434
-
435
805
  private func startProgressUpdates() {
436
806
  guard displayLink == nil else { return }
437
807
  displayLink = CADisplayLink(target: self, selector: #selector(emitProgressUpdate))
@@ -455,527 +825,612 @@ public class SwipeableView: ExpoView {
455
825
  }
456
826
 
457
827
  private func cancelAutoClose() {
828
+ autoCloseGeneration += 1
458
829
  autoCloseWorkItem?.cancel()
459
830
  autoCloseWorkItem = nil
831
+ autoCloseState = nil
460
832
  }
461
833
 
462
834
  private func scheduleAutoClose() {
463
835
  cancelAutoClose()
464
836
  let delay = autoCloseTimeout > 0 ? autoCloseTimeout / 1000.0 : 0
465
- let workItem = DispatchWorkItem { [weak self] in
466
- self?.tryAutoClose()
837
+ if let side = openSide {
838
+ autoCloseState = .waiting(side: side, deadline: ProcessInfo.processInfo.systemUptime + delay)
467
839
  }
468
- autoCloseWorkItem = workItem
469
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
840
+ enqueueAutoClose(
841
+ after: delay, generation: workGeneration, timerGeneration: autoCloseGeneration, key: recyclingKey
842
+ )
470
843
  }
471
844
 
472
- private func tryAutoClose() {
473
- 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
+ }
474
853
 
475
- // If animation is still running, keep retrying until it completes
476
- if isAnimating {
477
- let retryWorkItem = DispatchWorkItem { [weak self] in
478
- 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")
479
865
  }
480
- autoCloseWorkItem = retryWorkItem
481
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: retryWorkItem)
482
- } else if isOpen {
483
- // Emit "open" state when autoClose completes - user's swipe intent was achieved
484
- autoCloseEndEventOverride = true
485
- close()
486
866
  }
867
+ autoCloseWorkItem = item
868
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
487
869
  }
488
870
 
489
871
  @objc private func emitProgressUpdate() {
490
- guard actionsWidth > 0 else { return }
491
-
492
- // During animation, read from presentation layer to get actual animated value
493
- var translation = currentTranslation
494
- if isAnimating, let presentationLayer = contentView?.layer.presentation() {
495
- translation = presentationLayer.transform.m41 // tx component of transform
496
- // Update actions to match content position
497
- let progress = min(abs(translation) / actionsWidth, 1.0)
498
- let offset = isLeading ? -actionsWidth : actionsWidth
499
- let actionsTranslateX = offset * (1 - progress)
500
- actionsView?.transform = CGAffineTransform(translationX: actionsTranslateX, y: 0)
501
- }
502
-
503
- let progress = abs(translation) / actionsWidth
504
-
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)
505
879
  if abs(progress - lastEmittedProgress) > SwipeAnimationConfig.progressChangeThreshold {
506
880
  lastEmittedProgress = progress
507
- onSwipeProgress([
508
- "progress": progress,
509
- "translationX": translation
510
- ])
881
+ onSwipeProgress(payload(["progress": progress, "translationX": translation], side: side))
511
882
  }
512
883
  }
513
884
 
514
- private func updateActionsTransform() {
515
- guard let actionsView = actionsView, actionsWidth > 0 else { return }
516
- let progress = min(abs(currentTranslation) / actionsWidth, 1.0)
517
- let offset = isLeading ? -actionsWidth : actionsWidth
518
- let actionsTranslateX = offset * (1 - progress)
519
- 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
+ }
520
905
  }
521
906
 
522
- private func shouldSyncOpenLayoutForPropChange(oldValue: CGFloat, newValue: CGFloat) -> Bool {
523
- guard oldValue != newValue else { return false }
524
- guard isOpen, !isDragging, !isAnimating, !isGestureActivated else { return false }
525
- return newValue > 0
526
- }
907
+ // MARK: - React child roles
527
908
 
528
- private func syncOpenStateToCurrentWidth() {
529
- let targetX = isLeading ? actionsWidth : -actionsWidth
530
- currentTranslation = targetX
531
- 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
+ }
532
914
 
533
- if actionsView != nil {
534
- actionsView?.transform = .identity
535
- } else {
536
- onSwipeStart([:])
915
+ private func releaseChildRoles() {
916
+ for view in [leftActionsView, rightActionsView, contentView].compactMap({ $0 }) {
917
+ releaseChild(view)
537
918
  }
538
-
539
- onSwipeProgress(["progress": 1.0, "translationX": targetX])
919
+ railVisibility.releaseAll()
920
+ leftActionsView = nil
921
+ rightActionsView = nil
922
+ contentView = nil
923
+ slotChildren.removeAll()
540
924
  }
541
925
 
542
- // 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
+ }
543
961
 
544
- // Track React-managed children only. Fabric guarantees this fires solely
545
- // for views Fabric mounts (the JSX children) — never for UIKit helper
546
- // views that UIView.subviews would otherwise include.
547
962
  public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
548
963
  super.mountChildComponentView(childComponentView, index: index)
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
- // Fabric recycles UIView instances. Before handing this child back, undo any
556
- // transform / zPosition we may have applied — otherwise the next React
557
- // component that receives this recycled UIView inherits our stale state and
558
- // appears translated.
559
- childComponentView.transform = .identity
560
- childComponentView.layer.zPosition = 0
561
-
562
- if index >= 0 && index < reactChildren.count && reactChildren[index] === childComponentView {
563
- reactChildren.remove(at: index)
564
- } else if let found = reactChildren.firstIndex(where: { $0 === childComponentView }) {
565
- reactChildren.remove(at: found)
972
+ let cancelled = childComponentView === contentView
973
+ ? gestureCompletion.cancel(recyclingKey: recyclingKey, side: currentSide) : nil
974
+ if childComponentView === contentView {
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
566
995
  }
567
-
996
+ // Return both transforms and weakly-owned visibility/accessibility flags.
997
+ releaseChild(childComponentView)
998
+ reactChildren.unmount(childComponentView, at: index)
568
999
  if childComponentView === contentView { contentView = nil }
569
- if childComponentView === actionsView { actionsView = nil }
570
-
1000
+ if childComponentView === leftActionsView { leftActionsView = nil; requestedSides.remove(.left) }
1001
+ if childComponentView === rightActionsView { rightActionsView = nil; requestedSides.remove(.right) }
571
1002
  super.unmountChildComponentView(childComponentView, index: index)
572
- refreshChildAssignments()
573
- }
574
-
575
- private func refreshChildAssignments() {
576
- let wasActionsNil = actionsView == nil
577
- let wasContentNil = contentView == nil
578
-
579
- switch reactChildren.count {
580
- case 0:
581
- contentView = nil
582
- actionsView = nil
583
- case 1:
584
- contentView = reactChildren[0]
585
- contentView?.layer.zPosition = 1
586
- actionsView = nil
587
- case 2:
588
- actionsView = reactChildren[0]
589
- actionsView?.layer.zPosition = -1
590
- contentView = reactChildren[1]
591
- contentView?.layer.zPosition = 1
592
- default:
593
- #if DEBUG
594
- print("[SwipeableView] Warning: Expected 1-2 React children, got \(reactChildren.count)")
595
- #endif
596
- actionsView = reactChildren[0]
597
- actionsView?.layer.zPosition = -1
598
- contentView = reactChildren[1]
599
- contentView?.layer.zPosition = 1
600
- }
601
-
602
- setNeedsLayout()
603
-
604
- if wasContentNil && contentView != nil && (isOpen || currentTranslation != 0) {
605
- contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
606
- }
1003
+ let generation = workGeneration
1004
+ if let cancelled = cancelled { onGestureCancelled(cancelled) }
1005
+ guard generation == workGeneration else { return }
1006
+ reconcileChildRoles()
1007
+ scheduleReconciliation()
1008
+ }
607
1009
 
608
- if wasActionsNil && actionsView != nil {
609
- if isOpen || isAnimating {
610
- actionsView?.transform = .identity
611
- } else if pendingShouldOpen {
612
- pendingShouldOpen = false
613
- DispatchQueue.main.async { [weak self] in
614
- self?.snapToOpen()
615
- }
616
- }
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()
617
1021
  }
1022
+ visualRestoreWorkItem = item
1023
+ DispatchQueue.main.async(execute: item)
618
1024
  }
619
1025
 
620
1026
  public override func layoutSubviews() {
621
- guard !isDragging && !isAnimating else { return }
622
- super.layoutSubviews()
623
-
624
- if let actionsView = actionsView {
625
- var frame = actionsView.frame
626
- frame.size.height = bounds.height
627
- actionsView.frame = frame
628
-
629
- if currentTranslation == 0 && actionsWidth > 0 {
630
- let offset = isLeading ? -actionsWidth : actionsWidth
631
- actionsView.transform = CGAffineTransform(translationX: offset, y: 0)
632
- }
1027
+ guard !isDetached else { return }
1028
+ guard !isDragging && !isAnimating else {
1029
+ reconcileChildRoles(); updateActionsTransform()
1030
+ return
633
1031
  }
634
-
635
- if currentTranslation != 0 {
636
- contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
637
- 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)
638
1039
  }
1040
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
1041
+ updateActionsTransform()
1042
+ if currentTranslation != 0 { scheduleVisualRestore() }
639
1043
  }
640
1044
 
641
- // MARK: - Gesture Handling
1045
+ // MARK: - Gesture handling
642
1046
 
643
1047
  func handleGestureStart() {
644
- guard gestureEnabled, actionsWidth > 0 else { return }
645
-
646
- 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
647
1054
  startOffset = currentTranslation
648
- gestureStartTranslation = 0
649
- isGestureActivated = true
650
- onSwipeStart([:])
651
- startProgressUpdates()
1055
+ gestureStartedOpen = isOpen
1056
+ // Direct API has no direction at start. Latch when its first update arrives.
652
1057
  }
653
1058
 
654
1059
  func handleGestureUpdate(translationX: CGFloat, velocityX: CGFloat) {
655
- guard actionsWidth > 0 && isGestureActivated else { return }
656
-
657
- let rawTranslation = startOffset + (translationX * friction)
658
- let constrainedTranslation = SwipePhysics.applyRubberBand(
659
- translation: rawTranslation,
660
- actionsWidth: actionsWidth,
661
- isLeading: isLeading
662
- )
663
-
664
- currentTranslation = constrainedTranslation
665
- contentView?.transform = CGAffineTransform(translationX: constrainedTranslation, y: 0)
666
- 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)
667
1072
  }
668
1073
 
669
1074
  func handleGestureEnd(translationX: CGFloat, velocityX: CGFloat) {
670
- guard isGestureActivated else { return }
671
-
672
- isGestureActivated = false
673
- gestureStartTranslation = 0
674
- isDragging = false
675
- stopProgressUpdates()
676
-
677
- let projectedX = SwipePhysics.projectFinalPosition(
678
- translation: translationX,
679
- velocity: velocityX,
680
- friction: friction
681
- )
682
- let willOpen = SwipePhysics.shouldSnapToOpen(
683
- projectedTranslation: projectedX,
684
- actionsWidth: actionsWidth,
685
- threshold: threshold,
686
- isLeading: isLeading,
687
- isCurrentlyOpen: isOpen
688
- )
689
-
690
- if willOpen {
691
- open(velocity: velocityX)
692
- } else {
693
- 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
694
1080
  }
1081
+ let relative = translationX - gestureStartTranslation
1082
+ finishGesture(translation: relative, velocity: velocityX, side: side)
695
1083
  }
696
1084
 
697
1085
  @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
698
- guard actionsWidth > 0 else { return }
699
-
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
+ }
700
1098
  switch gesture.state {
701
1099
  case .began:
702
1100
  handlePanBegan(gesture)
703
1101
  case .changed:
704
1102
  handlePanChanged(gesture)
705
- case .ended, .cancelled:
1103
+ case .ended:
706
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)
707
1128
  default:
708
1129
  break
709
1130
  }
710
1131
  }
711
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
+
712
1146
  private func handlePanBegan(_ gesture: UIPanGestureRecognizer) {
713
- // Cancel RN touches immediately when pan begins to prevent press events during swipe
714
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
+ }
715
1159
 
716
- // Capture animation position if interrupted using UIViewPropertyAnimator (2.2)
717
- if isAnimating, let animator = currentAnimator {
718
- // Pause to capture current position, then stop completely
719
- animator.pauseAnimation()
720
- if let presentationLayer = contentView?.layer.presentation() {
721
- currentTranslation = presentationLayer.transform.m41
722
- contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
723
- }
724
- if let actionsPresentation = actionsView?.layer.presentation() {
725
- actionsView?.transform = CGAffineTransform(translationX: actionsPresentation.transform.m41, y: 0)
726
- }
727
- animator.stopAnimation(true)
728
- currentAnimator = nil
729
- isAnimating = false
730
- }
731
-
732
- let translation = gesture.translation(in: self).x
733
- gestureStartTranslation = translation
734
- isGestureActivated = (dragOffsetFromEdge <= 0)
735
-
736
- if isGestureActivated {
737
- activateGesture()
738
- }
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
739
1169
  }
740
1170
 
741
1171
  private func handlePanChanged(_ gesture: UIPanGestureRecognizer) {
742
1172
  let translation = gesture.translation(in: self).x
743
-
744
1173
  if !isGestureActivated {
745
- let dragDistance = abs(translation - gestureStartTranslation)
746
- guard dragDistance >= dragOffsetFromEdge else { return }
747
-
748
- isGestureActivated = true
749
- gestureStartTranslation = translation
750
- 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 }
751
1182
  }
1183
+ guard let side = currentSide else { return }
1184
+ updateGesture(translation: translation - gestureStartTranslation, side: side)
1185
+ }
752
1186
 
753
- let effectiveTranslation = translation - gestureStartTranslation
754
- let rawTranslation = startOffset + (effectiveTranslation * friction)
755
- let clampedTranslation = isLeading ? max(0, rawTranslation) : min(0, rawTranslation)
756
- let constrainedTranslation = SwipePhysics.applyRubberBand(
757
- translation: clampedTranslation,
758
- actionsWidth: actionsWidth,
759
- 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
760
1198
  )
1199
+ }
761
1200
 
762
- currentTranslation = constrainedTranslation
763
- contentView?.transform = CGAffineTransform(translationX: constrainedTranslation, y: 0)
764
- 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
+ }
765
1218
 
766
- // 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()
767
1228
  let wouldOpen = SwipePhysics.shouldSnapToOpen(
768
- projectedTranslation: constrainedTranslation,
769
- actionsWidth: actionsWidth,
770
- threshold: threshold,
771
- isLeading: isLeading,
772
- isCurrentlyOpen: isOpen
1229
+ projectedTranslation: translation * friction, actionsWidth: width(for: side), threshold: threshold,
1230
+ isLeading: side == .left, isCurrentlyOpen: gestureStartedOpen
773
1231
  )
774
- let intendedState = wouldOpen ? "open" : "closed"
775
- if lastEmittedWillEndState != intendedState {
776
- lastEmittedWillEndState = intendedState
777
- onSwipeStateChange(["state": intendedState])
778
- }
1232
+ emitIntendedState(wouldOpen, side: side)
779
1233
  }
780
1234
 
781
- private func handlePanEnded(_ gesture: UIPanGestureRecognizer) {
782
- guard isGestureActivated else {
783
- gestureStartTranslation = 0
784
- savedFirstResponder = nil // Clear saved responder if gesture wasn't activated
785
- 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))
786
1240
  }
1241
+ }
787
1242
 
788
- let translation = gesture.translation(in: self).x
789
- let velocity = gesture.velocity(in: self).x
790
- let effectiveTranslation = translation - gestureStartTranslation
791
-
1243
+ private func finishGesture(
1244
+ translation: CGFloat, velocity: CGFloat, side: SwipeSide
1245
+ ) {
1246
+ let generation = workGeneration
1247
+ let key = recyclingKey
792
1248
  isGestureActivated = false
1249
+ directGesturePending = false
793
1250
  gestureStartTranslation = 0
794
1251
  isDragging = false
795
1252
  stopProgressUpdates()
796
-
797
- let projectedX = SwipePhysics.projectFinalPosition(
798
- translation: effectiveTranslation,
799
- velocity: velocity,
800
- friction: friction
801
- )
802
- let willOpen = SwipePhysics.shouldSnapToOpen(
803
- projectedTranslation: projectedX,
804
- actionsWidth: actionsWidth,
805
- threshold: threshold,
806
- isLeading: isLeading,
807
- isCurrentlyOpen: isOpen
1253
+ let willOpen = SwipePhysics.shouldOpenOnSide(
1254
+ translation: translation, velocity: velocity, friction: friction,
1255
+ actionsWidth: width(for: side), threshold: threshold, side: side,
1256
+ isCurrentlyOpen: gestureStartedOpen
808
1257
  )
809
-
810
- // Only emit onSwipeStateChange if state changed from what was emitted during drag
811
- // (velocity projection may result in different final state)
812
- let intendedState = willOpen ? "open" : "closed"
813
- if lastEmittedWillEndState != intendedState {
814
- lastEmittedWillEndState = intendedState
815
- onSwipeStateChange(["state": intendedState])
816
- }
817
-
1258
+ emitIntendedState(willOpen, side: side)
1259
+ guard isCurrent(generation, key: key) else { return }
818
1260
  if willOpen {
819
- open(velocity: velocity)
820
- // Schedule auto-close if enabled
821
- if autoClose {
822
- scheduleAutoClose()
823
- }
1261
+ beginOpen(on: side, preserveFeedback: true, preserveGestureCompletion: true)
1262
+ if autoClose, gestureCompletion.isPending, isOpen { scheduleAutoClose() }
824
1263
  } else {
825
- close(velocity: velocity)
1264
+ performClose(animated: true, preserveGestureCompletion: true)
826
1265
  }
827
1266
  }
828
1267
 
829
- private func activateGesture() {
830
- isDragging = true
831
- startOffset = currentTranslation
832
- lastEmittedWillEndState = isOpen ? "open" : "closed"
833
- onSwipeStart([:])
834
- 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())
835
1275
  }
836
1276
 
837
- // 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
+ }
838
1283
 
839
- func open(velocity: CGFloat = 0) {
840
- // Cancel any existing animation
841
- currentAnimator?.stopAnimation(true)
842
- 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
+ }
843
1297
 
844
- 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
845
1313
  isAnimating = true
846
- if let key = recyclingKey { Self.setOpenState(for: key, isOpen: true) }
847
- let targetX = isLeading ? actionsWidth : -actionsWidth
848
-
849
- // 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)
850
1318
  startProgressUpdates()
851
-
852
- // Create interruptible spring animator (2.1)
853
1319
  let animator = UIViewPropertyAnimator(
854
1320
  duration: SwipeAnimationConfig.openDuration,
855
1321
  dampingRatio: SwipeAnimationConfig.openDamping
856
1322
  ) {
857
- // Only animate content - actions are synced via displayLink
858
1323
  self.contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
859
1324
  }
860
-
861
1325
  animator.addCompletion { [weak self] position in
862
- guard let self = self, position == .end else { return }
863
-
1326
+ guard let self = self, position == .end, self.isCurrent(generation, key: key) else { return }
864
1327
  self.currentTranslation = targetX
865
1328
  self.isAnimating = false
866
1329
  self.currentAnimator = nil
1330
+ self.animationSide = nil
867
1331
  self.stopProgressUpdates()
868
- // Ensure final actions position
869
- self.actionsView?.transform = .identity
870
- self.onSwipeProgress(["progress": 1.0, "translationX": targetX])
871
- // With autoClose, let close() emit onSwipeEnd("open") instead
872
- if !self.autoClose {
873
- self.onSwipeEnd(["state": "open"])
874
- }
875
-
876
- // Restore first responder if it was lost during swipe (keyboard preservation)
877
- if let saved = self.savedFirstResponder, !saved.isFirstResponder {
878
- saved.becomeFirstResponder()
879
- }
880
- 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()
881
1340
  }
882
-
883
- // Store reference for interruption handling (2.2)
884
1341
  currentAnimator = animator
885
1342
  animator.startAnimation()
886
1343
  }
887
1344
 
888
1345
  func snapToOpen() {
889
- isOpen = true
890
- isAnimating = false
891
- let targetX = isLeading ? actionsWidth : -actionsWidth
892
- currentTranslation = targetX
893
-
894
- // Set content translation immediately
895
- contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
896
-
897
- if actionsView != nil {
898
- // Actions already mounted - position them
899
- actionsView?.transform = .identity
900
- } else {
901
- // Actions not mounted yet (React lazy rendering)
902
- // Trigger onSwipeStart so React renders SwipeableActions
903
- // didAddSubview will handle positioning when actions are added
904
- onSwipeStart([:])
905
- }
1346
+ commitProps()
1347
+ guard let side = defaultOpenSide() else { return }
1348
+ snapToOpen(side: side)
1349
+ }
906
1350
 
907
- onSwipeProgress(["progress": 1.0, "translationX": targetX])
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))
908
1364
  }
909
1365
 
910
1366
  func close(animated: Bool = true, velocity: CGFloat = 0) {
911
- cancelAutoClose()
912
- stopProgressUpdates()
913
-
914
- // Cancel any existing animation (2.2)
915
- currentAnimator?.stopAnimation(true)
916
- currentAnimator = nil
917
-
918
- // Clear gesture state (unified from reset)
919
- isDragging = false
920
- isGestureActivated = false
921
- gestureStartTranslation = 0
922
- pendingShouldOpen = false
1367
+ performClose(animated: animated)
1368
+ }
923
1369
 
924
- isOpen = false
925
- // Only persist to cache when animated (user action), not when resetting (recycling)
926
- if animated {
927
- 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)
928
1384
  }
929
-
930
- let actionsOffset = isLeading ? -actionsWidth : actionsWidth
931
-
932
- if animated {
933
- // Animated close with spring physics using UIViewPropertyAnimator (2.1)
934
- isAnimating = true
935
- startProgressUpdates()
936
-
937
- let animator = UIViewPropertyAnimator(
938
- duration: SwipeAnimationConfig.closeDuration,
939
- dampingRatio: SwipeAnimationConfig.closeDamping
940
- ) {
941
- self.contentView?.transform = .identity
942
- }
943
-
944
- animator.addCompletion { [weak self] position in
945
- guard let self = self, position == .end else { return }
946
-
947
- self.currentTranslation = 0
948
- self.isAnimating = false
949
- self.currentAnimator = nil
950
- self.stopProgressUpdates()
951
- self.actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
952
- self.onSwipeProgress(["progress": 0.0, "translationX": 0.0])
953
- // Emit "open" when autoClose completes - user's swipe intent was achieved
954
- let endState = self.autoCloseEndEventOverride ? "open" : "closed"
955
- self.autoCloseEndEventOverride = false
956
- self.onSwipeEnd(["state": endState])
957
-
958
- // Restore first responder if it was lost during swipe (keyboard preservation)
959
- if let saved = self.savedFirstResponder, !saved.isFirstResponder {
960
- saved.becomeFirstResponder()
961
- }
962
- self.savedFirstResponder = nil
963
- }
964
-
965
- // Store reference for interruption handling (2.2)
966
- currentAnimator = animator
967
- animator.startAnimation()
968
- } else {
969
- // Instant close (formerly reset)
970
- isAnimating = false
1385
+ // Every explicit close clears cached intent, including instant resets.
1386
+ persistOpenSide()
1387
+ if !animated {
971
1388
  currentTranslation = 0
972
- startOffset = 0
973
- lastEmittedProgress = -1
1389
+ animationSide = nil
974
1390
  savedFirstResponder = nil
975
-
976
1391
  contentView?.transform = .identity
977
- actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
1392
+ updateActionsTransform()
1393
+ return
978
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
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
979
1434
  }
980
1435
  }
981
1436
 
@@ -985,15 +1440,21 @@ extension SwipeableView: UIGestureRecognizerDelegate {
985
1440
 
986
1441
  public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
987
1442
  guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
1443
+ if pan === panGesture && !panGesture.isCurrentBinding(commandTarget) { return false }
988
1444
  let velocity = pan.velocity(in: self)
989
1445
  let isHorizontal = abs(velocity.x) > abs(velocity.y)
990
1446
  guard isHorizontal else { return false }
991
- 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
992
1454
 
993
1455
  if shouldBegin {
994
- // Prevent our pan from starting when the touch is on a child view that has
995
- // its own gesture recognizers (e.g. RNGH). Delegate-based conflict resolution
996
- // 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.
997
1458
  let touchPoint = pan.location(in: self)
998
1459
  if let hitView = super.hitTest(touchPoint, with: nil), hasChildGesture(from: hitView) {
999
1460
  return false