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 +1 -1
- package/src/SheetHost.tsx +101 -0
- package/src/SmoothSheet.tsx +112 -48
- package/src/index.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "insyd-bottom-sheet",
|
|
3
|
-
"version": "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
|
+
}
|
package/src/SmoothSheet.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
3
|
import {
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
|
128
|
-
const [
|
|
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
|
-
|
|
176
|
+
setMounted(true);
|
|
136
177
|
translateY.value = withSpring(0, spring);
|
|
137
|
-
backdropOpacity.value = withTiming(1, {
|
|
178
|
+
backdropOpacity.value = withTiming(1, {
|
|
179
|
+
duration: 250,
|
|
180
|
+
});
|
|
138
181
|
} else {
|
|
139
182
|
translateY.value = withSpring(windowHeight, spring);
|
|
140
|
-
backdropOpacity.value = withTiming(
|
|
141
|
-
|
|
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(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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={[
|
|
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
|
-
</
|
|
249
|
-
</
|
|
312
|
+
</View>
|
|
313
|
+
</SheetPortal>
|
|
250
314
|
);
|
|
251
315
|
}
|
|
252
316
|
|