react-native-swipeable-actions 0.1.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/LICENSE +21 -0
  3. package/README.md +377 -207
  4. package/android/src/main/java/expo/modules/swipeable/SwipePhysics.kt +39 -0
  5. package/android/src/main/java/expo/modules/swipeable/SwipeableModule.kt +24 -10
  6. package/android/src/main/java/expo/modules/swipeable/SwipeableView.kt +804 -420
  7. package/build/Swipeable.d.ts +3 -8
  8. package/build/Swipeable.d.ts.map +1 -1
  9. package/build/Swipeable.js +87 -33
  10. package/build/Swipeable.js.map +1 -1
  11. package/build/Swipeable.types.d.ts +59 -9
  12. package/build/Swipeable.types.d.ts.map +1 -1
  13. package/build/SwipeableActions.d.ts +1 -1
  14. package/build/SwipeableActions.d.ts.map +1 -1
  15. package/build/SwipeableActions.js +4 -2
  16. package/build/SwipeableActions.js.map +1 -1
  17. package/build/SwipeableView.d.ts +11 -2
  18. package/build/SwipeableView.d.ts.map +1 -1
  19. package/build/SwipeableView.js.map +1 -1
  20. package/build/constants.d.ts +0 -1
  21. package/build/constants.d.ts.map +1 -1
  22. package/build/constants.js +0 -1
  23. package/build/constants.js.map +1 -1
  24. package/build/index.d.ts +1 -1
  25. package/build/index.d.ts.map +1 -1
  26. package/build/index.js.map +1 -1
  27. package/build/nativeModuleUtils.d.ts +3 -1
  28. package/build/nativeModuleUtils.d.ts.map +1 -1
  29. package/build/nativeModuleUtils.js +20 -9
  30. package/build/nativeModuleUtils.js.map +1 -1
  31. package/build/resolveSwipeableActions.d.ts +18 -0
  32. package/build/resolveSwipeableActions.d.ts.map +1 -0
  33. package/build/resolveSwipeableActions.js +26 -0
  34. package/build/resolveSwipeableActions.js.map +1 -0
  35. package/docs/migration-v1.md +191 -0
  36. package/docs/support.md +90 -0
  37. package/ios/SwipeAnimationConfig.swift +32 -0
  38. package/ios/SwipePhysics.swift +418 -0
  39. package/ios/SwipeableModule.swift +34 -12
  40. package/ios/SwipeableView.swift +1062 -613
  41. package/jestSetup.js +17 -5
  42. package/package.json +11 -5
  43. package/src/Swipeable.tsx +147 -54
  44. package/src/Swipeable.types.ts +50 -9
  45. package/src/SwipeableActions.tsx +6 -3
  46. package/src/SwipeableView.tsx +10 -3
  47. package/src/constants.ts +0 -1
  48. package/src/index.ts +7 -0
  49. package/src/nativeModuleUtils.ts +19 -8
  50. package/src/resolveSwipeableActions.ts +37 -0
@@ -64,3 +64,421 @@ struct SwipePhysics {
64
64
  return min(abs(gestureVelocity) / normalizer, maxVelocity)
65
65
  }
66
66
  }
