@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.
@@ -0,0 +1,92 @@
1
+ // src/native/PredictiveBack.js — New File (JS Bridge + Ownership Model)
2
+
3
+ // Create at src/native/PredictiveBack.js (adapt path to your project).
4
+
5
+
6
+
7
+ import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
8
+
9
+ const PredictiveBackModule =
10
+ Platform.OS === 'android' ? NativeModules.PredictiveBackModule : null;
11
+
12
+ /** True when the native back-event bridge is present (Android only). */
13
+ export const PREDICTIVE_BACK_SUPPORTED = PredictiveBackModule != null;
14
+
15
+ /**
16
+ * True when the OS also reports live gesture progress (Android 14 / API 34+).
17
+ * Below that only the commit arrives — callers should play a timed animation.
18
+ */
19
+ export const PREDICTIVE_BACK_HAS_PROGRESS =
20
+ PREDICTIVE_BACK_SUPPORTED && PredictiveBackModule.progressAvailable === true;
21
+
22
+ export const EDGE_LEFT = 0;
23
+ export const EDGE_RIGHT = 1;
24
+
25
+ /** Back is React Native's to handle (BackHandler + React Navigation). */
26
+ export const BACK_MODE_DEFAULT = 'default';
27
+ /**
28
+ * Nothing in the app claims back → Android plays its own back-to-home animation.
29
+ * Set this at the root of the stack (nothing to go back to).
30
+ */
31
+ export const BACK_MODE_SYSTEM = 'system';
32
+
33
+ let owner = null;
34
+ let handlers = null;
35
+ let fallbackMode = BACK_MODE_DEFAULT;
36
+ let appliedMode = null;
37
+ let subscriptions = null;
38
+
39
+ const apply = () => {
40
+ const mode = owner ? 'app' : fallbackMode;
41
+ if (mode === appliedMode) return;
42
+ appliedMode = mode;
43
+ PredictiveBackModule.setMode(mode);
44
+ };
45
+
46
+ const dispatch = (name, event) => {
47
+ const handler = handlers && handlers[name];
48
+ if (handler) handler(event);
49
+ };
50
+
51
+ const ensureSubscribed = () => {
52
+ if (subscriptions || !PredictiveBackModule) return;
53
+ const emitter = new NativeEventEmitter(PredictiveBackModule);
54
+ subscriptions = [
55
+ emitter.addListener('predictiveBackStart', e => dispatch('onStart', e)),
56
+ emitter.addListener('predictiveBackProgress', e => dispatch('onProgress', e)),
57
+ emitter.addListener('predictiveBackCancel', () => dispatch('onCancel')),
58
+ emitter.addListener('predictiveBackCommit', () => dispatch('onCommit')),
59
+ ];
60
+ };
61
+
62
+ /**
63
+ * What happens when no screen has claimed back.
64
+ * Call from navigator on state change.
65
+ */
66
+ export const setFallbackBackMode = mode => {
67
+ if (!PredictiveBackModule || fallbackMode === mode) return;
68
+ fallbackMode = mode;
69
+ apply();
70
+ };
71
+
72
+ /**
73
+ * Route back events to `nextHandlers` and enable the native callback.
74
+ * `token` is any stable object (e.g. useRef({}).current) that identifies the caller.
75
+ */
76
+ export const acquirePredictiveBack = (token, nextHandlers) => {
77
+ if (!PredictiveBackModule) return;
78
+ ensureSubscribed();
79
+ owner = token;
80
+ handlers = nextHandlers;
81
+ appliedMode = null; // force re-apply even if mode string unchanged
82
+ apply();
83
+ };
84
+
85
+ /** Give back control, but only if `token` still holds it. */
86
+ export const releasePredictiveBack = token => {
87
+ if (!PredictiveBackModule || owner !== token) return;
88
+ owner = null;
89
+ handlers = null;
90
+ apply();
91
+ };
92
+
@@ -0,0 +1,276 @@
1
+ import React, { useCallback, useEffect, useRef } from "react";
2
+ import { StyleSheet, Dimensions, View } from "react-native";
3
+ import { GestureDetector, Gesture } from "react-native-gesture-handler";
4
+ import Animated, {
5
+ useSharedValue,
6
+ useAnimatedStyle,
7
+ withTiming,
8
+ withSpring,
9
+ runOnJS,
10
+ interpolate,
11
+ Extrapolation,
12
+ Easing,
13
+ cancelAnimation,
14
+ } from "react-native-reanimated";
15
+ import { useNavigation, useFocusEffect } from "@react-navigation/native";
16
+ import { useTheme } from "react-native-paper";
17
+ import {
18
+ PREDICTIVE_BACK_HAS_PROGRESS,
19
+ PREDICTIVE_BACK_SUPPORTED,
20
+ acquirePredictiveBack,
21
+ releasePredictiveBack,
22
+ } from "./PredectiveBack";
23
+
24
+ const { width, height } = Dimensions.get("window");
25
+
26
+ // Material predictive-back peek: the card shrinks and drifts a little way to the right
27
+ // while the gesture is in flight, revealing the screen underneath. It only leaves the
28
+ // screen once the user commits.
29
+ const MAX_SCALE_DOWN = 0.13;
30
+ const MAX_PEEK_X = width * 0.12;
31
+ const MAX_PEEK_Y = height * 0.03;
32
+ const CORNER_RADIUS = 32;
33
+ const MAX_DIM = 0.5;
34
+
35
+ // Screens enter from the right and leave back to the right — the exit always mirrors
36
+ // the entry, whichever edge the gesture came from.
37
+ const ENTER_DURATION = 280;
38
+ const EXIT_DURATION = 240;
39
+ const EASING = Easing.out(Easing.bezierFn(0.25, 0.46, 0.45, 0.94));
40
+ const SPRING = { damping: 28, stiffness: 260, mass: 0.85 };
41
+
42
+ // Drag gesture: how far the finger travels for a full peek, and what commits it.
43
+ const EDGE_WIDTH = 60;
44
+ // Keeps the strip clear of the header, so the back button stays tappable.
45
+ const HEADER_INSET = 75;
46
+ const DRAG_RANGE = width * 0.6;
47
+ const PEEK_THRESHOLD = 0.35;
48
+ const VELOCITY_THRESHOLD = 900;
49
+ const MIN_VELOCITY_DISTANCE = 50;
50
+
51
+ const SwipeableScreen = ({ children, enabled = true, style, onHaptic }) => {
52
+ const navigation = useNavigation();
53
+ const theme = useTheme();
54
+
55
+ // Gesture progress (0…1) — drives the peek: shrink, drift right, rounded corners.
56
+ const peek = useSharedValue(0);
57
+ // Dismissal progress (0…1) — slides the card clear to the right. Also runs in
58
+ // reverse on mount so the entry mirrors the exit.
59
+ const exit = useSharedValue(1);
60
+ // -1…1, how far the touch sits above/below centre; tilts the peek vertically.
61
+ const pivot = useSharedValue(0);
62
+ // Set while the OS gesture owns the animation, so the pan cannot drive it too.
63
+ const nativeActive = useSharedValue(0);
64
+
65
+ // Guards against a second back event landing while the exit animation is running.
66
+ const isDismissing = useRef(false);
67
+
68
+ // ─── Mount: slide in from the right ─────────────────────────────────────
69
+ useEffect(() => {
70
+ exit.value = withTiming(0, { duration: ENTER_DURATION, easing: EASING });
71
+ }, [exit]);
72
+
73
+ // ─── JS-thread helpers ───────────────────────────────────────────────────
74
+ const fireHaptic = useCallback(() => {
75
+ try {
76
+ if (onHaptic) onHaptic();
77
+ } catch (e) {}
78
+ }, [onHaptic]);
79
+
80
+ const settle = useCallback(() => {
81
+ // Put the card back at rest — used when a pop is refused or cancelled.
82
+ isDismissing.current = false;
83
+ nativeActive.value = 0;
84
+ peek.value = withTiming(0, { duration: 160, easing: EASING });
85
+ exit.value = withTiming(0, { duration: 200, easing: EASING });
86
+ }, [peek, exit, nativeActive]);
87
+
88
+ const goBack = useCallback(() => {
89
+ if (!navigation.canGoBack()) {
90
+ settle();
91
+ return;
92
+ }
93
+ navigation.goBack();
94
+ // goBack dispatches through `beforeRemove`, so it may not actually pop.
95
+ requestAnimationFrame(() => {
96
+ if (navigation.isFocused()) {
97
+ settle();
98
+ }
99
+ });
100
+ }, [navigation, settle]);
101
+
102
+ // ─── Commit / cancel ─────────────────────────────────────────────────────
103
+ const commit = useCallback(
104
+ (duration = EXIT_DURATION) => {
105
+ if (isDismissing.current) {
106
+ return;
107
+ }
108
+ isDismissing.current = true;
109
+ fireHaptic();
110
+ cancelAnimation(exit);
111
+ exit.value = withTiming(1, { duration, easing: EASING }, (finished) => {
112
+ "worklet";
113
+ if (finished) {
114
+ runOnJS(goBack)();
115
+ }
116
+ });
117
+ },
118
+ [exit, fireHaptic, goBack],
119
+ );
120
+
121
+ const cancel = useCallback(() => {
122
+ nativeActive.value = 0;
123
+ cancelAnimation(peek);
124
+ peek.value = withSpring(0, SPRING);
125
+ }, [peek, nativeActive]);
126
+
127
+ // ─── System back gesture (Android) ───────────────────────────────────────
128
+ const token = useRef({}).current;
129
+ const isActive = enabled && navigation.canGoBack();
130
+
131
+ useFocusEffect(
132
+ useCallback(() => {
133
+ if (!PREDICTIVE_BACK_SUPPORTED || !isActive) {
134
+ return undefined;
135
+ }
136
+
137
+ const track = (event) => {
138
+ peek.value = event.progress;
139
+ pivot.value = (event.touchY / height) * 2 - 1;
140
+ };
141
+
142
+ acquirePredictiveBack(token, {
143
+ onStart: (event) => {
144
+ if (isDismissing.current) {
145
+ return;
146
+ }
147
+ nativeActive.value = 1;
148
+ cancelAnimation(peek);
149
+ 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
+ });
162
+
163
+ return () => releasePredictiveBack(token);
164
+ }, [token, isActive, peek, pivot, nativeActive, cancel, commit]),
165
+ );
166
+
167
+ // ─── Drag-to-close from the left edge ────────────────────────────────────
168
+ // On Android this covers the strip just inside the system gesture zone; on iOS it
169
+ // is the only way back. Both drive the same peek, so the card looks identical
170
+ // however the gesture arrived.
171
+ const panGesture = Gesture.Pan()
172
+ .enabled(isActive)
173
+ .hitSlop({ left: 0, width: EDGE_WIDTH, top: -HEADER_INSET })
174
+ .activeOffsetX([10, 500])
175
+ .failOffsetY([-18, 18])
176
+ .onBegin(() => {
177
+ "worklet";
178
+ if (nativeActive.value) {
179
+ return;
180
+ }
181
+ cancelAnimation(peek);
182
+ })
183
+ .onUpdate((event) => {
184
+ "worklet";
185
+ if (nativeActive.value || event.translationX <= 0) {
186
+ return;
187
+ }
188
+ peek.value = Math.min(event.translationX / DRAG_RANGE, 1);
189
+ pivot.value = (event.y / height) * 2 - 1;
190
+ })
191
+ .onEnd((event) => {
192
+ "worklet";
193
+ if (nativeActive.value) {
194
+ return;
195
+ }
196
+ const { translationX, velocityX } = event;
197
+ const shouldCommit =
198
+ peek.value > PEEK_THRESHOLD ||
199
+ (velocityX > VELOCITY_THRESHOLD &&
200
+ translationX > MIN_VELOCITY_DISTANCE);
201
+
202
+ if (shouldCommit) {
203
+ runOnJS(commit)(velocityX > VELOCITY_THRESHOLD ? 160 : EXIT_DURATION);
204
+ } else {
205
+ peek.value = withSpring(0, SPRING);
206
+ }
207
+ });
208
+
209
+ // ─── Card ────────────────────────────────────────────────────────────────
210
+ const screenStyle = useAnimatedStyle(() => {
211
+ const p = peek.value;
212
+
213
+ return {
214
+ transform: [
215
+ { translateX: p * MAX_PEEK_X + exit.value * width },
216
+ { translateY: pivot.value * p * MAX_PEEK_Y },
217
+ { scale: 1 - MAX_SCALE_DOWN * p },
218
+ ],
219
+ borderRadius: interpolate(
220
+ p,
221
+ [0, 0.05, 1],
222
+ [0, CORNER_RADIUS, CORNER_RADIUS],
223
+ Extrapolation.CLAMP,
224
+ ),
225
+ overflow: "hidden",
226
+ };
227
+ });
228
+
229
+ // Dims the screen underneath (visible because the navigator presents screens as
230
+ // transparent modals) and clears as this card moves out of the way.
231
+ const backdropStyle = useAnimatedStyle(() => {
232
+ const revealed = Math.max(peek.value, exit.value);
233
+ return {
234
+ opacity: interpolate(revealed, [0, 1], [MAX_DIM, 0], Extrapolation.CLAMP),
235
+ };
236
+ });
237
+
238
+ return (
239
+ <View style={styles.outerWrapper}>
240
+ <Animated.View
241
+ pointerEvents="none"
242
+ style={[styles.backdrop, backdropStyle]}
243
+ />
244
+
245
+ <GestureDetector gesture={panGesture}>
246
+ <Animated.View
247
+ style={[
248
+ styles.screen,
249
+ { backgroundColor: theme.colors.background },
250
+ screenStyle,
251
+ style,
252
+ ]}
253
+ >
254
+ {children}
255
+ </Animated.View>
256
+ </GestureDetector>
257
+ </View>
258
+ );
259
+ };
260
+
261
+ export default SwipeableScreen;
262
+
263
+ const styles = StyleSheet.create({
264
+ outerWrapper: {
265
+ flex: 1,
266
+ },
267
+ backdrop: {
268
+ ...StyleSheet.absoluteFillObject,
269
+ backgroundColor: "#000000",
270
+ zIndex: 0,
271
+ },
272
+ screen: {
273
+ flex: 1,
274
+ zIndex: 1,
275
+ },
276
+ });
package/src/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import SwipeableScreen from './SwipableScreen';
2
+ import {
3
+ PREDICTIVE_BACK_SUPPORTED,
4
+ PREDICTIVE_BACK_HAS_PROGRESS,
5
+ EDGE_LEFT,
6
+ EDGE_RIGHT,
7
+ BACK_MODE_DEFAULT,
8
+ BACK_MODE_SYSTEM,
9
+ setFallbackBackMode,
10
+ acquirePredictiveBack,
11
+ releasePredictiveBack,
12
+ } from './PredectiveBack';
13
+
14
+ export {
15
+ SwipeableScreen,
16
+ PREDICTIVE_BACK_SUPPORTED,
17
+ PREDICTIVE_BACK_HAS_PROGRESS,
18
+ EDGE_LEFT,
19
+ EDGE_RIGHT,
20
+ BACK_MODE_DEFAULT,
21
+ BACK_MODE_SYSTEM,
22
+ setFallbackBackMode,
23
+ acquirePredictiveBack,
24
+ releasePredictiveBack,
25
+ };
26
+
27
+ export default SwipeableScreen;