insyd-bottom-sheet 0.2.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 +8 -3
- package/src/SheetHost.tsx +101 -0
- package/src/SmoothSheet.tsx +147 -44
- 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",
|
|
@@ -42,11 +42,16 @@
|
|
|
42
42
|
"react": ">=18.0.0",
|
|
43
43
|
"react-native": ">=0.71.0",
|
|
44
44
|
"react-native-gesture-handler": ">=2.0.0",
|
|
45
|
-
"react-native-reanimated": ">=3.0.0"
|
|
45
|
+
"react-native-reanimated": ">=3.0.0",
|
|
46
|
+
"react-native-worklets": "*"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"react-native-worklets": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
46
52
|
},
|
|
47
53
|
"devDependencies": {
|
|
48
54
|
"@types/react": "^18.2.0",
|
|
49
|
-
"@types/react-native": "^0.73.0",
|
|
50
55
|
"typescript": "^5.3.0"
|
|
51
56
|
}
|
|
52
57
|
}
|
|
@@ -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,17 +1,17 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Modal,
|
|
4
|
+
BackHandler,
|
|
5
|
+
Keyboard,
|
|
6
6
|
Platform,
|
|
7
7
|
Pressable,
|
|
8
8
|
StyleSheet,
|
|
9
|
+
useWindowDimensions,
|
|
9
10
|
View,
|
|
10
11
|
} from "react-native";
|
|
11
12
|
import {
|
|
12
13
|
Gesture,
|
|
13
14
|
GestureDetector,
|
|
14
|
-
GestureHandlerRootView,
|
|
15
15
|
} from "react-native-gesture-handler";
|
|
16
16
|
import Animated, {
|
|
17
17
|
runOnJS,
|
|
@@ -21,9 +21,13 @@ import Animated, {
|
|
|
21
21
|
withTiming,
|
|
22
22
|
} from "react-native-reanimated";
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
import { SheetPortal } from "./SheetHost";
|
|
25
25
|
|
|
26
|
-
const DEFAULT_SPRING = {
|
|
26
|
+
const DEFAULT_SPRING = {
|
|
27
|
+
damping: 28,
|
|
28
|
+
stiffness: 160,
|
|
29
|
+
mass: 0.9,
|
|
30
|
+
};
|
|
27
31
|
|
|
28
32
|
export interface SpringConfig {
|
|
29
33
|
damping?: number;
|
|
@@ -39,12 +43,12 @@ export interface SmoothSheetProps {
|
|
|
39
43
|
/** Content rendered inside the sheet. */
|
|
40
44
|
children: React.ReactNode;
|
|
41
45
|
/**
|
|
42
|
-
* Minimum height of the sheet as a fraction of
|
|
46
|
+
* Minimum height of the sheet as a fraction of window height (0–1).
|
|
43
47
|
* @default 0.4
|
|
44
48
|
*/
|
|
45
49
|
minHeightFraction?: number;
|
|
46
50
|
/**
|
|
47
|
-
* Maximum height of the sheet as a fraction of
|
|
51
|
+
* Maximum height of the sheet as a fraction of window height (0–1).
|
|
48
52
|
* @default 0.97
|
|
49
53
|
*/
|
|
50
54
|
maxHeightFraction?: number;
|
|
@@ -83,6 +87,18 @@ export interface SmoothSheetProps {
|
|
|
83
87
|
* Merged over the default: { damping: 28, stiffness: 160, mass: 0.9 }
|
|
84
88
|
*/
|
|
85
89
|
springConfig?: SpringConfig;
|
|
90
|
+
/**
|
|
91
|
+
* Extra padding applied below the sheet content, e.g. the bottom safe-area
|
|
92
|
+
* inset on gesture-nav Android devices / notched iPhones. Pass
|
|
93
|
+
* `useSafeAreaInsets().bottom` from the host app.
|
|
94
|
+
* @default 0
|
|
95
|
+
*/
|
|
96
|
+
bottomInset?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Accessibility label announced for the backdrop's dismiss action.
|
|
99
|
+
* @default "Close"
|
|
100
|
+
*/
|
|
101
|
+
dismissAccessibilityLabel?: string;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
export function SmoothSheet({
|
|
@@ -98,35 +114,113 @@ export function SmoothSheet({
|
|
|
98
114
|
dismissThreshold = 80,
|
|
99
115
|
dismissVelocityThreshold = 800,
|
|
100
116
|
springConfig,
|
|
117
|
+
bottomInset = 0,
|
|
118
|
+
dismissAccessibilityLabel = "Close",
|
|
101
119
|
}: SmoothSheetProps) {
|
|
102
120
|
const spring = { ...DEFAULT_SPRING, ...springConfig };
|
|
103
121
|
|
|
104
|
-
|
|
122
|
+
// Live window height — correct across rotation, split-screen, and foldables.
|
|
123
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
124
|
+
|
|
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
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const translateY = useSharedValue(windowHeight);
|
|
105
167
|
const backdropOpacity = useSharedValue(0);
|
|
106
|
-
// Keep
|
|
107
|
-
const [
|
|
168
|
+
// Keep the sheet mounted through the closing animation so it can slide out.
|
|
169
|
+
const [mounted, setMounted] = useState(isVisible);
|
|
170
|
+
// Guards against double-dismiss (back button mashing, backdrop tap during close)
|
|
171
|
+
const isClosing = useRef(false);
|
|
108
172
|
|
|
109
173
|
useEffect(() => {
|
|
110
174
|
if (isVisible) {
|
|
111
|
-
|
|
175
|
+
isClosing.current = false;
|
|
176
|
+
setMounted(true);
|
|
112
177
|
translateY.value = withSpring(0, spring);
|
|
113
|
-
backdropOpacity.value = withTiming(1, {
|
|
114
|
-
|
|
115
|
-
translateY.value = withSpring(SCREEN_HEIGHT, spring);
|
|
116
|
-
backdropOpacity.value = withTiming(0, { duration: 200 }, () => {
|
|
117
|
-
runOnJS(setModalVisible)(false);
|
|
178
|
+
backdropOpacity.value = withTiming(1, {
|
|
179
|
+
duration: 250,
|
|
118
180
|
});
|
|
181
|
+
} else {
|
|
182
|
+
translateY.value = withSpring(windowHeight, spring);
|
|
183
|
+
backdropOpacity.value = withTiming(
|
|
184
|
+
0,
|
|
185
|
+
{ duration: 200 },
|
|
186
|
+
(finished) => {
|
|
187
|
+
if (finished) runOnJS(setMounted)(false);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
119
190
|
}
|
|
120
191
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
121
192
|
}, [isVisible]);
|
|
122
193
|
|
|
123
194
|
function dismiss() {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
195
|
+
if (isClosing.current) return;
|
|
196
|
+
isClosing.current = true;
|
|
197
|
+
translateY.value = withSpring(windowHeight, spring);
|
|
198
|
+
backdropOpacity.value = withTiming(
|
|
199
|
+
0,
|
|
200
|
+
{ duration: 200 },
|
|
201
|
+
(finished) => {
|
|
202
|
+
if (finished) {
|
|
203
|
+
runOnJS(setMounted)(false);
|
|
204
|
+
runOnJS(onDismiss)();
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
);
|
|
128
208
|
}
|
|
129
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
|
+
|
|
130
224
|
const panGesture = Gesture.Pan()
|
|
131
225
|
.onUpdate((e) => {
|
|
132
226
|
if (e.translationY > 0) {
|
|
@@ -145,22 +239,25 @@ export function SmoothSheet({
|
|
|
145
239
|
});
|
|
146
240
|
|
|
147
241
|
const sheetStyle = useAnimatedStyle(() => ({
|
|
148
|
-
transform: [
|
|
242
|
+
transform: [
|
|
243
|
+
{
|
|
244
|
+
translateY: translateY.value - keyboardHeight.value,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
149
247
|
}));
|
|
150
248
|
|
|
151
249
|
const backdropStyle = useAnimatedStyle(() => ({
|
|
152
250
|
opacity: backdropOpacity.value,
|
|
153
251
|
}));
|
|
154
252
|
|
|
253
|
+
if (!mounted) return null;
|
|
254
|
+
|
|
155
255
|
return (
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
onRequestClose={dismiss}
|
|
162
|
-
>
|
|
163
|
-
<GestureHandlerRootView style={StyleSheet.absoluteFill}>
|
|
256
|
+
<SheetPortal>
|
|
257
|
+
<View
|
|
258
|
+
style={StyleSheet.absoluteFill}
|
|
259
|
+
pointerEvents="box-none"
|
|
260
|
+
>
|
|
164
261
|
{/* Backdrop */}
|
|
165
262
|
<Animated.View
|
|
166
263
|
style={[
|
|
@@ -169,7 +266,12 @@ export function SmoothSheet({
|
|
|
169
266
|
backdropStyle,
|
|
170
267
|
]}
|
|
171
268
|
>
|
|
172
|
-
<Pressable
|
|
269
|
+
<Pressable
|
|
270
|
+
style={StyleSheet.absoluteFill}
|
|
271
|
+
onPress={dismiss}
|
|
272
|
+
accessibilityRole="button"
|
|
273
|
+
accessibilityLabel={dismissAccessibilityLabel}
|
|
274
|
+
/>
|
|
173
275
|
</Animated.View>
|
|
174
276
|
|
|
175
277
|
{/* Sheet */}
|
|
@@ -180,32 +282,33 @@ export function SmoothSheet({
|
|
|
180
282
|
borderTopLeftRadius: borderRadius,
|
|
181
283
|
borderTopRightRadius: borderRadius,
|
|
182
284
|
backgroundColor,
|
|
183
|
-
minHeight:
|
|
184
|
-
maxHeight:
|
|
285
|
+
minHeight: windowHeight * minHeightFraction,
|
|
286
|
+
maxHeight: windowHeight * maxHeightFraction,
|
|
287
|
+
paddingBottom: bottomInset,
|
|
185
288
|
},
|
|
186
289
|
sheetStyle,
|
|
187
290
|
]}
|
|
188
291
|
pointerEvents="box-none"
|
|
292
|
+
accessibilityViewIsModal
|
|
189
293
|
>
|
|
190
294
|
{/* Drag handle */}
|
|
191
295
|
<GestureDetector gesture={panGesture}>
|
|
192
296
|
<View style={styles.handleArea}>
|
|
193
297
|
<View
|
|
194
|
-
style={[
|
|
298
|
+
style={[
|
|
299
|
+
styles.handle,
|
|
300
|
+
{ backgroundColor: handleColor },
|
|
301
|
+
]}
|
|
195
302
|
/>
|
|
196
303
|
</View>
|
|
197
304
|
</GestureDetector>
|
|
198
305
|
|
|
199
|
-
{/* Content
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
style={styles.content}
|
|
203
|
-
>
|
|
204
|
-
{children}
|
|
205
|
-
</KeyboardAvoidingView>
|
|
306
|
+
{/* Content — keyboard avoidance is handled by translating the whole
|
|
307
|
+
sheet (see sheetStyle); no KeyboardAvoidingView needed. */}
|
|
308
|
+
<View style={styles.content}>{children}</View>
|
|
206
309
|
</Animated.View>
|
|
207
|
-
</
|
|
208
|
-
</
|
|
310
|
+
</View>
|
|
311
|
+
</SheetPortal>
|
|
209
312
|
);
|
|
210
313
|
}
|
|
211
314
|
|