react-native-snap-sheet 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Anthony
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # react-native-snap-sheet
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { SnapSheetProvider } from "./src/provider";
2
+ import SnapSheet from "./src/snapsheet";
3
+ import { SnapSheetModal } from "./src/snapsheet_modal";
4
+
5
+ export {
6
+ SnapSheet,
7
+ SnapSheetModal,
8
+ SnapSheetProvider
9
+ };
package/index.ts ADDED
@@ -0,0 +1,256 @@
1
+ import * as React from "react";
2
+ import { StyleProp, ViewStyle } from "react-native";
3
+
4
+ export interface SnapSheetBaseProps {
5
+ /**
6
+ * Child content inside the snap sheet
7
+ */
8
+ children?: React.ReactNode;
9
+
10
+ /**
11
+ * Called when snapping begins or index changes
12
+ */
13
+ onSnapIndex?: (index: number) => void;
14
+
15
+ /**
16
+ * Called when snapping animation fully finishes
17
+ */
18
+ onSnapFinish?: (index: number) => void;
19
+
20
+ /**
21
+ * If enabled, the sheet will snap to the nearest previous snap point
22
+ * (instead of jumping directly to index 0) while the gesture is decelerating.
23
+ *
24
+ * @default false
25
+ */
26
+ snapWhileDecelerating?: boolean;
27
+
28
+ /**
29
+ * Style applied to the sheet
30
+ */
31
+ style?: StyleProp<ViewStyle>;
32
+
33
+ /**
34
+ * Whether the sheet should transfer its remaining downward drag velocity to a child scroll view when expanding.
35
+ *
36
+ * @default false
37
+ */
38
+ inheritScrollVelocityOnExpand?: boolean;
39
+
40
+ /**
41
+ * Whether the sheet should inherit the remaining upward scroll velocity from a child scroll view when collapsing.
42
+ *
43
+ * @default false
44
+ */
45
+ inheritScrollVelocityOnCollapse?: boolean;
46
+
47
+ /**
48
+ * Function to render the sheet handle (top drag indicator)
49
+ *
50
+ * this is not used if `centered` is true
51
+ */
52
+ renderHandle?: React.ReactNode | (() => React.ReactNode);
53
+
54
+ /**
55
+ * Color of the sheet handle
56
+ */
57
+ handleColor?: string | undefined;
58
+
59
+ /**
60
+ * Behaviour for avoiding keyboard overlap
61
+ * - "off" → disables keyboard dodging
62
+ * - "optimum" → intelligently lift only needed amount
63
+ * - "whole" → move entire sheet above keyboard
64
+ *
65
+ * `react-native-dodge-keyboard` is used for keyboard dodging and automatic tag detection for scrollable and input components.
66
+ *
67
+ * By default, the known scrollable tags are:
68
+ * - "ScrollView"
69
+ * - "FlatList"
70
+ * - "SectionList"
71
+ * - "VirtualizedList"
72
+ *
73
+ * If you want a custom scrollable element to support dodging,
74
+ * add the prop: `dodge_keyboard_scrollable={true}`.
75
+ *
76
+ * By default, "TextInput" is the only known input tag.
77
+ * To enable dodging for a custom input element,
78
+ * add the prop: `dodge_keyboard_input={true}`.
79
+ *
80
+ * Input elements or views with dodge_keyboard_input={true} that are not inside a scrollable view must be manually lifted by responding to the `onHandleDodging` callback.
81
+ *
82
+ * @default 'optimum'
83
+ */
84
+ keyboardDodgingBehaviour?: "off" | "optimum" | "whole";
85
+
86
+ /**
87
+ * Additional offset to add when dodging keyboard
88
+ *
89
+ * @default 10
90
+ */
91
+ keyboardDodgingOffset?: number;
92
+ }
93
+
94
+ export interface SnapSheetProps extends SnapSheetBaseProps {
95
+ /**
96
+ * List of snap point heights (e.g. [0, 300, 600])
97
+ */
98
+ snapPoints?: number[];
99
+
100
+ /**
101
+ * The snap index to start from on initial mount
102
+ *
103
+ * @default 0
104
+ */
105
+ initialSnapIndex?: number;
106
+
107
+ /**
108
+ * Disable user interactions on the snap sheet
109
+ *
110
+ * @default false
111
+ */
112
+ disabled?: boolean;
113
+ }
114
+
115
+ export interface SnapSheetRef {
116
+ /**
117
+ * snap to an index
118
+ * @param index index to snap to
119
+ */
120
+ snap(index: number): void;
121
+ }
122
+
123
+ export declare const SnapSheet: React.ForwardRefExoticComponent<
124
+ React.PropsWithoutRef<SnapSheetProps> &
125
+ React.RefAttributes<SnapSheetRef | undefined>
126
+ >;
127
+
128
+
129
+ // <------- SnapSheetModal ------>
130
+
131
+ export type SnapSheetModalState = "closed" | "middle" | "opened";
132
+
133
+ export interface SnapSheetModalProps extends SnapSheetBaseProps {
134
+ /**
135
+ * Called when the sheet fully opens
136
+ */
137
+ onOpened?: () => void;
138
+
139
+ /**
140
+ * Called when the sheet fully closes
141
+ */
142
+ onClosed?: () => void;
143
+
144
+ /**
145
+ * Height when fully opened
146
+ *
147
+ * this is not needed if `centered` is true
148
+ */
149
+ modalHeight: number;
150
+
151
+ /**
152
+ * Height of "middle" snap point
153
+ *
154
+ * this is not used if `centered` is true
155
+ */
156
+ middleHeight: number;
157
+
158
+ /**
159
+ * Initial state of the modal
160
+ *
161
+ * @default 'closed'
162
+ */
163
+ initialState?: SnapSheetModalState;
164
+
165
+ /**
166
+ * Called when the sheet transitions to a new state
167
+ */
168
+ onStateChanged?: (state: SnapSheetModalState) => void;
169
+
170
+ /**
171
+ * Optional backdrop renderer (e.g. dim overlay)
172
+ */
173
+ renderBackDrop?: React.ReactNode | (() => React.ReactNode);
174
+
175
+ /**
176
+ * Disable backdrop press
177
+ *
178
+ * @default false
179
+ */
180
+ disableBackdrop?: boolean;
181
+
182
+ /**
183
+ * If true, modal can cover the whole screen area.
184
+ *
185
+ * setting this to `true` requires you to add `<SafeAreaProvider>` at the top of your App.js file
186
+ *
187
+ * @default false
188
+ */
189
+ fillScreen?: boolean;
190
+
191
+ /**
192
+ * When true, the children are unmounted when sheet is closed
193
+ *
194
+ * @default true
195
+ */
196
+ unMountChildrenWhenClosed?: boolean;
197
+
198
+ /**
199
+ * Style applied to the modal container (not the snap sheet)
200
+ */
201
+ containerStyle?: StyleProp<ViewStyle>;
202
+
203
+ /**
204
+ * Enable back button press handler when the modal is opened
205
+ *
206
+ * @default false
207
+ */
208
+ disableBackHandler?: boolean;
209
+
210
+ /**
211
+ * Disable user interactions on the snap sheet including indirect actions such as backdrop press and back button press.
212
+ *
213
+ * You can still control snap sheet programmatically via `ref.open()`, `ref.close()` or `ref.middleSnap()`
214
+ *
215
+ * @default false
216
+ */
217
+ disabled?: boolean;
218
+
219
+ /**
220
+ * Disable pan gesture on the snap sheet while still retaining indirect actions such as backdrop press and back button press.
221
+ *
222
+ * @default false
223
+ */
224
+ disablePanGesture?: boolean;
225
+
226
+ /**
227
+ * True to render children content at the center of the modal instead of the bottom
228
+ *
229
+ * @default false
230
+ */
231
+ centered?: boolean;
232
+ }
233
+
234
+ export interface SnapSheetModalRef {
235
+ /**
236
+ * fully open the modal
237
+ */
238
+ open(): void;
239
+
240
+ /**
241
+ * close the modal
242
+ */
243
+ close(): void;
244
+
245
+ /**
246
+ * partially open the modal and snap to `middleHeight`
247
+ */
248
+ middleSnap(): void;
249
+ }
250
+
251
+ export declare const SnapSheetModal: React.ForwardRefExoticComponent<
252
+ React.PropsWithoutRef<SnapSheetModalProps> &
253
+ React.RefAttributes<SnapSheetModalRef | undefined>
254
+ >;
255
+
256
+ export declare const SnapSheetProvider: React.Component;
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "react-native-snap-sheet",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "homepage": "https://github.com/deflexable/react-native-snap-sheet#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/deflexable/react-native-snap-sheet/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/deflexable/react-native-snap-sheet.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Anthony",
15
+ "type": "module",
16
+ "types": "./index.d.ts",
17
+ "main": "index.js",
18
+ "scripts": {
19
+ "test": "echo \"Error: no test specified\" && exit 1"
20
+ },
21
+ "dependencies": {
22
+ "react-native-dodge-keyboard": "^1.0.1",
23
+ "react-native-push-back": "^1.0.0"
24
+ }
25
+ }
@@ -0,0 +1,83 @@
1
+ import { createContext, useEffect, useMemo, useReducer, useRef, useState } from "react";
2
+ import { SnapSheetModalBase } from "./snapsheet_modal";
3
+
4
+ export const PortalContext = createContext();
5
+
6
+ export const SnapSheetProvider = ({ children }) => {
7
+ const doUpdating = useRef();
8
+
9
+ return (
10
+ <PortalContext.Provider
11
+ value={{
12
+ updateModal: (...args) => doUpdating.current(...args)
13
+ }}>
14
+ <ModalShell doUpdating={doUpdating} />
15
+ {children}
16
+ </PortalContext.Provider>
17
+ );
18
+ };
19
+
20
+ const ModalShell = ({ doUpdating }) => {
21
+ const [portals, setPortals] = useReducer((prev, [key, shouldAdd]) => {
22
+ if (shouldAdd) {
23
+ if (prev.includes(key)) return prev;
24
+ return [...prev, key];
25
+ }
26
+ return prev.filter(v => v !== key);
27
+ }, []);
28
+
29
+ const callerMap = useRef({});
30
+ const hasMounted = useRef();
31
+ const pendingEvent = useRef([]);
32
+
33
+ useMemo(() => {
34
+ doUpdating.current = (...args) => {
35
+ if (hasMounted.current) {
36
+ const [key, props, ref] = args;
37
+ if (props) {
38
+ if (callerMap.current[key]?.doUpdate) {
39
+ callerMap.current[key].doUpdate({ props, ref });
40
+ } else {
41
+ const willMount = !!callerMap.current[key];
42
+
43
+ callerMap.current[key] = {
44
+ initState: { props, ref },
45
+ onMounted: () => {
46
+ callerMap.current[key].onMounted = undefined;
47
+ callerMap.current[key].initState = undefined;
48
+ if (willMount) callerMap.current[key].doUpdate({ props, ref });
49
+ }
50
+ };
51
+ if (!willMount) setPortals([key, true]);
52
+ }
53
+ } else {
54
+ if (callerMap.current[key]) delete callerMap.current[key];
55
+ setPortals([key]);
56
+ }
57
+ } else pendingEvent.current.push(args);
58
+ };
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ hasMounted.current = true;
63
+ pendingEvent.current.forEach(v => doUpdating.current(...v));
64
+ pendingEvent.current = [];
65
+ }, []);
66
+
67
+ return portals.map(p =>
68
+ <ModalShellItem
69
+ key={p}
70
+ caller={callerMap.current[p]} />
71
+ );
72
+ }
73
+
74
+ const ModalShellItem = ({ caller }) => {
75
+ const [state, setState] = useState(caller.initState);
76
+
77
+ useEffect(() => {
78
+ caller.doUpdate = setState;
79
+ caller.onMounted();
80
+ }, []);
81
+
82
+ return <SnapSheetModalBase ref={state.ref} {...state.props} />;
83
+ }
@@ -0,0 +1,398 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
2
+ import { Animated, Keyboard, PanResponder, StyleSheet, useAnimatedValue, View } from "react-native";
3
+ import DodgeKeyboard, { ReactHijacker } from "react-native-dodge-keyboard";
4
+ import { doRendable, isNumber } from "./utils";
5
+ import { styling } from "./styling";
6
+
7
+ const PixelRate = 70 / 100; // 70ms to 100 pixels
8
+
9
+ const SnapSheet = forwardRef(function SnapSheet({
10
+ snapPoints = [],
11
+ initialSnapIndex = 0,
12
+ onSnapIndex,
13
+ onSnapFinish,
14
+ snapWhileDecelerating = false,
15
+ style,
16
+ inheritScrollVelocityOnExpand,
17
+ inheritScrollVelocityOnCollapse,
18
+ renderHandle,
19
+ handleColor,
20
+ keyboardDodgingBehaviour = 'optimum', // off, whole
21
+ keyboardDodgingOffset = 10,
22
+ children,
23
+ disabled,
24
+ currentAnchorId,
25
+ __shaky_sheet
26
+ }, ref) {
27
+ const isLift = keyboardDodgingBehaviour === 'whole';
28
+ const isOptimum = keyboardDodgingBehaviour === 'optimum';
29
+
30
+ if (!['optimum', 'whole', 'off'].includes(keyboardDodgingBehaviour))
31
+ throw `keyboardDodgingBehaviour must be any of ${['optimum', 'whole', 'off']} but got ${keyboardDodgingBehaviour}`;
32
+
33
+ useMemo(() => {
34
+ if (snapPoints.length < 2) throw new Error('snapPoints must have at least two items');
35
+ snapPoints.forEach((v, i, a) => {
36
+ if (typeof v !== 'number' || !isNumber(v))
37
+ throw new Error(`snapPoints must have a valid number but got ${v} at position ${i}`);
38
+ if (i !== a.length - 1 && v >= a[i + 1])
39
+ throw new Error(`snapPoints must be in accending order but got ${v} before ${a[i + 1]}`);
40
+ });
41
+ if (!Number.isInteger(initialSnapIndex) || initialSnapIndex < 0)
42
+ throw new Error(`initialSnapIndex should be a positive integer but got:${initialSnapIndex}`);
43
+ if (initialSnapIndex >= snapPoints.length) throw new Error(`initialSnapIndex is out of range`);
44
+ }, snapPoints);
45
+ const initSnapPoints = snapPoints;
46
+
47
+ const [scrollEnabled, setScrollEnabled] = useState(false);
48
+ const [dodgeOffset, setDodgeOffset] = useState(0);
49
+ const [requiredLift, setRequiredLift] = useState(0);
50
+ const [currentIndex, setCurrentIndex] = useState(initialSnapIndex);
51
+ const [finishedIndex, setFinishedIndex] = useState(initialSnapIndex);
52
+ const [prefferedAnchor, setPrefferedAnchor] = useState();
53
+
54
+ const extraLift = dodgeOffset && ((isOptimum ? dodgeOffset : isLift ? requiredLift : 0) || 0);
55
+
56
+ snapPoints = snapPoints.map(v => v + extraLift);
57
+ // console.log('sheetLifing:', { extraLift, dodgeOffset, requiredLift, initSnapPoints: `${initSnapPoints}`, snapPoints: `${snapPoints}` });
58
+ const MAX_HEIGHT = snapPoints.slice(-1)[0];
59
+
60
+ const snapTranslateValues = useMemo(() => snapPoints.map(h => MAX_HEIGHT - h), snapPoints);
61
+
62
+ const translateY = useAnimatedValue(snapTranslateValues[initialSnapIndex]);
63
+
64
+ /**
65
+ * @type {import("react").RefObject<{[key: string]: { ref: import('react-native').ScrollView, scrollY: 0, location: number[], anchorId: boolean }}>}
66
+ */
67
+ const scrollRefObj = useRef({});
68
+ const lastOffset = useRef(translateY._value);
69
+ const lastSnapIndex = useRef(initialSnapIndex);
70
+ const bottomFakePlaceholderRef = useRef();
71
+ const instantPrefferAnchor = useRef();
72
+ instantPrefferAnchor.current = prefferedAnchor;
73
+
74
+ const prevScrollY = useRef(0);
75
+ const prevTime = useRef(0);
76
+
77
+ const instantScrollEnabled = useRef(scrollEnabled);
78
+ instantScrollEnabled.current = scrollEnabled;
79
+
80
+ const updateKeyboardOffset = () => {
81
+ if (!isLift) {
82
+ setRequiredLift(0);
83
+ return;
84
+ }
85
+ const keyboardInfo = Keyboard.metrics();
86
+ if (keyboardInfo?.height && keyboardInfo.screenY) {
87
+ bottomFakePlaceholderRef.current.measureInWindow((x, y) => {
88
+ const remains = y - keyboardInfo.screenY;
89
+ setRequiredLift(Math.max(0, remains));
90
+ });
91
+ } else setRequiredLift(0);
92
+ }
93
+
94
+ useEffect(updateKeyboardOffset, [dodgeOffset, ...initSnapPoints]);
95
+
96
+ const getCurrentSnap = (draggingUpward) => {
97
+ const shownHeight = MAX_HEIGHT - translateY._value;
98
+ const currentSnapIndex = draggingUpward ? snapPoints.findIndex((v, i, a) => v <= shownHeight && (i === a.length - 1 || shownHeight < a[i + 1]))
99
+ : snapPoints.findIndex((v, i, a) => v >= shownHeight && (!i || shownHeight > a[i - 1]));
100
+
101
+ return currentSnapIndex;
102
+ }
103
+
104
+ const snapToIndex = (index, force, velocity, onFinish) => {
105
+ if (disabled && !force) return;
106
+
107
+ if (!Number.isInteger(index) || index < 0 || index > snapPoints.length - 1)
108
+ throw new Error(`invalid snap index:${index}, index must be within range 0 - ${snapPoints.length - 1}`);
109
+
110
+ const newY = snapTranslateValues[index];
111
+
112
+ if (__shaky_sheet && lastOffset.current !== newY)
113
+ translateY.setValue(lastOffset.current);
114
+
115
+ const prevY = translateY._value;
116
+ setScrollEnabled(index === snapPoints.length - 1);
117
+ setCurrentIndex(index);
118
+
119
+ // console.log('snapping:', index);
120
+ let wasFinished;
121
+ const guessFinish = () => {
122
+ if (wasFinished) return;
123
+ wasFinished = true;
124
+ onFinish?.();
125
+ onSnapFinish?.(index);
126
+ }
127
+ const pixel = Math.abs(prevY - newY);
128
+ const timeout = pixel * PixelRate;
129
+
130
+ const timer = setTimeout(guessFinish, Math.max(300, timeout));
131
+
132
+ // console.log('snapTimer:', { timeout, pixel });
133
+
134
+ Animated.spring(translateY, {
135
+ velocity,
136
+ toValue: newY,
137
+ useNativeDriver: true
138
+ }).start(() => {
139
+ clearTimeout(timer);
140
+ setFinishedIndex(index);
141
+ guessFinish();
142
+ });
143
+
144
+ lastOffset.current = newY;
145
+ lastSnapIndex.current = index;
146
+ onSnapIndex?.(index);
147
+ }
148
+
149
+ useImperativeHandle(ref, () => ({
150
+ snap: index => {
151
+ snapToIndex(index, true);
152
+ }
153
+ }), snapPoints);
154
+
155
+ useEffect(() => {
156
+ snapToIndex(Math.min(lastSnapIndex.current, snapPoints.length - 1), true);
157
+ }, snapPoints);
158
+
159
+ const panResponder = useMemo(() => {
160
+
161
+ return PanResponder.create({
162
+ onMoveShouldSetPanResponderCapture: (_, gesture) => {
163
+ const { scrollY } = scrollRefObj.current[instantPrefferAnchor.current] || {};
164
+
165
+ const shouldCapture = !disabled && (
166
+ !instantScrollEnabled.current ||
167
+ (scrollY <= 0 && gesture.dy > 1) // If sheet is expanded & ScrollView is at top → capture downward drags
168
+ );
169
+ // console.log('onMoveShouldSetPanResponderCapture shouldCapture:', shouldCapture, ' stats:', { gesture, scrollOffset: scrollY, instantScrollEnabled: instantScrollEnabled.current }, ' gesture.dy > 0:', gesture.dy > 1);
170
+ return shouldCapture;
171
+ },
172
+ onPanResponderMove: (_, gesture) => {
173
+ const newY = gesture.dy + lastOffset.current;
174
+
175
+ if (newY < 0) return; // prevent overscrolling upward
176
+ if (newY > MAX_HEIGHT) return;
177
+
178
+ translateY.setValue(newY);
179
+ },
180
+ onPanResponderRelease: (_, gesture) => {
181
+ const { dy, vy } = gesture; // when vy is lesser, it is scroll up
182
+ // console.log('onPanResponderRelease:', gesture);
183
+
184
+ const draggingUpward = vy <= 0;
185
+ const currentSnapIndex = getCurrentSnap(draggingUpward);
186
+
187
+ const newSnapIndex = Math.abs(dy) <= 30 ? currentSnapIndex :
188
+ draggingUpward ? Math.min(snapPoints.length - 1, currentSnapIndex + 1) :
189
+ snapWhileDecelerating ? Math.max(0, currentSnapIndex - 1) :
190
+ vy > 0.3 ? 0 : currentSnapIndex;
191
+ const willFullyShow = newSnapIndex === snapPoints.length - 1;
192
+
193
+ snapToIndex(newSnapIndex, true, draggingUpward ? vy : undefined);
194
+
195
+ // Only scroll if there was a fling velocity upward
196
+ if (inheritScrollVelocityOnExpand && willFullyShow && vy < -0.1) {
197
+ const newScrollY = Math.min(100, Math.max(0, -vy * 70)); // velocity → scroll inertia
198
+ const ref = scrollRefObj.current[instantPrefferAnchor.current]?.ref;
199
+ if (ref) {
200
+ if (ref.scrollTo) {
201
+ ref.scrollTo?.({ y: newScrollY, animated: true });
202
+ } else if (ref.scrollToOffset) {
203
+ ref.scrollToOffset?.({ offset: newScrollY, animated: true });
204
+ } else {
205
+ ref.getScrollResponder?.()?.scrollTo?.({ y: newScrollY, animated: true });
206
+ }
207
+ }
208
+ }
209
+ }
210
+ });
211
+ }, [!disabled, ...snapPoints]);
212
+
213
+ const conStyle = useMemo(() => ({
214
+ position: "absolute",
215
+ width: "100%",
216
+ backgroundColor: "#fff",
217
+ borderTopLeftRadius: 25,
218
+ borderTopRightRadius: 25,
219
+ zIndex: 1,
220
+ ...StyleSheet.flatten(style),
221
+ bottom: 0,
222
+ height: MAX_HEIGHT,
223
+ transform: [{ translateY }]
224
+ }), [MAX_HEIGHT, style]);
225
+
226
+ useEffect(() => updatePrefferAnchor, [currentAnchorId]);
227
+ const updateAnchorReducer = useRef();
228
+
229
+ const scheduleAnchorUpdate = (timeout = 100) => {
230
+ clearTimeout(updateAnchorReducer.current);
231
+ updateAnchorReducer.current = setTimeout(updatePrefferAnchor, timeout);
232
+ }
233
+
234
+ const updatePrefferAnchor = () => {
235
+ const rankedAnchors = Object.entries(scrollRefObj.current).sort((a, b) => compareReactPaths(a[1].location, b[1].location));
236
+ const directAnchor = rankedAnchors.find(v => v[1].anchorId === currentAnchorId);
237
+ if (directAnchor) return setPrefferedAnchor(directAnchor[0]);
238
+
239
+ const normalAnchor = rankedAnchors.find(v => !!v[1].anchorId);
240
+ if (normalAnchor) return setPrefferedAnchor(normalAnchor[0]);
241
+ setPrefferedAnchor(rankedAnchors[0]?.[0]);
242
+ }
243
+
244
+ const onAnchorScroll = (e, instanceId) => {
245
+ const scrollY = e.nativeEvent.contentOffset.y;
246
+ if (scrollRefObj.current[instanceId])
247
+ scrollRefObj.current[instanceId].scrollY = scrollY;
248
+
249
+ // console.log('onAnchorScroll scrollOffset:', scrollY);
250
+ if (!inheritScrollVelocityOnCollapse) {
251
+ prevScrollY.current = 0;
252
+ prevTime.current = 0;
253
+ return;
254
+ }
255
+ const now = Date.now();
256
+ let scrollVelocity = 0;
257
+
258
+ if (prevTime.current) {
259
+ const dy = scrollY - prevScrollY.current;
260
+ const dt = now - prevTime.current;
261
+
262
+ scrollVelocity = dt > 0 ? dy / dt : 0;
263
+ }
264
+
265
+ prevScrollY.current = scrollY;
266
+ prevTime.current = now;
267
+
268
+ // Handoff: ScrollView tries to overscroll upward
269
+ if (instantScrollEnabled.current && scrollY <= 0 && scrollVelocity < 0) {
270
+ instantScrollEnabled.current = false;
271
+
272
+ const currentSnapIndex = getCurrentSnap(false);
273
+ const newSnapIndex = snapWhileDecelerating ? Math.max(0, currentSnapIndex - 1) : 0;
274
+ snapToIndex(newSnapIndex, false, -scrollVelocity);
275
+ }
276
+ }
277
+
278
+ const handleDotStyle = useMemo(() => ({
279
+ ...styling.modalHandleItem,
280
+ ...handleColor ? { backgroundColor: handleColor } : {}
281
+ }), [handleColor]);
282
+
283
+ const disableDodging = keyboardDodgingBehaviour === 'off';
284
+ const sameIndex = currentIndex === finishedIndex;
285
+
286
+ return (
287
+ <View style={styling.absoluteFill}>
288
+ <Animated.View
289
+ style={conStyle}
290
+ {...panResponder.panHandlers}>
291
+ {doRendable?.(
292
+ renderHandle,
293
+ <View style={styling.modalHandle}>
294
+ <View style={handleDotStyle} />
295
+ </View>
296
+ )}
297
+ <View style={styling.flexer}>
298
+ <DodgeKeyboard
299
+ offset={keyboardDodgingOffset}
300
+ disabled={!sameIndex || disableDodging}
301
+ onHandleDodging={({ liftUp }) => {
302
+ setDodgeOffset(liftUp);
303
+ }}>
304
+ {ReactHijacker({
305
+ children,
306
+ doHijack: (node, path) => {
307
+ if (node?.props?.['snap_sheet_scan_off']) return { element: node };
308
+
309
+ if (isScrollable(node)) {
310
+ const instanceId = path.join(',');
311
+
312
+ const initNode = () => {
313
+ if (!scrollRefObj.current[instanceId])
314
+ scrollRefObj.current[instanceId] = { scrollY: 0, location: path };
315
+ const thisAnchorId = node.props?.snap_sheet_scroll_anchor;
316
+
317
+ if (scrollRefObj.current[instanceId].anchorId !== thisAnchorId) {
318
+ scheduleAnchorUpdate(300);
319
+ }
320
+ scrollRefObj.current[instanceId].anchorId = thisAnchorId;
321
+ }
322
+ initNode();
323
+
324
+ return {
325
+ props: {
326
+ ...node?.props,
327
+ ...disableDodging ? {} : { ['dodge_keyboard_scrollable']: true },
328
+ ref: r => {
329
+ if (r) {
330
+ initNode();
331
+ // if (scrollRefObj.current[instanceId].ref !== r) scheduleAnchorUpdate();
332
+ scrollRefObj.current[instanceId].ref = r;
333
+ } else if (scrollRefObj.current[instanceId]) {
334
+ delete scrollRefObj.current[instanceId];
335
+ scheduleAnchorUpdate();
336
+ }
337
+
338
+ const thatRef = node.props?.ref;
339
+ if (typeof thatRef === 'function') {
340
+ thatRef(r);
341
+ } else if (thatRef) thatRef.current = r;
342
+ },
343
+ ...prefferedAnchor === instanceId ? { scrollEnabled } : {},
344
+ onScroll: (e) => {
345
+ onAnchorScroll(e, instanceId);
346
+ return node.props?.onScroll?.(e);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+ })}
353
+ </DodgeKeyboard>
354
+ </View>
355
+ </Animated.View>
356
+ {isLift ?
357
+ <View
358
+ ref={bottomFakePlaceholderRef}
359
+ style={styling.fakePlaceholder}
360
+ onLayout={updateKeyboardOffset} /> : null}
361
+ </View>
362
+ );
363
+ });
364
+
365
+ const isScrollable = (element, disableTagCheck) => {
366
+ if (element?.props?.['snap_sheet_scroll_anchor']) return true;
367
+ if (!element?.type || element?.props?.horizontal || disableTagCheck) return false;
368
+
369
+ const scrollableTypes = ["ScrollView", "FlatList", "SectionList", "VirtualizedList"];
370
+
371
+ return scrollableTypes.includes(element.type.displayName)
372
+ || scrollableTypes.includes(element.type?.name);
373
+ };
374
+
375
+ function extractPath(entry) {
376
+ return entry.filter(v => typeof v === 'number');
377
+ }
378
+
379
+ // lexicographic comparison of two paths
380
+ function compareReactPaths(a, b) {
381
+ const pa = extractPath(a);
382
+ const pb = extractPath(b);
383
+
384
+ const len = Math.max(pa.length, pb.length);
385
+
386
+ for (let i = 0; i < len; i++) {
387
+ const av = pa[i];
388
+ const bv = pb[i];
389
+
390
+ if (av === undefined) return -1; // a ends early -> comes first
391
+ if (bv === undefined) return 1; // b ends early -> comes first
392
+
393
+ if (av !== bv) return av - bv; // normal numeric comparison
394
+ }
395
+ return 0;
396
+ }
397
+
398
+ export default SnapSheet;
@@ -0,0 +1,302 @@
1
+ import { forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState } from "react";
2
+ import { Pressable, StyleSheet, View } from "react-native";
3
+ import { isDodgeInput, ReactHijacker } from "react-native-dodge-keyboard";
4
+ import { useBackButton } from "react-native-push-back";
5
+ import { doRendable, isNumber } from "./utils";
6
+ import { PortalContext } from "./provider";
7
+ import { styling } from "./styling";
8
+
9
+ const ModalState = ['closed', 'middle', 'opened'];
10
+ const CenteredSheetStyle = { width: 0 };
11
+
12
+ export const SnapSheetModalBase = forwardRef(function SnapSheetModalBase({
13
+ onOpened,
14
+ onClosed,
15
+ modalHeight,
16
+ middleHeight,
17
+ initialState = 'closed',
18
+ centered,
19
+ onStateChanged,
20
+ renderBackDrop,
21
+ disableBackdrop,
22
+ fillScreen = true,
23
+ unMountChildrenWhenClosed = true,
24
+ disableBackHandler,
25
+ containerStyle,
26
+ disabled,
27
+ disablePanGesture,
28
+ children,
29
+ ...restProps
30
+ }, ref) {
31
+ centered = !!centered;
32
+ useMemo(() => {
33
+ if (centered) {
34
+ if (modalHeight) console.warn('modalHeight is not needed if centered={true}');
35
+ if (middleHeight) console.warn('middleHeight is not needed if centered={true}');
36
+ }
37
+ }, [centered]);
38
+
39
+ if (centered) {
40
+ middleHeight = undefined;
41
+ if (initialState === 'middle') initialState = 'opened';
42
+ }
43
+
44
+ const initialSnapIndex = ModalState.indexOf(initialState);
45
+ if (initialSnapIndex === -1)
46
+ throw `initialState must be any of ${initialState} but got ${initialState}`;
47
+ const hasMiddle = isNumber(middleHeight);
48
+
49
+ if (!hasMiddle && initialState === 'middle')
50
+ throw `middleHeight is required if initialState is "middle"`;
51
+
52
+ const [futureState, setFutureState] = useState(initialState);
53
+ const [currentState, setCurrentState] = useState(initialState);
54
+ const [autoIndexModal, setAutoIndexModal] = useState(initialSnapIndex);
55
+ const [viewDim, setViewDim] = useReducer((prev, incoming) => {
56
+ if (!incoming.some((v, i) => prev[i] !== v)) return prev;
57
+ return incoming;
58
+ }, [undefined, undefined]);
59
+ const [contentHeight, setContentHeight] = useState();
60
+ const [releaseUnmount, setReleaseUnmount] = useState();
61
+ const [viewWidth, viewHeight] = viewDim;
62
+
63
+ const sizingReadyCaller = useRef({ promise: undefined, callback: undefined });
64
+ const makeSizingPromise = () => {
65
+ if (centered)
66
+ sizingReadyCaller.current.promise = new Promise(resolve => {
67
+ sizingReadyCaller.current.callback = resolve;
68
+ });
69
+ }
70
+
71
+ useMemo(makeSizingPromise, []);
72
+
73
+ const sizingReady = !centered || (contentHeight !== undefined && viewHeight !== undefined);
74
+
75
+ useEffect(() => {
76
+ if (sizingReady) {
77
+ sizingReadyCaller.current.callback?.();
78
+ sizingReadyCaller.current.callback = undefined;
79
+ } else if (!sizingReadyCaller.current.callback) {
80
+ makeSizingPromise();
81
+ }
82
+ }, [sizingReady]);
83
+
84
+ const unmountChild = !!(hasClosed && unMountChildrenWhenClosed);
85
+ useEffect(() => {
86
+ if (unmountChild && centered) setContentHeight();
87
+ }, [unmountChild, !centered]);
88
+
89
+ const snapPoints = useMemo(() => {
90
+ if (centered) {
91
+ if (sizingReady)
92
+ return [-(contentHeight / 2), viewHeight / 2];
93
+ return [0, .3];
94
+ } else return [0, ...isNumber(middleHeight) ? [middleHeight] : [], modalHeight];
95
+ }, [viewHeight, contentHeight, centered, middleHeight, modalHeight]);
96
+
97
+ const willClose = futureState === 'closed';
98
+ const isOpened = futureState !== 'closed';
99
+ const hasClosed = futureState === 'closed' && currentState === 'closed';
100
+
101
+ const sheetRef = useRef();
102
+ const inputRefs = useRef({});
103
+ const snapModal = useRef();
104
+
105
+ snapModal.current = async (index, force) => {
106
+ if (sizingReadyCaller.current.callback) {
107
+ if (index && unMountChildrenWhenClosed && !releaseUnmount)
108
+ setReleaseUnmount(true);
109
+ await sizingReadyCaller.current.promise;
110
+ }
111
+
112
+ if (disabled && !force) return;
113
+ if (index && !sheetRef.current && !autoIndexModal) {
114
+ setAutoIndexModal(index);
115
+ } else if (sheetRef.current) {
116
+ sheetRef.current.snap(index);
117
+ }
118
+ }
119
+
120
+ useImperativeHandle(ref, () => ({
121
+ open: () => snapModal.current(hasMiddle ? 2 : 1, true),
122
+ close: () => snapModal.current(0, true),
123
+ middleSnap: () => {
124
+ if (!hasMiddle) throw 'calling middleSnap() requires middleHeight to be defined in the ref component';
125
+ snapModal.current(1, true);
126
+ }
127
+ }));
128
+
129
+ const hasMountAutoIndex = useRef();
130
+ useEffect(() => {
131
+ if (hasMountAutoIndex.current) {
132
+ if (autoIndexModal) snapModal.current(autoIndexModal, true);
133
+ }
134
+ hasMountAutoIndex.current = true;
135
+ }, [!autoIndexModal]);
136
+
137
+ useEffect(() => {
138
+ if (willClose)
139
+ try {
140
+ Object.values(inputRefs.current).forEach(e => {
141
+ if (e?.isFocused?.()) e?.blur?.();
142
+ });
143
+ } catch (error) {
144
+ console.error('snapSheet remove auto-blur err', error);
145
+ }
146
+ }, [willClose]);
147
+
148
+ const hasMountState = useRef();
149
+ useEffect(() => {
150
+ if (futureState !== currentState) return;
151
+ if (hasMountState.current) {
152
+ onStateChanged?.(futureState);
153
+ }
154
+ hasMountState.current = true;
155
+ }, [futureState, currentState]);
156
+
157
+ const hasMountOpened = useRef();
158
+ useEffect(() => {
159
+ if (futureState !== currentState) return;
160
+ if (hasClosed) setAutoIndexModal(0);
161
+ if (hasMountOpened.current)
162
+ if (hasClosed) {
163
+ setReleaseUnmount();
164
+ onClosed?.();
165
+ } else onOpened?.();
166
+ hasMountOpened.current = true;
167
+ }, [hasClosed]);
168
+
169
+ useBackButton(() => {
170
+ snapModal.current(0);
171
+ }, disableBackHandler || !isOpened);
172
+
173
+ const centeredStyle = useMemo(() => centered ? ({
174
+ position: 'absolute',
175
+ width: viewWidth || 0,
176
+ left: 0,
177
+ top: 0,
178
+ marginTop: -(contentHeight / 2) || 0
179
+ }) : undefined, [centered, contentHeight, viewWidth]);
180
+
181
+ const renderChild = () =>
182
+ <View
183
+ style={styling.absoluteFill}
184
+ onLayout={e => {
185
+ const { width, height } = e.nativeEvent.layout;
186
+ setViewDim([width, height]);
187
+ }}>
188
+ {hasClosed ? null :
189
+ doRendable(
190
+ renderBackDrop,
191
+ <Pressable
192
+ style={styling.backdropStyle}
193
+ disabled={!!disableBackdrop}
194
+ onPress={() => {
195
+ snapModal.current(0);
196
+ }} />
197
+ )}
198
+ <ReactHijacker
199
+ doHijack={(node, path) => {
200
+ if (isDodgeInput(node)) {
201
+ const inputId = path.join('=>');
202
+
203
+ return {
204
+ props: {
205
+ ...node.props,
206
+ ref: r => {
207
+ if (r) {
208
+ inputRefs.current[inputId] = r;
209
+ } else if (inputRefs.current[inputId]) {
210
+ delete inputRefs.current[inputId];
211
+ }
212
+
213
+ const thatRef = node.props?.ref;
214
+ if (typeof thatRef === 'function') {
215
+ thatRef(r);
216
+ } else if (thatRef) thatRef.current = r;
217
+ }
218
+ }
219
+ };
220
+ }
221
+ }}>
222
+ <SnapSheet
223
+ {...restProps}
224
+ ref={sheetRef}
225
+ snapPoints={snapPoints}
226
+ {...hasClosed ? { keyboardDodgingBehaviour: 'off' } : {}}
227
+ {...centered ? {
228
+ style: CenteredSheetStyle,
229
+ renderHandle: null
230
+ } : {}}
231
+ __shaky_sheet={centered}
232
+ initialSnapIndex={Math.min(ModalState.indexOf(currentState), centered ? 1 : 2)}
233
+ disabled={centered || disabled || disablePanGesture}
234
+ onSnapFinish={i => {
235
+ setCurrentState(ModalState[i]);
236
+ }}
237
+ onSnapIndex={i => {
238
+ setFutureState(ModalState[i]);
239
+ }}>
240
+ {(hasClosed && (!releaseUnmount && unMountChildrenWhenClosed))
241
+ ? null :
242
+ <View style={centered ? centeredStyle : styling.flexer}>
243
+ <View
244
+ style={centered ? restProps.style : styling.flexer}
245
+ onLayout={e => {
246
+ if (centered) setContentHeight(e.nativeEvent.layout.height);
247
+ }}>
248
+ {children}
249
+ </View>
250
+ </View>}
251
+ </SnapSheet>
252
+ </ReactHijacker>
253
+ </View>
254
+
255
+ const conStyle = useMemo(() => {
256
+ const flatStyle = StyleSheet.flatten(containerStyle);
257
+
258
+ return {
259
+ ...flatStyle,
260
+ position: 'absolute',
261
+ width: '100%',
262
+ height: '100%',
263
+ top: 0,
264
+ left: 0,
265
+ zIndex: hasClosed ? -99 : isNumber(flatStyle?.zIndex) ? flatStyle?.zIndex : 9999,
266
+ elevation: hasClosed ? 0 : isNumber(flatStyle?.elevation) ? flatStyle?.elevation : 9999,
267
+ ...hasClosed ? { opacity: 0 } : {}
268
+ };
269
+ }, [containerStyle, hasClosed]);
270
+
271
+ return (
272
+ <View style={conStyle}
273
+ pointerEvents={willClose ? 'none' : 'auto'}>
274
+ {renderChild()}
275
+ </View>
276
+ );
277
+ });
278
+
279
+ let mountIdIterator = 0;
280
+
281
+ export const SnapSheetModal = forwardRef(function SnapSheetModal({
282
+ fillScreen,
283
+ ...props
284
+ }, ref) {
285
+ const { updateModal } = useContext(PortalContext) || {};
286
+
287
+ const mountID = useMemo(() => ++mountIdIterator, []);
288
+
289
+ useEffect(() => {
290
+ if (fillScreen) updateModal(mountID, props, ref);
291
+ });
292
+
293
+ useEffect(() => {
294
+ if (fillScreen)
295
+ return () => {
296
+ updateModal(mountID);
297
+ }
298
+ }, [!fillScreen]);
299
+
300
+ if (fillScreen) return null;
301
+ return <SnapSheetModalBase ref={ref} {...props} />;
302
+ });
package/src/styling.js ADDED
@@ -0,0 +1,39 @@
1
+
2
+
3
+ export const styling = {
4
+ flexer: { flex: 1 },
5
+
6
+ modalHandle: {
7
+ alignItems: 'center',
8
+ justifyContent: 'center',
9
+ // backgroundColor: 'red',
10
+ paddingTop: 7,
11
+ paddingBottom: 7
12
+ },
13
+
14
+ modalHandleItem: {
15
+ width: 50,
16
+ height: 5,
17
+ backgroundColor: 'rgba(222, 222, 222, 1)',
18
+ borderRadius: 20
19
+ },
20
+
21
+ backdropStyle: {
22
+ position: 'absolute',
23
+ width: '100%',
24
+ height: '100%',
25
+ top: 0,
26
+ left: 0,
27
+ backgroundColor: 'rgba(0, 0, 0, 0.4)'
28
+ },
29
+
30
+ fakePlaceholder: { position: 'absolute', bottom: 0 },
31
+
32
+ absoluteFill: {
33
+ position: 'absolute',
34
+ width: '100%',
35
+ height: '100%',
36
+ left: 0,
37
+ top: 0
38
+ }
39
+ };
package/src/utils.js ADDED
@@ -0,0 +1,4 @@
1
+
2
+ export const doRendable = (e, d) => e === undefined ? d : typeof e === 'function' ? e() : e;
3
+
4
+ export const isNumber = t => typeof t === 'number' && !isNaN(t) && Number.isFinite(t);