react-native-swipeable-actions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +250 -0
- package/android/build.gradle +99 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/swipeable/SwipeConstants.kt +29 -0
- package/android/src/main/java/expo/modules/swipeable/SwipePhysics.kt +66 -0
- package/android/src/main/java/expo/modules/swipeable/SwipeableModule.kt +69 -0
- package/android/src/main/java/expo/modules/swipeable/SwipeableView.kt +951 -0
- package/build/Swipeable.d.ts +10 -0
- package/build/Swipeable.d.ts.map +1 -0
- package/build/Swipeable.js +114 -0
- package/build/Swipeable.js.map +1 -0
- package/build/Swipeable.types.d.ts +64 -0
- package/build/Swipeable.types.d.ts.map +1 -0
- package/build/Swipeable.types.js +3 -0
- package/build/Swipeable.types.js.map +1 -0
- package/build/SwipeableActions.d.ts +17 -0
- package/build/SwipeableActions.d.ts.map +1 -0
- package/build/SwipeableActions.js +31 -0
- package/build/SwipeableActions.js.map +1 -0
- package/build/SwipeableView.d.ts +10 -0
- package/build/SwipeableView.d.ts.map +1 -0
- package/build/SwipeableView.js +11 -0
- package/build/SwipeableView.js.map +1 -0
- package/build/constants.d.ts +11 -0
- package/build/constants.d.ts.map +1 -0
- package/build/constants.js +14 -0
- package/build/constants.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +21 -0
- package/build/index.js.map +1 -0
- package/build/nativeModuleUtils.d.ts +11 -0
- package/build/nativeModuleUtils.d.ts.map +1 -0
- package/build/nativeModuleUtils.js +49 -0
- package/build/nativeModuleUtils.js.map +1 -0
- package/build/swipeStateUtils.d.ts +16 -0
- package/build/swipeStateUtils.d.ts.map +1 -0
- package/build/swipeStateUtils.js +37 -0
- package/build/swipeStateUtils.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Package.swift +28 -0
- package/ios/SwipeAnimationConfig.swift +27 -0
- package/ios/SwipePhysics.swift +66 -0
- package/ios/SwipeableModule.swift +84 -0
- package/ios/SwipeableView.swift +845 -0
- package/ios/react-native-swipeable-actions.podspec +22 -0
- package/package.json +85 -0
- package/src/Swipeable.tsx +195 -0
- package/src/Swipeable.types.ts +70 -0
- package/src/SwipeableActions.tsx +41 -0
- package/src/SwipeableView.tsx +20 -0
- package/src/constants.ts +11 -0
- package/src/index.ts +22 -0
- package/src/nativeModuleUtils.ts +57 -0
- package/src/swipeStateUtils.ts +55 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
package expo.modules.swipeable
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.SystemClock
|
|
5
|
+
import android.view.Choreographer
|
|
6
|
+
import android.view.MotionEvent
|
|
7
|
+
import android.view.VelocityTracker
|
|
8
|
+
import android.view.View
|
|
9
|
+
import android.view.ViewConfiguration
|
|
10
|
+
import android.view.ViewGroup
|
|
11
|
+
import androidx.collection.LruCache
|
|
12
|
+
import androidx.dynamicanimation.animation.DynamicAnimation
|
|
13
|
+
import androidx.dynamicanimation.animation.SpringAnimation
|
|
14
|
+
import androidx.dynamicanimation.animation.SpringForce
|
|
15
|
+
import expo.modules.kotlin.AppContext
|
|
16
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
17
|
+
import expo.modules.kotlin.views.ExpoView
|
|
18
|
+
import java.lang.ref.WeakReference
|
|
19
|
+
import kotlin.math.abs
|
|
20
|
+
|
|
21
|
+
class SwipeableView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
22
|
+
|
|
23
|
+
companion object {
|
|
24
|
+
private const val MAX_CACHE_SIZE = 1000
|
|
25
|
+
private const val GESTURE_TIMEOUT_MS = 5000L
|
|
26
|
+
|
|
27
|
+
/** Thread-safe LRU cache for open state - prevents unbounded memory growth */
|
|
28
|
+
private val openStateCache = LruCache<String, Boolean>(MAX_CACHE_SIZE)
|
|
29
|
+
|
|
30
|
+
/** View registry for static method access by recyclingKey */
|
|
31
|
+
private val viewRegistry = mutableMapOf<String, WeakReference<SwipeableView>>()
|
|
32
|
+
|
|
33
|
+
/** Lock for thread-safe registry access */
|
|
34
|
+
private val registryLock = Any()
|
|
35
|
+
|
|
36
|
+
// Thread-safe accessors (LruCache is already synchronized internally)
|
|
37
|
+
private fun getOpenState(key: String): Boolean = openStateCache[key] ?: false
|
|
38
|
+
|
|
39
|
+
private fun setOpenState(key: String, isOpen: Boolean) {
|
|
40
|
+
openStateCache.put(key, isOpen)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun getView(key: String): SwipeableView? = synchronized(registryLock) {
|
|
44
|
+
viewRegistry[key]?.get()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun registerView(view: SwipeableView, key: String) = synchronized(registryLock) {
|
|
48
|
+
viewRegistry[key] = WeakReference(view)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private fun unregisterView(key: String) = synchronized(registryLock) {
|
|
52
|
+
viewRegistry.remove(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun getAllViews(): List<SwipeableView> = synchronized(registryLock) {
|
|
56
|
+
viewRegistry.values.mapNotNull { it.get() }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Static methods (by recyclingKey)
|
|
60
|
+
fun openByKey(key: String) {
|
|
61
|
+
getView(key)?.let { view ->
|
|
62
|
+
view.post { view.open() }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fun closeByKey(key: String, animated: Boolean = true) {
|
|
67
|
+
getView(key)?.let { view ->
|
|
68
|
+
view.post { view.close(animated) }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Close all swipeables. When animated=true, only closes open views. When animated=false, resets all views. */
|
|
73
|
+
fun closeAll(animated: Boolean = true) {
|
|
74
|
+
getAllViews().forEach { view ->
|
|
75
|
+
if (animated) {
|
|
76
|
+
// Only close open views when animated
|
|
77
|
+
if (view.isOpen) {
|
|
78
|
+
view.post { view.close(animated = true) }
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
// Reset all views when not animated
|
|
82
|
+
view.post { view.close(animated = false) }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Clear all cached state - useful for app reset scenarios */
|
|
88
|
+
fun clearCache() {
|
|
89
|
+
openStateCache.evictAll()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private val density = context.resources.displayMetrics.density
|
|
94
|
+
|
|
95
|
+
var actionsWidth: Float = 0f
|
|
96
|
+
set(value) {
|
|
97
|
+
val validated = maxOf(0f, value)
|
|
98
|
+
if (field == validated) return
|
|
99
|
+
field = validated
|
|
100
|
+
requestLayout()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private val actionsWidthPx: Float
|
|
104
|
+
get() = actionsWidth * density
|
|
105
|
+
|
|
106
|
+
var actionsPosition: String = "right"
|
|
107
|
+
set(value) {
|
|
108
|
+
val validated = if (value == "left" || value == "right") value else "right"
|
|
109
|
+
if (field == validated) return
|
|
110
|
+
field = validated
|
|
111
|
+
requestLayout()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
val isLeading: Boolean get() = actionsPosition == "left"
|
|
115
|
+
|
|
116
|
+
var friction: Float = 1.0f
|
|
117
|
+
set(value) { field = value.coerceIn(0f, 1f) }
|
|
118
|
+
|
|
119
|
+
var threshold: Float = 0.4f
|
|
120
|
+
set(value) { field = value.coerceIn(0f, 1f) }
|
|
121
|
+
|
|
122
|
+
var dragOffsetFromEdge: Float = 0f
|
|
123
|
+
set(value) { field = maxOf(0f, value) }
|
|
124
|
+
|
|
125
|
+
var autoClose: Boolean = false
|
|
126
|
+
|
|
127
|
+
var autoCloseTimeout: Float = 0f // milliseconds
|
|
128
|
+
set(value) { field = maxOf(0f, value) }
|
|
129
|
+
|
|
130
|
+
private val dragOffsetFromEdgePx: Float
|
|
131
|
+
get() = dragOffsetFromEdge * density
|
|
132
|
+
|
|
133
|
+
/** Key for recycling detection - when this changes, view saves/restores state from cache */
|
|
134
|
+
var recyclingKey: String? = null
|
|
135
|
+
set(value) {
|
|
136
|
+
val oldValue = field
|
|
137
|
+
if (oldValue == value) return
|
|
138
|
+
field = value
|
|
139
|
+
|
|
140
|
+
// Thread-safe read of new key's state
|
|
141
|
+
val shouldOpen = value?.let { getOpenState(it) } ?: false
|
|
142
|
+
pendingShouldOpen = shouldOpen
|
|
143
|
+
|
|
144
|
+
// Thread-safe write of old key's state and unregister
|
|
145
|
+
if (oldValue != null) {
|
|
146
|
+
setOpenState(oldValue, isOpen)
|
|
147
|
+
unregisterView(oldValue)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Thread-safe register with new key
|
|
151
|
+
if (value != null) {
|
|
152
|
+
registerView(this, value)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
close(animated = false)
|
|
156
|
+
|
|
157
|
+
// Apply using local shouldOpen (pendingShouldOpen was cleared by close)
|
|
158
|
+
if (shouldOpen) {
|
|
159
|
+
post { snapToOpen() }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private var currentTranslation: Float = 0f
|
|
164
|
+
private var isOpen: Boolean = false
|
|
165
|
+
private var startOffset: Float = 0f
|
|
166
|
+
private var isDragging: Boolean = false
|
|
167
|
+
private var isAnimating: Boolean = false
|
|
168
|
+
private var gestureStartTranslation: Float = 0f
|
|
169
|
+
private var isGestureActivated: Boolean = false
|
|
170
|
+
private var lastEmittedProgress: Float = -1f
|
|
171
|
+
private var pendingShouldOpen: Boolean = false
|
|
172
|
+
private var lastEmittedWillEndState: String? = null
|
|
173
|
+
|
|
174
|
+
// Track if open animation was explicitly interrupted by close() for autoClose
|
|
175
|
+
private var openAnimationInterruptedByClose: Boolean = false
|
|
176
|
+
|
|
177
|
+
// Override onSwipeEnd state when autoClose triggers close() (emit "open" instead of "closed")
|
|
178
|
+
private var autoCloseEndEventOverride: Boolean = false
|
|
179
|
+
|
|
180
|
+
private var contentView: View? = null
|
|
181
|
+
private var actionsView: View? = null
|
|
182
|
+
|
|
183
|
+
private val onSwipeProgress by EventDispatcher<Map<String, Any>>()
|
|
184
|
+
private val onSwipeStart by EventDispatcher<Map<String, Any>>()
|
|
185
|
+
private val onSwipeStateChange by EventDispatcher<Map<String, Any>>()
|
|
186
|
+
private val onSwipeEnd by EventDispatcher<Map<String, Any>>()
|
|
187
|
+
|
|
188
|
+
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
189
|
+
private var velocityTracker: VelocityTracker? = null
|
|
190
|
+
private var touchStartX: Float = 0f
|
|
191
|
+
private var touchStartY: Float = 0f
|
|
192
|
+
private var isIntercepting: Boolean = false
|
|
193
|
+
|
|
194
|
+
// Touch interception for swipe detection
|
|
195
|
+
private var dispatchTouchStartX: Float = 0f
|
|
196
|
+
private var dispatchTouchStartY: Float = 0f
|
|
197
|
+
private var isBlockingChildEvents: Boolean = false
|
|
198
|
+
|
|
199
|
+
private var contentSpringAnimation: SpringAnimation? = null
|
|
200
|
+
private var actionsSpringAnimation: SpringAnimation? = null
|
|
201
|
+
|
|
202
|
+
private var frameCallback: Choreographer.FrameCallback? = null
|
|
203
|
+
|
|
204
|
+
// Gesture timeout for stuck gesture recovery
|
|
205
|
+
private var gestureTimeoutRunnable: Runnable? = null
|
|
206
|
+
|
|
207
|
+
// Auto-close runnable for tracking and cancellation
|
|
208
|
+
private var autoCloseRunnable: Runnable? = null
|
|
209
|
+
|
|
210
|
+
init {
|
|
211
|
+
clipChildren = false
|
|
212
|
+
clipToPadding = false
|
|
213
|
+
setWillNotDraw(false)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
override fun setClipChildren(clipChildren: Boolean) {
|
|
217
|
+
super.setClipChildren(false)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
override fun setClipToPadding(clipToPadding: Boolean) {
|
|
221
|
+
super.setClipToPadding(false)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
override fun onViewAdded(child: View?) {
|
|
225
|
+
super.onViewAdded(child)
|
|
226
|
+
child ?: return
|
|
227
|
+
|
|
228
|
+
val wasActionsNil = actionsView == null
|
|
229
|
+
val wasContentNil = contentView == null
|
|
230
|
+
|
|
231
|
+
// With lazy Actions rendering:
|
|
232
|
+
// - When Actions not mounted: only content at index 0
|
|
233
|
+
// - When Actions mounted: actions at index 0, content at index 1
|
|
234
|
+
// Always re-evaluate all children since React may add them in any order
|
|
235
|
+
if (childCount == 1) {
|
|
236
|
+
// Only one child - it's content (Actions not mounted yet)
|
|
237
|
+
contentView = getChildAt(0)
|
|
238
|
+
actionsView = null
|
|
239
|
+
} else if (childCount >= 2) {
|
|
240
|
+
// Always check both positions - React may have reordered children
|
|
241
|
+
actionsView = getChildAt(0)
|
|
242
|
+
contentView = getChildAt(1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (wasContentNil && contentView != null && (isOpen || currentTranslation != 0f)) {
|
|
246
|
+
contentView?.translationX = currentTranslation
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (wasActionsNil && actionsView != null) {
|
|
250
|
+
if (isOpen || isAnimating) {
|
|
251
|
+
actionsView?.visibility = View.VISIBLE
|
|
252
|
+
actionsView?.translationX = 0f
|
|
253
|
+
}
|
|
254
|
+
else if (isDragging) {
|
|
255
|
+
actionsView?.visibility = View.VISIBLE
|
|
256
|
+
updateActionsTransform()
|
|
257
|
+
}
|
|
258
|
+
else if (pendingShouldOpen) {
|
|
259
|
+
pendingShouldOpen = false
|
|
260
|
+
post { snapToOpen() }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
override fun onViewRemoved(child: View?) {
|
|
266
|
+
super.onViewRemoved(child)
|
|
267
|
+
if (child === actionsView) {
|
|
268
|
+
actionsView = null
|
|
269
|
+
} else if (child === contentView) {
|
|
270
|
+
contentView = null
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
275
|
+
val width = right - left
|
|
276
|
+
val height = bottom - top
|
|
277
|
+
|
|
278
|
+
for (i in 0 until childCount) {
|
|
279
|
+
val child = getChildAt(i)
|
|
280
|
+
if (child === actionsView && actionsWidthPx > 0f) {
|
|
281
|
+
val actionsWidthInt = actionsWidthPx.toInt()
|
|
282
|
+
if (isLeading) {
|
|
283
|
+
child.layout(0, 0, actionsWidthInt, height)
|
|
284
|
+
} else {
|
|
285
|
+
child.layout(width - actionsWidthInt, 0, width, height)
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
child.layout(0, 0, width, height)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!isDragging && !isAnimating && currentTranslation == 0f && actionsWidthPx > 0f) {
|
|
293
|
+
val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
|
|
294
|
+
actionsView?.translationX = actionsOffset
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Cancels React Native's JS touch handling using the official API.
|
|
300
|
+
* Calls onChildStartedNativeGesture() which properly notifies
|
|
301
|
+
* JSTouchDispatcher and JSPointerDispatcher to cancel tracking.
|
|
302
|
+
*/
|
|
303
|
+
private fun cancelReactTouches(event: MotionEvent) {
|
|
304
|
+
// Find ReactRootView by walking up hierarchy and checking for the method
|
|
305
|
+
var currentView: ViewGroup? = parent as? ViewGroup
|
|
306
|
+
while (currentView != null) {
|
|
307
|
+
try {
|
|
308
|
+
// Try to find onChildStartedNativeGesture method
|
|
309
|
+
val method = currentView.javaClass.getMethod(
|
|
310
|
+
"onChildStartedNativeGesture",
|
|
311
|
+
View::class.java,
|
|
312
|
+
MotionEvent::class.java
|
|
313
|
+
)
|
|
314
|
+
method.invoke(currentView, this, event)
|
|
315
|
+
return
|
|
316
|
+
} catch (e: NoSuchMethodException) {
|
|
317
|
+
// Method not found, continue up the hierarchy
|
|
318
|
+
} catch (e: Exception) {
|
|
319
|
+
// Method found but invocation failed
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
currentView = currentView.parent as? ViewGroup
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Simple touch interception: forward DOWN, send CANCEL when swipe detected.
|
|
328
|
+
*
|
|
329
|
+
* Flow:
|
|
330
|
+
* - DOWN: Forward to children (they start tracking), init our tracking
|
|
331
|
+
* - MOVE: If horizontal swipe → send CANCEL to children, block further events
|
|
332
|
+
* - UP: If blocking → consume (children never see UP, no onPress)
|
|
333
|
+
*/
|
|
334
|
+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
|
335
|
+
if (actionsWidth <= 0f) {
|
|
336
|
+
return super.dispatchTouchEvent(event)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
when (event.actionMasked) {
|
|
340
|
+
MotionEvent.ACTION_DOWN -> {
|
|
341
|
+
dispatchTouchStartX = event.rawX
|
|
342
|
+
dispatchTouchStartY = event.rawY
|
|
343
|
+
isBlockingChildEvents = false
|
|
344
|
+
|
|
345
|
+
// Initialize tracking
|
|
346
|
+
touchStartX = event.rawX
|
|
347
|
+
touchStartY = event.rawY
|
|
348
|
+
velocityTracker?.recycle()
|
|
349
|
+
velocityTracker = VelocityTracker.obtain()
|
|
350
|
+
velocityTracker?.addMovement(event)
|
|
351
|
+
|
|
352
|
+
// Handle in onTouchEvent for animation interruption
|
|
353
|
+
onTouchEvent(event)
|
|
354
|
+
|
|
355
|
+
// Forward DOWN to children
|
|
356
|
+
return super.dispatchTouchEvent(event)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
MotionEvent.ACTION_MOVE -> {
|
|
360
|
+
velocityTracker?.addMovement(event)
|
|
361
|
+
|
|
362
|
+
// Already blocking - consume all MOVE events
|
|
363
|
+
if (isBlockingChildEvents) {
|
|
364
|
+
onTouchEvent(event)
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
val dx = abs(event.rawX - dispatchTouchStartX)
|
|
369
|
+
val dy = abs(event.rawY - dispatchTouchStartY)
|
|
370
|
+
|
|
371
|
+
// Detect horizontal swipe
|
|
372
|
+
if (dx > touchSlop && dx > dy) {
|
|
373
|
+
val horizontalDelta = event.rawX - dispatchTouchStartX
|
|
374
|
+
val isCorrectDirection = if (isOpen) {
|
|
375
|
+
true
|
|
376
|
+
} else {
|
|
377
|
+
if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (isCorrectDirection) {
|
|
381
|
+
// Swipe detected - cancel React touches, start blocking
|
|
382
|
+
isBlockingChildEvents = true
|
|
383
|
+
cancelReactTouches(event)
|
|
384
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
385
|
+
onTouchEvent(event)
|
|
386
|
+
return true
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Not a swipe - forward to children
|
|
391
|
+
return super.dispatchTouchEvent(event)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
MotionEvent.ACTION_UP -> {
|
|
395
|
+
velocityTracker?.addMovement(event)
|
|
396
|
+
|
|
397
|
+
if (isBlockingChildEvents) {
|
|
398
|
+
// Swipe ended - consume UP (children already got CANCEL)
|
|
399
|
+
onTouchEvent(event)
|
|
400
|
+
isBlockingChildEvents = false
|
|
401
|
+
return true
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Not blocking - forward to children (tap)
|
|
405
|
+
return super.dispatchTouchEvent(event)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
MotionEvent.ACTION_CANCEL -> {
|
|
409
|
+
isBlockingChildEvents = false
|
|
410
|
+
onTouchEvent(event)
|
|
411
|
+
return super.dispatchTouchEvent(event)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return super.dispatchTouchEvent(event)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
|
419
|
+
if (actionsWidth <= 0f) return false
|
|
420
|
+
|
|
421
|
+
when (event.actionMasked) {
|
|
422
|
+
MotionEvent.ACTION_DOWN -> {
|
|
423
|
+
touchStartX = event.rawX
|
|
424
|
+
touchStartY = event.rawY
|
|
425
|
+
isIntercepting = false
|
|
426
|
+
|
|
427
|
+
if (isAnimating) {
|
|
428
|
+
contentSpringAnimation?.cancel()
|
|
429
|
+
isAnimating = false
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
velocityTracker?.recycle()
|
|
433
|
+
velocityTracker = VelocityTracker.obtain()
|
|
434
|
+
velocityTracker?.addMovement(event)
|
|
435
|
+
}
|
|
436
|
+
MotionEvent.ACTION_MOVE -> {
|
|
437
|
+
velocityTracker?.addMovement(event)
|
|
438
|
+
val horizontalDelta = event.rawX - touchStartX
|
|
439
|
+
val verticalDelta = event.rawY - touchStartY
|
|
440
|
+
|
|
441
|
+
if (abs(horizontalDelta) > touchSlop && abs(horizontalDelta) > abs(verticalDelta)) {
|
|
442
|
+
val isCorrectDirection = if (isOpen) {
|
|
443
|
+
true
|
|
444
|
+
} else {
|
|
445
|
+
if (isLeading) horizontalDelta > 0 else horizontalDelta < 0
|
|
446
|
+
}
|
|
447
|
+
val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
|
|
448
|
+
|
|
449
|
+
if (isCorrectDirection && meetsOffset) {
|
|
450
|
+
isIntercepting = true
|
|
451
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
452
|
+
return true
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
457
|
+
isIntercepting = false
|
|
458
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
465
|
+
if (actionsWidth <= 0f) return false
|
|
466
|
+
|
|
467
|
+
velocityTracker?.addMovement(event)
|
|
468
|
+
|
|
469
|
+
when (event.actionMasked) {
|
|
470
|
+
MotionEvent.ACTION_DOWN -> {
|
|
471
|
+
touchStartX = event.rawX
|
|
472
|
+
touchStartY = event.rawY
|
|
473
|
+
return true
|
|
474
|
+
}
|
|
475
|
+
MotionEvent.ACTION_MOVE -> {
|
|
476
|
+
val horizontalDelta = event.rawX - touchStartX
|
|
477
|
+
|
|
478
|
+
if (!isGestureActivated) {
|
|
479
|
+
val meetsOffset = dragOffsetFromEdgePx <= 0f || abs(horizontalDelta) >= dragOffsetFromEdgePx
|
|
480
|
+
if (meetsOffset) {
|
|
481
|
+
activateGesture(horizontalDelta)
|
|
482
|
+
} else {
|
|
483
|
+
return true
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
handlePanUpdate(horizontalDelta)
|
|
488
|
+
}
|
|
489
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
490
|
+
if (isGestureActivated) {
|
|
491
|
+
velocityTracker?.computeCurrentVelocity(1000)
|
|
492
|
+
val horizontalDelta = event.rawX - touchStartX
|
|
493
|
+
handlePanEnd(horizontalDelta, velocityTracker?.xVelocity ?: 0f)
|
|
494
|
+
}
|
|
495
|
+
velocityTracker?.recycle()
|
|
496
|
+
velocityTracker = null
|
|
497
|
+
isIntercepting = false
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return isGestureActivated || isIntercepting
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private fun activateGesture(initialTranslation: Float) {
|
|
504
|
+
// Cancel any running animations and capture current position
|
|
505
|
+
contentSpringAnimation?.let { anim ->
|
|
506
|
+
if (anim.isRunning) {
|
|
507
|
+
currentTranslation = contentView?.translationX ?: currentTranslation
|
|
508
|
+
anim.cancel()
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
actionsSpringAnimation?.cancel()
|
|
512
|
+
isAnimating = false
|
|
513
|
+
|
|
514
|
+
isGestureActivated = true
|
|
515
|
+
isDragging = true
|
|
516
|
+
startOffset = currentTranslation
|
|
517
|
+
gestureStartTranslation = initialTranslation
|
|
518
|
+
lastEmittedWillEndState = if (isOpen) "open" else "closed"
|
|
519
|
+
|
|
520
|
+
actionsView?.visibility = View.VISIBLE
|
|
521
|
+
parent?.requestDisallowInterceptTouchEvent(true) // Prevent parent from stealing gesture
|
|
522
|
+
|
|
523
|
+
onSwipeStart(emptyMap())
|
|
524
|
+
startProgressUpdates()
|
|
525
|
+
startGestureTimeout()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private fun startGestureTimeout() {
|
|
529
|
+
cancelGestureTimeout()
|
|
530
|
+
gestureTimeoutRunnable = Runnable {
|
|
531
|
+
if (isGestureActivated) {
|
|
532
|
+
// Force gesture end if stuck
|
|
533
|
+
handlePanEnd(0f, 0f)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
postDelayed(gestureTimeoutRunnable, GESTURE_TIMEOUT_MS)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private fun cancelGestureTimeout() {
|
|
540
|
+
gestureTimeoutRunnable?.let { removeCallbacks(it) }
|
|
541
|
+
gestureTimeoutRunnable = null
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private fun cancelAutoClose() {
|
|
545
|
+
autoCloseRunnable?.let { removeCallbacks(it) }
|
|
546
|
+
autoCloseRunnable = null
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private fun scheduleAutoClose() {
|
|
550
|
+
cancelAutoClose()
|
|
551
|
+
val delay = if (autoCloseTimeout > 0) autoCloseTimeout.toLong() else 0L
|
|
552
|
+
autoCloseRunnable = Runnable {
|
|
553
|
+
autoCloseRunnable = null
|
|
554
|
+
// Emit "open" state when autoClose completes - user's swipe intent was achieved
|
|
555
|
+
autoCloseEndEventOverride = true
|
|
556
|
+
close()
|
|
557
|
+
}
|
|
558
|
+
postDelayed(autoCloseRunnable, delay)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private fun handlePanUpdate(translationX: Float) {
|
|
562
|
+
if (!isGestureActivated || actionsWidth <= 0f) return
|
|
563
|
+
|
|
564
|
+
val effectiveTranslation = translationX - gestureStartTranslation
|
|
565
|
+
|
|
566
|
+
val rawTranslation = startOffset + (effectiveTranslation * friction)
|
|
567
|
+
// Always clamp: never allow translation past closed position (0)
|
|
568
|
+
val clampedTranslation = if (isLeading) maxOf(0f, rawTranslation) else minOf(0f, rawTranslation)
|
|
569
|
+
val targetTranslation = SwipePhysics.applyRubberBand(clampedTranslation, actionsWidthPx, isLeading)
|
|
570
|
+
|
|
571
|
+
currentTranslation = targetTranslation
|
|
572
|
+
contentView?.translationX = targetTranslation
|
|
573
|
+
updateActionsTransform()
|
|
574
|
+
|
|
575
|
+
// Emit onSwipeStateChange when threshold crossing changes intended state
|
|
576
|
+
val thresholdDistance = actionsWidthPx * threshold
|
|
577
|
+
val wouldOpen = SwipePhysics.shouldSnapToOpen(targetTranslation, thresholdDistance, isOpen, isLeading)
|
|
578
|
+
val intendedState = if (wouldOpen) "open" else "closed"
|
|
579
|
+
if (lastEmittedWillEndState != intendedState) {
|
|
580
|
+
lastEmittedWillEndState = intendedState
|
|
581
|
+
onSwipeStateChange(mapOf("state" to intendedState))
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private fun handlePanEnd(translationX: Float, velocityX: Float) {
|
|
586
|
+
if (!isGestureActivated) return
|
|
587
|
+
|
|
588
|
+
val effectiveTranslation = translationX - gestureStartTranslation
|
|
589
|
+
|
|
590
|
+
isGestureActivated = false
|
|
591
|
+
gestureStartTranslation = 0f
|
|
592
|
+
isDragging = false
|
|
593
|
+
stopProgressUpdates()
|
|
594
|
+
cancelGestureTimeout()
|
|
595
|
+
|
|
596
|
+
val projectedX = SwipePhysics.projectFinalPosition(effectiveTranslation, velocityX, friction)
|
|
597
|
+
val thresholdDistance = actionsWidthPx * threshold
|
|
598
|
+
val shouldOpen = SwipePhysics.shouldSnapToOpen(projectedX, thresholdDistance, isOpen, isLeading)
|
|
599
|
+
|
|
600
|
+
// Only emit onSwipeStateChange if state changed from what was emitted during drag
|
|
601
|
+
// (velocity projection may result in different final state)
|
|
602
|
+
val intendedState = if (shouldOpen) "open" else "closed"
|
|
603
|
+
if (lastEmittedWillEndState != intendedState) {
|
|
604
|
+
lastEmittedWillEndState = intendedState
|
|
605
|
+
onSwipeStateChange(mapOf("state" to intendedState))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (shouldOpen) {
|
|
609
|
+
// Only use velocity if it's in the opening direction (prevents backward bounce)
|
|
610
|
+
val openingVelocity = if (isLeading) {
|
|
611
|
+
if (velocityX > 0) velocityX else 0f // Leading: positive velocity opens
|
|
612
|
+
} else {
|
|
613
|
+
if (velocityX < 0) velocityX else 0f // Trailing: negative velocity opens
|
|
614
|
+
}
|
|
615
|
+
open(openingVelocity)
|
|
616
|
+
// Schedule auto-close if enabled
|
|
617
|
+
if (autoClose) {
|
|
618
|
+
scheduleAutoClose()
|
|
619
|
+
}
|
|
620
|
+
} else {
|
|
621
|
+
// Only use velocity if it's in the closing direction (prevents backward bounce)
|
|
622
|
+
val closingVelocity = if (isLeading) {
|
|
623
|
+
if (velocityX < 0) velocityX else 0f // Leading: negative velocity closes
|
|
624
|
+
} else {
|
|
625
|
+
if (velocityX > 0) velocityX else 0f // Trailing: positive velocity closes
|
|
626
|
+
}
|
|
627
|
+
close(velocity = closingVelocity)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private fun updateActionsTransform() {
|
|
632
|
+
val actions = actionsView ?: return
|
|
633
|
+
if (actionsWidthPx <= 0f) return
|
|
634
|
+
|
|
635
|
+
val progress = minOf(abs(currentTranslation) / actionsWidthPx, 1f)
|
|
636
|
+
val offset = if (isLeading) -actionsWidthPx else actionsWidthPx
|
|
637
|
+
val actionsTranslateX = offset * (1f - progress)
|
|
638
|
+
|
|
639
|
+
actions.translationX = actionsTranslateX
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
fun open(velocity: Float = 0f) {
|
|
643
|
+
isOpen = true
|
|
644
|
+
isAnimating = true
|
|
645
|
+
// Thread-safe persist to static cache for FlashList recycling
|
|
646
|
+
recyclingKey?.let { key -> setOpenState(key, true) }
|
|
647
|
+
// Note: actionsView visibility is already set during drag phase
|
|
648
|
+
if (actionsView?.visibility != View.VISIBLE) {
|
|
649
|
+
actionsView?.visibility = View.VISIBLE
|
|
650
|
+
}
|
|
651
|
+
val targetX = if (isLeading) actionsWidthPx else -actionsWidthPx
|
|
652
|
+
|
|
653
|
+
val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
|
|
654
|
+
|
|
655
|
+
// Start progress updates to emit progress during animation
|
|
656
|
+
startProgressUpdates()
|
|
657
|
+
|
|
658
|
+
// Reset the flag - close() will set it to true if it interrupts us
|
|
659
|
+
openAnimationInterruptedByClose = false
|
|
660
|
+
contentSpringAnimation?.cancel()
|
|
661
|
+
actionsSpringAnimation?.cancel()
|
|
662
|
+
|
|
663
|
+
val content = contentView
|
|
664
|
+
val actions = actionsView
|
|
665
|
+
|
|
666
|
+
if (content == null) {
|
|
667
|
+
currentTranslation = targetX
|
|
668
|
+
isAnimating = false
|
|
669
|
+
emitFinalProgress(1f, targetX)
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Define bounds and clamp starting position to prevent IllegalArgumentException
|
|
674
|
+
val minBound = if (isLeading) 0f else -actionsWidthPx
|
|
675
|
+
val maxBound = if (isLeading) actionsWidthPx else 0f
|
|
676
|
+
|
|
677
|
+
// Clamp content's current position to within bounds before starting animation
|
|
678
|
+
// (user may have overswept beyond the target)
|
|
679
|
+
val clampedStart = content.translationX.coerceIn(minBound, maxBound)
|
|
680
|
+
val wasClamped = content.translationX != clampedStart
|
|
681
|
+
if (wasClamped) {
|
|
682
|
+
content.translationX = clampedStart
|
|
683
|
+
currentTranslation = clampedStart
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// If we clamped to the target, use zero velocity to prevent spring bounce
|
|
687
|
+
val effectiveVelocity = if (wasClamped && clampedStart == targetX) 0f else springVelocity
|
|
688
|
+
|
|
689
|
+
contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, targetX).apply {
|
|
690
|
+
spring = SpringForce(targetX).apply {
|
|
691
|
+
stiffness = SwipeConstants.OPEN_STIFFNESS
|
|
692
|
+
dampingRatio = SwipeConstants.OPEN_DAMPING_RATIO
|
|
693
|
+
}
|
|
694
|
+
// Clamp animation bounds to prevent spring overshoot from affecting UI
|
|
695
|
+
setMinValue(minBound)
|
|
696
|
+
setMaxValue(maxBound)
|
|
697
|
+
setStartVelocity(effectiveVelocity)
|
|
698
|
+
addUpdateListener { _, value, _ ->
|
|
699
|
+
currentTranslation = value
|
|
700
|
+
updateActionsTransform()
|
|
701
|
+
}
|
|
702
|
+
addEndListener { _, _, _, _ ->
|
|
703
|
+
if (openAnimationInterruptedByClose) {
|
|
704
|
+
// Animation was interrupted by close() (e.g., autoClose)
|
|
705
|
+
return@addEndListener
|
|
706
|
+
}
|
|
707
|
+
isAnimating = false
|
|
708
|
+
emitFinalProgress(1f, targetX)
|
|
709
|
+
// With autoClose, let close() emit onSwipeEnd("open") instead
|
|
710
|
+
if (!autoClose) {
|
|
711
|
+
onSwipeEnd(mapOf("state" to "open"))
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
start()
|
|
715
|
+
}
|
|
716
|
+
// Actions are now driven by updateActionsTransform() called from content's update listener
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Instantly snap to open state without animation (for recycling) */
|
|
720
|
+
fun snapToOpen() {
|
|
721
|
+
contentSpringAnimation?.cancel()
|
|
722
|
+
actionsSpringAnimation?.cancel()
|
|
723
|
+
|
|
724
|
+
isOpen = true
|
|
725
|
+
isAnimating = false
|
|
726
|
+
val targetX = if (isLeading) actionsWidthPx else -actionsWidthPx
|
|
727
|
+
currentTranslation = targetX
|
|
728
|
+
|
|
729
|
+
// Set content translation immediately
|
|
730
|
+
contentView?.translationX = targetX
|
|
731
|
+
|
|
732
|
+
if (actionsView != null) {
|
|
733
|
+
// Actions already mounted - position them
|
|
734
|
+
actionsView?.visibility = View.VISIBLE
|
|
735
|
+
actionsView?.translationX = 0f
|
|
736
|
+
} else {
|
|
737
|
+
// Actions not mounted yet (React lazy rendering)
|
|
738
|
+
// Trigger onSwipeStart so React renders SwipeableActions
|
|
739
|
+
// onViewAdded will handle positioning when actions are added
|
|
740
|
+
onSwipeStart(emptyMap())
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Emit final progress
|
|
744
|
+
emitFinalProgress(1f, targetX)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
fun close(animated: Boolean = true, velocity: Float = 0f) {
|
|
748
|
+
cancelAutoClose()
|
|
749
|
+
stopProgressUpdates()
|
|
750
|
+
|
|
751
|
+
// Cancel any running animations
|
|
752
|
+
openAnimationInterruptedByClose = true
|
|
753
|
+
contentSpringAnimation?.cancel()
|
|
754
|
+
actionsSpringAnimation?.cancel()
|
|
755
|
+
|
|
756
|
+
// Clear gesture state (unified from reset)
|
|
757
|
+
isDragging = false
|
|
758
|
+
isGestureActivated = false
|
|
759
|
+
gestureStartTranslation = 0f
|
|
760
|
+
pendingShouldOpen = false
|
|
761
|
+
|
|
762
|
+
isOpen = false
|
|
763
|
+
// Only persist to cache when animated (user action), not when resetting (recycling)
|
|
764
|
+
if (animated) {
|
|
765
|
+
recyclingKey?.let { key -> setOpenState(key, false) }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
val actionsOffset = if (isLeading) -actionsWidthPx else actionsWidthPx
|
|
769
|
+
|
|
770
|
+
if (animated) {
|
|
771
|
+
// Animated close with spring physics
|
|
772
|
+
isAnimating = true
|
|
773
|
+
startProgressUpdates()
|
|
774
|
+
|
|
775
|
+
val springVelocity = SwipePhysics.calculateSpringVelocity(velocity)
|
|
776
|
+
val content = contentView
|
|
777
|
+
|
|
778
|
+
if (content == null) {
|
|
779
|
+
currentTranslation = 0f
|
|
780
|
+
actionsView?.translationX = actionsOffset
|
|
781
|
+
isAnimating = false
|
|
782
|
+
openAnimationInterruptedByClose = false
|
|
783
|
+
emitFinalProgress(0f, 0f)
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Define bounds and clamp starting position
|
|
788
|
+
val minBound = if (isLeading) 0f else -actionsWidthPx
|
|
789
|
+
val maxBound = if (isLeading) actionsWidthPx else 0f
|
|
790
|
+
|
|
791
|
+
val clampedStart = content.translationX.coerceIn(minBound, maxBound)
|
|
792
|
+
val wasClamped = content.translationX != clampedStart
|
|
793
|
+
if (wasClamped) {
|
|
794
|
+
content.translationX = clampedStart
|
|
795
|
+
currentTranslation = clampedStart
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
val effectiveVelocity = if (wasClamped && clampedStart == 0f) 0f else springVelocity
|
|
799
|
+
|
|
800
|
+
contentSpringAnimation = SpringAnimation(content, DynamicAnimation.TRANSLATION_X, 0f).apply {
|
|
801
|
+
spring = SpringForce(0f).apply {
|
|
802
|
+
stiffness = SwipeConstants.CLOSE_STIFFNESS
|
|
803
|
+
dampingRatio = SwipeConstants.CLOSE_DAMPING_RATIO
|
|
804
|
+
}
|
|
805
|
+
setMinValue(minBound)
|
|
806
|
+
setMaxValue(maxBound)
|
|
807
|
+
setStartVelocity(effectiveVelocity)
|
|
808
|
+
addUpdateListener { _, value, _ ->
|
|
809
|
+
currentTranslation = value
|
|
810
|
+
updateActionsTransform()
|
|
811
|
+
}
|
|
812
|
+
addEndListener { _, _, _, _ ->
|
|
813
|
+
isAnimating = false
|
|
814
|
+
openAnimationInterruptedByClose = false
|
|
815
|
+
actionsView?.visibility = View.INVISIBLE
|
|
816
|
+
emitFinalProgress(0f, 0f)
|
|
817
|
+
// Emit "open" when autoClose completes - user's swipe intent was achieved
|
|
818
|
+
val endState = if (autoCloseEndEventOverride) "open" else "closed"
|
|
819
|
+
autoCloseEndEventOverride = false
|
|
820
|
+
onSwipeEnd(mapOf("state" to endState))
|
|
821
|
+
}
|
|
822
|
+
start()
|
|
823
|
+
}
|
|
824
|
+
} else {
|
|
825
|
+
// Instant close (formerly reset)
|
|
826
|
+
isAnimating = false
|
|
827
|
+
openAnimationInterruptedByClose = false
|
|
828
|
+
currentTranslation = 0f
|
|
829
|
+
startOffset = 0f
|
|
830
|
+
lastEmittedProgress = -1f
|
|
831
|
+
|
|
832
|
+
contentView?.translationX = 0f
|
|
833
|
+
actionsView?.translationX = actionsOffset
|
|
834
|
+
actionsView?.visibility = View.INVISIBLE
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
fun handleGestureStart() {
|
|
839
|
+
if (actionsWidth <= 0f) return
|
|
840
|
+
|
|
841
|
+
isDragging = true
|
|
842
|
+
startOffset = currentTranslation
|
|
843
|
+
gestureStartTranslation = 0f
|
|
844
|
+
isGestureActivated = true
|
|
845
|
+
lastEmittedWillEndState = if (isOpen) "open" else "closed"
|
|
846
|
+
onSwipeStart(emptyMap())
|
|
847
|
+
startProgressUpdates()
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
fun handleGestureUpdate(translationX: Float, velocityX: Float) {
|
|
851
|
+
if (actionsWidth <= 0f || !isGestureActivated) return
|
|
852
|
+
|
|
853
|
+
val rawTranslation = startOffset + (translationX * friction)
|
|
854
|
+
val targetTranslation = SwipePhysics.applyRubberBand(rawTranslation, actionsWidthPx, isLeading)
|
|
855
|
+
|
|
856
|
+
currentTranslation = targetTranslation
|
|
857
|
+
contentView?.translationX = targetTranslation
|
|
858
|
+
updateActionsTransform()
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
fun handleGestureEnd(translationX: Float, velocityX: Float) {
|
|
862
|
+
if (!isGestureActivated) return
|
|
863
|
+
|
|
864
|
+
isGestureActivated = false
|
|
865
|
+
gestureStartTranslation = 0f
|
|
866
|
+
isDragging = false
|
|
867
|
+
stopProgressUpdates()
|
|
868
|
+
|
|
869
|
+
val projectedX = SwipePhysics.projectFinalPosition(translationX, velocityX, friction)
|
|
870
|
+
val thresholdDistance = actionsWidthPx * threshold
|
|
871
|
+
val shouldOpen = SwipePhysics.shouldSnapToOpen(projectedX, thresholdDistance, isOpen, isLeading)
|
|
872
|
+
|
|
873
|
+
if (shouldOpen) {
|
|
874
|
+
open(velocityX)
|
|
875
|
+
} else {
|
|
876
|
+
close(velocity = velocityX)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private fun startProgressUpdates() {
|
|
881
|
+
if (frameCallback != null) return
|
|
882
|
+
|
|
883
|
+
frameCallback = Choreographer.FrameCallback {
|
|
884
|
+
emitProgressIfChanged()
|
|
885
|
+
if (isDragging || isAnimating) {
|
|
886
|
+
frameCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
|
|
887
|
+
} else {
|
|
888
|
+
// Callback stopped naturally - clear reference so startProgressUpdates() can restart it
|
|
889
|
+
frameCallback = null
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
frameCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private fun stopProgressUpdates() {
|
|
896
|
+
frameCallback?.let {
|
|
897
|
+
Choreographer.getInstance().removeFrameCallback(it)
|
|
898
|
+
}
|
|
899
|
+
frameCallback = null
|
|
900
|
+
lastEmittedProgress = -1f
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private fun emitProgressIfChanged() {
|
|
904
|
+
if (actionsWidthPx <= 0f) return
|
|
905
|
+
|
|
906
|
+
// During animation, read from view's actual translationX to get real animated value
|
|
907
|
+
// (SpringAnimation updates view.translationX directly, but currentTranslation may lag
|
|
908
|
+
// due to Choreographer callback ordering - our callback may run before SpringAnimation's)
|
|
909
|
+
val translation = if (isAnimating) {
|
|
910
|
+
contentView?.translationX ?: currentTranslation
|
|
911
|
+
} else {
|
|
912
|
+
currentTranslation
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
val progress = (abs(translation) / actionsWidthPx).coerceIn(0f, 1f)
|
|
916
|
+
|
|
917
|
+
if (abs(progress - lastEmittedProgress) > SwipeConstants.PROGRESS_CHANGE_THRESHOLD) {
|
|
918
|
+
lastEmittedProgress = progress
|
|
919
|
+
onSwipeProgress(mapOf(
|
|
920
|
+
"progress" to progress.toDouble(),
|
|
921
|
+
"translationX" to translation.toDouble()
|
|
922
|
+
))
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private fun emitFinalProgress(progress: Float, translationX: Float) {
|
|
927
|
+
lastEmittedProgress = progress
|
|
928
|
+
// Use fresh map for each emission
|
|
929
|
+
onSwipeProgress(mapOf(
|
|
930
|
+
"progress" to progress.toDouble(),
|
|
931
|
+
"translationX" to translationX.toDouble()
|
|
932
|
+
))
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
override fun onDetachedFromWindow() {
|
|
936
|
+
super.onDetachedFromWindow()
|
|
937
|
+
stopProgressUpdates()
|
|
938
|
+
cancelGestureTimeout()
|
|
939
|
+
cancelAutoClose()
|
|
940
|
+
contentSpringAnimation?.cancel()
|
|
941
|
+
actionsSpringAnimation?.cancel()
|
|
942
|
+
velocityTracker?.recycle()
|
|
943
|
+
velocityTracker = null
|
|
944
|
+
// Reset state
|
|
945
|
+
isAnimating = false
|
|
946
|
+
isDragging = false
|
|
947
|
+
isGestureActivated = false
|
|
948
|
+
// Thread-safe deregister from view registry
|
|
949
|
+
recyclingKey?.let { unregisterView(it) }
|
|
950
|
+
}
|
|
951
|
+
}
|