insyd-bottom-sheet 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insyd-bottom-sheet",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A smooth, physics-based bottom sheet for React Native — by Ishan Gupta. Works with Expo Go, bare workflow, and React Native CLI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,101 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+
10
+ /**
11
+ * In-app portal for SmoothSheet. Sheets render their content HERE (a single
12
+ * layer near the app root, in the app's own window) instead of inside a native
13
+ * <Modal>. That's the whole reason the keyboard behaves correctly on Android
14
+ * edge-to-edge builds: a native Modal is a separate window whose keyboard
15
+ * insets React Native can't observe, so the sheet never rose. Rendered in the
16
+ * main window, ordinary keyboard handling just works.
17
+ *
18
+ * Mount <SheetHost> once, high in the tree but BELOW every provider your sheet
19
+ * content needs (theme, safe-area, query client, etc.) and inside your
20
+ * GestureHandlerRootView — portaled content resolves context from where the
21
+ * host sits, not from where the sheet element was created.
22
+ */
23
+
24
+ type Registry = {
25
+ set: (id: string, node: React.ReactNode) => void;
26
+ remove: (id: string) => void;
27
+ };
28
+
29
+ const SheetPortalContext = createContext<Registry | null>(
30
+ null,
31
+ );
32
+
33
+ export function SheetHost({
34
+ children,
35
+ }: {
36
+ children: React.ReactNode;
37
+ }) {
38
+ const [nodes, setNodes] = useState<
39
+ Record<string, React.ReactNode>
40
+ >({});
41
+
42
+ const registry = useMemo<Registry>(
43
+ () => ({
44
+ set: (id, node) =>
45
+ setNodes((prev) => ({ ...prev, [id]: node })),
46
+ remove: (id) =>
47
+ setNodes((prev) => {
48
+ if (!(id in prev)) return prev;
49
+ const next = { ...prev };
50
+ delete next[id];
51
+ return next;
52
+ }),
53
+ }),
54
+ [],
55
+ );
56
+
57
+ return (
58
+ <SheetPortalContext.Provider value={registry}>
59
+ {children}
60
+ {Object.entries(nodes).map(([id, node]) => (
61
+ <React.Fragment key={id}>{node}</React.Fragment>
62
+ ))}
63
+ </SheetPortalContext.Provider>
64
+ );
65
+ }
66
+
67
+ let warnedNoHost = false;
68
+
69
+ /**
70
+ * Teleports its children into the nearest <SheetHost>. Updates on every render
71
+ * so live content stays fresh; unmounts its slot on cleanup. Falls back to
72
+ * rendering inline (with a one-time dev warning) if no host is mounted, so a
73
+ * missing provider degrades instead of crashing.
74
+ */
75
+ export function SheetPortal({
76
+ children,
77
+ }: {
78
+ children: React.ReactNode;
79
+ }) {
80
+ const registry = useContext(SheetPortalContext);
81
+ const id = useId();
82
+
83
+ useEffect(() => {
84
+ if (!registry) return;
85
+ registry.set(id, children);
86
+ return () => registry.remove(id);
87
+ });
88
+
89
+ if (!registry) {
90
+ if (__DEV__ && !warnedNoHost) {
91
+ warnedNoHost = true;
92
+ console.warn(
93
+ "[insyd-bottom-sheet] No <SheetHost> found — rendering the sheet inline. " +
94
+ "Wrap your app in <SheetHost> (inside GestureHandlerRootView, below your providers) " +
95
+ "for correct overlaying and keyboard behavior.",
96
+ );
97
+ }
98
+ return <>{children}</>;
99
+ }
100
+ return null;
101
+ }
@@ -1,6 +1,9 @@
1
- import React, { useEffect, useRef, useState } from "react";
1
+ import type React from "react";
2
+ import { useEffect, useRef, useState } from "react";
2
3
  import {
3
- Modal,
4
+ BackHandler,
5
+ Keyboard,
6
+ Platform,
4
7
  Pressable,
5
8
  StyleSheet,
6
9
  useWindowDimensions,
@@ -9,19 +12,22 @@ import {
9
12
  import {
10
13
  Gesture,
11
14
  GestureDetector,
12
- GestureHandlerRootView,
13
15
  } from "react-native-gesture-handler";
14
16
  import Animated, {
15
- KeyboardState,
16
17
  runOnJS,
17
- useAnimatedKeyboard,
18
18
  useAnimatedStyle,
19
19
  useSharedValue,
20
20
  withSpring,
21
21
  withTiming,
22
22
  } from "react-native-reanimated";
23
23
 
24
- const DEFAULT_SPRING = { damping: 28, stiffness: 160, mass: 0.9 };
24
+ import { SheetPortal } from "./SheetHost";
25
+
26
+ const DEFAULT_SPRING = {
27
+ damping: 28,
28
+ stiffness: 160,
29
+ mass: 0.9,
30
+ };
25
31
 
26
32
  export interface SpringConfig {
27
33
  damping?: number;
@@ -116,30 +122,71 @@ export function SmoothSheet({
116
122
  // Live window height — correct across rotation, split-screen, and foldables.
117
123
  const { height: windowHeight } = useWindowDimensions();
118
124
 
119
- // The keyboard is tracked on the UI thread and the sheet is *translated*
120
- // above it. This works identically under edge-to-edge (forced on Android
121
- // 15+/targetSdk 35+, where adjustResize is a no-op) and in legacy resize
122
- // modethe sheet never depends on the window resizing.
123
- const keyboard = useAnimatedKeyboard();
125
+ // The sheet is *translated* above the keyboard, driven by global Keyboard
126
+ // events. Because the sheet now renders in the app's own window (via
127
+ // SheetPortal, not a native <Modal>), the focused TextInput lives in the same
128
+ // window these events observe so this fires and reports the right height in
129
+ // Expo Go, dev clients, AND release builds, including Android 15+ forced
130
+ // edge-to-edge where a Modal's separate window used to break it.
131
+ const keyboardHeight = useSharedValue(0);
132
+
133
+ useEffect(() => {
134
+ const showEvent =
135
+ Platform.OS === "ios"
136
+ ? "keyboardWillShow"
137
+ : "keyboardDidShow";
138
+ const hideEvent =
139
+ Platform.OS === "ios"
140
+ ? "keyboardWillHide"
141
+ : "keyboardDidHide";
142
+ const showSub = Keyboard.addListener(showEvent, (e) => {
143
+ keyboardHeight.value = withTiming(
144
+ e.endCoordinates.height,
145
+ {
146
+ duration:
147
+ Platform.OS === "ios"
148
+ ? (e.duration ?? 250)
149
+ : 160,
150
+ },
151
+ );
152
+ });
153
+ const hideSub = Keyboard.addListener(hideEvent, (e) => {
154
+ keyboardHeight.value = withTiming(0, {
155
+ duration:
156
+ Platform.OS === "ios" ? (e.duration ?? 250) : 160,
157
+ });
158
+ });
159
+ return () => {
160
+ showSub.remove();
161
+ hideSub.remove();
162
+ };
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ }, []);
124
165
 
125
166
  const translateY = useSharedValue(windowHeight);
126
167
  const backdropOpacity = useSharedValue(0);
127
- // Keep Modal mounted during the closing animation so the sheet can slide out
128
- const [modalVisible, setModalVisible] = useState(isVisible);
168
+ // Keep the sheet mounted through the closing animation so it can slide out.
169
+ const [mounted, setMounted] = useState(isVisible);
129
170
  // Guards against double-dismiss (back button mashing, backdrop tap during close)
130
171
  const isClosing = useRef(false);
131
172
 
132
173
  useEffect(() => {
133
174
  if (isVisible) {
134
175
  isClosing.current = false;
135
- setModalVisible(true);
176
+ setMounted(true);
136
177
  translateY.value = withSpring(0, spring);
137
- backdropOpacity.value = withTiming(1, { duration: 250 });
178
+ backdropOpacity.value = withTiming(1, {
179
+ duration: 250,
180
+ });
138
181
  } else {
139
182
  translateY.value = withSpring(windowHeight, spring);
140
- backdropOpacity.value = withTiming(0, { duration: 200 }, (finished) => {
141
- if (finished) runOnJS(setModalVisible)(false);
142
- });
183
+ backdropOpacity.value = withTiming(
184
+ 0,
185
+ { duration: 200 },
186
+ (finished) => {
187
+ if (finished) runOnJS(setMounted)(false);
188
+ },
189
+ );
143
190
  }
144
191
  // eslint-disable-next-line react-hooks/exhaustive-deps
145
192
  }, [isVisible]);
@@ -148,14 +195,32 @@ export function SmoothSheet({
148
195
  if (isClosing.current) return;
149
196
  isClosing.current = true;
150
197
  translateY.value = withSpring(windowHeight, spring);
151
- backdropOpacity.value = withTiming(0, { duration: 200 }, (finished) => {
152
- if (finished) {
153
- runOnJS(setModalVisible)(false);
154
- runOnJS(onDismiss)();
155
- }
156
- });
198
+ backdropOpacity.value = withTiming(
199
+ 0,
200
+ { duration: 200 },
201
+ (finished) => {
202
+ if (finished) {
203
+ runOnJS(setMounted)(false);
204
+ runOnJS(onDismiss)();
205
+ }
206
+ },
207
+ );
157
208
  }
158
209
 
210
+ // Android hardware back closes the sheet (replaces Modal's onRequestClose).
211
+ useEffect(() => {
212
+ if (!mounted || Platform.OS !== "android") return;
213
+ const sub = BackHandler.addEventListener(
214
+ "hardwareBackPress",
215
+ () => {
216
+ dismiss();
217
+ return true;
218
+ },
219
+ );
220
+ return () => sub.remove();
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ }, [mounted]);
223
+
159
224
  const panGesture = Gesture.Pan()
160
225
  .onUpdate((e) => {
161
226
  if (e.translationY > 0) {
@@ -173,32 +238,26 @@ export function SmoothSheet({
173
238
  }
174
239
  });
175
240
 
176
- const sheetStyle = useAnimatedStyle(() => {
177
- // Only lift the sheet while the keyboard is actually present; when it is
178
- // fully closed the translation collapses back to the drag/entry offset.
179
- const keyboardOffset =
180
- keyboard.state.value === KeyboardState.CLOSED
181
- ? 0
182
- : keyboard.height.value;
183
- return {
184
- transform: [{ translateY: translateY.value - keyboardOffset }],
185
- };
186
- });
241
+ const sheetStyle = useAnimatedStyle(() => ({
242
+ transform: [
243
+ {
244
+ translateY: translateY.value - keyboardHeight.value,
245
+ },
246
+ ],
247
+ }));
187
248
 
188
249
  const backdropStyle = useAnimatedStyle(() => ({
189
250
  opacity: backdropOpacity.value,
190
251
  }));
191
252
 
253
+ if (!mounted) return null;
254
+
192
255
  return (
193
- <Modal
194
- visible={modalVisible}
195
- transparent
196
- animationType="none"
197
- statusBarTranslucent
198
- navigationBarTranslucent
199
- onRequestClose={dismiss}
200
- >
201
- <GestureHandlerRootView style={StyleSheet.absoluteFill}>
256
+ <SheetPortal>
257
+ <View
258
+ style={StyleSheet.absoluteFill}
259
+ pointerEvents="box-none"
260
+ >
202
261
  {/* Backdrop */}
203
262
  <Animated.View
204
263
  style={[
@@ -236,7 +295,10 @@ export function SmoothSheet({
236
295
  <GestureDetector gesture={panGesture}>
237
296
  <View style={styles.handleArea}>
238
297
  <View
239
- style={[styles.handle, { backgroundColor: handleColor }]}
298
+ style={[
299
+ styles.handle,
300
+ { backgroundColor: handleColor },
301
+ ]}
240
302
  />
241
303
  </View>
242
304
  </GestureDetector>
@@ -245,8 +307,8 @@ export function SmoothSheet({
245
307
  sheet (see sheetStyle); no KeyboardAvoidingView needed. */}
246
308
  <View style={styles.content}>{children}</View>
247
309
  </Animated.View>
248
- </GestureHandlerRootView>
249
- </Modal>
310
+ </View>
311
+ </SheetPortal>
250
312
  );
251
313
  }
252
314
 
package/src/index.ts CHANGED
@@ -1,2 +1,6 @@
1
+ export { SheetHost } from "./SheetHost";
2
+ export type {
3
+ SmoothSheetProps,
4
+ SpringConfig,
5
+ } from "./SmoothSheet";
1
6
  export { SmoothSheet } from "./SmoothSheet";
2
- export type { SmoothSheetProps, SpringConfig } from "./SmoothSheet";