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