react-native-swipeable-actions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +250 -0
  2. package/android/build.gradle +99 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/expo/modules/swipeable/SwipeConstants.kt +29 -0
  5. package/android/src/main/java/expo/modules/swipeable/SwipePhysics.kt +66 -0
  6. package/android/src/main/java/expo/modules/swipeable/SwipeableModule.kt +69 -0
  7. package/android/src/main/java/expo/modules/swipeable/SwipeableView.kt +951 -0
  8. package/build/Swipeable.d.ts +10 -0
  9. package/build/Swipeable.d.ts.map +1 -0
  10. package/build/Swipeable.js +114 -0
  11. package/build/Swipeable.js.map +1 -0
  12. package/build/Swipeable.types.d.ts +64 -0
  13. package/build/Swipeable.types.d.ts.map +1 -0
  14. package/build/Swipeable.types.js +3 -0
  15. package/build/Swipeable.types.js.map +1 -0
  16. package/build/SwipeableActions.d.ts +17 -0
  17. package/build/SwipeableActions.d.ts.map +1 -0
  18. package/build/SwipeableActions.js +31 -0
  19. package/build/SwipeableActions.js.map +1 -0
  20. package/build/SwipeableView.d.ts +10 -0
  21. package/build/SwipeableView.d.ts.map +1 -0
  22. package/build/SwipeableView.js +11 -0
  23. package/build/SwipeableView.js.map +1 -0
  24. package/build/constants.d.ts +11 -0
  25. package/build/constants.d.ts.map +1 -0
  26. package/build/constants.js +14 -0
  27. package/build/constants.js.map +1 -0
  28. package/build/index.d.ts +9 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +21 -0
  31. package/build/index.js.map +1 -0
  32. package/build/nativeModuleUtils.d.ts +11 -0
  33. package/build/nativeModuleUtils.d.ts.map +1 -0
  34. package/build/nativeModuleUtils.js +49 -0
  35. package/build/nativeModuleUtils.js.map +1 -0
  36. package/build/swipeStateUtils.d.ts +16 -0
  37. package/build/swipeStateUtils.d.ts.map +1 -0
  38. package/build/swipeStateUtils.js +37 -0
  39. package/build/swipeStateUtils.js.map +1 -0
  40. package/expo-module.config.json +9 -0
  41. package/ios/Package.swift +28 -0
  42. package/ios/SwipeAnimationConfig.swift +27 -0
  43. package/ios/SwipePhysics.swift +66 -0
  44. package/ios/SwipeableModule.swift +84 -0
  45. package/ios/SwipeableView.swift +845 -0
  46. package/ios/react-native-swipeable-actions.podspec +22 -0
  47. package/package.json +85 -0
  48. package/src/Swipeable.tsx +195 -0
  49. package/src/Swipeable.types.ts +70 -0
  50. package/src/SwipeableActions.tsx +41 -0
  51. package/src/SwipeableView.tsx +20 -0
  52. package/src/constants.ts +11 -0
  53. package/src/index.ts +22 -0
  54. package/src/nativeModuleUtils.ts +57 -0
  55. package/src/swipeStateUtils.ts +55 -0
