@honeypathkar/react-native-predictive-back-gesture 1.0.4 → 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 CHANGED
@@ -1,485 +1,127 @@
1
- # Android Predictive Back Gesture — Implementation Guide
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
- ## What This Feature Does
7
+ A lightweight React Native library providing Android 14+ Predictive Back gesture animations and edge drag-to-close card transitions.
8
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.
9
+ ## Features
14
10
 
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
- ```
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
- ## 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
- ---
19
+ ## Installation
45
20
 
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
- }
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
- ### 2. `android/app/src/main/AndroidManifest.xml` Opt In
29
+ Ensure `react-native-gesture-handler` and `react-native-reanimated` are installed in your project:
62
30
 
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">
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
- ### 3. `PredictiveBackPackage.kt` — New File
82
-
83
- Create at `android/app/src/main/java/com/<yourpackage>/PredictiveBackPackage.kt`
37
+ ## Android Setup
84
38
 
85
- ```kotlin
86
- package com.<yourpackage>
39
+ ### 1. Opt-in to Predictive Back in `AndroidManifest.xml`
87
40
 
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
41
+ In your `android/app/src/main/AndroidManifest.xml`, add `android:enableOnBackInvokedCallback="true"` to the `<application>` tag:
92
42
 
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
- }
43
+ ```xml
44
+ <application
45
+ android:name=".MainApplication"
46
+ android:enableOnBackInvokedCallback="true"
47
+ ... >
102
48
  ```
103
49
 
104
50
  ---
105
51
 
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
- ---
52
+ ## Usage
258
53
 
259
- ### 5. `MainApplication.kt` Register the Package
54
+ ### 1. Wrap Root with `GestureHandlerRootView`
260
55
 
261
- ```diff
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
- ### 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
- ```
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
- **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
- };
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
- ### 8. `src/navigation/AppNavigator.jsx` — Modified
112
+ ## React Navigation Integration
472
113
 
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
114
+ If you use `@react-navigation/native`, synchronize `setFallbackBackMode` directly on `NavigationContainer`:
474
115
 
