insyd-bottom-sheet 0.3.0 → 0.4.1

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.1",
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,27 @@ 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
+ // Keyboard avoidance grows the sheet (animated bottom padding) instead of
242
+ // translating it translating lifts the sheet's bottom edge off the screen
243
+ // floor, exposing a keyboard-height strip of backdrop behind the keyboard.
244
+ // Growing keeps the sheet anchored so its background fills that strip.
245
+ const sheetStyle = useAnimatedStyle(() => ({
246
+ transform: [{ translateY: translateY.value }],
247
+ paddingBottom: bottomInset + keyboardHeight.value,
248
+ }));
187
249
 
188
250
  const backdropStyle = useAnimatedStyle(() => ({
189
251
  opacity: backdropOpacity.value,
190
252
  }));
191
253
 
254
+ if (!mounted) return null;
255
+
192
256
  return (
193
- <Modal
194
- visible={modalVisible}
195
- transparent
196
- animationType="none"
197
- statusBarTranslucent
198
- navigationBarTranslucent
199
- onRequestClose={dismiss}
200
- >
201
- <GestureHandlerRootView style={StyleSheet.absoluteFill}>
257
+ <SheetPortal>
258
+ <View
259
+ style={StyleSheet.absoluteFill}
260
+ pointerEvents="box-none"
261
+ >
202
262
  {/* Backdrop */}
203
263
  <Animated.View
204
264
  style={[
@@ -225,7 +285,8 @@ export function SmoothSheet({
225
285
  backgroundColor,
226
286
  minHeight: windowHeight * minHeightFraction,
227
287
  maxHeight: windowHeight * maxHeightFraction,
228
- paddingBottom: bottomInset,
288
+ // paddingBottom lives in sheetStyle so it can grow with the
289
+ // keyboard.
229
290
  },
230
291
  sheetStyle,
231
292
  ]}
@@ -236,7 +297,10 @@ export function SmoothSheet({
236
297
  <GestureDetector gesture={panGesture}>
237
298
  <View style={styles.handleArea}>
238
299
  <View
239
- style={[styles.handle, { backgroundColor: handleColor }]}
300
+ style={[
301
+ styles.handle,
302
+ { backgroundColor: handleColor },
303
+ ]}
240
304
  />
241
305
  </View>
242
306
  </GestureDetector>
@@ -245,8 +309,8 @@ export function SmoothSheet({
245
309
  sheet (see sheetStyle); no KeyboardAvoidingView needed. */}
246
310
  <View style={styles.content}>{children}</View>
247
311
  </Animated.View>
248
- </GestureHandlerRootView>
249
- </Modal>
312
+ </View>
313
+ </SheetPortal>
250
314
  );
251
315
  }
252
316
 
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";