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