@honeypathkar/react-native-predictive-back-gesture 1.0.5 → 1.0.6
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 +107 -497
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -1,485 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @honeypathkar/react-native-predictive-back-gesture
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
4
|
<img src="https://mintcdn.com/honeypathkar/2crkeqrZKInE-b4M/images/ezgif-154f2a3ceac3de29.gif?s=be7bda13989332cd5d3b93d1c5ced59f" width="280" alt="Predictive Back Gesture Demo" />
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
A lightweight React Native library providing Android 14+ Predictive Back gesture animations and edge drag-to-close card transitions.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- **Android < 14:** Only the commit event fires → timed slide-out fallback animation plays instead.
|
|
11
|
-
- **All platforms:** A drag-to-close gesture from the left edge of the screen does the same card animation.
|
|
12
|
-
- **Root screen:** App stands down completely so Android plays its native _back-to-home_ animation (app shrinks into a card over the wallpaper).
|
|
13
|
-
- Haptic feedback on dismiss, proper gesture cancellation, and `beforeRemove` guard support.
|
|
9
|
+
## Features
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Android OS
|
|
21
|
-
└── OnBackPressedDispatcher
|
|
22
|
-
└── PredictiveBackModule.kt (native, intercepts back events)
|
|
23
|
-
└── NativeEventEmitter
|
|
24
|
-
└── PredictiveBack.js (JS bridge, ownership model)
|
|
25
|
-
└── SwipeableScreen.jsx (consumes events → Reanimated)
|
|
26
|
-
└── AppNavigator.jsx (sets fallback mode per nav state)
|
|
27
|
-
```
|
|
11
|
+
- **Android 14+ (API 34+):** Live OS gesture progress. The card shrinks 13%, tilts dynamically, and reveals the underlying screen during mid-swipe.
|
|
12
|
+
- **Android < 14 / Back Button:** Plays a smooth slide-out card exit animation on back commit.
|
|
13
|
+
- **Cross-Platform Swipe Gesture:** Left-edge drag-to-close gesture on all platforms (iOS & Android).
|
|
14
|
+
- **Root Back-To-Home:** Root screen stands down so Android plays its native *back-to-home* animation (app shrinking over wallpaper).
|
|
15
|
+
- **Zero Config Autolinking:** Native Android module auto-linked seamlessly.
|
|
28
16
|
|
|
29
17
|
---
|
|
30
18
|
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
| File | Status | Role |
|
|
34
|
-
| ------------------------------------------------------ | -------- | ---------------------------------- |
|
|
35
|
-
| `android/app/build.gradle` | Modified | Add `activity-ktx` dependency |
|
|
36
|
-
| `android/app/src/main/AndroidManifest.xml` | Modified | Opt-in to predictive back |
|
|
37
|
-
| `android/app/src/main/java/…/MainApplication.kt` | Modified | Register `PredictiveBackPackage` |
|
|
38
|
-
| `android/app/src/main/java/…/PredictiveBackModule.kt` | **New** | Native Kotlin bridge |
|
|
39
|
-
| `android/app/src/main/java/…/PredictiveBackPackage.kt` | **New** | ReactPackage wrapper |
|
|
40
|
-
| `src/native/PredictiveBack.js` | **New** | JS ownership model + event routing |
|
|
41
|
-
| `src/components/SwipeableScreen.jsx` | Modified | Reanimated card animation |
|
|
42
|
-
| `src/navigation/AppNavigator.jsx` | Modified | Sync fallback back mode |
|
|
43
|
-
|
|
44
|
-
---
|
|
19
|
+
## Installation
|
|
45
20
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
```diff
|
|
51
|
-
dependencies {
|
|
52
|
-
// ... existing deps
|
|
53
|
-
|
|
54
|
-
+ // BackEventCompat / predictive-back callbacks on OnBackPressedDispatcher (1.8.0+)
|
|
55
|
-
+ implementation("androidx.activity:activity-ktx:1.9.3")
|
|
56
|
-
}
|
|
21
|
+
```bash
|
|
22
|
+
npm install @honeypathkar/react-native-predictive-back-gesture
|
|
23
|
+
# or
|
|
24
|
+
yarn add @honeypathkar/react-native-predictive-back-gesture
|
|
57
25
|
```
|
|
58
26
|
|
|
59
|
-
|
|
27
|
+
### Peer Dependencies
|
|
60
28
|
|
|
61
|
-
|
|
29
|
+
Ensure `react-native-gesture-handler` and `react-native-reanimated` are installed in your project:
|
|
62
30
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```diff
|
|
67
|
-
<application
|
|
68
|
-
android:allowBackup="false"
|
|
69
|
-
android:theme="@style/AppTheme"
|
|
70
|
-
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
|
71
|
-
- android:supportsRtl="true">
|
|
72
|
-
+ android:supportsRtl="true"
|
|
73
|
-
+ android:enableOnBackInvokedCallback="true">
|
|
31
|
+
```bash
|
|
32
|
+
npm install react-native-gesture-handler react-native-reanimated
|
|
74
33
|
```
|
|
75
34
|
|
|
76
|
-
> [!IMPORTANT]
|
|
77
|
-
> This flag is the single most common reason the feature silently does nothing. It must be present at the `<application>` level (not `<activity>`).
|
|
78
|
-
|
|
79
35
|
---
|
|
80
36
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
Create at `android/app/src/main/java/com/<yourpackage>/PredictiveBackPackage.kt`
|
|
37
|
+
## Android Setup
|
|
84
38
|
|
|
85
|
-
|
|
86
|
-
package com.<yourpackage>
|
|
39
|
+
### 1. Opt-in to Predictive Back in `AndroidManifest.xml`
|
|
87
40
|
|
|
88
|
-
|
|
89
|
-
import com.facebook.react.bridge.NativeModule
|
|
90
|
-
import com.facebook.react.bridge.ReactApplicationContext
|
|
91
|
-
import com.facebook.react.uimanager.ViewManager
|
|
41
|
+
In your `android/app/src/main/AndroidManifest.xml`, add `android:enableOnBackInvokedCallback="true"` to the `<application>` tag:
|
|
92
42
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
99
|
-
return emptyList()
|
|
100
|
-
}
|
|
101
|
-
}
|
|
43
|
+
```xml
|
|
44
|
+
<application
|
|
45
|
+
android:name=".MainApplication"
|
|
46
|
+
android:enableOnBackInvokedCallback="true"
|
|
47
|
+
... >
|
|
102
48
|
```
|
|
103
49
|
|
|
104
50
|
---
|
|
105
51
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Create at `android/app/src/main/java/com/<yourpackage>/PredictiveBackModule.kt`
|
|
109
|
-
|
|
110
|
-
**Key design decisions:**
|
|
111
|
-
|
|
112
|
-
- Uses `OnBackPressedCallback` (via `androidx.activity:activity-ktx`) NOT `OnBackInvokedCallback`. This is intentional — it lets the `OnBackPressedDispatcher` stack work correctly with React Native's own back handler and react-native-screens.
|
|
113
|
-
- Re-adds the callback on every switch to `app` mode so it stays on top of the dispatcher stack (LIFO order).
|
|
114
|
-
- Finds and manages React Native's own internal `OnBackPressedCallback` via reflection to enable the _back-to-home_ OS animation (the `system` mode).
|
|
115
|
-
|
|
116
|
-
```kotlin
|
|
117
|
-
package com.<yourpackage>
|
|
118
|
-
|
|
119
|
-
import android.os.Build
|
|
120
|
-
import android.util.Log
|
|
121
|
-
import androidx.activity.ComponentActivity
|
|
122
|
-
import androidx.activity.OnBackPressedCallback
|
|
123
|
-
import androidx.activity.BackEventCompat
|
|
124
|
-
import com.facebook.react.bridge.*
|
|
125
|
-
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
126
|
-
|
|
127
|
-
class PredictiveBackModule(reactContext: ReactApplicationContext) :
|
|
128
|
-
ReactContextBaseJavaModule(reactContext) {
|
|
129
|
-
|
|
130
|
-
private val backCallback = object : OnBackPressedCallback(false) {
|
|
131
|
-
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
|
132
|
-
emit(EVENT_START, backEvent.toEventMap())
|
|
133
|
-
}
|
|
134
|
-
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
|
135
|
-
emit(EVENT_PROGRESS, backEvent.toEventMap())
|
|
136
|
-
}
|
|
137
|
-
override fun handleOnBackCancelled() {
|
|
138
|
-
emit(EVENT_CANCEL, Arguments.createMap())
|
|
139
|
-
}
|
|
140
|
-
override fun handleOnBackPressed() {
|
|
141
|
-
emit(EVENT_COMMIT, Arguments.createMap())
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
private var isRegistered = false
|
|
146
|
-
private var reactCallback: OnBackPressedCallback? = null
|
|
147
|
-
private var reactCallbackHost: ComponentActivity? = null
|
|
148
|
-
|
|
149
|
-
override fun getName(): String = NAME
|
|
150
|
-
|
|
151
|
-
override fun getConstants(): MutableMap<String, Any> =
|
|
152
|
-
hashMapOf(
|
|
153
|
-
"progressAvailable" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
@ReactMethod
|
|
157
|
-
fun setMode(mode: String) {
|
|
158
|
-
UiThreadUtil.runOnUiThread {
|
|
159
|
-
val activity = reactApplicationContext.currentActivity as? ComponentActivity
|
|
160
|
-
when (mode) {
|
|
161
|
-
MODE_APP -> {
|
|
162
|
-
if (activity != null) {
|
|
163
|
-
attachTo(activity)
|
|
164
|
-
setReactCallbackEnabled(activity, true)
|
|
165
|
-
}
|
|
166
|
-
backCallback.isEnabled = true
|
|
167
|
-
}
|
|
168
|
-
MODE_SYSTEM -> {
|
|
169
|
-
backCallback.isEnabled = false
|
|
170
|
-
if (activity != null) setReactCallbackEnabled(activity, false)
|
|
171
|
-
}
|
|
172
|
-
MODE_DEFAULT -> {
|
|
173
|
-
backCallback.isEnabled = false
|
|
174
|
-
if (activity != null) setReactCallbackEnabled(activity, true)
|
|
175
|
-
}
|
|
176
|
-
else -> Log.w(NAME, "Unknown back mode: $mode")
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
@ReactMethod fun addListener(eventName: String) = Unit
|
|
182
|
-
@ReactMethod fun removeListeners(count: Int) = Unit
|
|
183
|
-
|
|
184
|
-
override fun invalidate() {
|
|
185
|
-
UiThreadUtil.runOnUiThread {
|
|
186
|
-
backCallback.isEnabled = false
|
|
187
|
-
if (isRegistered) { backCallback.remove(); isRegistered = false }
|
|
188
|
-
reactCallback?.isEnabled = true
|
|
189
|
-
reactCallback = null
|
|
190
|
-
reactCallbackHost = null
|
|
191
|
-
}
|
|
192
|
-
super.invalidate()
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private fun attachTo(activity: ComponentActivity) {
|
|
196
|
-
if (isRegistered) backCallback.remove()
|
|
197
|
-
activity.onBackPressedDispatcher.addCallback(backCallback)
|
|
198
|
-
isRegistered = true
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private fun setReactCallbackEnabled(activity: ComponentActivity, enabled: Boolean) {
|
|
202
|
-
findReactCallback(activity)?.isEnabled = enabled
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private fun findReactCallback(activity: ComponentActivity): OnBackPressedCallback? {
|
|
206
|
-
if (reactCallbackHost === activity) return reactCallback
|
|
207
|
-
var cls: Class<*>? = activity.javaClass
|
|
208
|
-
while (cls != null && cls != ComponentActivity::class.java) {
|
|
209
|
-
for (field in cls.declaredFields) {
|
|
210
|
-
if (OnBackPressedCallback::class.java.isAssignableFrom(field.type)) {
|
|
211
|
-
try {
|
|
212
|
-
field.isAccessible = true
|
|
213
|
-
val found = field.get(activity) as? OnBackPressedCallback
|
|
214
|
-
if (found != null) {
|
|
215
|
-
reactCallback = found; reactCallbackHost = activity
|
|
216
|
-
return found
|
|
217
|
-
}
|
|
218
|
-
} catch (e: Exception) {
|
|
219
|
-
Log.w(NAME, "Could not read React Native's back callback", e)
|
|
220
|
-
return null
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
cls = cls.superclass
|
|
225
|
-
}
|
|
226
|
-
Log.w(NAME, "React Native's back callback not found; system back mode is a no-op")
|
|
227
|
-
return null
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
private fun emit(event: String, payload: WritableMap) {
|
|
231
|
-
val context = reactApplicationContext
|
|
232
|
-
if (!context.hasActiveReactInstance()) return
|
|
233
|
-
context.emitDeviceEvent(event, payload)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private fun BackEventCompat.toEventMap(): WritableMap =
|
|
237
|
-
Arguments.createMap().apply {
|
|
238
|
-
putDouble("progress", progress.toDouble())
|
|
239
|
-
putInt("swipeEdge", swipeEdge) // 0 = left, 1 = right
|
|
240
|
-
putDouble("touchX", touchX.toDouble())
|
|
241
|
-
putDouble("touchY", touchY.toDouble())
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
companion object {
|
|
245
|
-
const val NAME = "PredictiveBackModule"
|
|
246
|
-
private const val MODE_APP = "app"
|
|
247
|
-
private const val MODE_DEFAULT = "default"
|
|
248
|
-
private const val MODE_SYSTEM = "system"
|
|
249
|
-
private const val EVENT_START = "predictiveBackStart"
|
|
250
|
-
private const val EVENT_PROGRESS = "predictiveBackProgress"
|
|
251
|
-
private const val EVENT_CANCEL = "predictiveBackCancel"
|
|
252
|
-
private const val EVENT_COMMIT = "predictiveBackCommit"
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
---
|
|
52
|
+
## Usage
|
|
258
53
|
|
|
259
|
-
###
|
|
54
|
+
### 1. Wrap Root with `GestureHandlerRootView`
|
|
260
55
|
|
|
261
|
-
|
|
262
|
-
+ add(PredictiveBackPackage())
|
|
263
|
-
```
|
|
56
|
+
At the entry point of your app (`App.jsx` or `index.js`), wrap your root layout with `GestureHandlerRootView`:
|
|
264
57
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
export
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const dispatch = (name, event) => {
|
|
312
|
-
const handler = handlers && handlers[name];
|
|
313
|
-
if (handler) handler(event);
|
|
314
|
-
};
|
|
315
|
-
|
|
316
|
-
const ensureSubscribed = () => {
|
|
317
|
-
if (subscriptions || !PredictiveBackModule) return;
|
|
318
|
-
const emitter = new NativeEventEmitter(PredictiveBackModule);
|
|
319
|
-
subscriptions = [
|
|
320
|
-
emitter.addListener("predictiveBackStart", (e) => dispatch("onStart", e)),
|
|
321
|
-
emitter.addListener("predictiveBackProgress", (e) =>
|
|
322
|
-
dispatch("onProgress", e),
|
|
323
|
-
),
|
|
324
|
-
emitter.addListener("predictiveBackCancel", () => dispatch("onCancel")),
|
|
325
|
-
emitter.addListener("predictiveBackCommit", () => dispatch("onCommit")),
|
|
326
|
-
];
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* What happens when no screen has claimed back.
|
|
331
|
-
* Call from navigator on state change.
|
|
332
|
-
*/
|
|
333
|
-
export const setFallbackBackMode = (mode) => {
|
|
334
|
-
if (!PredictiveBackModule || fallbackMode === mode) return;
|
|
335
|
-
fallbackMode = mode;
|
|
336
|
-
apply();
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Route back events to `nextHandlers` and enable the native callback.
|
|
341
|
-
* `token` is any stable object (e.g. useRef({}).current) that identifies the caller.
|
|
342
|
-
*/
|
|
343
|
-
export const acquirePredictiveBack = (token, nextHandlers) => {
|
|
344
|
-
if (!PredictiveBackModule) return;
|
|
345
|
-
ensureSubscribed();
|
|
346
|
-
owner = token;
|
|
347
|
-
handlers = nextHandlers;
|
|
348
|
-
appliedMode = null; // force re-apply even if mode string unchanged
|
|
349
|
-
apply();
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
/** Give back control, but only if `token` still holds it. */
|
|
353
|
-
export const releasePredictiveBack = (token) => {
|
|
354
|
-
if (!PredictiveBackModule || owner !== token) return;
|
|
355
|
-
owner = null;
|
|
356
|
-
handlers = null;
|
|
357
|
-
apply();
|
|
358
|
-
};
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
---
|
|
362
|
-
|
|
363
|
-
### 7. `src/components/SwipeableScreen.jsx` — Modified
|
|
364
|
-
|
|
365
|
-
This is the most complex piece. The component wraps any screen and provides both the drag-to-close gesture and the Android predictive-back animation. Full code Available Inside src folder for this file.
|
|
366
|
-
|
|
367
|
-
**Key constants / parameters (tune to your taste):**
|
|
368
|
-
|
|
369
|
-
```js
|
|
370
|
-
const ENTER_DURATION = 240; // ms — slide-in
|
|
371
|
-
const EXIT_DURATION = 280; // ms — slide-out
|
|
372
|
-
const SPRING = { damping: 20, stiffness: 200 };
|
|
373
|
-
const EASING = Easing.out(Easing.cubic);
|
|
374
|
-
const EDGE_WIDTH = 60; // px — hit-slop width for drag zone
|
|
375
|
-
const HEADER_INSET = 75; // px — how far up the hit-slop extends above the screen
|
|
376
|
-
const DRAG_RANGE = 200; // px — full-drag distance maps to peek=1
|
|
377
|
-
const PEEK_THRESHOLD = 0.42; // commit if peek > this
|
|
378
|
-
const VELOCITY_THRESHOLD = 500; // px/s
|
|
379
|
-
const MIN_VELOCITY_DISTANCE = 20; // px
|
|
380
|
-
const MAX_PEEK_X = 24; // px drift right at peek=1
|
|
381
|
-
const MAX_PEEK_Y = 18; // px vertical pivot offset at peek=1
|
|
382
|
-
const MAX_SCALE_DOWN = 0.13; // card shrinks by 13% at peek=1
|
|
383
|
-
const CORNER_RADIUS = 20; // border-radius at peek=1
|
|
384
|
-
const MAX_DIM = 0.35; // backdrop opacity at rest
|
|
385
|
-
```
|
|
58
|
+
```jsx
|
|
59
|
+
import React from 'react';
|
|
60
|
+
import { StyleSheet, View } from 'react-native';
|
|
61
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
62
|
+
import SwipeableScreen, {
|
|
63
|
+
setFallbackBackMode,
|
|
64
|
+
BACK_MODE_SYSTEM,
|
|
65
|
+
BACK_MODE_DEFAULT,
|
|
66
|
+
} from '@honeypathkar/react-native-predictive-back-gesture';
|
|
67
|
+
|
|
68
|
+
import HomeScreen from './screens/HomeScreen';
|
|
69
|
+
import DetailsScreen from './screens/DetailsScreen';
|
|
70
|
+
|
|
71
|
+
export default function App() {
|
|
72
|
+
const [currentScreen, setCurrentScreen] = React.useState('home');
|
|
73
|
+
|
|
74
|
+
// Synchronize back mode:
|
|
75
|
+
// When at root -> BACK_MODE_SYSTEM (plays Android native back-to-home wallpaper animation)
|
|
76
|
+
// When on sub-screen -> BACK_MODE_DEFAULT
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
setFallbackBackMode(
|
|
79
|
+
currentScreen === 'home' ? BACK_MODE_SYSTEM : BACK_MODE_DEFAULT,
|
|
80
|
+
);
|
|
81
|
+
}, [currentScreen]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<GestureHandlerRootView style={styles.flex}>
|
|
85
|
+
<View style={styles.flex}>
|
|
86
|
+
{/* Root screen stays mounted underneath */}
|
|
87
|
+
<HomeScreen onOpenDetails={() => setCurrentScreen('details')} />
|
|
88
|
+
|
|
89
|
+
{/* Sub-screen wrapped inside SwipeableScreen */}
|
|
90
|
+
{currentScreen === 'details' ? (
|
|
91
|
+
<View style={StyleSheet.absoluteFill}>
|
|
92
|
+
<SwipeableScreen
|
|
93
|
+
onGoBack={() => setCurrentScreen('home')}
|
|
94
|
+
backgroundColor="#0A0A0F"
|
|
95
|
+
>
|
|
96
|
+
<DetailsScreen onBack={() => setCurrentScreen('home')} />
|
|
97
|
+
</SwipeableScreen>
|
|
98
|
+
</View>
|
|
99
|
+
) : null}
|
|
100
|
+
</View>
|
|
101
|
+
</GestureHandlerRootView>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
386
104
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
| Value | Purpose |
|
|
390
|
-
| -------------- | ---------------------------------------------------------------------- |
|
|
391
|
-
| `peek` | 0→1, how far the card has "peeked" (both gesture sources drive this) |
|
|
392
|
-
| `exit` | 0→1, the final slide-out to the right when committing |
|
|
393
|
-
| `pivot` | -1→1, vertical position of the drag (drives Y offset for depth feel) |
|
|
394
|
-
| `nativeActive` | 0 or 1, blocks the pan gesture while Android system gesture is running |
|
|
395
|
-
|
|
396
|
-
**The animated style formula:**
|
|
397
|
-
|
|
398
|
-
```js
|
|
399
|
-
const screenStyle = useAnimatedStyle(() => {
|
|
400
|
-
const p = peek.value;
|
|
401
|
-
return {
|
|
402
|
-
transform: [
|
|
403
|
-
{ translateX: p * MAX_PEEK_X + exit.value * width },
|
|
404
|
-
{ translateY: pivot.value * p * MAX_PEEK_Y },
|
|
405
|
-
{ scale: 1 - MAX_SCALE_DOWN * p },
|
|
406
|
-
],
|
|
407
|
-
borderRadius: interpolate(
|
|
408
|
-
p,
|
|
409
|
-
[0, 0.05, 1],
|
|
410
|
-
[0, CORNER_RADIUS, CORNER_RADIUS],
|
|
411
|
-
Extrapolation.CLAMP,
|
|
412
|
-
),
|
|
413
|
-
overflow: "hidden",
|
|
414
|
-
};
|
|
105
|
+
const styles = StyleSheet.create({
|
|
106
|
+
flex: { flex: 1 },
|
|
415
107
|
});
|
|
416
108
|
```
|
|
417
109
|
|
|
418
|
-
**useFocusEffect for Android predictive back:**
|
|
419
|
-
|
|
420
|
-
```js
|
|
421
|
-
useFocusEffect(
|
|
422
|
-
useCallback(() => {
|
|
423
|
-
if (!PREDICTIVE_BACK_SUPPORTED || !isActive) return undefined;
|
|
424
|
-
const track = (event) => {
|
|
425
|
-
peek.value = event.progress;
|
|
426
|
-
pivot.value = (event.touchY / height) * 2 - 1;
|
|
427
|
-
};
|
|
428
|
-
acquirePredictiveBack(token, {
|
|
429
|
-
onStart: (event) => {
|
|
430
|
-
nativeActive.value = 1;
|
|
431
|
-
cancelAnimation(peek);
|
|
432
|
-
track(event);
|
|
433
|
-
},
|
|
434
|
-
onProgress: (event) => {
|
|
435
|
-
if (!isDismissing.current) track(event);
|
|
436
|
-
},
|
|
437
|
-
onCancel: cancel,
|
|
438
|
-
onCommit: () =>
|
|
439
|
-
commit(PREDICTIVE_BACK_HAS_PROGRESS ? EXIT_DURATION : ENTER_DURATION),
|
|
440
|
-
});
|
|
441
|
-
return () => releasePredictiveBack(token);
|
|
442
|
-
}, [token, isActive, peek, pivot, nativeActive, cancel, commit]),
|
|
443
|
-
);
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
**Required imports:**
|
|
447
|
-
|
|
448
|
-
```js
|
|
449
|
-
import Animated, {
|
|
450
|
-
useSharedValue,
|
|
451
|
-
useAnimatedStyle,
|
|
452
|
-
withTiming,
|
|
453
|
-
withSpring,
|
|
454
|
-
cancelAnimation,
|
|
455
|
-
runOnJS,
|
|
456
|
-
interpolate,
|
|
457
|
-
Extrapolation,
|
|
458
|
-
} from "react-native-reanimated";
|
|
459
|
-
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
460
|
-
import { useFocusEffect } from "@react-navigation/native";
|
|
461
|
-
import {
|
|
462
|
-
PREDICTIVE_BACK_SUPPORTED,
|
|
463
|
-
PREDICTIVE_BACK_HAS_PROGRESS,
|
|
464
|
-
acquirePredictiveBack,
|
|
465
|
-
releasePredictiveBack,
|
|
466
|
-
} from "../native/PredictiveBack";
|
|
467
|
-
```
|
|
468
|
-
|
|
469
110
|
---
|
|
470
111
|
|
|
471
|
-
|
|
112
|
+
## React Navigation Integration
|
|
472
113
|
|
|
473
|
-
|
|
114
|
+
If you use `@react-navigation/native`, synchronize `setFallbackBackMode` directly on `NavigationContainer`:
|
|
474
115
|
|
|
475
|
-
```
|
|
116
|
+
```jsx
|
|
476
117
|
import {
|
|
118
|
+
setFallbackBackMode,
|
|
477
119
|
BACK_MODE_DEFAULT,
|
|
478
120
|
BACK_MODE_SYSTEM,
|
|
479
|
-
|
|
480
|
-
|
|
121
|
+
} from '@honeypathkar/react-native-predictive-back-gesture';
|
|
122
|
+
|
|
123
|
+
const navigationRef = useNavigationContainerRef();
|
|
481
124
|
|
|
482
|
-
// Inside the AppNavigator component:
|
|
483
125
|
const syncBackMode = React.useCallback(() => {
|
|
484
126
|
setFallbackBackMode(
|
|
485
127
|
navigationRef.isReady() && navigationRef.canGoBack()
|
|
@@ -488,76 +130,44 @@ const syncBackMode = React.useCallback(() => {
|
|
|
488
130
|
);
|
|
489
131
|
}, []);
|
|
490
132
|
|
|
491
|
-
// On the NavigationContainer:
|
|
492
133
|
<NavigationContainer
|
|
493
134
|
ref={navigationRef}
|
|
494
135
|
onReady={syncBackMode}
|
|
495
136
|
onStateChange={syncBackMode}
|
|
496
137
|
>
|
|
138
|
+
{/* Stack Navigator */}
|
|
139
|
+
</NavigationContainer>
|
|
497
140
|
```
|
|
498
141
|
|
|
499
|
-
|
|
500
|
-
> `BACK_MODE_SYSTEM` (nothing claims back) at the root screen is what unlocks the OS _back-to-home_ animation. Without this call the app permanently claims back and that animation never plays.
|
|
501
|
-
|
|
502
|
-
---
|
|
503
|
-
|
|
504
|
-
## Dependencies Required
|
|
142
|
+
Wrap individual stack screens or custom modals with `<SwipeableScreen>`:
|
|
505
143
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
| `react-native-reanimated` | ≥ 3.x | Worklet animations (`useSharedValue`, `withSpring`, etc.) |
|
|
509
|
-
| `react-native-gesture-handler` | ≥ 2.x | `Gesture.Pan()`, `GestureDetector` |
|
|
510
|
-
| `@react-navigation/native` | ≥ 6.x | `useFocusEffect`, `navigationRef` |
|
|
511
|
-
| `androidx.activity:activity-ktx` | 1.9.3 | `BackEventCompat`, `OnBackPressedCallback` |
|
|
144
|
+
```jsx
|
|
145
|
+
import SwipeableScreen from '@honeypathkar/react-native-predictive-back-gesture';
|
|
512
146
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
| `app` | ✅ | ✅ | Your JS `onCommit` fires; you control navigation |
|
|
522
|
-
| `default` | ❌ | ✅ | React Navigation / BackHandler handle it as usual |
|
|
523
|
-
| `system` | ❌ | ❌ | OS plays back-to-home animation (root screen) |
|
|
524
|
-
|
|
525
|
-
---
|
|
526
|
-
|
|
527
|
-
## Ownership Model (Why It's Needed)
|
|
528
|
-
|
|
529
|
-
Only **one screen at a time** should respond to a back event. The JS-side `PredictiveBack.js` uses a lightweight token-based ownership model:
|
|
530
|
-
|
|
531
|
-
- `acquirePredictiveBack(token, handlers)` — the focused screen takes ownership.
|
|
532
|
-
- `releasePredictiveBack(token)` — the screen gives it up on blur.
|
|
533
|
-
- The check `owner !== token` inside `release` ensures that if two screens blur/focus in quick succession (the order is unspecified), the wrong screen can never accidentally release ownership.
|
|
147
|
+
function ProfileModal() {
|
|
148
|
+
return (
|
|
149
|
+
<SwipeableScreen backgroundColor="#12121A">
|
|
150
|
+
<ProfileContent />
|
|
151
|
+
</SwipeableScreen>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
```
|
|
534
155
|
|
|
535
156
|
---
|
|
536
157
|
|
|
537
|
-
##
|
|
158
|
+
## Props Reference (`<SwipeableScreen />`)
|
|
538
159
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
- [ ] Ensure `react-native-reanimated` and `react-native-gesture-handler` are set up (Babel plugin, `GestureHandlerRootView`, Reanimated plugin)
|
|
160
|
+
| Prop | Type | Default | Description |
|
|
161
|
+
| --- | --- | --- | --- |
|
|
162
|
+
| `children` | `ReactNode` | **Required** | Content inside the swipeable screen card. |
|
|
163
|
+
| `enabled` | `boolean` | `true` | Enables or disables predictive back gesture handling. |
|
|
164
|
+
| `backgroundColor` | `string` | `'#000000'` | Background color of the card container. |
|
|
165
|
+
| `onGoBack` | `function` | `undefined` | Optional callback invoked when the screen pops or is swiped back. |
|
|
166
|
+
| `onHaptic` | `function` | `undefined` | Optional haptic feedback callback when swipe commits. |
|
|
167
|
+
| `style` | `ViewStyle` | `undefined` | Additional styles for the animated screen container. |
|
|
548
168
|
|
|
549
169
|
---
|
|
550
170
|
|
|
551
|
-
##
|
|
552
|
-
|
|
553
|
-
> [!WARNING]
|
|
554
|
-
> **Do NOT use `OnBackInvokedCallback`** (the `android.window` API) directly. It has equal-priority LIFO behaviour and silently breaks React Native's own `BackHandler`.
|
|
555
|
-
|
|
556
|
-
> [!WARNING]
|
|
557
|
-
> **Re-add on every `app` mode switch.** The `OnBackPressedDispatcher` is a stack. `react-native-screens` adds its own callbacks as fragments mount. Re-adding ensures your callback stays on top.
|
|
558
|
-
|
|
559
|
-
> [!NOTE]
|
|
560
|
-
> **Reflection on RN's internal callback** is used for `system` mode. It's stable across RN 0.72–0.76 but could theoretically break if React Native changes its internal `ReactActivity` implementation. Failure is non-fatal — back still works, the OS animation just won't play.
|
|
171
|
+
## License
|
|
561
172
|
|
|
562
|
-
|
|
563
|
-
> On **iOS**, the `PredictiveBack.js` APIs are all no-ops (`PredictiveBackModule` is `null`). Only the drag-to-close pan gesture from `SwipeableScreen` applies.
|
|
173
|
+
MIT © [honeypathkar](https://github.com/honeypathkar)
|
package/package.json
CHANGED