67
+
68
+ enum SwipeSide: String, CaseIterable {
69
+ case left
70
+ case right
71
+
72
+ var sign: CGFloat { self == .left ? 1 : -1 }
73
+ }
74
+
75
+ /// Gesture-owned completion survives width reconciliation, but not commands or rebinding.
76
+ enum SwipeAutoCloseState: Equatable {
77
+ case waiting(side: SwipeSide, deadline: TimeInterval)
78
+ case closing(side: SwipeSide)
79
+
80
+ var side: SwipeSide {
81
+ switch self {
82
+ case .waiting(let side, _), .closing(let side): return side
83
+ }
84
+ }
85
+
86
+ func remainingDelay(at now: TimeInterval) -> TimeInterval {
87
+ guard case .waiting(_, let deadline) = self else { return 0 }
88
+ return max(0, deadline - now)
89
+ }
90
+ }
91
+
92
+ struct SwipeReconciledState {
93
+ let openSide: SwipeSide?
94
+ let autoClose: SwipeAutoCloseState?
95
+ }
96
+
97
+ /// A released gesture retains its callback ownership until completion or cancellation.
98
+ /// Touch activation is deliberately independent: settling and auto-close have no active touch.
99
+ struct SwipeGestureCompletion {
100
+ private(set) var isPending = false
101
+ private var owner: [String: String]?
102
+
103
+ mutating func begin() { isPending = true; owner = nil }
104
+
105
+ mutating func begin(recyclingKey: String?, side: SwipeSide) {
106
+ begin()
107
+ var metadata = ["side": side.rawValue]
108
+ if let key = recyclingKey { metadata["recyclingKey"] = key }
109
+ owner = metadata
110
+ }
111
+
112
+ mutating func complete() { isPending = false; owner = nil }
113
+
114
+ /// Capture the abandoned owner before native work is reset. No public end is owed.
115
+ mutating func cancel(recyclingKey: String?, side: SwipeSide?) -> [String: String]? {
116
+ let capturedOwner = owner
117
+ guard invalidate() else { return nil }
118
+ if let capturedOwner = capturedOwner { return capturedOwner }
119
+ var metadata: [String: String] = [:]
120
+ if let key = recyclingKey { metadata["recyclingKey"] = key }
121
+ if let side = side { metadata["side"] = side.rawValue }
122
+ return metadata
123
+ }
124
+
125
+ @discardableResult
126
+ mutating func invalidate(preserving: Bool = false) -> Bool {
127
+ guard !preserving else { return false }
128
+ let cancelled = isPending
129
+ isPending = false
130
+ owner = nil
131
+ return cancelled
132
+ }
133
+ }
134
+
135
+ /// An immutable registry binding. Its identity changes only when a view is
136
+ /// registered again, never when an earlier command interrupts animation work.
137
+ final class SwipeCommandTarget<View: AnyObject> {
138
+ weak var value: View?
139
+ let identity: ObjectIdentifier
140
+
141
+ init(_ value: View) {
142
+ self.value = value
143
+ identity = ObjectIdentifier(value)
144
+ }
145
+
146
+ func perform(ifCurrent current: SwipeCommandTarget<View>?, _ command: (View) -> Void) {
147
+ guard self === current, let view = value else { return }
148
+ command(view)
149
+ }
150
+ }
151
+
152
+ /// Capture ownership at physical touch-down, before a pan can leave .possible.
153
+ /// Additional fingers cannot transfer an unfinished sequence to a new binding.
154
+ struct SwipeTouchBinding<Owner: AnyObject> {
155
+ private var touches: Set<ObjectIdentifier> = []
156
+ private var target: SwipeCommandTarget<Owner>?
157
+
158
+ mutating func begin<Touch: AnyObject>(_ newTouches: [Touch], target: SwipeCommandTarget<Owner>?) {
159
+ guard !newTouches.isEmpty else { return }
160
+ if touches.isEmpty { self.target = target }
161
+ touches.formUnion(newTouches.map { ObjectIdentifier($0) })
162
+ }
163
+
164
+ mutating func end<Touch: AnyObject>(_ endedTouches: [Touch]) {
165
+ touches.subtract(endedTouches.map { ObjectIdentifier($0) })
166
+ // Retain identity for a queued ended/cancelled action until the next down.
167
+ }
168
+
169
+ mutating func prune<Touch: AnyObject>(keeping activeTouches: [Touch]) {
170
+ touches.formIntersection(activeTouches.map { ObjectIdentifier($0) })
171
+ }
172
+
173
+ func isCurrent(_ current: SwipeCommandTarget<Owner>) -> Bool {
174
+ target === current && target?.value != nil
175
+ }
176
+ }
177
+
178
+ /// The keyed close intent of one binding. Only the view that holds a key can
179
+ /// persist that key's open state, so a close marks the views that hold the key,
180
+ /// not the key. `closedAt` is the last close or bind that makes an earlier open
181
+ /// intent stale. Read and write it only through SwipeOpenStateCache.
182
+ final class SwipeCloseMark {
183
+ fileprivate(set) var key: String?
184
+ fileprivate(set) var closedAt = 0
185
+ fileprivate var isTracked = false
186
+
187
+ init() {}
188
+ }
189
+
190
+ /// Only open targets are cached. Closing removes the FIFO entry as well as its value.
191
+ struct SwipeOpenStateCache {
192
+ private struct WeakMark {
193
+ weak var value: SwipeCloseMark?
194
+ }
195
+
196
+ let capacity: Int
197
+ private var sides: [String: SwipeSide] = [:]
198
+ private var order: [String] = []
199
+ // Each close takes the next sequence number. An open intent records the
200
+ // sequence it saw, so a close issued after it wins in every binding order.
201
+ private var closeSequence = 0
202
+ // One weak entry for each live binding: memory stays per live view.
203
+ private var marks: [WeakMark] = []
204
+
205
+ init(capacity: Int) {
206
+ self.capacity = capacity
207
+ }
208
+
209
+ func side(for key: String) -> SwipeSide? { sides[key] }
210
+
211
+ /// The intent an open records now. Read it on the registry queue.
212
+ var currentIntent: Int { closeSequence }
213
+
214
+ /// False if a close issued after the intent marked this binding.
215
+ func isCurrent(_ intent: Int, for mark: SwipeCloseMark) -> Bool {
216
+ intent >= mark.closedAt
217
+ }
218
+
219
+ var trackedMarkCount: Int { marks.count }
220
+
221
+ /// A queued close skips a binding that opened after the close command:
222
+ /// that open is the latest intent and wins.
223
+ static func closeApplies(_ close: Int, toOpenIntent openIntent: Int) -> Bool {
224
+ openIntent < close
225
+ }
226
+
227
+ /// The cached side and the intent it holds, read in one step.
228
+ func restore(for key: String) -> (side: SwipeSide?, intent: Int) {
229
+ (sides[key], closeSequence)
230
+ }
231
+
232
+ /// Moves a binding to its new key and reads that key's restore in one step.
233
+ /// The binding no longer holds the old key, so the old key's mark ends. An intent
234
+ /// stamped before the bind belongs to an earlier binding and cannot persist the new key.
235
+ mutating func bind(_ mark: SwipeCloseMark, to key: String?) -> (side: SwipeSide?, intent: Int)? {
236
+ if !mark.isTracked {
237
+ mark.isTracked = true
238
+ marks.removeAll { $0.value == nil }
239
+ marks.append(WeakMark(value: mark))
240
+ }
241
+ if mark.key != key {
242
+ mark.key = key
243
+ mark.closedAt = closeSequence
244
+ }
245
+ return key.map { restore(for: $0) }
246
+ }
247
+
248
+ private mutating func markLiveBindings(where holds: (SwipeCloseMark) -> Bool) {
249
+ marks.removeAll { $0.value == nil }
250
+ for entry in marks {
251
+ if let mark = entry.value, holds(mark) { mark.closedAt = closeSequence }
252
+ }
253
+ }
254
+
255
+ // Call while holding the registry queue: capture and the close must be atomic.
256
+ // The close marks every binding that holds the key, the registered one and
257
+ // any older one: no binding can restore or persist an earlier open.
258
+ mutating func captureCloseTarget<View: AnyObject>(
259
+ for key: String, current: SwipeCommandTarget<View>?
260
+ ) -> SwipeCommandTarget<View>? {
261
+ closeSequence += 1
262
+ markLiveBindings { $0.key == key }
263
+ set(nil, for: key)
264
+ return current?.value != nil ? current : nil
265
+ }
266
+
267
+ /// Like captureCloseTarget for every key, bound or not, mounted or not.
268
+ mutating func closeAll() {
269
+ closeSequence += 1
270
+ markLiveBindings { _ in true }
271
+ sides.removeAll()
272
+ order.removeAll()
273
+ }
274
+
275
+ /// An open that a later close made stale persists closed. A binding that
276
+ /// no longer holds the key cannot persist it open.
277
+ mutating func persist(_ side: SwipeSide?, for key: String, from mark: SwipeCloseMark, intent: Int) {
278
+ set(mark.key == key && isCurrent(intent, for: mark) ? side : nil, for: key)
279
+ }
280
+
281
+ mutating func set(_ side: SwipeSide?, for key: String) {
282
+ guard let side = side else {
283
+ sides.removeValue(forKey: key)
284
+ order.removeAll { $0 == key }
285
+ return
286
+ }
287
+ if sides[key] == nil { order.append(key) }
288
+ sides[key] = side
289
+ while order.count > max(0, capacity) {
290
+ sides.removeValue(forKey: order.removeFirst())
291
+ }
292
+ }
293
+ }
294
+
295
+ /// Only Fabric's mount/unmount hooks participate in role assignment.
296
+ struct SwipeReactChildren<Child: AnyObject> {
297
+ private(set) var views: [Child] = []
298
+
299
+ mutating func mount(_ child: Child, at index: Int) {
300
+ views.removeAll { $0 === child }
301
+ views.insert(child, at: min(max(index, 0), views.count))
302
+ }
303
+
304
+ mutating func unmount(_ child: Child, at index: Int) {
305
+ if views.indices.contains(index), views[index] === child {
306
+ views.remove(at: index)
307
+ } else {
308
+ views.removeAll { $0 === child }
309
+ }
310
+ }
311
+ }
312
+
313
+ struct SwipeChildRoles: Equatable {
314
+ let left: Int?
315
+ let right: Int?
316
+ let content: Int
317
+ }
318
+
319
+ protocol SwipeChildVisibility: AnyObject {
320
+ var isHidden: Bool { get set }
321
+ var isUserInteractionEnabled: Bool { get set }
322
+ var accessibilityElementsHidden: Bool { get set }
323
+ }
324
+
325
+ /// Return native-owned rail flags before Fabric reuses a shell as arbitrary content.
326
+ /// Keep the caller's original values; unowned content must not be forcibly unhidden.
327
+ final class SwipeRailVisibility {
328
+ private struct State {
329
+ weak var view: SwipeChildVisibility?
330
+ let isHidden: Bool
331
+ let isUserInteractionEnabled: Bool
332
+ let accessibilityElementsHidden: Bool
333
+
334
+ init(_ view: SwipeChildVisibility) {
335
+ self.view = view
336
+ isHidden = view.isHidden
337
+ isUserInteractionEnabled = view.isUserInteractionEnabled
338
+ accessibilityElementsHidden = view.accessibilityElementsHidden
339
+ }
340
+
341
+ func restore() {
342
+ view?.isHidden = isHidden
343
+ view?.isUserInteractionEnabled = isUserInteractionEnabled
344
+ view?.accessibilityElementsHidden = accessibilityElementsHidden
345
+ }
346
+ }
347
+
348
+ private var states: [ObjectIdentifier: State] = [:]
349
+
350
+ func setRevealed(_ revealed: Bool, for view: SwipeChildVisibility) {
351
+ let identity = ObjectIdentifier(view)
352
+ if states[identity]?.view !== view { states[identity] = State(view) }
353
+ view.isHidden = !revealed
354
+ view.isUserInteractionEnabled = revealed
355
+ view.accessibilityElementsHidden = !revealed
356
+ }
357
+
358
+ func release(_ view: SwipeChildVisibility) {
359
+ guard let state = states.removeValue(forKey: ObjectIdentifier(view)),
360
+ state.view === view else { return }
361
+ state.restore()
362
+ }
363
+
364
+ func releaseAll() {
365
+ for state in states.values { state.restore() }
366
+ states.removeAll()
367
+ }
368
+ }
369
+
370
+ /// Threshold state is independent of the feedback preference and the animation clock.
371
+ struct SwipeFeedbackState {
372
+ private(set) var isActive = false
373
+ private(set) var hasCrossedThreshold = false
374
+ private var wasAboveThreshold = false
375
+
376
+ mutating func activate(progress: CGFloat, threshold: CGFloat) {
377
+ activate(isOpening: progress > threshold)
378
+ }
379
+
380
+ mutating func activate(isOpening: Bool) {
381
+ isActive = true
382
+ hasCrossedThreshold = false
383
+ wasAboveThreshold = isOpening
384
+ }
385
+
386
+ mutating func update(progress: CGFloat, threshold: CGFloat) -> Bool {
387
+ update(isOpening: progress > threshold)
388
+ }
389
+
390
+ mutating func update(isOpening: Bool) -> Bool {
391
+ guard isActive else { return false }
392
+ let above = isOpening
393
+ let firstCrossing = above && !wasAboveThreshold && !hasCrossedThreshold
394
+ wasAboveThreshold = above
395
+ if firstCrossing { hasCrossedThreshold = true }
396
+ return firstCrossing
397
+ }
398
+
399
+ mutating func reset() {
400
+ isActive = false
401
+ hasCrossedThreshold = false
402
+ wasAboveThreshold = false
403
+ }
404
+ }
405
+
406
+ extension SwipePhysics {
407
+ static func reconcileTwoSidedState(
408
+ openSide: SwipeSide?, autoCloseState: SwipeAutoCloseState?,
409
+ leftWidth: CGFloat, rightWidth: CGFloat,
410
+ wasGestureEnabled: Bool, gestureEnabled: Bool, autoClose: Bool
411
+ ) -> SwipeReconciledState {
412
+ // Disabling is a transition. A subsequent imperative open remains valid while disabled.
413
+ guard !wasGestureEnabled || gestureEnabled else {
414
+ return SwipeReconciledState(openSide: nil, autoClose: nil)
415
+ }
416
+ func enabled(_ side: SwipeSide) -> Bool {
417
+ validWidth(side == .left ? leftWidth : rightWidth) > 0
418
+ }
419
+ let restored = openSide.flatMap { enabled($0) ? $0 : nil }
420
+ let pending = autoCloseState.flatMap { state -> SwipeAutoCloseState? in
421
+ guard autoClose, enabled(state.side) else { return nil }
422
+ switch state {
423
+ case .waiting: return restored == state.side ? state : nil
424
+ case .closing: return restored == nil ? state : nil
425
+ }
426
+ }
427
+ return SwipeReconciledState(openSide: restored, autoClose: pending)
428
+ }
429
+
430
+ static func shouldRestoreAfterDetach(hasPendingInteraction: Bool) -> Bool {
431
+ !hasPendingInteraction
432
+ }
433
+
434
+ static func shouldCloseForCloseAll(
435
+ isOpen: Bool, isAnimating: Bool, hasPendingInteraction: Bool
436
+ ) -> Bool {
437
+ // A closing animator can own a close-then-open continuation while logically closed.
438
+ isOpen || isAnimating || hasPendingInteraction
439
+ }
440
+
441
+ static func validWidth(_ width: CGFloat) -> CGFloat {
442
+ width.isFinite && width > 0 ? width : 0
443
+ }
444
+
445
+ static func childRoles(count: Int) -> SwipeChildRoles? {
446
+ guard count == 3 else { return nil }
447
+ return SwipeChildRoles(left: 0, right: 1, content: 2)
448
+ }
449
+
450
+ static func latchedSide(
451
+ openSide: SwipeSide?, translation: CGFloat, leftWidth: CGFloat, rightWidth: CGFloat
452
+ ) -> SwipeSide? {
453
+ let candidate: SwipeSide?
454
+ if let openSide = openSide {
455
+ candidate = openSide
456
+ } else {
457
+ candidate = translation > 0 ? .left : (translation < 0 ? .right : nil)
458
+ }
459
+ guard let side = candidate,
460
+ validWidth(side == .left ? leftWidth : rightWidth) > 0 else { return nil }
461
+ return side
462
+ }
463
+
464
+ static func constrainToSide(translation: CGFloat, actionsWidth: CGFloat, side: SwipeSide) -> CGFloat {
465
+ guard validWidth(actionsWidth) > 0 else { return 0 }
466
+ return applyRubberBand(
467
+ translation: side == .left ? max(0, translation) : min(0, translation),
468
+ actionsWidth: actionsWidth, isLeading: side == .left
469
+ )
470
+ }
471
+
472
+ static func shouldOpenOnSide(
473
+ translation: CGFloat, velocity: CGFloat, friction: CGFloat,
474
+ actionsWidth: CGFloat, threshold: CGFloat, side: SwipeSide, isCurrentlyOpen: Bool
475
+ ) -> Bool {
476
+ // Translation is relative gesture displacement. The legacy snap helper accounts
477
+ // for the starting-open state; adding the row's start offset here counts it twice.
478
+ shouldSnapToOpen(
479
+ projectedTranslation: projectFinalPosition(translation: translation, velocity: velocity, friction: friction),
480
+ actionsWidth: actionsWidth, threshold: threshold,
481
+ isLeading: side == .left, isCurrentlyOpen: isCurrentlyOpen
482
+ )
483
+ }
484
+ }
@@ -25,16 +25,38 @@ public class SwipeableModule: Module {
25
25
  SwipeableView.getOpenState(for: key)
26
26
  }
