@honeypathkar/react-native-predictive-back-gesture 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +563 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/predictiveback/PredictiveBackModule.kt +138 -0
- package/android/src/main/java/com/predictiveback/PredictiveBackPackage.kt +16 -0
- package/lib/commonjs/AppNavigator.js +12 -0
- package/lib/commonjs/PredectiveBack.js +85 -0
- package/lib/commonjs/SwipableScreen.js +254 -0
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/tsconfig.tsbuildinfo +1 -0
- package/lib/typescript/AppNavigator.d.ts +1 -0
- package/lib/typescript/PredectiveBack.d.ts +19 -0
- package/lib/typescript/SwipableScreen.d.ts +8 -0
- package/lib/typescript/index.d.ts +12 -0
- package/package.json +44 -0
- package/react-native-predictive-back-gesture.podspec +19 -0
- package/src/AppNavigator.jsx +23 -0
- package/src/PredectiveBack.js +92 -0
- package/src/SwipableScreen.jsx +276 -0
- package/src/index.js +27 -0
package/Readme.md
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
# Android Predictive Back Gesture — Implementation Guide
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
<img src="https://mintcdn.com/honeypathkar/2crkeqrZKInE-b4M/images/ezgif-154f2a3ceac3de29.gif?s=be7bda13989332cd5d3b93d1c5ced59f" width="280" alt="Predictive Back Gesture Demo" />
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
## What This Feature Does
|
|
8
|
+
|
|
9
|
+
- **Android 14+ (API 34+):** Live gesture progress from the OS → card shrinks 13%, drifts right, reveals the screen underneath while the finger is mid-swipe.
|
|
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.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Architecture Overview
|
|
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
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Files Changed / Created
|
|
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
|
+
---
|
|
45
|
+
|
|
46
|
+
## Step-by-Step Implementation
|
|
47
|
+
|
|
48
|
+
### 1. `android/app/build.gradle` — Add the Dependency
|
|
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
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### 2. `android/app/src/main/AndroidManifest.xml` — Opt In
|
|
62
|
+
|
|
63
|
+
Add **`android:enableOnBackInvokedCallback="true"`** to the `<application>` tag.
|
|
64
|
+
Without this the OS never delivers predictive back events to your app.
|
|
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">
|
|
74
|
+
```
|
|
75
|
+
|
|
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
|
+
---
|
|
80
|
+
|
|
81
|
+
### 3. `PredictiveBackPackage.kt` — New File
|
|
82
|
+
|
|
83
|
+
Create at `android/app/src/main/java/com/<yourpackage>/PredictiveBackPackage.kt`
|
|
84
|
+
|
|
85
|
+
```kotlin
|
|
86
|
+
package com.<yourpackage>
|
|
87
|
+
|
|
88
|
+
import com.facebook.react.ReactPackage
|
|
89
|
+
import com.facebook.react.bridge.NativeModule
|
|
90
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
91
|
+
import com.facebook.react.uimanager.ViewManager
|
|
92
|
+
|
|
93
|
+
class PredictiveBackPackage : ReactPackage {
|
|
94
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
95
|
+
return listOf(PredictiveBackModule(reactContext))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
99
|
+
return emptyList()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### 4. `PredictiveBackModule.kt` — New File (Core Native Logic)
|
|
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
|
+
---
|
|
258
|
+
|
|
259
|
+
### 5. `MainApplication.kt` — Register the Package
|
|
260
|
+
|
|
261
|
+
```diff
|
|
262
|
+
+ add(PredictiveBackPackage())
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### 6. `src/native/PredictiveBack.js` — New File (JS Bridge + Ownership Model)
|
|
268
|
+
|
|
269
|
+
Create at `src/native/PredictiveBack.js` (adapt path to your project).
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
import { NativeEventEmitter, NativeModules, Platform } from "react-native";
|
|
273
|
+
|
|
274
|
+
const PredictiveBackModule =
|
|
275
|
+
Platform.OS === "android" ? NativeModules.PredictiveBackModule : null;
|
|
276
|
+
|
|
277
|
+
/** True when the native back-event bridge is present (Android only). */
|
|
278
|
+
export const PREDICTIVE_BACK_SUPPORTED = PredictiveBackModule != null;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* True when the OS also reports live gesture progress (Android 14 / API 34+).
|
|
282
|
+
* Below that only the commit arrives — callers should play a timed animation.
|
|
283
|
+
*/
|
|
284
|
+
export const PREDICTIVE_BACK_HAS_PROGRESS =
|
|
285
|
+
PREDICTIVE_BACK_SUPPORTED && PredictiveBackModule.progressAvailable === true;
|
|
286
|
+
|
|
287
|
+
export const EDGE_LEFT = 0;
|
|
288
|
+
export const EDGE_RIGHT = 1;
|
|
289
|
+
|
|
290
|
+
/** Back is React Native's to handle (BackHandler + React Navigation). */
|
|
291
|
+
export const BACK_MODE_DEFAULT = "default";
|
|
292
|
+
/**
|
|
293
|
+
* Nothing in the app claims back → Android plays its own back-to-home animation.
|
|
294
|
+
* Set this at the root of the stack (nothing to go back to).
|
|
295
|
+
*/
|
|
296
|
+
export const BACK_MODE_SYSTEM = "system";
|
|
297
|
+
|
|
298
|
+
let owner = null;
|
|
299
|
+
let handlers = null;
|
|
300
|
+
let fallbackMode = BACK_MODE_DEFAULT;
|
|
301
|
+
let appliedMode = null;
|
|
302
|
+
let subscriptions = null;
|
|
303
|
+
|
|
304
|
+
const apply = () => {
|
|
305
|
+
const mode = owner ? "app" : fallbackMode;
|
|
306
|
+
if (mode === appliedMode) return;
|
|
307
|
+
appliedMode = mode;
|
|
308
|
+
PredictiveBackModule.setMode(mode);
|
|
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
|
+
```
|
|
386
|
+
|
|
387
|
+
**Shared values used:**
|
|
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
|
+
};
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
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
|
+
---
|
|
470
|
+
|
|
471
|
+
### 8. `src/navigation/AppNavigator.jsx` — Modified
|
|
472
|
+
|
|
473
|
+
Sync the fallback back mode whenever the navigation state changes. In your file where you have managed all the navigation setup or insde App.jsx
|
|
474
|
+
|
|
475
|
+
```js
|
|
476
|
+
import {
|
|
477
|
+
BACK_MODE_DEFAULT,
|
|
478
|
+
BACK_MODE_SYSTEM,
|
|
479
|
+
setFallbackBackMode,
|
|
480
|
+
} from '../native/PredictiveBack';
|
|
481
|
+
|
|
482
|
+
// Inside the AppNavigator component:
|
|
483
|
+
const syncBackMode = React.useCallback(() => {
|
|
484
|
+
setFallbackBackMode(
|
|
485
|
+
navigationRef.isReady() && navigationRef.canGoBack()
|
|
486
|
+
? BACK_MODE_DEFAULT
|
|
487
|
+
: BACK_MODE_SYSTEM,
|
|
488
|
+
);
|
|
489
|
+
}, []);
|
|
490
|
+
|
|
491
|
+
// On the NavigationContainer:
|
|
492
|
+
<NavigationContainer
|
|
493
|
+
ref={navigationRef}
|
|
494
|
+
onReady={syncBackMode}
|
|
495
|
+
onStateChange={syncBackMode}
|
|
496
|
+
>
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
> [!IMPORTANT]
|
|
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
|
|
505
|
+
|
|
506
|
+
| Package | Version Used | Purpose |
|
|
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` |
|
|
512
|
+
|
|
513
|
+
No new JS packages required beyond what a standard React Navigation + Reanimated project already has.
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## Three Back Modes Explained
|
|
518
|
+
|
|
519
|
+
| Mode | `backCallback.isEnabled` | RN's own callback | Result |
|
|
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.
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## Checklist for Porting to a New Project
|
|
538
|
+
|
|
539
|
+
- [ ] Add `implementation("androidx.activity:activity-ktx:1.9.3")` to `build.gradle`
|
|
540
|
+
- [ ] Add `android:enableOnBackInvokedCallback="true"` to `AndroidManifest.xml`
|
|
541
|
+
- [ ] Create `PredictiveBackPackage.kt` (replace package name)
|
|
542
|
+
- [ ] Create `PredictiveBackModule.kt` (replace package name)
|
|
543
|
+
- [ ] Register `PredictiveBackPackage()` in `MainApplication.kt`
|
|
544
|
+
- [ ] Create `src/native/PredictiveBack.js`
|
|
545
|
+
- [ ] Wrap screens in `SwipeableScreen` component (or adapt the Reanimated logic into your existing wrapper)
|
|
546
|
+
- [ ] Add `syncBackMode` calls to `NavigationContainer` (`onReady` + `onStateChange`)
|
|
547
|
+
- [ ] Ensure `react-native-reanimated` and `react-native-gesture-handler` are set up (Babel plugin, `GestureHandlerRootView`, Reanimated plugin)
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## Known Gotchas
|
|
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.
|
|
561
|
+
|
|
562
|
+
> [!NOTE]
|
|
563
|
+
> On **iOS**, the `PredictiveBack.js` APIs are all no-ops (`PredictiveBackModule` is `null`). Only the drag-to-close pan gesture from `SwipeableScreen` applies.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext {
|
|
3
|
+
buildToolsVersion = "34.0.0"
|
|
4
|
+
minSdkVersion = 21
|
|
5
|
+
compileSdkVersion = 34
|
|
6
|
+
targetSdkVersion = 34
|
|
7
|
+
}
|
|
8
|
+
repositories {
|
|
9
|
+
google()
|
|
10
|
+
mavenCentral()
|
|
11
|
+
}
|
|
12
|
+
dependencies {
|
|
13
|
+
classpath("com.android.tools.build:gradle:8.1.1")
|
|
14
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
apply plugin: "com.android.library"
|
|
19
|
+
apply plugin: "kotlin-android"
|
|
20
|
+
|
|
21
|
+
android {
|
|
22
|
+
compileSdkVersion 34
|
|
23
|
+
|
|
24
|
+
defaultConfig {
|
|
25
|
+
minSdkVersion 21
|
|
26
|
+
targetSdkVersion 34
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
lintOptions {
|
|
30
|
+
abortOnError false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
sourceSets {
|
|
34
|
+
main {
|
|
35
|
+
manifest.srcFile "src/main/AndroidManifest.xml"
|
|
36
|
+
java.srcDirs = ["src/main/java"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
repositories {
|
|
42
|
+
google()
|
|
43
|
+
mavenCentral()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
dependencies {
|
|
47
|
+
implementation("com.facebook.react:react-native:+")
|
|
48
|
+
implementation("androidx.activity:activity-ktx:1.9.3")
|
|
49
|
+
}
|