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.
- package/README.md +250 -0
- package/android/build.gradle +99 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/swipeable/SwipeConstants.kt +29 -0
- package/android/src/main/java/expo/modules/swipeable/SwipePhysics.kt +66 -0
- package/android/src/main/java/expo/modules/swipeable/SwipeableModule.kt +69 -0
- package/android/src/main/java/expo/modules/swipeable/SwipeableView.kt +951 -0
- package/build/Swipeable.d.ts +10 -0
- package/build/Swipeable.d.ts.map +1 -0
- package/build/Swipeable.js +114 -0
- package/build/Swipeable.js.map +1 -0
- package/build/Swipeable.types.d.ts +64 -0
- package/build/Swipeable.types.d.ts.map +1 -0
- package/build/Swipeable.types.js +3 -0
- package/build/Swipeable.types.js.map +1 -0
- package/build/SwipeableActions.d.ts +17 -0
- package/build/SwipeableActions.d.ts.map +1 -0
- package/build/SwipeableActions.js +31 -0
- package/build/SwipeableActions.js.map +1 -0
- package/build/SwipeableView.d.ts +10 -0
- package/build/SwipeableView.d.ts.map +1 -0
- package/build/SwipeableView.js +11 -0
- package/build/SwipeableView.js.map +1 -0
- package/build/constants.d.ts +11 -0
- package/build/constants.d.ts.map +1 -0
- package/build/constants.js +14 -0
- package/build/constants.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +21 -0
- package/build/index.js.map +1 -0
- package/build/nativeModuleUtils.d.ts +11 -0
- package/build/nativeModuleUtils.d.ts.map +1 -0
- package/build/nativeModuleUtils.js +49 -0
- package/build/nativeModuleUtils.js.map +1 -0
- package/build/swipeStateUtils.d.ts +16 -0
- package/build/swipeStateUtils.d.ts.map +1 -0
- package/build/swipeStateUtils.js +37 -0
- package/build/swipeStateUtils.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Package.swift +28 -0
- package/ios/SwipeAnimationConfig.swift +27 -0
- package/ios/SwipePhysics.swift +66 -0
- package/ios/SwipeableModule.swift +84 -0
- package/ios/SwipeableView.swift +845 -0
- package/ios/react-native-swipeable-actions.podspec +22 -0
- package/package.json +85 -0
- package/src/Swipeable.tsx +195 -0
- package/src/Swipeable.types.ts +70 -0
- package/src/SwipeableActions.tsx +41 -0
- package/src/SwipeableView.tsx +20 -0
- package/src/constants.ts +11 -0
- package/src/index.ts +22 -0
- package/src/nativeModuleUtils.ts +57 -0
- 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
|
+
}
|