475
- ```js
116
+ ```jsx
476
117
  import {
118
+ setFallbackBackMode,
477
119
  BACK_MODE_DEFAULT,
478
120
  BACK_MODE_SYSTEM,
479
- setFallbackBackMode,
480
- } from '../native/PredictiveBack';
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
- > [!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
142
+ Wrap individual stack screens or custom modals with `<SwipeableScreen>`:
505
143
 
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` |
144
+ ```jsx
145
+ import SwipeableScreen from '@honeypathkar/react-native-predictive-back-gesture';
512
146
 
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.
147
+ function ProfileModal() {
148
+ return (
149
+ <SwipeableScreen backgroundColor="#12121A">
150
+ <ProfileContent />
151
+ </SwipeableScreen>
152
+ );
153
+ }
154
+ ```
534
155
 
535
156
  ---
536
157
 
537
- ## Checklist for Porting to a New Project
158
+ ## Props Reference (`<SwipeableScreen />`)
538
159
 
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)
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
- ## 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.
171
+ ## License
561
172
 
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.
173
+ MIT © [honeypathkar](https://github.com/honeypathkar)
@@ -9,4 +9,4 @@ const syncBackMode = React.useCallback(() => {
9
9
  : PredictiveBack_1.BACK_MODE_SYSTEM);
10
10
  }, []);
11
11
  // On the NavigationContainer:
12
- <NavigationContainer ref={navigationRef} onReady={syncBackMode} onStateChange={syncBackMode}></NavigationContainer>;
12
+ React.createElement(NavigationContainer, { ref: navigationRef, onReady: syncBackMode, onStateChange: syncBackMode });
@@ -38,7 +38,6 @@ const react_native_1 = require("react-native");
38
38
  const react_native_gesture_handler_1 = require("react-native-gesture-handler");
39
39
  const react_native_reanimated_1 = __importStar(require("react-native-reanimated"));
40
40
  const native_1 = require("@react-navigation/native");
41
- const react_native_paper_1 = require("react-native-paper");
42
41
  const PredectiveBack_1 = require("./PredectiveBack");
43
42
  const { width, height } = react_native_1.Dimensions.get("window");
44
43
  // Material predictive-back peek: the card shrinks and drifts a little way to the right
@@ -63,9 +62,14 @@ const DRAG_RANGE = width * 0.6;
63
62
  const PEEK_THRESHOLD = 0.35;
64
63
  const VELOCITY_THRESHOLD = 900;
65
64
  const MIN_VELOCITY_DISTANCE = 50;
66
- const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
67
- const navigation = (0, native_1.useNavigation)();
68
- const theme = (0, react_native_paper_1.useTheme)();
65
+ const SwipeableScreen = ({ children, enabled = true, style, onHaptic, backgroundColor = '#000000', onGoBack }) => {
66
+ let navigation = null;
67
+ try {
68
+ navigation = (0, native_1.useNavigation)();
69
+ }
70
+ catch (e) {
71
+ // Component rendered outside React Navigation container
72
+ }
69
73
  // Gesture progress (0…1) — drives the peek: shrink, drift right, rounded corners.
70
74
  const peek = (0, react_native_reanimated_1.useSharedValue)(0);
71
75
  // Dismissal progress (0…1) — slides the card clear to the right. Also runs in
@@ -97,18 +101,22 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
97
101
  exit.value = (0, react_native_reanimated_1.withTiming)(0, { duration: 200, easing: EASING });
98
102
  }, [peek, exit, nativeActive]);
99
103
  const goBack = (0, react_1.useCallback)(() => {
100
- if (!navigation.canGoBack()) {
104
+ if (onGoBack) {
105
+ onGoBack();
106
+ return;
107
+ }
108
+ if (!navigation || !navigation.canGoBack()) {
101
109
  settle();
102
110
  return;
103
111
  }
104
112
  navigation.goBack();
105
113
  // goBack dispatches through `beforeRemove`, so it may not actually pop.
106
114
  requestAnimationFrame(() => {
107
- if (navigation.isFocused()) {
115
+ if (navigation && navigation.isFocused && navigation.isFocused()) {
108
116
  settle();
109
117
  }
110
118
  });
111
- }, [navigation, settle]);
119
+ }, [navigation, settle, onGoBack]);
112
120
  // ─── Commit / cancel ─────────────────────────────────────────────────────
113
121
  const commit = (0, react_1.useCallback)((duration = EXIT_DURATION) => {
114
122
  if (isDismissing.current) {
@@ -131,8 +139,9 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
131
139
  }, [peek, nativeActive]);
132
140
  // ─── System back gesture (Android) ───────────────────────────────────────
133
141
  const token = (0, react_1.useRef)({}).current;
134
- const isActive = enabled && navigation.canGoBack();
135
- (0, native_1.useFocusEffect)((0, react_1.useCallback)(() => {
142
+ const canGoBackInNav = navigation && navigation.canGoBack ? navigation.canGoBack() : false;
143
+ const isActive = enabled && (canGoBackInNav || !!onGoBack);
144
+ const focusEffectCallback = (0, react_1.useCallback)(() => {
136
145
  if (!PredectiveBack_1.PREDICTIVE_BACK_SUPPORTED || !isActive) {
137
146
  return undefined;
138
147
  }
@@ -160,7 +169,15 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
160
169
  onCommit: () => commit(PredectiveBack_1.PREDICTIVE_BACK_HAS_PROGRESS ? EXIT_DURATION : ENTER_DURATION),
161
170
  });
162
171
  return () => (0, PredectiveBack_1.releasePredictiveBack)(token);
163
- }, [token, isActive, peek, pivot, nativeActive, cancel, commit]));
172
+ }, [token, isActive, peek, pivot, nativeActive, cancel, commit]);
173
+ if (navigation) {
174
+ // eslint-disable-next-line react-hooks/rules-of-hooks
175
+ (0, native_1.useFocusEffect)(focusEffectCallback);
176
+ }
177
+ else {
178
+ // eslint-disable-next-line react-hooks/rules-of-hooks
179
+ (0, react_1.useEffect)(focusEffectCallback, [focusEffectCallback]);
180
+ }
164
181
  // ─── Drag-to-close from the left edge ────────────────────────────────────
165
182
  // On Android this covers the strip just inside the system gesture zone; on iOS it
166
183
  // is the only way back. Both drive the same peek, so the card looks identical
@@ -222,20 +239,15 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
222
239
  opacity: (0, react_native_reanimated_1.interpolate)(revealed, [0, 1], [MAX_DIM, 0], react_native_reanimated_1.Extrapolation.CLAMP),
223
240
  };
224
241
  });
225
- return (<react_native_1.View style={styles.outerWrapper}>
226
- <react_native_reanimated_1.default.View pointerEvents="none" style={[styles.backdrop, backdropStyle]}/>
227
-
228
- <react_native_gesture_handler_1.GestureDetector gesture={panGesture}>
229
- <react_native_reanimated_1.default.View style={[
230
- styles.screen,
231
- { backgroundColor: theme.colors.background },
232
- screenStyle,
233
- style,
234
- ]}>
235
- {children}
236
- </react_native_reanimated_1.default.View>
237
- </react_native_gesture_handler_1.GestureDetector>
238
- </react_native_1.View>);
242
+ return (react_1.default.createElement(react_native_1.View, { style: styles.outerWrapper },
243
+ react_1.default.createElement(react_native_reanimated_1.default.View, { pointerEvents: "none", style: [styles.backdrop, backdropStyle] }),
244
+ react_1.default.createElement(react_native_gesture_handler_1.GestureDetector, { gesture: panGesture },
245
+ react_1.default.createElement(react_native_reanimated_1.default.View, { style: [
246
+ styles.screen,
247
+ { backgroundColor },
248
+ screenStyle,
249
+ style,
250
+ ] }, children))));
239
251
  };
240
252
  exports.default = SwipeableScreen;
241
253
  const styles = react_native_1.StyleSheet.create({
@@ -0,0 +1 @@
1
+ {"root":["../src/appnavigator.jsx","../src/predectiveback.js","../src/swipablescreen.jsx","../src/index.js"],"version":"5.9.3"}
@@ -1,8 +1,10 @@
1
1
  export default SwipeableScreen;
2
- declare function SwipeableScreen({ children, enabled, style, onHaptic }: {
2
+ declare function SwipeableScreen({ children, enabled, style, onHaptic, backgroundColor, onGoBack }: {
3
3
  children: any;
4
4
  enabled?: boolean;
5
5
  style: any;
6
6
  onHaptic: any;
7
+ backgroundColor?: string;
8
+ onGoBack: any;
7
9
  }): React.JSX.Element;
8
10
  import React from "react";
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@honeypathkar/react-native-predictive-back-gesture",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Android 14+ Predictive Back gesture animations and swipeable screen component for React Native.",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
- "main": "src/index.js",
9
- "module": "src/index.js",
8
+ "main": "lib/commonjs/index.js",
9
+ "module": "lib/module/index.js",
10
10
  "types": "lib/typescript/index.d.ts",
11
11
  "files": [
12
12
  "src",
@@ -34,13 +34,12 @@
34
34
  "react": "*",
35
35
  "react-native": "*",
36
36
  "react-native-gesture-handler": "*",
37
- "react-native-reanimated": "*",
38
- "react-native-paper": "*"
37
+ "react-native-reanimated": "*"
39
38
  },
40
39
  "devDependencies": {
41
40
  "@types/react": "^18.3.31",
42
41
  "react": "^18.2.0",
43
- "react-native": "^0.72.0",
42
+ "react-native": "^0.72.17",
44
43
  "react-native-gesture-handler": "^2.12.0",
45
44
  "react-native-reanimated": "^3.3.0",
46
45
  "typescript": "^5.9.3"
@@ -13,7 +13,6 @@ import Animated, {
13
13
  cancelAnimation,
14
14
  } from "react-native-reanimated";
15
15
  import { useNavigation, useFocusEffect } from "@react-navigation/native";
16
- import { useTheme } from "react-native-paper";
17
16
  import {
18
17
  PREDICTIVE_BACK_HAS_PROGRESS,
19
18
  PREDICTIVE_BACK_SUPPORTED,
@@ -48,9 +47,13 @@ const PEEK_THRESHOLD = 0.35;
48
47
  const VELOCITY_THRESHOLD = 900;
49
48
  const MIN_VELOCITY_DISTANCE = 50;
50
49
 
51
- const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
52
- const navigation = useNavigation();
53
- const theme = useTheme();
50
+ const SwipeableScreen = ({ children, enabled = true, style, onHaptic, backgroundColor = '#000000', onGoBack }) => {
51
+ let navigation = null;
52
+ try {
53
+ navigation = useNavigation();
54
+ } catch (e) {
55
+ // Component rendered outside React Navigation container
56
+ }
54
57
 
55
58
  // Gesture progress (0…1) — drives the peek: shrink, drift right, rounded corners.
56
59
  const peek = useSharedValue(0);
@@ -86,18 +89,22 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
86
89
  }, [peek, exit, nativeActive]);
87
90
 
88
91
  const goBack = useCallback(() => {
89
- if (!navigation.canGoBack()) {
92
+ if (onGoBack) {
93
+ onGoBack();
94
+ return;
95
+ }
96
+ if (!navigation || !navigation.canGoBack()) {
90
97
  settle();
91
98
  return;
92
99
  }
93
100
  navigation.goBack();
94
101
  // goBack dispatches through `beforeRemove`, so it may not actually pop.
95
102
  requestAnimationFrame(() => {
96
- if (navigation.isFocused()) {
103
+ if (navigation && navigation.isFocused && navigation.isFocused()) {
97
104
  settle();
98
105
  }
99
106
  });
100
- }, [navigation, settle]);
107
+ }, [navigation, settle, onGoBack]);
101
108
 
102
109
  // ─── Commit / cancel ─────────────────────────────────────────────────────
103
110
  const commit = useCallback(
@@ -126,43 +133,50 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
126
133
 
127
134
  // ─── System back gesture (Android) ───────────────────────────────────────
128
135
  const token = useRef({}).current;
129
- const isActive = enabled && navigation.canGoBack();
136
+ const canGoBackInNav = navigation && navigation.canGoBack ? navigation.canGoBack() : false;
137
+ const isActive = enabled && (canGoBackInNav || !!onGoBack);
130
138
 
131
- useFocusEffect(
132
- useCallback(() => {
133
- if (!PREDICTIVE_BACK_SUPPORTED || !isActive) {
134
- return undefined;
135
- }
139
+ const focusEffectCallback = useCallback(() => {
140
+ if (!PREDICTIVE_BACK_SUPPORTED || !isActive) {
141
+ return undefined;
142
+ }
136
143
 
137
- const track = (event) => {
138
- peek.value = event.progress;
139
- pivot.value = (event.touchY / height) * 2 - 1;
140
- };
144
+ const track = (event) => {
145
+ peek.value = event.progress;
146
+ pivot.value = (event.touchY / height) * 2 - 1;
147
+ };
141
148
 
142
- acquirePredictiveBack(token, {
143
- onStart: (event) => {
144
- if (isDismissing.current) {
145
- return;
146
- }
147
- nativeActive.value = 1;
148
- cancelAnimation(peek);
149
+ acquirePredictiveBack(token, {
150
+ onStart: (event) => {
151
+ if (isDismissing.current) {
152
+ return;
153
+ }
154
+ nativeActive.value = 1;
155
+ cancelAnimation(peek);
156
+ track(event);
157
+ },
158
+ onProgress: (event) => {
159
+ if (!isDismissing.current) {
149
160
  track(event);
150
- },
151
- onProgress: (event) => {
152
- if (!isDismissing.current) {
153
- track(event);
154
- }
155
- },
156
- onCancel: cancel,
157
- // Without live progress (pre-Android 14, or a 3-button back press) the card
158
- // has not moved yet, so give the slide-out a little more room to breathe.
159
- onCommit: () =>
160
- commit(PREDICTIVE_BACK_HAS_PROGRESS ? EXIT_DURATION : ENTER_DURATION),
161
- });
161
+ }
162
+ },
163
+ onCancel: cancel,
164
+ // Without live progress (pre-Android 14, or a 3-button back press) the card
165
+ // has not moved yet, so give the slide-out a little more room to breathe.
166
+ onCommit: () =>
167
+ commit(PREDICTIVE_BACK_HAS_PROGRESS ? EXIT_DURATION : ENTER_DURATION),
168
+ });
162
169
 
163
- return () => releasePredictiveBack(token);
164
- }, [token, isActive, peek, pivot, nativeActive, cancel, commit]),
165
- );
170
+ return () => releasePredictiveBack(token);
171
+ }, [token, isActive, peek, pivot, nativeActive, cancel, commit]);
172
+
173
+ if (navigation) {
174
+ // eslint-disable-next-line react-hooks/rules-of-hooks
175
+ useFocusEffect(focusEffectCallback);
176
+ } else {
177
+ // eslint-disable-next-line react-hooks/rules-of-hooks
178
+ useEffect(focusEffectCallback, [focusEffectCallback]);
179
+ }
166
180
 
167
181
  // ─── Drag-to-close from the left edge ────────────────────────────────────
168
182
  // On Android this covers the strip just inside the system gesture zone; on iOS it
@@ -246,7 +260,7 @@ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
246
260
  <Animated.View
247
261
  style={[
248
262
  styles.screen,
249
- { backgroundColor: theme.colors.background },
263
+ { backgroundColor },
250
264
  screenStyle,
251
265
  style,
252
266
  ]}
@@ -1 +0,0 @@
1
- {"root":["../../src/appnavigator.jsx","../../src/predectiveback.js","../../src/swipablescreen.jsx","../../src/index.js"],"version":"5.9.3"}