27
27
 
28
+ Function("openByKeyOnSide") { (key: String, side: String) in
29
+ SwipeableView.openByKeyOnSide(key, side: side)
30
+ }
31
+
32
+ Function("getOpenSideByKey") { (key: String) -> String? in
33
+ SwipeableView.getOpenSide(for: key)
34
+ }
35
+
28
36
  View(SwipeableView.self) {
29
- // Props
30
- Prop("actionsWidth") { (view: SwipeableView, width: CGFloat) in
31
- view.actionsWidth = width
37
+
38
+ Prop("leftActionsWidth") { (view: SwipeableView, width: CGFloat) in
39
+ view.leftActionsWidth = width
40
+ }
41
+
42
+ Prop("rightActionsWidth") { (view: SwipeableView, width: CGFloat) in
43
+ view.rightActionsWidth = width
44
+ }
45
+
46
+ Prop("appearance") { (view: SwipeableView, value: String) in
47
+ view.appearance = value
32
48
  }
33
49
 
34
- Prop("actionsPosition") { (view: SwipeableView, position: String) in
35
- view.actionsPosition = position
50
+ Prop("hapticFeedback") { (view: SwipeableView, value: Bool?) in
51
+ view.hapticFeedback = value
36
52
  }
37
53
 
54
+ OnViewDidUpdateProps { (view: SwipeableView) in
55
+ view.commitProps()
56
+ }
57
+
58
+ // Props
59
+
38
60
  Prop("friction") { (view: SwipeableView, friction: CGFloat) in
39
61
  view.friction = friction
40
62
  }
@@ -64,24 +86,24 @@ public class SwipeableModule: Module {
64
86
  }
65
87
 
66
88
  // Events
67
- Events("onSwipeProgress", "onSwipeStart", "onSwipeStateChange", "onSwipeEnd")
89
+ Events("onSwipeProgress", "onSwipeStart", "onSwipeStateChange", "onSwipeEnd", "onActionsNeeded", "onGestureCancelled")
68
90
 
69
91
  // Imperative methods
70
92
  AsyncFunction("close") { (view: SwipeableView, animated: Bool?) in
71
- DispatchQueue.main.async {
72
- view.close(animated: animated ?? true)
93
+ view.enqueueCommand {
94
+ $0.close(animated: animated ?? true)
73
95
  }
74
96
  }
75
97
 
76
98
  AsyncFunction("open") { (view: SwipeableView) in
77
- DispatchQueue.main.async {
78
- view.open()
99
+ view.enqueueCommand {
100
+ $0.open()
79
101
  }
80
102
  }
81
103
 
82
104
  AsyncFunction("cancel") { (view: SwipeableView) in
83
- DispatchQueue.main.async {
84
- view.cancelGesture()
105
+ view.enqueueCommand {
106
+ $0.cancelGesture()
85
107
  }
86
108
  }
87
109