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 +1 -1
- package/src/SheetHost.tsx +101 -0
- package/src/SmoothSheet.tsx +109 -47
- 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.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
|
+
}
|
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,26 @@ export function SmoothSheet({
|
|
|
173
238
|
}
|
|
174
239
|
});
|
|
175
240
|
|
|
176
|
-
const sheetStyle = useAnimatedStyle(() => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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={[
|
|
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
|
-
</
|
|
249
|
-
</
|
|
310
|
+
</View>
|
|
311
|
+
</SheetPortal>
|
|
250
312
|
);
|
|
251
313
|
}
|
|
252
314
|
|