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
|
@@ -12,11 +12,14 @@ import androidx.collection.LruCache
|
|
|
12
12
|
import androidx.dynamicanimation.animation.DynamicAnimation
|
|
13
13
|
import androidx.dynamicanimation.animation.SpringAnimation
|
|
14
14
|
import androidx.dynamicanimation.animation.SpringForce
|
|
15
|
+
import com.facebook.react.uimanager.PointerEvents
|
|
15
16
|
import com.facebook.react.uimanager.events.NativeGestureUtil
|
|
17
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
16
18
|
import expo.modules.kotlin.AppContext
|
|
17
19
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
18
20
|
import expo.modules.kotlin.views.ExpoView
|
|
19
21
|
import java.lang.ref.WeakReference
|
|
22
|
+
import java.util.WeakHashMap
|
|
20
23
|
import kotlin.math.abs
|
|
21
24
|
|
|
22
25
|
class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
@@ -26,7 +29,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
26
29
|
private const val GESTURE_TIMEOUT_MS = 5000L
|
|
27
30
|
|
|
28
31
|
/** Thread-safe LRU cache for open state - prevents unbounded memory growth */
|
|
29
|
-
private val openStateCache = LruCache<String,
|
|
32
|
+
private val openStateCache = LruCache<String, SwipeSide>(MAX_CACHE_SIZE)
|
|
30
33
|
|
|
31
34
|
/** View registry for static method access by recyclingKey */
|
|
32
35
|
private val viewRegistry = mutableMapOf<String, WeakReference<SwipeableView>>()
|
|
@@ -34,11 +37,25 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
34
37
|
/** Lock for thread-safe registry access */
|
|
35
38
|
private val registryLock = Any()
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Close ordering (guarded by registryLock). Each close takes the next sequence
|
|
42
|
+
* number. An open intent records the sequence it saw, so a close issued after it
|
|
43
|
+
* wins in every binding order.
|
|
44
|
+
*/
|
|
45
|
+
private var closeSequence = 0L
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every live view, registered or not (guarded by registryLock). Only a view that
|
|
49
|
+
* holds a key can persist that key's open state, so a close marks the views that
|
|
50
|
+
* hold the key, not the key. Memory stays per live view.
|
|
51
|
+
*/
|
|
52
|
+
private val liveViews = WeakHashMap<SwipeableView, Unit>()
|
|
53
|
+
|
|
54
|
+
// LruCache synchronizes access internally; absence represents closed.
|
|
55
|
+
private fun getOpenState(key: String): Boolean = openStateCache[key] != null
|
|
56
|
+
|
|
57
|
+
private fun setOpenState(key: String, side: SwipeSide?) {
|
|
58
|
+
if (side == null) openStateCache.remove(key) else openStateCache.put(key, side)
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
private fun getView(key: String): SwipeableView? = synchronized(registryLock) {
|
|
@@ -49,44 +66,117 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
49
66
|
viewRegistry[key] = WeakReference(view)
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
private fun unregisterView(key: String) = synchronized(registryLock) {
|
|
53
|
-
viewRegistry.remove(key)
|
|
69
|
+
private fun unregisterView(view: SwipeableView, key: String) = synchronized(registryLock) {
|
|
70
|
+
if (viewRegistry[key]?.get() === view) viewRegistry.remove(key)
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
private fun getAllViews(): List<SwipeableView> = synchronized(registryLock) {
|
|
57
74
|
viewRegistry.values.mapNotNull { it.get() }
|
|
58
75
|
}
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
private fun currentIntent(): Long = synchronized(registryLock) { closeSequence }
|
|
78
|
+
|
|
79
|
+
private fun trackLiveView(view: SwipeableView) = synchronized(registryLock) {
|
|
80
|
+
liveViews[view] = Unit
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Call while holding registryLock.
|
|
84
|
+
private fun markHolders(sequence: Long, holds: (SwipeableView) -> Boolean) {
|
|
85
|
+
liveViews.keys.forEach { if (holds(it)) it.closedAtSequence = sequence }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The cached side and the intent it holds, read in one step. */
|
|
89
|
+
private fun readRestore(key: String): Pair<SwipeSide?, Long> = synchronized(registryLock) {
|
|
90
|
+
openStateCache[key] to closeSequence
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Moves a view to its new key and reads that key's restore in one step. The view
|
|
95
|
+
* no longer holds the old key, so the old key's mark ends. An intent stamped
|
|
96
|
+
* before the bind belongs to an earlier binding and cannot persist the new key.
|
|
97
|
+
*/
|
|
98
|
+
private fun bindRestore(view: SwipeableView, key: String?): Pair<SwipeSide?, Long>? =
|
|
99
|
+
synchronized(registryLock) {
|
|
100
|
+
if (view.heldKey != key) {
|
|
101
|
+
view.heldKey = key
|
|
102
|
+
view.closedAtSequence = closeSequence
|
|
103
|
+
}
|
|
104
|
+
key?.let(::readRestore)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Test seam: runs between a keyed open's registry read and its post. */
|
|
108
|
+
internal var afterOpenCapture: (() -> Unit)? = null
|
|
109
|
+
|
|
110
|
+
// The intent is read with the target and its binding, so a later close marks that
|
|
111
|
+
// target and a rebind before the post drops the open like any queued command.
|
|
112
|
+
private fun postOpen(key: String, side: SwipeSide?) {
|
|
113
|
+
val (view, generation, intent) = synchronized(registryLock) {
|
|
114
|
+
val view = getView(key)
|
|
115
|
+
Triple(view, view?.bindingGeneration, closeSequence)
|
|
64
116
|
}
|
|
117
|
+
if (view == null || generation == null) return
|
|
118
|
+
afterOpenCapture?.invoke()
|
|
119
|
+
view.postCommand(generation, key) { view.openCommand(side, intent) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Keep the existing command signatures unchanged.
|
|
123
|
+
fun openByKey(key: String) = postOpen(key, null)
|
|
124
|
+
|
|
125
|
+
fun openByKeyOnSide(key: String, side: SwipeSide?) {
|
|
126
|
+
if (side == null) return
|
|
127
|
+
postOpen(key, side)
|
|
65
128
|
}
|
|
66
129
|
|
|
130
|
+
fun getOpenSideByKey(key: String): String? = openStateCache[key]?.value
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Records a keyed close intent and reads the bound view in one step. Mark every
|
|
134
|
+
* view that holds the key, the registered one and any older one, and clear the
|
|
135
|
+
* cache: a view that leaves the key cannot persist an earlier open, and a view
|
|
136
|
+
* that takes it reads closed.
|
|
137
|
+
*/
|
|
138
|
+
private fun captureCloseTarget(key: String): Pair<SwipeableView?, Long> =
|
|
139
|
+
synchronized(registryLock) {
|
|
140
|
+
val sequence = ++closeSequence
|
|
141
|
+
markHolders(sequence) { it.heldKey == key }
|
|
142
|
+
setOpenState(key, null)
|
|
143
|
+
getView(key) to sequence
|
|
144
|
+
}
|
|
145
|
+
|
|
67
146
|
fun closeByKey(key: String, animated: Boolean = true) {
|
|
68
|
-
|
|
69
|
-
|
|
147
|
+
val (view, sequence) = captureCloseTarget(key)
|
|
148
|
+
if (view == null) return
|
|
149
|
+
view.postCommand {
|
|
150
|
+
// An open issued after this command is the latest intent and wins.
|
|
151
|
+
if (view.recyclingKey == key && view.openIntent < sequence) view.close(animated)
|
|
70
152
|
}
|
|
71
153
|
}
|
|
72
154
|
|
|
155
|
+
/** A keyed cancel clears the cached open intent like a keyed close, mounted or not. */
|
|
73
156
|
fun cancelByKey(key: String) {
|
|
74
|
-
|
|
75
|
-
|
|
157
|
+
val view = captureCloseTarget(key).first ?: return
|
|
158
|
+
view.postCommand {
|
|
159
|
+
if (view.recyclingKey == key) view.cancelGesture()
|
|
76
160
|
}
|
|
77
161
|
}
|
|
78
162
|
|
|
79
|
-
/**
|
|
163
|
+
/** Include pending interactions and closing continuations, not only settled open rows. */
|
|
80
164
|
fun closeAll(animated: Boolean = true) {
|
|
165
|
+
val sequence = synchronized(registryLock) {
|
|
166
|
+
// Like closeByKey for every key, bound or not, mounted or not.
|
|
167
|
+
val sequence = ++closeSequence
|
|
168
|
+
markHolders(sequence) { true }
|
|
169
|
+
openStateCache.evictAll()
|
|
170
|
+
sequence
|
|
171
|
+
}
|
|
81
172
|
getAllViews().forEach { view ->
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
if (view.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
view.post { view.close(animated = false) }
|
|
173
|
+
view.postCommand {
|
|
174
|
+
// An open issued after this command is the latest intent and wins.
|
|
175
|
+
if (view.openIntent >= sequence) return@postCommand
|
|
176
|
+
// Earlier commands for this binding may still be queued.
|
|
177
|
+
val activeInteraction = (view.isAnimating || view.isDragging ||
|
|
178
|
+
view.directGesturePending || view.restorePending)
|
|
179
|
+
if (!animated || view.isOpen || activeInteraction) view.close(animated)
|
|
90
180
|
}
|
|
91
181
|
}
|
|
92
182
|
}
|
|
@@ -102,30 +192,31 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
102
192
|
|
|
103
193
|
private val density = context.resources.displayMetrics.density
|
|
104
194
|
|
|
105
|
-
|
|
195
|
+
private val actionsWidthPx: Float
|
|
196
|
+
get() = activeSide?.let(::widthFor) ?: 0f
|
|
197
|
+
|
|
198
|
+
var leftActionsWidth: Float = 0f
|
|
106
199
|
set(value) {
|
|
107
|
-
val
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
syncOpenStateToCurrentWidth()
|
|
113
|
-
}
|
|
114
|
-
requestLayout()
|
|
200
|
+
val width = validWidth(value)
|
|
201
|
+
if (field == width) return
|
|
202
|
+
field = width
|
|
203
|
+
leftWidthPx = width * density
|
|
204
|
+
configurationChanged()
|
|
115
205
|
}
|
|
116
206
|
|
|
117
|
-
|
|
118
|
-
get() = actionsWidth * density
|
|
119
|
-
|
|
120
|
-
var actionsPosition: String = "right"
|
|
207
|
+
var rightActionsWidth: Float = 0f
|
|
121
208
|
set(value) {
|
|
122
|
-
val
|
|
123
|
-
if (field ==
|
|
124
|
-
field =
|
|
125
|
-
|
|
209
|
+
val width = validWidth(value)
|
|
210
|
+
if (field == width) return
|
|
211
|
+
field = width
|
|
212
|
+
rightWidthPx = width * density
|
|
213
|
+
configurationChanged()
|
|
126
214
|
}
|
|
127
215
|
|
|
128
|
-
|
|
216
|
+
private var leftWidthPx = 0f
|
|
217
|
+
private var rightWidthPx = 0f
|
|
218
|
+
|
|
219
|
+
val isLeading: Boolean get() = activeSide == SwipeSide.LEFT
|
|
129
220
|
|
|
130
221
|
var friction: Float = 1.0f
|
|
131
222
|
set(value) { field = value.coerceIn(0f, 1f) }
|
|
@@ -137,13 +228,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
137
228
|
set(value) {
|
|
138
229
|
if (field == value) return
|
|
139
230
|
field = value
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
abortActiveGesture()
|
|
143
|
-
if (isOpen || currentTranslation != 0f) {
|
|
144
|
-
close(animated = false)
|
|
145
|
-
}
|
|
146
|
-
}
|
|
231
|
+
pendingGestureDisable = !value
|
|
232
|
+
configurationChanged()
|
|
147
233
|
}
|
|
148
234
|
|
|
149
235
|
/**
|
|
@@ -155,27 +241,17 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
155
241
|
* gesture activation (before any horizontal motion is detected).
|
|
156
242
|
*/
|
|
157
243
|
fun cancelGesture() {
|
|
158
|
-
abortActiveGesture()
|
|
159
244
|
isCurrentTouchCancelled = true
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
245
|
+
velocityTracker?.recycle()
|
|
246
|
+
velocityTracker = null
|
|
247
|
+
// Cancel even at zero: a side may be pending activation, restoration,
|
|
248
|
+
// or the first spring frame. Do not apply a pending restore first.
|
|
249
|
+
closeInternal(animated = false)
|
|
164
250
|
}
|
|
165
251
|
|
|
166
|
-
private fun
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (isDragging || isGestureActivated) {
|
|
171
|
-
isDragging = false
|
|
172
|
-
isGestureActivated = false
|
|
173
|
-
gestureStartTranslation = 0f
|
|
174
|
-
cancelGestureTimeout()
|
|
175
|
-
stopProgressUpdates()
|
|
176
|
-
velocityTracker?.recycle()
|
|
177
|
-
velocityTracker = null
|
|
178
|
-
}
|
|
252
|
+
private fun suppressCancelledTouch(event: MotionEvent): Boolean {
|
|
253
|
+
if (event.actionMasked == MotionEvent.ACTION_DOWN) isCurrentTouchCancelled = false
|
|
254
|
+
return isCurrentTouchCancelled
|
|
179
255
|
}
|
|
180
256
|
|
|
181
257
|
var dragOffsetFromEdge: Float = 0f
|
|
@@ -189,38 +265,41 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
189
265
|
private val dragOffsetFromEdgePx: Float
|
|
190
266
|
get() = dragOffsetFromEdge * density
|
|
191
267
|
|
|
192
|
-
/**
|
|
268
|
+
/** A rebind invalidates old work before restoring from the complete prop transaction. */
|
|
193
269
|
var recyclingKey: String? = null
|
|
194
270
|
set(value) {
|
|
195
271
|
val oldValue = field
|
|
196
272
|
if (oldValue == value) return
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// Thread-safe read of new key's state
|
|
200
|
-
val shouldOpen = value?.let { getOpenState(it) } ?: false
|
|
201
|
-
pendingShouldOpen = shouldOpen
|
|
202
|
-
|
|
203
|
-
// Thread-safe write of old key's state and unregister
|
|
273
|
+
val cancelled = takeGestureCancellation()
|
|
274
|
+
bindingGeneration++
|
|
204
275
|
if (oldValue != null) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Thread-safe register with new key
|
|
210
|
-
if (value != null) {
|
|
211
|
-
registerView(this, value)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
close(animated = false)
|
|
215
|
-
|
|
216
|
-
// Apply using local shouldOpen (pendingShouldOpen was cleared by close).
|
|
217
|
-
// Set recycleSnapPending so onViewAdded can position actions immediately
|
|
218
|
-
// when React renders them (isOpenByKey initializes hasActionsRendered=true
|
|
219
|
-
// before snapToOpen runs on the next frame).
|
|
220
|
-
recycleSnapPending = shouldOpen
|
|
221
|
-
if (shouldOpen) {
|
|
222
|
-
post { snapToOpen() }
|
|
276
|
+
// An abandoned auto-close wait cannot resume, so its transient open must not restore.
|
|
277
|
+
persistOpenSide(if (isOpen && autoCloseDeadline == null) openSide else null, oldValue)
|
|
278
|
+
unregisterView(this, oldValue)
|
|
223
279
|
}
|
|
280
|
+
// The view stops holding the old key: its mark moves with the cache read.
|
|
281
|
+
val restored = bindRestore(this, value)
|
|
282
|
+
// A rebind consumes even a not-yet-activated physical sequence.
|
|
283
|
+
// cancelActiveWork clears activation, so suppress before clearing it.
|
|
284
|
+
isCurrentTouchCancelled = true
|
|
285
|
+
velocityTracker?.recycle()
|
|
286
|
+
velocityTracker = null
|
|
287
|
+
cancelActiveWork()
|
|
288
|
+
isOpen = false
|
|
289
|
+
openSide = null
|
|
290
|
+
animationSide = null
|
|
291
|
+
resetPosition()
|
|
292
|
+
requestedActions.clear()
|
|
293
|
+
observedSlotCounts.clear()
|
|
294
|
+
field = value
|
|
295
|
+
value?.let { registerView(this, it) }
|
|
296
|
+
pendingRestoreSide = restored?.first
|
|
297
|
+
pendingRestoreIntent = restored?.second ?: 0L
|
|
298
|
+
restorePending = true
|
|
299
|
+
configurationChanged()
|
|
300
|
+
// Finish registry bookkeeping before a synchronous callback can issue
|
|
301
|
+
// another command; the payload still belongs to the pre-rebind start.
|
|
302
|
+
if (cancelled != null) onGestureCancelled(cancelled)
|
|
224
303
|
}
|
|
225
304
|
|
|
226
305
|
private var currentTranslation: Float = 0f
|
|
@@ -230,26 +309,50 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
230
309
|
private var isAnimating: Boolean = false
|
|
231
310
|
private var gestureStartTranslation: Float = 0f
|
|
232
311
|
private var isGestureActivated: Boolean = false
|
|
312
|
+
// Release ends touch activation, not ownership of the eventual completion.
|
|
313
|
+
private var gestureCompletionPending = false
|
|
314
|
+
private var gestureCompletionMetadata: Map<String, Any>? = null
|
|
233
315
|
private var lastEmittedProgress: Float = -1f
|
|
234
|
-
private var pendingShouldOpen: Boolean = false
|
|
235
|
-
/** Tracks pending snapToOpen from recyclingKey setter. Unlike pendingShouldOpen,
|
|
236
|
-
* this survives close(animated:false) so onViewAdded can position actions correctly. */
|
|
237
|
-
private var recycleSnapPending: Boolean = false
|
|
238
316
|
private var lastEmittedWillEndState: String? = null
|
|
239
317
|
|
|
240
|
-
|
|
241
|
-
private var
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
private var
|
|
318
|
+
private var openSide: SwipeSide? = null
|
|
319
|
+
private var gestureSide: SwipeSide? = null
|
|
320
|
+
private var animationSide: SwipeSide? = null
|
|
321
|
+
private var directGesturePending = false
|
|
322
|
+
private var workGeneration = 0
|
|
323
|
+
// Commands in the same binding execute in order, even as they cancel animations.
|
|
324
|
+
@Volatile private var bindingGeneration = 0L
|
|
325
|
+
private var configurationDirty = false
|
|
326
|
+
private var pendingGestureDisable = false
|
|
327
|
+
private var pendingRestoreSide: SwipeSide? = null
|
|
328
|
+
private var pendingRestoreIntent = 0L
|
|
329
|
+
// The close sequence this view's open intent saw. A later keyed close makes it stale.
|
|
330
|
+
private var openIntent = 0L
|
|
331
|
+
// The key this view holds, and the last close or bind that makes an earlier open
|
|
332
|
+
// intent stale for it (guarded by registryLock).
|
|
333
|
+
private var heldKey: String? = null
|
|
334
|
+
private var closedAtSequence = 0L
|
|
335
|
+
private var restorePending = false
|
|
336
|
+
private var restoreCacheOnAttach = false
|
|
337
|
+
private val requestedActions = mutableSetOf<SwipeSide>()
|
|
338
|
+
private val observedSlotCounts = mutableMapOf<SwipeSide, Int>()
|
|
339
|
+
private val configurationRunnable = Runnable { applyPendingConfiguration() }
|
|
340
|
+
|
|
341
|
+
private val activeSide: SwipeSide?
|
|
342
|
+
get() = gestureSide ?: animationSide ?: openSide ?: defaultOpenSide()
|
|
245
343
|
|
|
246
344
|
private var contentView: View? = null
|
|
247
|
-
private var
|
|
345
|
+
private var leftActionsView: View? = null
|
|
346
|
+
private var rightActionsView: View? = null
|
|
347
|
+
private val actionsView: View?
|
|
348
|
+
get() = railFor(activeSide)
|
|
248
349
|
|
|
249
350
|
private val onSwipeProgress by EventDispatcher<Map<String, Any>>()
|
|
250
351
|
private val onSwipeStart by EventDispatcher<Map<String, Any>>()
|
|
251
352
|
private val onSwipeStateChange by EventDispatcher<Map<String, Any>>()
|
|
252
353
|
private val onSwipeEnd by EventDispatcher<Map<String, Any>>()
|
|
354
|
+
private val onActionsNeeded by EventDispatcher<Map<String, Any>>()
|
|
355
|
+
private val onGestureCancelled by EventDispatcher<Map<String, Any>>()
|
|
253
356
|
|
|
254
357
|
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
255
358
|
private var velocityTracker: VelocityTracker? = null
|
|
@@ -273,6 +376,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
273
376
|
|
|
274
377
|
// Auto-close runnable for tracking and cancellation
|
|
275
378
|
private var autoCloseRunnable: Runnable? = null
|
|
379
|
+
private var autoCloseDeadline: Long? = null
|
|
380
|
+
private var autoCloseClosingSide: SwipeSide? = null
|
|
276
381
|
|
|
277
382
|
// Track when a child (e.g. RNGH gesture handler) claims the touch
|
|
278
383
|
private var childRequestedDisallowIntercept: Boolean = false
|
|
@@ -313,6 +418,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
313
418
|
}
|
|
314
419
|
|
|
315
420
|
init {
|
|
421
|
+
trackLiveView(this)
|
|
316
422
|
clipChildren = false
|
|
317
423
|
clipToPadding = false
|
|
318
424
|
setWillNotDraw(false)
|
|
@@ -334,67 +440,30 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
334
440
|
override fun onViewAdded(child: View?) {
|
|
335
441
|
super.onViewAdded(child)
|
|
336
442
|
child ?: return
|
|
337
|
-
|
|
338
|
-
val wasActionsNil = actionsView == null
|
|
339
|
-
val wasContentNil = contentView == null
|
|
340
|
-
|
|
341
|
-
// With lazy Actions rendering:
|
|
342
|
-
// - When Actions not mounted: only content at index 0
|
|
343
|
-
// - When Actions mounted: actions at index 0, content at index 1
|
|
344
|
-
// Always re-evaluate all children since React may add them in any order
|
|
345
|
-
if (childCount == 1) {
|
|
346
|
-
// Only one child - it's content (Actions not mounted yet)
|
|
347
|
-
contentView = getChildAt(0)
|
|
348
|
-
actionsView = null
|
|
349
|
-
} else if (childCount >= 2) {
|
|
350
|
-
// Always check both positions - React may have reordered children
|
|
351
|
-
actionsView = getChildAt(0)
|
|
352
|
-
contentView = getChildAt(1)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (wasContentNil && contentView != null && (isOpen || currentTranslation != 0f)) {
|
|
356
|
-
contentView?.translationX = currentTranslation
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
if (wasActionsNil && actionsView != null) {
|
|
360
|
-
// Prevent child clipping: Yoga lays out children for absoluteFill (full parent width)
|
|
361
|
-
// but our onLayout overrides the actionsView bounds to actionsWidth. Without this,
|
|
362
|
-
// children positioned by Yoga's flex-end would be clipped beyond the narrower bounds.
|
|
363
|
-
(actionsView as? ViewGroup)?.clipChildren = false
|
|
364
|
-
(actionsView as? ViewGroup)?.clipToPadding = false
|
|
365
|
-
|
|
366
|
-
if (isOpen || isAnimating) {
|
|
367
|
-
actionsView?.visibility = View.VISIBLE
|
|
368
|
-
actionsView?.translationX = 0f
|
|
369
|
-
}
|
|
370
|
-
else if (isDragging) {
|
|
371
|
-
actionsView?.visibility = View.VISIBLE
|
|
372
|
-
updateActionsTransform()
|
|
373
|
-
}
|
|
374
|
-
else if (pendingShouldOpen) {
|
|
375
|
-
pendingShouldOpen = false
|
|
376
|
-
post { snapToOpen() }
|
|
377
|
-
}
|
|
378
|
-
else if (recycleSnapPending) {
|
|
379
|
-
// Actions were rendered by React (via isOpenByKey) before native snapToOpen ran.
|
|
380
|
-
// Position them immediately so they're visible when UIAutomator checks.
|
|
381
|
-
recycleSnapPending = false
|
|
382
|
-
actionsView?.visibility = View.VISIBLE
|
|
383
|
-
actionsView?.translationX = 0f
|
|
384
|
-
}
|
|
385
|
-
}
|
|
443
|
+
reconcileSideChildren()
|
|
386
444
|
}
|
|
387
445
|
|
|
388
446
|
override fun onViewRemoved(child: View?) {
|
|
389
447
|
super.onViewRemoved(child)
|
|
390
|
-
if (child ===
|
|
391
|
-
|
|
392
|
-
}
|
|
448
|
+
if (child === leftActionsView || child === rightActionsView) {
|
|
449
|
+
(child as? ReactViewGroup)?.pointerEvents = PointerEvents.AUTO
|
|
450
|
+
}
|
|
451
|
+
if (child === contentView) {
|
|
393
452
|
contentView = null
|
|
453
|
+
isCurrentTouchCancelled = true
|
|
454
|
+
if (!cancelActiveWork()) return
|
|
455
|
+
// The drag offset left with the removed content. Replacement content
|
|
456
|
+
// must match the logical state, not the abandoned gesture.
|
|
457
|
+
val side = openSide
|
|
458
|
+
currentTranslation = if (isOpen && side != null) side.sign * widthFor(side) else 0f
|
|
459
|
+
startOffset = 0f
|
|
394
460
|
}
|
|
461
|
+
reconcileSideChildren()
|
|
395
462
|
}
|
|
396
463
|
|
|
397
464
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
465
|
+
applyPendingConfiguration()
|
|
466
|
+
reconcileSideChildren()
|
|
398
467
|
val width = right - left
|
|
399
468
|
val height = bottom - top
|
|
400
469
|
|
|
@@ -408,7 +477,9 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
408
477
|
child.layout(0, 0, width, height)
|
|
409
478
|
}
|
|
410
479
|
|
|
411
|
-
|
|
480
|
+
updateActionsTransform()
|
|
481
|
+
|
|
482
|
+
if (!isDragging && !isAnimating && currentTranslation == 0f && actionsWidthPx > 0f) {
|
|
412
483
|
val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
|
|
413
484
|
actionsView?.translationX = actionsOffset
|
|
414
485
|
}
|
|
@@ -417,7 +488,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
417
488
|
// prop reconciliation after a list reorder. The post {} ensures we run after
|
|
418
489
|
// Fabric finishes applying all mutations in the current frame.
|
|
419
490
|
if (isOpen && currentTranslation != 0f && !isDragging && !isAnimating) {
|
|
420
|
-
|
|
491
|
+
postCurrent {
|
|
421
492
|
if (isOpen && currentTranslation != 0f && !isDragging && !isAnimating) {
|
|
422
493
|
contentView?.translationX = currentTranslation
|
|
423
494
|
updateActionsTransform()
|
|
@@ -444,23 +515,12 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
444
515
|
* - UP: If blocking → consume (children never see UP, no onPress)
|
|
445
516
|
*/
|
|
446
517
|
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
|
447
|
-
|
|
518
|
+
// Every native touch entry point shares the same cancellation boundary.
|
|
519
|
+
if (suppressCancelledTouch(event)) return true
|
|
520
|
+
if (!gestureEnabled || !hasEnabledSide()) {
|
|
448
521
|
return super.dispatchTouchEvent(event)
|
|
449
522
|
}
|
|
450
523
|
|
|
451
|
-
// After cancelGesture(), swallow the rest of the touch sequence so the user
|
|
452
|
-
// can't reactivate the swipe by continuing to drag the same finger down.
|
|
453
|
-
if (isCurrentTouchCancelled) {
|
|
454
|
-
when (event.actionMasked) {
|
|
455
|
-
MotionEvent.ACTION_DOWN -> isCurrentTouchCancelled = false
|
|
456
|
-
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
457
|
-
isCurrentTouchCancelled = false
|
|
458
|
-
return true
|
|
459
|
-
}
|
|
460
|
-
else -> return true
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
524
|
when (event.actionMasked) {
|
|
465
525
|
MotionEvent.ACTION_DOWN -> {
|
|
466
526
|
dispatchTouchStartX = event.rawX
|
|
@@ -475,7 +535,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
475
535
|
velocityTracker = VelocityTracker.obtain()
|
|
476
536
|
velocityTracker?.addMovement(event)
|
|
477
537
|
|
|
478
|
-
//
|
|
538
|
+
// Track the touch start in onTouchEvent. Only a drag interrupts settling work.
|
|
479
539
|
onTouchEvent(event)
|
|
480
540
|
|
|
481
541
|
// Forward DOWN to children
|
|
@@ -502,13 +562,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
502
562
|
}
|
|
503
563
|
|
|
504
564
|
val horizontalDelta = event.rawX - dispatchTouchStartX
|
|
505
|
-
val isCorrectDirection =
|
|
506
|
-
true
|
|
507
|
-
} else {
|
|
508
|
-
if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
|
|
509
|
-
}
|
|
565
|
+
val isCorrectDirection = selectGestureSide(horizontalDelta) != null
|
|
510
566
|
|
|
511
|
-
|
|
567
|
+
val meetsOffset = dragOffsetFromEdgePx <= 0f || dx >= dragOffsetFromEdgePx
|
|
568
|
+
if (isCorrectDirection && meetsOffset && !childRequestedDisallowIntercept) {
|
|
512
569
|
// Swipe detected - cancel React touches, start blocking
|
|
513
570
|
isBlockingChildEvents = true
|
|
514
571
|
cancelReactTouches(event)
|
|
@@ -547,7 +604,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
547
604
|
}
|
|
548
605
|
|
|
549
606
|
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
|
550
|
-
if (
|
|
607
|
+
if (suppressCancelledTouch(event)) return false
|
|
608
|
+
if (!gestureEnabled || !hasEnabledSide()) return false
|
|
551
609
|
|
|
552
610
|
when (event.actionMasked) {
|
|
553
611
|
MotionEvent.ACTION_DOWN -> {
|
|
@@ -555,11 +613,7 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
555
613
|
touchStartY = event.rawY
|
|
556
614
|
isIntercepting = false
|
|
557
615
|
|
|
558
|
-
|
|
559
|
-
contentSpringAnimation?.cancel()
|
|
560
|
-
isAnimating = false
|
|
561
|
-
}
|
|
562
|
-
|
|
616
|
+
// Like iOS, only drag activation interrupts settling work. A tap never does.
|
|
563
617
|
velocityTracker?.recycle()
|
|
564
618
|
velocityTracker = VelocityTracker.obtain()
|
|
565
619
|
velocityTracker?.addMovement(event)
|
|
@@ -570,14 +624,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
570
624
|
val verticalDelta = event.rawY - touchStartY
|
|
571
625
|
|
|
572
626
|
if (abs(horizontalDelta) > touchSlop && abs(horizontalDelta) > abs(verticalDelta)) {
|
|
573
|
-
val isCorrectDirection =
|
|
574
|
-
true
|
|
575
|
-
} else {
|
|
576
|
-
if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
|
|
577
|
-
}
|
|
627
|
+
val isCorrectDirection = selectGestureSide(horizontalDelta) != null
|
|
578
628
|
val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
|
|
579
629
|
|
|
580
|
-
if (isCorrectDirection && meetsOffset) {
|
|
630
|
+
if (isCorrectDirection && meetsOffset && !childRequestedDisallowIntercept) {
|
|
581
631
|
isIntercepting = true
|
|
582
632
|
parent?.requestDisallowInterceptTouchEvent(true)
|
|
583
633
|
return true
|
|
@@ -593,7 +643,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
593
643
|
}
|
|
594
644
|
|
|
595
645
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
596
|
-
if (
|
|
646
|
+
if (suppressCancelledTouch(event)) return true
|
|
647
|
+
if (!gestureEnabled || !hasEnabledSide()) return false
|
|
597
648
|
|
|
598
649
|
velocityTracker?.addMovement(event)
|
|
599
650
|
|
|
@@ -608,7 +659,8 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
608
659
|
|
|
609
660
|
if (!isGestureActivated) {
|
|
610
661
|
val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
|
|
611
|
-
|
|
662
|
+
val meetsActivation = (abs(horizontalDelta) > touchSlop && abs(horizontalDelta) > abs(event.rawY - touchStartY))
|
|
663
|
+
if (meetsOffset && meetsActivation) {
|
|
612
664
|
activateGesture(horizontalDelta)
|
|
613
665
|
} else {
|
|
614
666
|
return true
|
|
@@ -621,7 +673,11 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
621
673
|
if (isGestureActivated) {
|
|
622
674
|
velocityTracker?.computeCurrentVelocity(1000)
|
|
623
675
|
val horizontalDelta = event.rawX - touchStartX
|
|
624
|
-
|
|
676
|
+
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
677
|
+
cancelTouchGesture()
|
|
678
|
+
} else {
|
|
679
|
+
handlePanEnd(horizontalDelta, velocityTracker?.xVelocity ?: 0f)
|
|
680
|
+
}
|
|
625
681
|
}
|
|
626
682
|
velocityTracker?.recycle()
|
|
627
683
|
velocityTracker = null
|
|
@@ -632,41 +688,83 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
632
688
|
}
|
|
633
689
|
|
|
634
690
|
private fun activateGesture(initialTranslation: Float) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
691
|
+
if (isCurrentTouchCancelled) return
|
|
692
|
+
applyPendingConfiguration()
|
|
693
|
+
if (isCurrentTouchCancelled) return
|
|
694
|
+
val side = selectGestureSide(initialTranslation) ?: return
|
|
695
|
+
val blockingChildren = isBlockingChildEvents
|
|
696
|
+
val intercepting = isIntercepting
|
|
697
|
+
if (!cancelActiveWork()) return
|
|
698
|
+
isBlockingChildEvents = blockingChildren
|
|
699
|
+
isIntercepting = intercepting
|
|
700
|
+
gestureSide = side
|
|
701
|
+
animationSide = null
|
|
645
702
|
isGestureActivated = true
|
|
646
703
|
isDragging = true
|
|
647
704
|
startOffset = currentTranslation
|
|
648
705
|
gestureStartTranslation = initialTranslation
|
|
649
706
|
lastEmittedWillEndState = if (isOpen) "open" else "closed"
|
|
650
|
-
|
|
707
|
+
val generation = workGeneration
|
|
651
708
|
actionsView?.visibility = View.VISIBLE
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
709
|
+
requestActions(side, retryEmptySlot = true)
|
|
710
|
+
if (generation != workGeneration || !isGestureActivated) return
|
|
711
|
+
refreshRailVisibility()
|
|
712
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
713
|
+
gestureCompletionPending = true
|
|
714
|
+
gestureCompletionMetadata = sideMetadata(side)
|
|
715
|
+
onSwipeStart(gestureCompletionMetadata!!)
|
|
716
|
+
if (generation != workGeneration || !isGestureActivated) return
|
|
655
717
|
startProgressUpdates()
|
|
656
718
|
startGestureTimeout()
|
|
657
719
|
}
|
|
658
720
|
|
|
659
721
|
private fun startGestureTimeout() {
|
|
660
722
|
cancelGestureTimeout()
|
|
723
|
+
val generation = workGeneration
|
|
661
724
|
gestureTimeoutRunnable = Runnable {
|
|
662
|
-
if (isGestureActivated) {
|
|
663
|
-
//
|
|
664
|
-
|
|
725
|
+
if (generation == workGeneration && isGestureActivated) {
|
|
726
|
+
// A stuck gesture ends like an OS cancel, not a release. The finger can
|
|
727
|
+
// still be down, so the rest of this touch sequence stays consumed.
|
|
728
|
+
cancelTouchGesture()
|
|
665
729
|
}
|
|
666
730
|
}
|
|
667
731
|
postDelayed(gestureTimeoutRunnable, GESTURE_TIMEOUT_MS)
|
|
668
732
|
}
|
|
669
733
|
|
|
734
|
+
/**
|
|
735
|
+
* An OS or watchdog cancel returns the row to its logical state. Progress and the
|
|
736
|
+
* intended state then report that position, so JS UIs do not stay mid-drag.
|
|
737
|
+
* A cancel never completes the swipe, so it emits no onSwipeEnd.
|
|
738
|
+
*/
|
|
739
|
+
private fun cancelTouchGesture() {
|
|
740
|
+
val side = gestureSide
|
|
741
|
+
val reportedState = lastEmittedWillEndState
|
|
742
|
+
isCurrentTouchCancelled = true
|
|
743
|
+
velocityTracker?.recycle()
|
|
744
|
+
velocityTracker = null
|
|
745
|
+
// The drag cleared the auto-close wait. A row that settles open waits again,
|
|
746
|
+
// like a release that reopens it, and the auto-close completes the swipe.
|
|
747
|
+
val deadline = if (autoClose && isOpen) {
|
|
748
|
+
SystemClock.uptimeMillis() + maxOf(0L, autoCloseTimeout.toLong())
|
|
749
|
+
} else {
|
|
750
|
+
null
|
|
751
|
+
}
|
|
752
|
+
if (!reconcileLogicalState(deadline = deadline)) return
|
|
753
|
+
val generation = workGeneration
|
|
754
|
+
val settledSide = openSide ?: side
|
|
755
|
+
val settledState = if (isOpen) "open" else "closed"
|
|
756
|
+
emitFinalProgress(if (isOpen) 1f else 0f, currentTranslation, settledSide)
|
|
757
|
+
if (generation != workGeneration || reportedState == settledState) return
|
|
758
|
+
onSwipeStateChange(stateEvent(settledState, settledSide))
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The watchdog measures inactivity: each move of the live gesture restarts it. */
|
|
762
|
+
private fun restartGestureTimeout() {
|
|
763
|
+
val runnable = gestureTimeoutRunnable ?: return
|
|
764
|
+
removeCallbacks(runnable)
|
|
765
|
+
postDelayed(runnable, GESTURE_TIMEOUT_MS)
|
|
766
|
+
}
|
|
767
|
+
|
|
670
768
|
private fun cancelGestureTimeout() {
|
|
671
769
|
gestureTimeoutRunnable?.let { removeCallbacks(it) }
|
|
672
770
|
gestureTimeoutRunnable = null
|
|
@@ -675,29 +773,34 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
675
773
|
private fun cancelAutoClose() {
|
|
676
774
|
autoCloseRunnable?.let { removeCallbacks(it) }
|
|
677
775
|
autoCloseRunnable = null
|
|
776
|
+
autoCloseDeadline = null
|
|
678
777
|
}
|
|
679
778
|
|
|
680
|
-
private fun scheduleAutoClose() {
|
|
779
|
+
private fun scheduleAutoClose(deadline: Long = SystemClock.uptimeMillis() + maxOf(0L, autoCloseTimeout.toLong())) {
|
|
681
780
|
cancelAutoClose()
|
|
682
|
-
|
|
781
|
+
autoCloseDeadline = deadline
|
|
782
|
+
val generation = workGeneration
|
|
783
|
+
val key = recyclingKey
|
|
784
|
+
val side = openSide
|
|
683
785
|
autoCloseRunnable = Runnable {
|
|
684
786
|
autoCloseRunnable = null
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
787
|
+
autoCloseDeadline = null
|
|
788
|
+
if (generation == workGeneration && key == recyclingKey && isOpen && openSide == side && autoClose) {
|
|
789
|
+
closeInternal(animated = true, autoCloseCompletion = true)
|
|
790
|
+
}
|
|
688
791
|
}
|
|
689
|
-
postDelayed(autoCloseRunnable,
|
|
792
|
+
postDelayed(autoCloseRunnable, maxOf(0L, deadline - SystemClock.uptimeMillis()))
|
|
690
793
|
}
|
|
691
794
|
|
|
692
795
|
private fun handlePanUpdate(translationX: Float) {
|
|
693
|
-
if (!isGestureActivated ||
|
|
796
|
+
if (!isGestureActivated || actionsWidthPx <= 0f) return
|
|
797
|
+
restartGestureTimeout()
|
|
694
798
|
|
|
695
799
|
val effectiveTranslation = translationX - gestureStartTranslation
|
|
696
800
|
|
|
697
|
-
val
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
val targetTranslation = SwipePhysics.applyRubberBand(clampedTranslation, actionsWidthPx, isLeading)
|
|
801
|
+
val targetTranslation = SwipePhysics.applyLatchedTranslation(
|
|
802
|
+
startOffset, effectiveTranslation, friction, actionsWidthPx, gestureSide ?: return
|
|
803
|
+
)
|
|
701
804
|
|
|
702
805
|
currentTranslation = targetTranslation
|
|
703
806
|
contentView?.translationX = targetTranslation
|
|
@@ -705,17 +808,19 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
705
808
|
|
|
706
809
|
// Emit onSwipeStateChange when threshold crossing changes intended state
|
|
707
810
|
val thresholdDistance = actionsWidthPx * threshold
|
|
708
|
-
val
|
|
811
|
+
val decisionTranslation = effectiveTranslation * friction
|
|
812
|
+
val wouldOpen = SwipePhysics.shouldSnapToOpen(decisionTranslation, thresholdDistance, isOpen, isLeading)
|
|
709
813
|
val intendedState = if (wouldOpen) "open" else "closed"
|
|
710
814
|
if (lastEmittedWillEndState != intendedState) {
|
|
711
815
|
lastEmittedWillEndState = intendedState
|
|
712
|
-
onSwipeStateChange(
|
|
816
|
+
onSwipeStateChange(stateEvent(intendedState))
|
|
713
817
|
}
|
|
714
818
|
}
|
|
715
819
|
|
|
716
820
|
private fun handlePanEnd(translationX: Float, velocityX: Float) {
|
|
717
821
|
if (!isGestureActivated) return
|
|
718
822
|
|
|
823
|
+
val generation = workGeneration
|
|
719
824
|
val effectiveTranslation = translationX - gestureStartTranslation
|
|
720
825
|
|
|
721
826
|
isGestureActivated = false
|
|
@@ -733,9 +838,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
733
838
|
val intendedState = if (shouldOpen) "open" else "closed"
|
|
734
839
|
if (lastEmittedWillEndState != intendedState) {
|
|
735
840
|
lastEmittedWillEndState = intendedState
|
|
736
|
-
onSwipeStateChange(
|
|
841
|
+
onSwipeStateChange(stateEvent(intendedState))
|
|
737
842
|
}
|
|
738
843
|
|
|
844
|
+
if (generation != workGeneration || isCurrentTouchCancelled) return
|
|
739
845
|
if (shouldOpen) {
|
|
740
846
|
// Only use velocity if it's in the opening direction (prevents backward bounce)
|
|
741
847
|
val openingVelocity = if (isLeading) {
|
|
@@ -743,9 +849,10 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
743
849
|
} else {
|
|
744
850
|
if (velocityX < 0) velocityX else 0f // Trailing: negative velocity opens
|
|
745
851
|
}
|
|
746
|
-
|
|
852
|
+
// A release settles its latch; an argumentless command uses the configured default.
|
|
853
|
+
openInternal(gestureSide ?: return, openingVelocity, preserveGestureCompletion = true)
|
|
747
854
|
// Schedule auto-close if enabled
|
|
748
|
-
if (autoClose) {
|
|
855
|
+
if (autoClose && isOpen && !isCurrentTouchCancelled) {
|
|
749
856
|
scheduleAutoClose()
|
|
750
857
|
}
|
|
751
858
|
} else {
|
|
@@ -755,48 +862,69 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
755
862
|
} else {
|
|
756
863
|
if (velocityX > 0) velocityX else 0f // Trailing: positive velocity closes
|
|
757
864
|
}
|
|
758
|
-
|
|
865
|
+
applyPendingConfiguration()
|
|
866
|
+
closeInternal(animated = true, velocity = closingVelocity, preserveGestureCompletion = true)
|
|
759
867
|
}
|
|
760
868
|
}
|
|
761
869
|
|
|
762
870
|
private fun updateActionsTransform() {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
actions.translationX = actionsTranslateX
|
|
871
|
+
for (side in SwipeSide.values()) {
|
|
872
|
+
val rail = railFor(side) ?: continue
|
|
873
|
+
val width = widthFor(side)
|
|
874
|
+
val progress = if (side == activeSide && width > 0f) minOf(abs(currentTranslation) / width, 1f) else 0f
|
|
875
|
+
rail.translationX = -side.sign * width * (1f - progress)
|
|
876
|
+
}
|
|
877
|
+
refreshRailVisibility()
|
|
771
878
|
}
|
|
772
879
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
880
|
+
fun open(velocity: Float = 0f) {
|
|
881
|
+
applyPendingConfiguration()
|
|
882
|
+
val side = defaultOpenSide() ?: return
|
|
883
|
+
open(side, velocity)
|
|
777
884
|
}
|
|
778
885
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
contentView?.translationX = targetX
|
|
783
|
-
|
|
784
|
-
if (actionsView != null) {
|
|
785
|
-
actionsView?.visibility = View.VISIBLE
|
|
786
|
-
actionsView?.translationX = 0f
|
|
787
|
-
} else {
|
|
788
|
-
onSwipeStart(emptyMap())
|
|
789
|
-
}
|
|
886
|
+
fun open(side: SwipeSide, velocity: Float = 0f) {
|
|
887
|
+
openInternal(side, velocity)
|
|
888
|
+
}
|
|
790
889
|
|
|
791
|
-
|
|
792
|
-
|
|
890
|
+
/** A keyed open keeps the intent of its command time, not of the main-thread turn. */
|
|
891
|
+
private fun openCommand(side: SwipeSide?, intent: Long) {
|
|
892
|
+
applyPendingConfiguration()
|
|
893
|
+
openInternal(side ?: defaultOpenSide() ?: return, 0f, intent = intent)
|
|
793
894
|
}
|
|
794
895
|
|
|
795
|
-
fun
|
|
896
|
+
private fun openInternal(
|
|
897
|
+
side: SwipeSide,
|
|
898
|
+
velocity: Float,
|
|
899
|
+
preserveGestureCompletion: Boolean = false,
|
|
900
|
+
intent: Long = currentIntent()
|
|
901
|
+
) {
|
|
902
|
+
applyPendingConfiguration()
|
|
903
|
+
if (!isSideEnabled(side)) return
|
|
904
|
+
if (!preserveGestureCompletion && (isGestureActivated || directGesturePending)) {
|
|
905
|
+
isCurrentTouchCancelled = true
|
|
906
|
+
}
|
|
907
|
+
val previousSide = gestureSide ?: animationSide ?: openSide
|
|
908
|
+
if (previousSide != null && previousSide != side &&
|
|
909
|
+
(isOpen || currentTranslation != 0f || isAnimating || gestureCompletionPending)) {
|
|
910
|
+
// The side switch carries this intent through its closing leg.
|
|
911
|
+
openIntent = intent
|
|
912
|
+
closeInternal(animated = true, afterClose = { openInternal(side, velocity, intent = intent) })
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
// An ordinary open can deliver the pending end. An imperative auto-close
|
|
916
|
+
// open neither schedules a timer nor emits an end, so it abandons ownership.
|
|
917
|
+
if (!cancelActiveWork(preserveGestureCompletion || !autoClose)) return
|
|
918
|
+
animationSide = side
|
|
919
|
+
openSide = side
|
|
920
|
+
openIntent = intent
|
|
921
|
+
val generation = workGeneration
|
|
922
|
+
requestActions(side, retryEmptySlot = true)
|
|
923
|
+
if (generation != workGeneration) return
|
|
796
924
|
isOpen = true
|
|
797
925
|
isAnimating = true
|
|
798
926
|
// Thread-safe persist to static cache for FlashList recycling
|
|
799
|
-
|
|
927
|
+
persistOpenSide(side)
|
|
800
928
|
// Note: actionsView visibility is already set during drag phase
|
|
801
929
|
if (actionsView?.visibility != View.VISIBLE) {
|
|
802
930
|
actionsView?.visibility = View.VISIBLE
|
|
@@ -808,18 +936,16 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
808
936
|
// Start progress updates to emit progress during animation
|
|
809
937
|
startProgressUpdates()
|
|
810
938
|
|
|
811
|
-
// Reset the flag - close() will set it to true if it interrupts us
|
|
812
|
-
openAnimationInterruptedByClose = false
|
|
813
|
-
contentSpringAnimation?.cancel()
|
|
814
|
-
actionsSpringAnimation?.cancel()
|
|
815
|
-
|
|
816
939
|
val content = contentView
|
|
817
|
-
val actions = actionsView
|
|
818
940
|
|
|
819
941
|
if (content == null) {
|
|
820
942
|
currentTranslation = targetX
|
|
821
943
|
isAnimating = false
|
|
822
944
|
emitFinalProgress(1f, targetX)
|
|
945
|
+
if (generation != workGeneration) return
|
|
946
|
+
if (!autoClose) emitSwipeEnd("open", side)
|
|
947
|
+
if (generation != workGeneration) return
|
|
948
|
+
refreshRailVisibility()
|
|
823
949
|
return
|
|
824
950
|
}
|
|
825
951
|
|
|
@@ -849,20 +975,19 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
849
975
|
setMaxValue(maxBound)
|
|
850
976
|
setStartVelocity(effectiveVelocity)
|
|
851
977
|
addUpdateListener { _, value, _ ->
|
|
978
|
+
if (generation != workGeneration) return@addUpdateListener
|
|
852
979
|
currentTranslation = value
|
|
853
980
|
updateActionsTransform()
|
|
854
981
|
}
|
|
855
|
-
addEndListener { _,
|
|
856
|
-
if (
|
|
857
|
-
// Animation was interrupted by close() (e.g., autoClose)
|
|
858
|
-
return@addEndListener
|
|
859
|
-
}
|
|
982
|
+
addEndListener { _, canceled, _, _ ->
|
|
983
|
+
if (canceled || generation != workGeneration) return@addEndListener
|
|
860
984
|
isAnimating = false
|
|
861
985
|
startLayoutGuard()
|
|
862
986
|
emitFinalProgress(1f, targetX)
|
|
987
|
+
if (generation != workGeneration) return@addEndListener
|
|
863
988
|
// With autoClose, let close() emit onSwipeEnd("open") instead
|
|
864
989
|
if (!autoClose) {
|
|
865
|
-
|
|
990
|
+
emitSwipeEnd("open", side)
|
|
866
991
|
}
|
|
867
992
|
}
|
|
868
993
|
start()
|
|
@@ -870,168 +995,153 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
870
995
|
// Actions are now driven by updateActionsTransform() called from content's update listener
|
|
871
996
|
}
|
|
872
997
|
|
|
873
|
-
/**
|
|
998
|
+
/** Snaps to the current edge, or the canonical default enabled edge. */
|
|
874
999
|
fun snapToOpen() {
|
|
875
|
-
|
|
876
|
-
|
|
1000
|
+
applyPendingConfiguration()
|
|
1001
|
+
val side = openSide ?: defaultOpenSide() ?: return
|
|
1002
|
+
snapToOpen(side)
|
|
1003
|
+
}
|
|
877
1004
|
|
|
1005
|
+
private fun snapToOpen(side: SwipeSide, intent: Long = currentIntent()) {
|
|
1006
|
+
if (!isSideEnabled(side)) return
|
|
1007
|
+
if (isGestureActivated || directGesturePending) isCurrentTouchCancelled = true
|
|
1008
|
+
if (!cancelActiveWork()) return
|
|
878
1009
|
isOpen = true
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1010
|
+
openSide = side
|
|
1011
|
+
openIntent = intent
|
|
1012
|
+
animationSide = side
|
|
1013
|
+
currentTranslation = side.sign * widthFor(side)
|
|
1014
|
+
contentView?.translationX = currentTranslation
|
|
1015
|
+
val generation = workGeneration
|
|
1016
|
+
requestActions(side, retryEmptySlot = true)
|
|
1017
|
+
if (generation != workGeneration) return
|
|
886
1018
|
if (actionsView != null) {
|
|
887
|
-
// Actions already mounted - position them
|
|
888
1019
|
actionsView?.visibility = View.VISIBLE
|
|
889
1020
|
actionsView?.translationX = 0f
|
|
890
|
-
} else {
|
|
891
|
-
// Actions not mounted yet (React lazy rendering)
|
|
892
|
-
// Trigger onSwipeStart so React renders SwipeableActions
|
|
893
|
-
// onViewAdded will handle positioning when actions are added
|
|
894
|
-
onSwipeStart(emptyMap())
|
|
895
1021
|
}
|
|
896
|
-
|
|
1022
|
+
if (generation != workGeneration) return
|
|
1023
|
+
updateActionsTransform()
|
|
897
1024
|
startLayoutGuard()
|
|
898
|
-
|
|
899
|
-
// Emit final progress
|
|
900
|
-
emitFinalProgress(1f, targetX)
|
|
1025
|
+
emitFinalProgress(1f, currentTranslation)
|
|
901
1026
|
}
|
|
902
1027
|
|
|
903
1028
|
fun close(animated: Boolean = true, velocity: Float = 0f) {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
// Cancel any running animations
|
|
909
|
-
openAnimationInterruptedByClose = true
|
|
910
|
-
contentSpringAnimation?.cancel()
|
|
911
|
-
actionsSpringAnimation?.cancel()
|
|
912
|
-
|
|
913
|
-
// Clear gesture state (unified from reset)
|
|
914
|
-
isDragging = false
|
|
915
|
-
isGestureActivated = false
|
|
916
|
-
gestureStartTranslation = 0f
|
|
917
|
-
pendingShouldOpen = false
|
|
1029
|
+
applyPendingConfiguration()
|
|
1030
|
+
closeInternal(animated, velocity)
|
|
1031
|
+
}
|
|
918
1032
|
|
|
1033
|
+
private fun closeInternal(
|
|
1034
|
+
animated: Boolean,
|
|
1035
|
+
velocity: Float = 0f,
|
|
1036
|
+
autoCloseCompletion: Boolean = false,
|
|
1037
|
+
preserveGestureCompletion: Boolean = false,
|
|
1038
|
+
afterClose: (() -> Unit)? = null
|
|
1039
|
+
) {
|
|
1040
|
+
// Closing an already-closed row reports no side, like iOS.
|
|
1041
|
+
val endSide = gestureSide ?: animationSide ?: openSide
|
|
1042
|
+
val side = activeSide ?: SwipeSide.RIGHT
|
|
1043
|
+
val width = widthFor(side)
|
|
1044
|
+
if (isGestureActivated || directGesturePending) {
|
|
1045
|
+
isCurrentTouchCancelled = true
|
|
1046
|
+
}
|
|
1047
|
+
// An animated close takes over the pending gesture's eventual end.
|
|
1048
|
+
// Keep ownership until that end or a later silent interruption.
|
|
1049
|
+
if (!cancelActiveWork(animated || preserveGestureCompletion || autoCloseCompletion)) return
|
|
1050
|
+
val generation = workGeneration
|
|
1051
|
+
animationSide = side
|
|
1052
|
+
autoCloseClosingSide = if (autoCloseCompletion) side else null
|
|
919
1053
|
isOpen = false
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1054
|
+
openSide = null
|
|
1055
|
+
restorePending = false
|
|
1056
|
+
pendingRestoreSide = null
|
|
1057
|
+
persistOpenSide(null)
|
|
1058
|
+
|
|
1059
|
+
if (!animated) {
|
|
1060
|
+
animationSide = null
|
|
1061
|
+
resetPosition()
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
isAnimating = true
|
|
1065
|
+
startProgressUpdates()
|
|
1066
|
+
val content = contentView
|
|
1067
|
+
if (content == null) {
|
|
1068
|
+
currentTranslation = 0f
|
|
1069
|
+
actionsView?.translationX = -side.sign * width
|
|
1070
|
+
isAnimating = false
|
|
1071
|
+
emitFinalProgress(0f, 0f)
|
|
1072
|
+
if (generation != workGeneration) return
|
|
1073
|
+
autoCloseClosingSide = null
|
|
1074
|
+
emitSwipeEnd(if (autoCloseCompletion) "open" else "closed", endSide)
|
|
1075
|
+
if (generation != workGeneration) return
|
|
1076
|
+
animationSide = null
|
|
1077
|
+
refreshRailVisibility()
|
|
1078
|
+
if (generation == workGeneration) afterClose?.invoke()
|
|
1079
|
+
return
|
|
923
1080
|
}
|
|
924
1081
|
|
|
925
|
-
val
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
openAnimationInterruptedByClose = false
|
|
940
|
-
emitFinalProgress(0f, 0f)
|
|
941
|
-
return
|
|
1082
|
+
val minBound = if (side == SwipeSide.LEFT) 0f else -width
|
|
1083
|
+
val maxBound = if (side == SwipeSide.LEFT) width else 0f
|
|
1084
|
+
val clampedStart = content.translationX.coerceIn(minBound, maxBound)
|
|
1085
|
+
val wasClamped = content.translationX != clampedStart
|
|
1086
|
+
if (wasClamped) {
|
|
1087
|
+
content.translationX = clampedStart
|
|
1088
|
+
currentTranslation = clampedStart
|
|
1089
|
+
}
|
|
1090
|
+
val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
|
|
1091
|
+
val effectiveVelocity = if (wasClamped && clampedStart == 0f) 0f else springVelocity
|
|
1092
|
+
contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, 0f).apply {
|
|
1093
|
+
spring = SpringForce(0f).apply {
|
|
1094
|
+
stiffness = SwipeConstants.CLOSE_STIFFNESS
|
|
1095
|
+
dampingRatio = SwipeConstants.CLOSE_DAMPING_RATIO
|
|
942
1096
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
if (wasClamped) {
|
|
951
|
-
content.translationX = clampedStart
|
|
952
|
-
currentTranslation = clampedStart
|
|
1097
|
+
setMinValue(minBound)
|
|
1098
|
+
setMaxValue(maxBound)
|
|
1099
|
+
setStartVelocity(effectiveVelocity)
|
|
1100
|
+
addUpdateListener { _, value, _ ->
|
|
1101
|
+
if (generation != workGeneration) return@addUpdateListener
|
|
1102
|
+
currentTranslation = value
|
|
1103
|
+
updateActionsTransform()
|
|
953
1104
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
updateActionsTransform()
|
|
968
|
-
}
|
|
969
|
-
addEndListener { _, _, _, _ ->
|
|
970
|
-
isAnimating = false
|
|
971
|
-
openAnimationInterruptedByClose = false
|
|
972
|
-
actionsView?.visibility = View.INVISIBLE
|
|
973
|
-
emitFinalProgress(0f, 0f)
|
|
974
|
-
// Emit "open" when autoClose completes - user's swipe intent was achieved
|
|
975
|
-
val endState = if (autoCloseEndEventOverride) "open" else "closed"
|
|
976
|
-
autoCloseEndEventOverride = false
|
|
977
|
-
onSwipeEnd(mapOf("state" to endState))
|
|
978
|
-
}
|
|
979
|
-
start()
|
|
1105
|
+
addEndListener { _, canceled, _, _ ->
|
|
1106
|
+
if (canceled || generation != workGeneration) return@addEndListener
|
|
1107
|
+
isAnimating = false
|
|
1108
|
+
currentTranslation = 0f
|
|
1109
|
+
actionsView?.visibility = View.INVISIBLE
|
|
1110
|
+
emitFinalProgress(0f, 0f)
|
|
1111
|
+
if (generation != workGeneration) return@addEndListener
|
|
1112
|
+
autoCloseClosingSide = null
|
|
1113
|
+
emitSwipeEnd(if (autoCloseCompletion) "open" else "closed", endSide)
|
|
1114
|
+
if (generation != workGeneration) return@addEndListener
|
|
1115
|
+
animationSide = null
|
|
1116
|
+
refreshRailVisibility()
|
|
1117
|
+
if (generation == workGeneration) afterClose?.invoke()
|
|
980
1118
|
}
|
|
981
|
-
|
|
982
|
-
// Instant close (formerly reset)
|
|
983
|
-
isAnimating = false
|
|
984
|
-
openAnimationInterruptedByClose = false
|
|
985
|
-
currentTranslation = 0f
|
|
986
|
-
startOffset = 0f
|
|
987
|
-
lastEmittedProgress = -1f
|
|
988
|
-
|
|
989
|
-
contentView?.translationX = 0f
|
|
990
|
-
actionsView?.translationX = actionsOffset
|
|
991
|
-
actionsView?.visibility = View.INVISIBLE
|
|
1119
|
+
start()
|
|
992
1120
|
}
|
|
993
1121
|
}
|
|
994
1122
|
|
|
995
1123
|
fun handleGestureStart() {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
lastEmittedWillEndState = if (isOpen) "open" else "closed"
|
|
1003
|
-
onSwipeStart(emptyMap())
|
|
1004
|
-
startProgressUpdates()
|
|
1124
|
+
applyPendingConfiguration()
|
|
1125
|
+
if (!gestureEnabled || !hasEnabledSide()) return
|
|
1126
|
+
// The direct API start is the equivalent of a fresh ACTION_DOWN.
|
|
1127
|
+
isCurrentTouchCancelled = false
|
|
1128
|
+
if (!cancelActiveWork()) return
|
|
1129
|
+
directGesturePending = true
|
|
1005
1130
|
}
|
|
1006
1131
|
|
|
1007
1132
|
fun handleGestureUpdate(translationX: Float, velocityX: Float) {
|
|
1008
|
-
if (
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
contentView?.translationX = targetTranslation
|
|
1015
|
-
updateActionsTransform()
|
|
1133
|
+
if (isCurrentTouchCancelled) return
|
|
1134
|
+
if (directGesturePending && abs(translationX) > touchSlop && abs(translationX) >= dragOffsetFromEdgePx) {
|
|
1135
|
+
activateGesture(translationX)
|
|
1136
|
+
if (isGestureActivated) gestureStartTranslation = 0f
|
|
1137
|
+
}
|
|
1138
|
+
handlePanUpdate(translationX)
|
|
1016
1139
|
}
|
|
1017
1140
|
|
|
1018
1141
|
fun handleGestureEnd(translationX: Float, velocityX: Float) {
|
|
1019
|
-
if (
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
gestureStartTranslation = 0f
|
|
1023
|
-
isDragging = false
|
|
1024
|
-
stopProgressUpdates()
|
|
1025
|
-
|
|
1026
|
-
val projectedX = SwipePhysics.projectFinalPosition(translationX, velocityX, friction)
|
|
1027
|
-
val thresholdDistance = actionsWidthPx * threshold
|
|
1028
|
-
val shouldOpen = SwipePhysics.shouldSnapToOpen(projectedX, thresholdDistance, isOpen, isLeading)
|
|
1029
|
-
|
|
1030
|
-
if (shouldOpen) {
|
|
1031
|
-
open(velocityX)
|
|
1032
|
-
} else {
|
|
1033
|
-
close(velocity = velocityX)
|
|
1034
|
-
}
|
|
1142
|
+
if (isCurrentTouchCancelled) return
|
|
1143
|
+
directGesturePending = false
|
|
1144
|
+
handlePanEnd(translationX, velocityX)
|
|
1035
1145
|
}
|
|
1036
1146
|
|
|
1037
1147
|
private fun startProgressUpdates() {
|
|
@@ -1076,54 +1186,328 @@ class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context
|
|
|
1076
1186
|
onSwipeProgress(mapOf(
|
|
1077
1187
|
"progress" to progress.toDouble(),
|
|
1078
1188
|
"translationX" to translation.toDouble()
|
|
1079
|
-
))
|
|
1189
|
+
) + sideMetadata())
|
|
1080
1190
|
}
|
|
1081
1191
|
}
|
|
1082
1192
|
|
|
1083
|
-
private fun emitFinalProgress(progress: Float, translationX: Float) {
|
|
1193
|
+
private fun emitFinalProgress(progress: Float, translationX: Float, side: SwipeSide? = activeSide) {
|
|
1084
1194
|
lastEmittedProgress = progress
|
|
1085
1195
|
// Use fresh map for each emission
|
|
1086
1196
|
onSwipeProgress(mapOf(
|
|
1087
1197
|
"progress" to progress.toDouble(),
|
|
1088
1198
|
"translationX" to translationX.toDouble()
|
|
1089
|
-
))
|
|
1199
|
+
) + sideMetadata(side))
|
|
1090
1200
|
}
|
|
1091
1201
|
|
|
1092
1202
|
override fun onAttachedToWindow() {
|
|
1093
1203
|
super.onAttachedToWindow()
|
|
1094
1204
|
recyclingKey?.let { registerView(this, it) }
|
|
1095
|
-
|
|
1205
|
+
if (restoreCacheOnAttach) {
|
|
1206
|
+
restoreCacheOnAttach = false
|
|
1207
|
+
recyclingKey?.let { key ->
|
|
1208
|
+
// Detached commands can change cached intent without a registered
|
|
1209
|
+
// view. The same instance must restore that intent too.
|
|
1210
|
+
val (side, intent) = readRestore(key)
|
|
1211
|
+
pendingRestoreSide = side
|
|
1212
|
+
pendingRestoreIntent = intent
|
|
1213
|
+
restorePending = true
|
|
1214
|
+
isOpen = false
|
|
1215
|
+
openSide = null
|
|
1216
|
+
animationSide = null
|
|
1217
|
+
configurationChanged()
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
applyPendingConfiguration()
|
|
1221
|
+
reconcileSideChildren()
|
|
1222
|
+
if (isOpen) openSide?.let(::requestActions)
|
|
1096
1223
|
if (isOpen && currentTranslation != 0f) {
|
|
1097
|
-
|
|
1224
|
+
postCurrent {
|
|
1098
1225
|
if (isOpen && currentTranslation != 0f && !isDragging && !isAnimating) {
|
|
1099
1226
|
contentView?.translationX = currentTranslation
|
|
1100
1227
|
updateActionsTransform()
|
|
1101
1228
|
actionsView?.visibility = View.VISIBLE
|
|
1102
1229
|
}
|
|
1103
1230
|
}
|
|
1104
|
-
|
|
1105
|
-
// Restart autoClose timer if it was cancelled during detach
|
|
1106
|
-
if (autoClose) {
|
|
1107
|
-
scheduleAutoClose()
|
|
1108
|
-
}
|
|
1109
1231
|
}
|
|
1110
1232
|
}
|
|
1111
1233
|
|
|
1112
1234
|
override fun onDetachedFromWindow() {
|
|
1235
|
+
val cancelled = takeGestureCancellation()
|
|
1236
|
+
bindingGeneration++
|
|
1237
|
+
restoreCacheOnAttach = true
|
|
1238
|
+
isCurrentTouchCancelled = true
|
|
1113
1239
|
super.onDetachedFromWindow()
|
|
1114
|
-
|
|
1115
|
-
stopLayoutGuard()
|
|
1116
|
-
cancelGestureTimeout()
|
|
1117
|
-
cancelAutoClose()
|
|
1118
|
-
contentSpringAnimation?.cancel()
|
|
1119
|
-
actionsSpringAnimation?.cancel()
|
|
1240
|
+
removeCallbacks(configurationRunnable)
|
|
1120
1241
|
velocityTracker?.recycle()
|
|
1121
1242
|
velocityTracker = null
|
|
1122
|
-
|
|
1243
|
+
requestedActions.clear()
|
|
1244
|
+
observedSlotCounts.clear()
|
|
1245
|
+
if (autoCloseDeadline != null) {
|
|
1246
|
+
// The abandoned auto-close cannot resume, so the transient open must not restore.
|
|
1247
|
+
isOpen = false
|
|
1248
|
+
openSide = null
|
|
1249
|
+
persistOpenSide(null)
|
|
1250
|
+
}
|
|
1251
|
+
recyclingKey?.let { unregisterView(this, it) }
|
|
1252
|
+
cancelActiveWork()
|
|
1253
|
+
reconcileLogicalState(restartLayoutGuard = false)
|
|
1254
|
+
if (cancelled != null) onGestureCancelled(cancelled)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private fun validWidth(value: Float): Float = if (value.isFinite() && value > 0f) value else 0f
|
|
1258
|
+
|
|
1259
|
+
private fun widthFor(side: SwipeSide): Float =
|
|
1260
|
+
if (side == SwipeSide.LEFT) leftWidthPx else rightWidthPx
|
|
1261
|
+
|
|
1262
|
+
private fun isSideEnabled(side: SwipeSide): Boolean = widthFor(side) > 0f
|
|
1263
|
+
|
|
1264
|
+
private fun hasEnabledSide(): Boolean = leftWidthPx > 0f || rightWidthPx > 0f
|
|
1265
|
+
|
|
1266
|
+
private fun defaultOpenSide(): SwipeSide? =
|
|
1267
|
+
SwipePhysics.defaultOpenSide(leftWidthPx, rightWidthPx)
|
|
1268
|
+
|
|
1269
|
+
private fun selectGestureSide(translation: Float): SwipeSide? = SwipePhysics.selectSide(
|
|
1270
|
+
translation, openSide ?: animationSide, gestureSide, leftWidthPx, rightWidthPx
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
private fun railFor(side: SwipeSide?): View? = when (side) {
|
|
1274
|
+
SwipeSide.LEFT -> leftActionsView
|
|
1275
|
+
SwipeSide.RIGHT -> rightActionsView
|
|
1276
|
+
null -> null
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
private fun sideMetadata(side: SwipeSide? = activeSide): Map<String, Any> {
|
|
1280
|
+
val key = recyclingKey
|
|
1281
|
+
val keyMetadata = if (key != null) mapOf("recyclingKey" to key) else emptyMap()
|
|
1282
|
+
if (side == null) return keyMetadata
|
|
1283
|
+
return mapOf("side" to side.value) + keyMetadata
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
private fun stateEvent(state: String, side: SwipeSide? = activeSide): Map<String, Any> =
|
|
1287
|
+
mapOf("state" to state) + sideMetadata(side)
|
|
1288
|
+
|
|
1289
|
+
private fun emitSwipeEnd(state: String, side: SwipeSide?) {
|
|
1290
|
+
gestureCompletionPending = false
|
|
1291
|
+
gestureCompletionMetadata = null
|
|
1292
|
+
onSwipeEnd(stateEvent(state, side))
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private fun requestActions(side: SwipeSide, retryEmptySlot: Boolean = false) {
|
|
1296
|
+
// A slot may be emptied without changing this view's own layout bounds.
|
|
1297
|
+
if (retryEmptySlot && (railFor(side) as? ViewGroup)?.childCount == 0) {
|
|
1298
|
+
requestedActions.remove(side)
|
|
1299
|
+
}
|
|
1300
|
+
if (isSideEnabled(side) && requestedActions.add(side)) {
|
|
1301
|
+
onActionsNeeded(mapOf("side" to side.value) + sideMetadata(side))
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
private fun configurationChanged() {
|
|
1306
|
+
// Invalidates callbacks immediately, but commits related widths/key together.
|
|
1307
|
+
workGeneration++
|
|
1308
|
+
configurationDirty = true
|
|
1309
|
+
removeCallbacks(configurationRunnable)
|
|
1310
|
+
post(configurationRunnable)
|
|
1311
|
+
requestLayout()
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
internal fun applyPendingConfiguration() {
|
|
1315
|
+
if (!configurationDirty) return
|
|
1316
|
+
configurationDirty = false
|
|
1317
|
+
removeCallbacks(configurationRunnable)
|
|
1318
|
+
if (isGestureActivated || directGesturePending) {
|
|
1319
|
+
// Configuration changes consume the remainder of this physical/direct gesture.
|
|
1320
|
+
isCurrentTouchCancelled = true
|
|
1321
|
+
}
|
|
1322
|
+
reconcileSideChildren()
|
|
1323
|
+
if (pendingGestureDisable && !gestureEnabled) {
|
|
1324
|
+
if (!cancelActiveWork()) return
|
|
1325
|
+
isOpen = false
|
|
1326
|
+
openSide = null
|
|
1327
|
+
animationSide = null
|
|
1328
|
+
resetPosition()
|
|
1329
|
+
// A pending cached restore owns the cache: gestureEnabled turns off only the gesture.
|
|
1330
|
+
if (!restorePending) persistOpenSide(null)
|
|
1331
|
+
}
|
|
1332
|
+
pendingGestureDisable = false
|
|
1333
|
+
if (restorePending) {
|
|
1334
|
+
val restored = pendingRestoreSide
|
|
1335
|
+
val intent = pendingRestoreIntent
|
|
1336
|
+
restorePending = false
|
|
1337
|
+
pendingRestoreSide = null
|
|
1338
|
+
// A keyed close issued after the cache read wins over the read side.
|
|
1339
|
+
val key = recyclingKey
|
|
1340
|
+
if (restored != null && isSideEnabled(restored) && (key == null || isCurrentIntent(intent))) {
|
|
1341
|
+
snapToOpen(restored, intent)
|
|
1342
|
+
} else {
|
|
1343
|
+
persistOpenSide(null)
|
|
1344
|
+
reconcileLogicalState()
|
|
1345
|
+
}
|
|
1346
|
+
} else {
|
|
1347
|
+
reconcileLogicalState()
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** True when the row settled at its logical state now, not in later work. */
|
|
1352
|
+
private fun reconcileLogicalState(
|
|
1353
|
+
restartLayoutGuard: Boolean = true,
|
|
1354
|
+
deadline: Long? = autoCloseDeadline
|
|
1355
|
+
): Boolean {
|
|
1356
|
+
val side = openSide
|
|
1357
|
+
val closingSide = autoCloseClosingSide
|
|
1358
|
+
val resumesCompletion = autoClose && (
|
|
1359
|
+
closingSide != null && isSideEnabled(closingSide) ||
|
|
1360
|
+
isOpen && side != null && isSideEnabled(side) && deadline != null
|
|
1361
|
+
)
|
|
1362
|
+
if (!cancelActiveWork(preserveGestureCompletion = resumesCompletion)) return false
|
|
1363
|
+
animationSide = null
|
|
1364
|
+
if (autoClose && closingSide != null && isSideEnabled(closingSide)) {
|
|
1365
|
+
// Continue an actual auto-close animation, preserving its original side/intent.
|
|
1366
|
+
animationSide = closingSide
|
|
1367
|
+
closeInternal(animated = true, autoCloseCompletion = true)
|
|
1368
|
+
return false
|
|
1369
|
+
}
|
|
1370
|
+
if (isOpen && side != null && isSideEnabled(side)) {
|
|
1371
|
+
openSide = side
|
|
1372
|
+
currentTranslation = openSide!!.sign * widthFor(openSide!!)
|
|
1373
|
+
contentView?.translationX = currentTranslation
|
|
1374
|
+
updateActionsTransform()
|
|
1375
|
+
if (restartLayoutGuard) startLayoutGuard()
|
|
1376
|
+
} else {
|
|
1377
|
+
isOpen = false
|
|
1378
|
+
openSide = null
|
|
1379
|
+
resetPosition()
|
|
1380
|
+
}
|
|
1381
|
+
persistOpenSide(openSide)
|
|
1382
|
+
if (autoClose && isOpen && deadline != null) scheduleAutoClose(deadline)
|
|
1383
|
+
return true
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
private fun persistOpenSide(side: SwipeSide?, key: String? = recyclingKey) {
|
|
1387
|
+
if (key == null) return
|
|
1388
|
+
// One critical section with the close commands: an open that a later close
|
|
1389
|
+
// made stale persists closed, whether this binding ends first or last.
|
|
1390
|
+
synchronized(registryLock) {
|
|
1391
|
+
if (getView(key) === this) {
|
|
1392
|
+
setOpenState(key, side?.takeIf { heldKey == key && openIntent >= closedAtSequence })
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** False if a close issued after the intent marked this view. */
|
|
1398
|
+
private fun isCurrentIntent(intent: Long): Boolean = synchronized(registryLock) {
|
|
1399
|
+
intent >= closedAtSequence
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private fun resetPosition() {
|
|
1403
|
+
currentTranslation = 0f
|
|
1404
|
+
startOffset = 0f
|
|
1405
|
+
contentView?.translationX = 0f
|
|
1406
|
+
updateActionsTransform()
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
private fun takeGestureCancellation(): Map<String, Any>? {
|
|
1410
|
+
val cancelled = if (gestureCompletionPending) gestureCompletionMetadata else null
|
|
1411
|
+
gestureCompletionPending = false
|
|
1412
|
+
gestureCompletionMetadata = null
|
|
1413
|
+
return cancelled
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
private fun cancelActiveWork(preserveGestureCompletion: Boolean = false): Boolean {
|
|
1417
|
+
// Capture the original start's identity before any reset/rebind. Dispatch
|
|
1418
|
+
// after clearing ownership, but before the caller can emit a replacement start.
|
|
1419
|
+
val cancelled = if (!preserveGestureCompletion) takeGestureCancellation() else null
|
|
1420
|
+
workGeneration++
|
|
1421
|
+
val generation = workGeneration
|
|
1422
|
+
currentTranslation = contentView?.translationX ?: currentTranslation
|
|
1423
|
+
contentSpringAnimation?.cancel()
|
|
1424
|
+
contentSpringAnimation = null
|
|
1425
|
+
actionsSpringAnimation?.cancel()
|
|
1426
|
+
actionsSpringAnimation = null
|
|
1427
|
+
cancelAutoClose()
|
|
1428
|
+
autoCloseClosingSide = null
|
|
1429
|
+
cancelGestureTimeout()
|
|
1430
|
+
stopProgressUpdates()
|
|
1431
|
+
stopLayoutGuard()
|
|
1123
1432
|
isAnimating = false
|
|
1124
1433
|
isDragging = false
|
|
1125
1434
|
isGestureActivated = false
|
|
1126
|
-
|
|
1127
|
-
|
|
1435
|
+
directGesturePending = false
|
|
1436
|
+
gestureSide = null
|
|
1437
|
+
gestureStartTranslation = 0f
|
|
1438
|
+
lastEmittedWillEndState = null
|
|
1439
|
+
isBlockingChildEvents = false
|
|
1440
|
+
isIntercepting = false
|
|
1441
|
+
childRequestedDisallowIntercept = false
|
|
1442
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
1443
|
+
if (cancelled != null) onGestureCancelled(cancelled)
|
|
1444
|
+
// Event dispatch is normally asynchronous, but native callbacks can reenter.
|
|
1445
|
+
// In that case the newer work owns the view and the caller must stop.
|
|
1446
|
+
return generation == workGeneration
|
|
1128
1447
|
}
|
|
1448
|
+
|
|
1449
|
+
private fun postCurrent(action: () -> Unit) {
|
|
1450
|
+
val generation = workGeneration
|
|
1451
|
+
val key = recyclingKey
|
|
1452
|
+
post { if (generation == workGeneration && key == recyclingKey) action() }
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
internal fun postCommand(action: () -> Unit) = postCommand(bindingGeneration, recyclingKey, action)
|
|
1456
|
+
|
|
1457
|
+
/** Runs the action only if the binding read at command time still owns the view. */
|
|
1458
|
+
private fun postCommand(generation: Long, key: String?, action: () -> Unit) {
|
|
1459
|
+
post {
|
|
1460
|
+
if (generation == bindingGeneration && key == recyclingKey &&
|
|
1461
|
+
(key == null || getView(key) === this)) {
|
|
1462
|
+
action()
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
private fun reconcileSideChildren() {
|
|
1468
|
+
val previousContent = contentView
|
|
1469
|
+
val previousLeft = leftActionsView
|
|
1470
|
+
val previousRight = rightActionsView
|
|
1471
|
+
if (childCount == 3) {
|
|
1472
|
+
leftActionsView = getChildAt(0)
|
|
1473
|
+
rightActionsView = getChildAt(1)
|
|
1474
|
+
contentView = getChildAt(2)
|
|
1475
|
+
} else {
|
|
1476
|
+
// Retain only known identities. A rail must never become content mid-transaction.
|
|
1477
|
+
if (contentView?.parent !== this) contentView = null
|
|
1478
|
+
if (leftActionsView?.parent !== this) leftActionsView = null
|
|
1479
|
+
if (rightActionsView?.parent !== this) rightActionsView = null
|
|
1480
|
+
}
|
|
1481
|
+
for (side in SwipeSide.values()) {
|
|
1482
|
+
val rail = railFor(side)
|
|
1483
|
+
val previous = if (side == SwipeSide.LEFT) previousLeft else previousRight
|
|
1484
|
+
val count = (rail as? ViewGroup)?.childCount ?: 0
|
|
1485
|
+
if (rail !== previous || (observedSlotCounts[side] ?: 0) > 0 && count == 0) requestedActions.remove(side)
|
|
1486
|
+
observedSlotCounts[side] = count
|
|
1487
|
+
(rail as? ViewGroup)?.apply {
|
|
1488
|
+
clipChildren = false
|
|
1489
|
+
clipToPadding = false
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (contentView !== previousContent) contentView?.translationX = currentTranslation
|
|
1493
|
+
if (isOpen || isDragging || isAnimating) activeSide?.let(::requestActions)
|
|
1494
|
+
updateActionsTransform()
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
private fun refreshRailVisibility() {
|
|
1498
|
+
for (side in SwipeSide.values()) {
|
|
1499
|
+
val visible = childCount == 3 && isSideEnabled(side) && side == activeSide &&
|
|
1500
|
+
(isOpen || isDragging || isAnimating) && (currentTranslation != 0f || isOpen)
|
|
1501
|
+
railFor(side)?.apply {
|
|
1502
|
+
visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
|
1503
|
+
// RN's TouchTargetHelper ignores Android visibility. An empty
|
|
1504
|
+
// zero-width rail still spans the row and otherwise steals taps
|
|
1505
|
+
// from the exposed opposite action, even with no descendants.
|
|
1506
|
+
(this as? ReactViewGroup)?.pointerEvents = if (visible) PointerEvents.AUTO else PointerEvents.NONE
|
|
1507
|
+
importantForAccessibility = if (visible) View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
1508
|
+
else View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1129
1513
|
}
|