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 +21 -0
- package/README.md +1 -0
- package/index.js +9 -0
- package/index.ts +256 -0
- package/package.json +25 -0
- package/src/provider.js +83 -0
- package/src/snapsheet.js +398 -0
- package/src/snapsheet_modal.js +302 -0
- package/src/styling.js +39 -0
- package/src/utils.js +4 -0
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
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
|
+
}
|
package/src/provider.js
ADDED
|
@@ -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
|
+
}
|
package/src/snapsheet.js
ADDED
|
@@ -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