@@ -0,0 +1,845 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ #if canImport(React)
5
+ import React
6
+ #endif
7
+
8
+ // MARK: - WeakRef Helper
9
+
10
+ private class WeakRef<T: AnyObject> {
11
+ weak var value: T?
12
+ init(_ value: T) { self.value = value }
13
+ }
14
+
15
+ public class SwipeableView: ExpoView {
16
+
17
+ // MARK: - Cached RN Touch Handler Classes (2.4)
18
+ // Cache NSClassFromString results to avoid repeated lookups and improve type safety
19
+ private static let surfaceTouchHandlerClass: AnyClass? = NSClassFromString("RCTSurfaceTouchHandler")
20
+ private static let touchHandlerClass: AnyClass? = NSClassFromString("RCTTouchHandler")
21
+
22
+ /// Check if a gesture recognizer is a React Native touch handler
23
+ /// Uses cached class references with string fallback for compatibility
24
+ private static func isRNTouchHandler(_ recognizer: UIGestureRecognizer) -> Bool {
25
+ // Primary: Check against cached classes
26
+ if let cls = surfaceTouchHandlerClass, recognizer.isKind(of: cls) { return true }
27
+ if let cls = touchHandlerClass, recognizer.isKind(of: cls) { return true }
28
+
29
+ // Fallback: String-based check for different RN versions or renamed classes
30
+ let typeName = String(describing: type(of: recognizer))
31
+ return typeName.contains("SurfaceTouchHandler") || typeName.contains("RCTTouchHandler")
32
+ }
33
+
34
+ // MARK: - Thread-Safe Static Registry
35
+
36
+ private static let registryQueue = DispatchQueue(label: "com.swipeable.registry", qos: .userInteractive)
37
+ private static var _openStateCache: [String: Bool] = [:]
38
+ private static var _viewRegistry: [String: WeakRef<SwipeableView>] = [:]
39
+
40
+ private static func getOpenState(for key: String) -> Bool {
41
+ registryQueue.sync { _openStateCache[key] ?? false }
42
+ }
43
+
44
+ private static func setOpenState(for key: String, isOpen: Bool) {
45
+ registryQueue.async(flags: .barrier) { _openStateCache[key] = isOpen }
46
+ }
47
+
48
+ private static func getView(for key: String) -> SwipeableView? {
49
+ registryQueue.sync { _viewRegistry[key]?.value }
50
+ }
51
+
52
+ private static func registerView(_ view: SwipeableView, for key: String) {
53
+ registryQueue.async(flags: .barrier) { _viewRegistry[key] = WeakRef(view) }
54
+ }
55
+
56
+ private static func unregisterView(for key: String) {
57
+ registryQueue.async(flags: .barrier) { _viewRegistry.removeValue(forKey: key) }
58
+ }
59
+
60
+ private static func getAllViews() -> [SwipeableView] {
61
+ registryQueue.sync { _viewRegistry.values.compactMap { $0.value } }
62
+ }
63
+
64
+ // MARK: - Static Methods (by recyclingKey)
65
+
66
+ static func openByKey(_ key: String) {
67
+ DispatchQueue.main.async {
68
+ getView(for: key)?.open()
69
+ }
70
+ }
71
+
72
+ static func closeByKey(_ key: String, animated: Bool = true) {
73
+ DispatchQueue.main.async {
74
+ getView(for: key)?.close(animated: animated)
75
+ }
76
+ }
77
+
78
+ /// Close all swipeables. When animated=true, only closes open views. When animated=false, resets all views.
79
+ static func closeAll(animated: Bool = true) {
80
+ let views = getAllViews()
81
+ DispatchQueue.main.async {
82
+ if animated {
83
+ // Only close open views when animated
84
+ views.filter { $0.isOpen }.forEach { $0.close(animated: true) }
85
+ } else {
86
+ // Reset all views when not animated
87
+ views.forEach { $0.close(animated: false) }
88
+ }
89
+ }
90
+ }
91
+
92
+ // MARK: - State
93
+ private var actionsView: UIView?
94
+ private var contentView: UIView?
95
+ private var currentTranslation: CGFloat = 0
96
+ private var isOpen = false
97
+ private var startOffset: CGFloat = 0
98
+ private var isDragging = false
99
+ private var isAnimating = false
100
+ private var gestureStartTranslation: CGFloat = 0
101
+ private var isGestureActivated = false
102
+ private var pendingShouldOpen = false
103
+ private var lastEmittedWillEndState: String?
104
+ private weak var savedFirstResponder: UIView?
105
+
106
+ // MARK: - Props
107
+
108
+ var actionsWidth: CGFloat = 0 {
109
+ didSet {
110
+ let validated = max(0, actionsWidth)
111
+ if validated != actionsWidth {
112
+ actionsWidth = validated
113
+ return
114
+ }
115
+ setNeedsLayout()
116
+ }
117
+ }
118
+
119
+ var actionsPosition: String = "right" {
120
+ didSet {
121
+ let validPositions = ["left", "right"]
122
+ if !validPositions.contains(actionsPosition) {
123
+ actionsPosition = "right"
124
+ return
125
+ }
126
+ setNeedsLayout()
127
+ }
128
+ }
129
+
130
+ var isLeading: Bool { actionsPosition == "left" }
131
+
132
+ var friction: CGFloat = 1.0 {
133
+ didSet { friction = max(0.0, min(1.0, friction)) }
134
+ }
135
+
136
+ var threshold: CGFloat = 0.4 {
137
+ didSet { threshold = max(0.0, min(1.0, threshold)) }
138
+ }
139
+
140
+ var dragOffsetFromEdge: CGFloat = 0 {
141
+ didSet { dragOffsetFromEdge = max(0, dragOffsetFromEdge) }
142
+ }
143
+
144
+ var autoClose: Bool = false
145
+
146
+ var autoCloseTimeout: CGFloat = 0 { // milliseconds
147
+ didSet { autoCloseTimeout = max(0, autoCloseTimeout) }
148
+ }
149
+
150
+ var recyclingKey: String? {
151
+ didSet {
152
+ guard oldValue != recyclingKey else { return }
153
+
154
+ // Thread-safe read of new key's state
155
+ let shouldOpen = recyclingKey.map { Self.getOpenState(for: $0) } ?? false
156
+ pendingShouldOpen = shouldOpen
157
+
158
+ // Thread-safe write of old key's state and unregister
159
+ if let old = oldValue {
160
+ Self.setOpenState(for: old, isOpen: isOpen)
161
+ Self.unregisterView(for: old)
162
+ }
163
+
164
+ // Thread-safe register with new key
165
+ if let new = recyclingKey {
166
+ Self.registerView(self, for: new)
167
+ }
168
+
169
+ close(animated: false)
170
+
171
+ // Apply using local shouldOpen (pendingShouldOpen was cleared by close)
172
+ if shouldOpen {
173
+ DispatchQueue.main.async { [weak self] in self?.snapToOpen() }
174
+ }
175
+ }
176
+ }
177
+
178
+ // MARK: - Events
179
+
180
+ let onSwipeProgress = EventDispatcher()
181
+ let onSwipeStart = EventDispatcher()
182
+ let onSwipeStateChange = EventDispatcher()
183
+ let onSwipeEnd = EventDispatcher()
184
+ private var displayLink: CADisplayLink?
185
+ private var lastEmittedProgress: CGFloat = -1
186
+
187
+ // Auto-close work item for tracking and cancellation
188
+ private var autoCloseWorkItem: DispatchWorkItem?
189
+
190
+ // Override onSwipeEnd state when autoClose triggers close() (emit "open" instead of "closed")
191
+ private var autoCloseEndEventOverride = false
192
+
193
+ // UIViewPropertyAnimator for interruptible animations (2.1, 2.2)
194
+ private var currentAnimator: UIViewPropertyAnimator?
195
+
196
+ // MARK: - First Responder Helper
197
+
198
+ private func findFirstResponder(in view: UIView?) -> UIView? {
199
+ guard let view = view else { return nil }
200
+ if view.isFirstResponder { return view }
201
+ for subview in view.subviews {
202
+ if let found = findFirstResponder(in: subview) { return found }
203
+ }
204
+ return nil
205
+ }
206
+
207
+ private lazy var panGesture: UIPanGestureRecognizer = {
208
+ let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
209
+ gesture.delegate = self
210
+ gesture.cancelsTouchesInView = false
211
+ gesture.delaysTouchesBegan = true
212
+ gesture.delaysTouchesEnded = false
213
+ return gesture
214
+ }()
215
+
216
+ // MARK: - Init
217
+
218
+ public required init(appContext: AppContext? = nil) {
219
+ super.init(appContext: appContext)
220
+ setupViews()
221
+ }
222
+
223
+ required init?(coder: NSCoder) {
224
+ fatalError("init(coder:) has not been implemented")
225
+ }
226
+
227
+ deinit {
228
+ stopProgressUpdates()
229
+ cancelAutoClose()
230
+ // Stop any running animations (2.2)
231
+ currentAnimator?.stopAnimation(true)
232
+ currentAnimator = nil
233
+ removeGestureRecognizer(panGesture)
234
+ // Thread-safe deregister from view registry
235
+ if let key = recyclingKey {
236
+ Self.unregisterView(for: key)
237
+ }
238
+ }
239
+
240
+ public override func willMove(toSuperview newSuperview: UIView?) {
241
+ if newSuperview == nil {
242
+ stopProgressUpdates()
243
+ }
244
+ super.willMove(toSuperview: newSuperview)
245
+ }
246
+
247
+ private func setupViews() {
248
+ clipsToBounds = true
249
+ isUserInteractionEnabled = true
250
+ addGestureRecognizer(panGesture)
251
+ }
252
+
253
+ public override func didMoveToWindow() {
254
+ super.didMoveToWindow()
255
+ guard window != nil else { return }
256
+
257
+ // Ensure gesture is attached to this view (handles view hierarchy changes)
258
+ if panGesture.view !== self {
259
+ panGesture.view?.removeGestureRecognizer(panGesture)
260
+ addGestureRecognizer(panGesture)
261
+ }
262
+ }
263
+
264
+ public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
265
+ if isDragging || isGestureActivated {
266
+ // Return self to capture the touch but still allow our pan gesture to work
267
+ return self.bounds.contains(point) ? self : nil
268
+ }
269
+ return super.hitTest(point, with: event)
270
+ }
271
+
272
+ public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
273
+ savedFirstResponder = findFirstResponder(in: window)
274
+ super.touchesBegan(touches, with: event)
275
+ }
276
+
277
+ /// Cancels Fabric touch handling to prevent gesture conflicts
278
+ /// Uses cached class check via isRNTouchHandler helper (2.4)
279
+ private func cancelFabricTouches() {
280
+ var currentView: UIView? = self
281
+ while let view = currentView {
282
+ for recognizer in view.gestureRecognizers ?? [] {
283
+ if Self.isRNTouchHandler(recognizer) {
284
+ recognizer.isEnabled = false
285
+ recognizer.isEnabled = true
286
+ return
287
+ }
288
+ }
289
+ currentView = view.superview
290
+ }
291
+ }
292
+
293
+
294
+ private func startProgressUpdates() {
295
+ guard displayLink == nil else { return }
296
+ displayLink = CADisplayLink(target: self, selector: #selector(emitProgressUpdate))
297
+
298
+ // Optimize for ProMotion displays (iOS 15+)
299
+ if #available(iOS 15.0, *) {
300
+ displayLink?.preferredFrameRateRange = CAFrameRateRange(
301
+ minimum: 60,
302
+ maximum: 120,
303
+ preferred: 120
304
+ )
305
+ }
306
+
307
+ displayLink?.add(to: .main, forMode: .common)
308
+ }
309
+
310
+ private func stopProgressUpdates() {
311
+ displayLink?.invalidate()
312
+ displayLink = nil
313
+ lastEmittedProgress = -1
314
+ }
315
+
316
+ private func cancelAutoClose() {
317
+ autoCloseWorkItem?.cancel()
318
+ autoCloseWorkItem = nil
319
+ }
320
+
321
+ private func scheduleAutoClose() {
322
+ cancelAutoClose()
323
+ let delay = autoCloseTimeout > 0 ? autoCloseTimeout / 1000.0 : 0
324
+ let workItem = DispatchWorkItem { [weak self] in
325
+ self?.tryAutoClose()
326
+ }
327
+ autoCloseWorkItem = workItem
328
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
329
+ }
330
+
331
+ private func tryAutoClose() {
332
+ autoCloseWorkItem = nil
333
+
334
+ // If animation is still running, keep retrying until it completes
335
+ if isAnimating {
336
+ let retryWorkItem = DispatchWorkItem { [weak self] in
337
+ self?.tryAutoClose()
338
+ }
339
+ autoCloseWorkItem = retryWorkItem
340
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: retryWorkItem)
341
+ } else if isOpen {
342
+ // Emit "open" state when autoClose completes - user's swipe intent was achieved
343
+ autoCloseEndEventOverride = true
344
+ close()
345
+ }
346
+ }
347
+
348
+ @objc private func emitProgressUpdate() {
349
+ guard actionsWidth > 0 else { return }
350
+
351
+ // During animation, read from presentation layer to get actual animated value
352
+ var translation = currentTranslation
353
+ if isAnimating, let presentationLayer = contentView?.layer.presentation() {
354
+ translation = presentationLayer.transform.m41 // tx component of transform
355
+ // Update actions to match content position
356
+ let progress = min(abs(translation) / actionsWidth, 1.0)
357
+ let offset = isLeading ? -actionsWidth : actionsWidth
358
+ let actionsTranslateX = offset * (1 - progress)
359
+ actionsView?.transform = CGAffineTransform(translationX: actionsTranslateX, y: 0)
360
+ }
361
+
362
+ let progress = abs(translation) / actionsWidth
363
+
364
+ if abs(progress - lastEmittedProgress) > SwipeAnimationConfig.progressChangeThreshold {
365
+ lastEmittedProgress = progress
366
+ onSwipeProgress([
367
+ "progress": progress,
368
+ "translationX": translation
369
+ ])
370
+ }
371
+ }
372
+
373
+ private func updateActionsTransform() {
374
+ guard let actionsView = actionsView, actionsWidth > 0 else { return }
375
+ let progress = min(abs(currentTranslation) / actionsWidth, 1.0)
376
+ let offset = isLeading ? -actionsWidth : actionsWidth
377
+ let actionsTranslateX = offset * (1 - progress)
378
+ actionsView.transform = CGAffineTransform(translationX: actionsTranslateX, y: 0)
379
+ }
380
+
381
+ // MARK: - View Lifecycle
382
+
383
+ public override func didAddSubview(_ subview: UIView) {
384
+ super.didAddSubview(subview)
385
+
386
+ let reactSubviews = subviews
387
+ let wasActionsNil = actionsView == nil
388
+ let wasContentNil = contentView == nil
389
+
390
+ // Validate subview count before assignment
391
+ switch reactSubviews.count {
392
+ case 0:
393
+ // Should not happen - we just added a subview
394
+ contentView = nil
395
+ actionsView = nil
396
+ case 1:
397
+ contentView = reactSubviews[0]
398
+ contentView?.layer.zPosition = 1
399
+ actionsView = nil
400
+ case 2:
401
+ actionsView = reactSubviews[0]
402
+ actionsView?.layer.zPosition = -1
403
+ contentView = reactSubviews[1]
404
+ contentView?.layer.zPosition = 1
405
+ default:
406
+ // More than 2 children - log warning but use first two
407
+ #if DEBUG
408
+ print("[SwipeableView] Warning: Expected 1-2 subviews, got \(reactSubviews.count)")
409
+ #endif
410
+ actionsView = reactSubviews[0]
411
+ actionsView?.layer.zPosition = -1
412
+ contentView = reactSubviews[1]
413
+ contentView?.layer.zPosition = 1
414
+ }
415
+
416
+ setNeedsLayout()
417
+
418
+ if wasContentNil && contentView != nil && (isOpen || currentTranslation != 0) {
419
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
420
+ }
421
+
422
+ if wasActionsNil && actionsView != nil {
423
+ if isOpen || isAnimating {
424
+ actionsView?.transform = .identity
425
+ } else if pendingShouldOpen {
426
+ pendingShouldOpen = false
427
+ DispatchQueue.main.async { [weak self] in
428
+ self?.snapToOpen()
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ public override func layoutSubviews() {
435
+ guard !isDragging && !isAnimating else { return }
436
+ super.layoutSubviews()
437
+
438
+ if let actionsView = actionsView {
439
+ var frame = actionsView.frame
440
+ frame.size.height = bounds.height
441
+ actionsView.frame = frame
442
+
443
+ if currentTranslation == 0 && actionsWidth > 0 {
444
+ let offset = isLeading ? -actionsWidth : actionsWidth
445
+ actionsView.transform = CGAffineTransform(translationX: offset, y: 0)
446
+ }
447
+ }
448
+
449
+ if currentTranslation != 0 {
450
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
451
+ updateActionsTransform()
452
+ }
453
+ }
454
+
455
+ // MARK: - Gesture Handling
456
+
457
+ func handleGestureStart() {
458
+ guard actionsWidth > 0 else { return }
459
+
460
+ isDragging = true
461
+ startOffset = currentTranslation
462
+ gestureStartTranslation = 0
463
+ isGestureActivated = true
464
+ onSwipeStart([:])
465
+ startProgressUpdates()
466
+ }
467
+
468
+ func handleGestureUpdate(translationX: CGFloat, velocityX: CGFloat) {
469
+ guard actionsWidth > 0 && isGestureActivated else { return }
470
+
471
+ let rawTranslation = startOffset + (translationX * friction)
472
+ let constrainedTranslation = SwipePhysics.applyRubberBand(
473
+ translation: rawTranslation,
474
+ actionsWidth: actionsWidth,
475
+ isLeading: isLeading
476
+ )
477
+
478
+ currentTranslation = constrainedTranslation
479
+ contentView?.transform = CGAffineTransform(translationX: constrainedTranslation, y: 0)
480
+ updateActionsTransform()
481
+ }
482
+
483
+ func handleGestureEnd(translationX: CGFloat, velocityX: CGFloat) {
484
+ guard isGestureActivated else { return }
485
+
486
+ isGestureActivated = false
487
+ gestureStartTranslation = 0
488
+ isDragging = false
489
+ stopProgressUpdates()
490
+
491
+ let projectedX = SwipePhysics.projectFinalPosition(
492
+ translation: translationX,
493
+ velocity: velocityX,
494
+ friction: friction
495
+ )
496
+ let willOpen = SwipePhysics.shouldSnapToOpen(
497
+ projectedTranslation: projectedX,
498
+ actionsWidth: actionsWidth,
499
+ threshold: threshold,
500
+ isLeading: isLeading,
501
+ isCurrentlyOpen: isOpen
502
+ )
503
+
504
+ if willOpen {
505
+ open(velocity: velocityX)
506
+ } else {
507
+ close(velocity: velocityX)
508
+ }
509
+ }
510
+
511
+ @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
512
+ guard actionsWidth > 0 else { return }
513
+
514
+ switch gesture.state {
515
+ case .began:
516
+ handlePanBegan(gesture)
517
+ case .changed:
518
+ handlePanChanged(gesture)
519
+ case .ended, .cancelled:
520
+ handlePanEnded(gesture)
521
+ default:
522
+ break
523
+ }
524
+ }
525
+
526
+ private func handlePanBegan(_ gesture: UIPanGestureRecognizer) {
527
+ // Cancel RN touches immediately when pan begins to prevent press events during swipe
528
+ cancelFabricTouches()
529
+
530
+ // Capture animation position if interrupted using UIViewPropertyAnimator (2.2)
531
+ if isAnimating, let animator = currentAnimator {
532
+ // Pause to capture current position, then stop completely
533
+ animator.pauseAnimation()
534
+ if let presentationLayer = contentView?.layer.presentation() {
535
+ currentTranslation = presentationLayer.transform.m41
536
+ contentView?.transform = CGAffineTransform(translationX: currentTranslation, y: 0)
537
+ }
538
+ if let actionsPresentation = actionsView?.layer.presentation() {
539
+ actionsView?.transform = CGAffineTransform(translationX: actionsPresentation.transform.m41, y: 0)
540
+ }
541
+ animator.stopAnimation(true)
542
+ currentAnimator = nil
543
+ isAnimating = false
544
+ }
545
+
546
+ let translation = gesture.translation(in: self).x
547
+ gestureStartTranslation = translation
548
+ isGestureActivated = (dragOffsetFromEdge <= 0)
549
+
550
+ if isGestureActivated {
551
+ activateGesture()
552
+ }
553
+ }
554
+
555
+ private func handlePanChanged(_ gesture: UIPanGestureRecognizer) {
556
+ let translation = gesture.translation(in: self).x
557
+
558
+ if !isGestureActivated {
559
+ let dragDistance = abs(translation - gestureStartTranslation)
560
+ guard dragDistance >= dragOffsetFromEdge else { return }
561
+
562
+ isGestureActivated = true
563
+ gestureStartTranslation = translation
564
+ activateGesture()
565
+ }
566
+
567
+ let effectiveTranslation = translation - gestureStartTranslation
568
+ let rawTranslation = startOffset + (effectiveTranslation * friction)
569
+ let clampedTranslation = isLeading ? max(0, rawTranslation) : min(0, rawTranslation)
570
+ let constrainedTranslation = SwipePhysics.applyRubberBand(
571
+ translation: clampedTranslation,
572
+ actionsWidth: actionsWidth,
573
+ isLeading: isLeading
574
+ )
575
+
576
+ currentTranslation = constrainedTranslation
577
+ contentView?.transform = CGAffineTransform(translationX: constrainedTranslation, y: 0)
578
+ updateActionsTransform()
579
+
580
+ // Emit onSwipeStateChange when threshold crossing changes intended state
581
+ let wouldOpen = SwipePhysics.shouldSnapToOpen(
582
+ projectedTranslation: constrainedTranslation,
583
+ actionsWidth: actionsWidth,
584
+ threshold: threshold,
585
+ isLeading: isLeading,
586
+ isCurrentlyOpen: isOpen
587
+ )
588
+ let intendedState = wouldOpen ? "open" : "closed"
589
+ if lastEmittedWillEndState != intendedState {
590
+ lastEmittedWillEndState = intendedState
591
+ onSwipeStateChange(["state": intendedState])
592
+ }
593
+ }
594
+
595
+ private func handlePanEnded(_ gesture: UIPanGestureRecognizer) {
596
+ guard isGestureActivated else {
597
+ gestureStartTranslation = 0
598
+ savedFirstResponder = nil // Clear saved responder if gesture wasn't activated
599
+ return
600
+ }
601
+
602
+ let translation = gesture.translation(in: self).x
603
+ let velocity = gesture.velocity(in: self).x
604
+ let effectiveTranslation = translation - gestureStartTranslation
605
+
606
+ isGestureActivated = false
607
+ gestureStartTranslation = 0
608
+ isDragging = false
609
+ stopProgressUpdates()
610
+
611
+ let projectedX = SwipePhysics.projectFinalPosition(
612
+ translation: effectiveTranslation,
613
+ velocity: velocity,
614
+ friction: friction
615
+ )
616
+ let willOpen = SwipePhysics.shouldSnapToOpen(
617
+ projectedTranslation: projectedX,
618
+ actionsWidth: actionsWidth,
619
+ threshold: threshold,
620
+ isLeading: isLeading,
621
+ isCurrentlyOpen: isOpen
622
+ )
623
+
624
+ // Only emit onSwipeStateChange if state changed from what was emitted during drag
625
+ // (velocity projection may result in different final state)
626
+ let intendedState = willOpen ? "open" : "closed"
627
+ if lastEmittedWillEndState != intendedState {
628
+ lastEmittedWillEndState = intendedState
629
+ onSwipeStateChange(["state": intendedState])
630
+ }
631
+
632
+ if willOpen {
633
+ open(velocity: velocity)
634
+ // Schedule auto-close if enabled
635
+ if autoClose {
636
+ scheduleAutoClose()
637
+ }
638
+ } else {
639
+ close(velocity: velocity)
640
+ }
641
+ }
642
+
643
+ private func activateGesture() {
644
+ isDragging = true
645
+ startOffset = currentTranslation
646
+ lastEmittedWillEndState = isOpen ? "open" : "closed"
647
+ onSwipeStart([:])
648
+ startProgressUpdates()
649
+ }
650
+
651
+ // MARK: - Open/Close
652
+
653
+ func open(velocity: CGFloat = 0) {
654
+ // Cancel any existing animation
655
+ currentAnimator?.stopAnimation(true)
656
+ currentAnimator = nil
657
+
658
+ isOpen = true
659
+ isAnimating = true
660
+ if let key = recyclingKey { Self.setOpenState(for: key, isOpen: true) }
661
+ let targetX = isLeading ? actionsWidth : -actionsWidth
662
+
663
+ // Start displayLink to sync actions with content during animation
664
+ startProgressUpdates()
665
+
666
+ // Create interruptible spring animator (2.1)
667
+ let animator = UIViewPropertyAnimator(
668
+ duration: SwipeAnimationConfig.openDuration,
669
+ dampingRatio: SwipeAnimationConfig.openDamping
670
+ ) {
671
+ // Only animate content - actions are synced via displayLink
672
+ self.contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
673
+ }
674
+
675
+ animator.addCompletion { [weak self] position in
676
+ guard let self = self, position == .end else { return }
677
+
678
+ self.currentTranslation = targetX
679
+ self.isAnimating = false
680
+ self.currentAnimator = nil
681
+ self.stopProgressUpdates()
682
+ // Ensure final actions position
683
+ self.actionsView?.transform = .identity
684
+ self.onSwipeProgress(["progress": 1.0, "translationX": targetX])
685
+ // With autoClose, let close() emit onSwipeEnd("open") instead
686
+ if !self.autoClose {
687
+ self.onSwipeEnd(["state": "open"])
688
+ }
689
+
690
+ // Restore first responder if it was lost during swipe (keyboard preservation)
691
+ if let saved = self.savedFirstResponder, !saved.isFirstResponder {
692
+ saved.becomeFirstResponder()
693
+ }
694
+ self.savedFirstResponder = nil
695
+ }
696
+
697
+ // Store reference for interruption handling (2.2)
698
+ currentAnimator = animator
699
+ animator.startAnimation()
700
+ }
701
+
702
+ func snapToOpen() {
703
+ isOpen = true
704
+ isAnimating = false
705
+ let targetX = isLeading ? actionsWidth : -actionsWidth
706
+ currentTranslation = targetX
707
+
708
+ // Set content translation immediately
709
+ contentView?.transform = CGAffineTransform(translationX: targetX, y: 0)
710
+
711
+ if actionsView != nil {
712
+ // Actions already mounted - position them
713
+ actionsView?.transform = .identity
714
+ } else {
715
+ // Actions not mounted yet (React lazy rendering)
716
+ // Trigger onSwipeStart so React renders SwipeableActions
717
+ // didAddSubview will handle positioning when actions are added
718
+ onSwipeStart([:])
719
+ }
720
+
721
+ onSwipeProgress(["progress": 1.0, "translationX": targetX])
722
+ }
723
+
724
+ func close(animated: Bool = true, velocity: CGFloat = 0) {
725
+ cancelAutoClose()
726
+ stopProgressUpdates()
727
+
728
+ // Cancel any existing animation (2.2)
729
+ currentAnimator?.stopAnimation(true)
730
+ currentAnimator = nil
731
+
732
+ // Clear gesture state (unified from reset)
733
+ isDragging = false
734
+ isGestureActivated = false
735
+ gestureStartTranslation = 0
736
+ pendingShouldOpen = false
737
+
738
+ isOpen = false
739
+ // Only persist to cache when animated (user action), not when resetting (recycling)
740
+ if animated {
741
+ if let key = recyclingKey { Self.setOpenState(for: key, isOpen: false) }
742
+ }
743
+
744
+ let actionsOffset = isLeading ? -actionsWidth : actionsWidth
745
+
746
+ if animated {
747
+ // Animated close with spring physics using UIViewPropertyAnimator (2.1)
748
+ isAnimating = true
749
+ startProgressUpdates()
750
+
751
+ let animator = UIViewPropertyAnimator(
752
+ duration: SwipeAnimationConfig.closeDuration,
753
+ dampingRatio: SwipeAnimationConfig.closeDamping
754
+ ) {
755
+ self.contentView?.transform = .identity
756
+ }
757
+
758
+ animator.addCompletion { [weak self] position in
759
+ guard let self = self, position == .end else { return }
760
+
761
+ self.currentTranslation = 0
762
+ self.isAnimating = false
763
+ self.currentAnimator = nil
764
+ self.stopProgressUpdates()
765
+ self.actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
766
+ self.onSwipeProgress(["progress": 0.0, "translationX": 0.0])
767
+ // Emit "open" when autoClose completes - user's swipe intent was achieved
768
+ let endState = self.autoCloseEndEventOverride ? "open" : "closed"
769
+ self.autoCloseEndEventOverride = false
770
+ self.onSwipeEnd(["state": endState])
771
+
772
+ // Restore first responder if it was lost during swipe (keyboard preservation)
773
+ if let saved = self.savedFirstResponder, !saved.isFirstResponder {
774
+ saved.becomeFirstResponder()
775
+ }
776
+ self.savedFirstResponder = nil
777
+ }
778
+
779
+ // Store reference for interruption handling (2.2)
780
+ currentAnimator = animator
781
+ animator.startAnimation()
782
+ } else {
783
+ // Instant close (formerly reset)
784
+ isAnimating = false
785
+ currentTranslation = 0
786
+ startOffset = 0
787
+ lastEmittedProgress = -1
788
+ savedFirstResponder = nil
789
+
790
+ contentView?.transform = .identity
791
+ actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
792
+ }
793
+ }
794
+ }
795
+
796
+ // MARK: - UIGestureRecognizerDelegate
797
+
798
+ extension SwipeableView: UIGestureRecognizerDelegate {
799
+
800
+ public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
801
+ guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
802
+ let velocity = pan.velocity(in: self)
803
+ let isHorizontal = abs(velocity.x) > abs(velocity.y)
804
+ guard isHorizontal else { return false }
805
+ let shouldBegin = isOpen || (isLeading ? velocity.x > 0 : velocity.x < 0)
806
+
807
+ if shouldBegin {
808
+ cancelFabricTouches()
809
+ }
810
+
811
+ return shouldBegin
812
+ }
813
+
814
+ public func gestureRecognizer(
815
+ _ gestureRecognizer: UIGestureRecognizer,
816
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
817
+ ) -> Bool {
818
+ if Self.isRNTouchHandler(otherGestureRecognizer) {
819
+ return false
820
+ }
821
+ return true
822
+ }
823
+
824
+ public func gestureRecognizer(
825
+ _ gestureRecognizer: UIGestureRecognizer,
826
+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
827
+ ) -> Bool {
828
+ if otherGestureRecognizer.view is UIScrollView {
829
+ return true
830
+ }
831
+
832
+ if Self.isRNTouchHandler(otherGestureRecognizer) {
833
+ return true
834
+ }
835
+
836
+ return false
837
+ }
838
+
839
+ public func gestureRecognizer(
840
+ _ gestureRecognizer: UIGestureRecognizer,
841
+ shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
842
+ ) -> Bool {
843
+ false
844
+ }
845
+ }