@oneplatformdev/react-native-uikit 1.2.9 → 1.2.11
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/README.md +5 -1
- package/package.json +1 -1
- package/src/components/BottomModal/index.ts +7 -1
- package/src/components/BottomModal/presenters/useBottomModal.ts +144 -25
- package/src/components/BottomModal/presenters/useBottomModalInsets.ts +6 -2
- package/src/components/BottomModal/types/types.ts +22 -0
- package/src/components/BottomModal/ui/BottomModal.tsx +48 -20
- package/src/components/BottomModal/ui/styles.ts +32 -12
package/README.md
CHANGED
|
@@ -359,7 +359,11 @@ For non-scrolling keyboard avoidance, omitting `keyboardAvoidingBehavior` keeps
|
|
|
359
359
|
|
|
360
360
|
`CustomAlert` animates a centered modal with a dismissible backdrop. Supply `header`, children, and optional `footer` content; compose your own actions with `Button`. Optional `closeAction` accepts an icon and an application-supplied accessibility label. No strings are translated by the kit.
|
|
361
361
|
|
|
362
|
-
`BottomModal` provides
|
|
362
|
+
`BottomModal` provides slide/handle-drag dismissal and keyboard-height-aware positioning. Supply `children`, `visible`, `onClose`, and optionally `title`, `customHeader`, and a labeled `closeAction` icon. Without `sizing`, the existing `isFullScreen` prop still selects the original fullscreen layout; otherwise the modal follows its content. When both are supplied, `sizing` takes precedence over `isFullScreen`. Use `sizing={{ mode: 'content' }}` for intrinsic height, `sizing={{ mode: 'fixed', fraction: 0.5 }}` for half the available height, `sizing={{ mode: 'fullscreen' }}` to fill the area below `topInset`, or `sizing={{ mode: 'expandable', initialFraction: 0.6 }}` for two positions: 60% and the full available height. A fixed fraction must be finite, greater than 0 and at most 1; invalid values throw in development. In production, positive finite values above 1 clamp to 1, while non-positive or non-finite values use a fixed 0.5 fallback. An expandable fraction must be finite, greater than 0 and less than 1; invalid values throw in development and use 0.6 in production. Normal `ScrollView` or `FlatList` children remain caller-owned for tall content.
|
|
363
|
+
|
|
364
|
+
`topInset` defaults to the resolved top safe-area inset; `topInset={0}` starts at the physical top. `showHeader={false}` removes the package header and ignores `customHeader`. Expandable sizing requires `showHeader=true` to provide a resize gesture target; disabling it throws in development and leaves the modal collapsed without gesture resizing in production. A `customHeader` participates in the same handle gesture area. `backdrop={false}` hides the backdrop; an object may override its `color`, `opacity`, or `dismissOnPress`, leaving other defaults intact. `cornerRadius` is an unscaled design value. `dragToDismiss="handle"` is the default; `"none"` disables drag dismissal. In expandable mode, dragging the header up expands; dragging down collapses first, then dismisses from collapsed only when drag dismissal is enabled. The height animates after gesture release rather than tracking the finger. With `"none"`, header resizing still works. Scrollable content owns its gestures and does not resize the modal. Each new presentation starts collapsed; orientation changes recompute both heights. Backdrop and hardware Back always request close rather than collapse.
|
|
365
|
+
|
|
366
|
+
A close action, dismissible backdrop, handle gesture, or Android hardware back calls `onCloseRequest(reason)` once when closing starts. The keyboard is dismissed, and the existing `onClose` runs after the 250-ms exit animation; the parent still owns `visible`. An external `visible: true → false` hides immediately, cancels any pending package animation, and does not call `onCloseRequest` or `onClose`. `onDismiss` fires once after a committed controlled `true → false` transition, not after a guaranteed native OS dialog teardown. An internal close calls it only if the parent subsequently sets `visible=false`; do not use it yet to sequence native modal stacking or camera/gallery presentation. `closeRef` retains its previous close behavior without a close-request reason. The kit does not render app-specific toasts.
|
|
363
367
|
|
|
364
368
|
Both modes require consumers to install the declared native peers used by these components: `@d11/react-native-fast-image`, `react-native-keyboard-controller`, `react-native-linear-gradient`, `react-native-reanimated`, and `react-native-safe-area-context`. Applications using keyboard-aware or safe-area behavior should configure the providers and native setup required by those libraries. The kit does not bundle those peers or install them through `add`.
|
|
365
369
|
|
package/package.json
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export { BottomModal } from './ui/BottomModal';
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
BottomModalBackdrop,
|
|
4
|
+
BottomModalCloseAction,
|
|
5
|
+
BottomModalCloseReason,
|
|
6
|
+
BottomModalProps,
|
|
7
|
+
BottomModalSizing,
|
|
8
|
+
} from './types/types';
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { Animated, Easing, Keyboard, PanResponder, useWindowDimensions } from 'react-native';
|
|
3
|
-
import type { BottomModalProps } from '../types/types';
|
|
3
|
+
import type { BottomModalCloseReason, BottomModalProps } from '../types/types';
|
|
4
4
|
import { useBottomModalInsets } from './useBottomModalInsets';
|
|
5
5
|
|
|
6
|
-
type UseBottomModalProps = Pick<
|
|
6
|
+
type UseBottomModalProps = Pick<
|
|
7
|
+
BottomModalProps,
|
|
8
|
+
'visible' | 'onClose' | 'onCloseRequest' | 'onDismiss' | 'closeRef' | 'topInset' | 'sizing' | 'dragToDismiss'
|
|
9
|
+
>;
|
|
7
10
|
|
|
8
11
|
const CLOSE_DISTANCE = 120;
|
|
9
12
|
const CLOSE_VELOCITY = 1;
|
|
@@ -12,18 +15,75 @@ const CLOSE_ANIMATION_DURATION = 250;
|
|
|
12
15
|
|
|
13
16
|
const isDragDownGesture = (gestureState: { dx: number; dy: number }) =>
|
|
14
17
|
gestureState.dy > 0 && Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
|
|
18
|
+
const isVerticalGesture = (gestureState: { dx: number; dy: number }) =>
|
|
19
|
+
gestureState.dy !== 0 && Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
|
|
15
20
|
|
|
16
21
|
export const useBottomModal = ({
|
|
22
|
+
visible,
|
|
17
23
|
onClose,
|
|
24
|
+
onCloseRequest,
|
|
25
|
+
onDismiss,
|
|
18
26
|
closeRef,
|
|
27
|
+
topInset: requestedTopInset,
|
|
28
|
+
sizing,
|
|
29
|
+
dragToDismiss,
|
|
19
30
|
}: UseBottomModalProps) => {
|
|
20
31
|
const { height } = useWindowDimensions();
|
|
21
|
-
const { topInset, bottomInset } = useBottomModalInsets();
|
|
22
|
-
const
|
|
23
|
-
const
|
|
32
|
+
const { topInset, bottomInset } = useBottomModalInsets(requestedTopInset);
|
|
33
|
+
const isExpandable = sizing?.mode === 'expandable';
|
|
34
|
+
const initialFraction = sizing?.mode === 'expandable' ? sizing.initialFraction : undefined;
|
|
35
|
+
const validInitialFraction = initialFraction !== undefined && Number.isFinite(initialFraction)
|
|
36
|
+
&& initialFraction > 0 && initialFraction < 1;
|
|
37
|
+
if (__DEV__ && isExpandable && !validInitialFraction) {
|
|
38
|
+
throw new Error('BottomModal expandable initialFraction must be finite, greater than 0 and less than 1.');
|
|
39
|
+
}
|
|
40
|
+
const maxHeight = Math.max(0, height - topInset);
|
|
41
|
+
const collapsedHeight = maxHeight * (validInitialFraction ? initialFraction : 0.6);
|
|
42
|
+
const [translateY] = useState(() => new Animated.Value(height));
|
|
43
|
+
const [backdropOpacity] = useState(() => new Animated.Value(0));
|
|
44
|
+
const [expandableHeight] = useState(() => new Animated.Value(collapsedHeight));
|
|
45
|
+
const expandedRef = useRef(false);
|
|
46
|
+
const previousVisibleRef = useRef(visible);
|
|
47
|
+
const visibleRef = useRef(visible);
|
|
48
|
+
const openedRef = useRef(false);
|
|
49
|
+
const closingRef = useRef(false);
|
|
50
|
+
const animationIdRef = useRef(0);
|
|
51
|
+
const onCloseRef = useRef(onClose);
|
|
52
|
+
const onCloseRequestRef = useRef(onCloseRequest);
|
|
53
|
+
visibleRef.current = visible;
|
|
54
|
+
onCloseRef.current = onClose;
|
|
55
|
+
onCloseRequestRef.current = onCloseRequest;
|
|
56
|
+
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!isExpandable) {
|
|
59
|
+
expandedRef.current = false;
|
|
60
|
+
expandableHeight.stopAnimation();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
expandableHeight.stopAnimation();
|
|
64
|
+
expandableHeight.setValue(expandedRef.current ? maxHeight : collapsedHeight);
|
|
65
|
+
}, [collapsedHeight, expandableHeight, isExpandable, maxHeight]);
|
|
66
|
+
|
|
67
|
+
const onResize = useCallback((expanded: boolean) => {
|
|
68
|
+
if (closingRef.current) return;
|
|
69
|
+
expandedRef.current = expanded;
|
|
70
|
+
expandableHeight.stopAnimation();
|
|
71
|
+
Animated.timing(expandableHeight, {
|
|
72
|
+
toValue: expanded ? maxHeight : collapsedHeight,
|
|
73
|
+
duration: CLOSE_ANIMATION_DURATION,
|
|
74
|
+
easing: Easing.out(Easing.cubic),
|
|
75
|
+
useNativeDriver: false,
|
|
76
|
+
}).start();
|
|
77
|
+
}, [collapsedHeight, expandableHeight, maxHeight]);
|
|
24
78
|
|
|
25
79
|
const onRunCloseAnimation = useCallback(
|
|
26
|
-
(shouldNotify
|
|
80
|
+
(shouldNotify: boolean, reason?: BottomModalCloseReason) => {
|
|
81
|
+
if (closingRef.current) return;
|
|
82
|
+
closingRef.current = true;
|
|
83
|
+
const animationId = ++animationIdRef.current;
|
|
84
|
+
if (reason) onCloseRequestRef.current?.(reason);
|
|
85
|
+
Keyboard.dismiss();
|
|
86
|
+
if (isExpandable) expandableHeight.stopAnimation();
|
|
27
87
|
translateY.stopAnimation();
|
|
28
88
|
backdropOpacity.stopAnimation();
|
|
29
89
|
Animated.parallel([
|
|
@@ -40,24 +100,22 @@ export const useBottomModal = ({
|
|
|
40
100
|
useNativeDriver: true,
|
|
41
101
|
}),
|
|
42
102
|
]).start(({ finished }) => {
|
|
43
|
-
if (finished
|
|
103
|
+
if (!finished || animationId !== animationIdRef.current) return;
|
|
104
|
+
closingRef.current = false;
|
|
105
|
+
if (shouldNotify) onCloseRef.current();
|
|
44
106
|
});
|
|
45
107
|
},
|
|
46
|
-
[backdropOpacity, height,
|
|
108
|
+
[backdropOpacity, expandableHeight, height, isExpandable, translateY],
|
|
47
109
|
);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
onRunCloseAnimation(shouldNotify);
|
|
52
|
-
},
|
|
53
|
-
[onRunCloseAnimation],
|
|
54
|
-
);
|
|
55
|
-
const onClosePress = useCallback(() => onCloseAnimated(), [onCloseAnimated]);
|
|
56
|
-
const onShow = useCallback(() => {
|
|
110
|
+
const onRunOpenAnimation = useCallback((reset: boolean) => {
|
|
111
|
+
++animationIdRef.current;
|
|
112
|
+
closingRef.current = false;
|
|
57
113
|
translateY.stopAnimation();
|
|
58
114
|
backdropOpacity.stopAnimation();
|
|
59
|
-
|
|
60
|
-
|
|
115
|
+
if (reset) {
|
|
116
|
+
translateY.setValue(height);
|
|
117
|
+
backdropOpacity.setValue(0);
|
|
118
|
+
}
|
|
61
119
|
Animated.parallel([
|
|
62
120
|
Animated.timing(translateY, {
|
|
63
121
|
toValue: 0,
|
|
@@ -73,6 +131,51 @@ export const useBottomModal = ({
|
|
|
73
131
|
}),
|
|
74
132
|
]).start();
|
|
75
133
|
}, [backdropOpacity, height, translateY]);
|
|
134
|
+
const onShow = useCallback(() => {
|
|
135
|
+
if (!visibleRef.current || closingRef.current || openedRef.current) return;
|
|
136
|
+
openedRef.current = true;
|
|
137
|
+
onRunOpenAnimation(true);
|
|
138
|
+
}, [onRunOpenAnimation]);
|
|
139
|
+
|
|
140
|
+
useLayoutEffect(() => {
|
|
141
|
+
const wasVisible = previousVisibleRef.current;
|
|
142
|
+
previousVisibleRef.current = visible;
|
|
143
|
+
if (visible === wasVisible) return;
|
|
144
|
+
++animationIdRef.current;
|
|
145
|
+
closingRef.current = false;
|
|
146
|
+
translateY.stopAnimation();
|
|
147
|
+
backdropOpacity.stopAnimation();
|
|
148
|
+
if (visible) {
|
|
149
|
+
expandedRef.current = false;
|
|
150
|
+
if (isExpandable) expandableHeight.setValue(collapsedHeight);
|
|
151
|
+
openedRef.current = true;
|
|
152
|
+
onRunOpenAnimation(false);
|
|
153
|
+
} else {
|
|
154
|
+
openedRef.current = false;
|
|
155
|
+
if (isExpandable) {
|
|
156
|
+
expandableHeight.stopAnimation();
|
|
157
|
+
expandedRef.current = false;
|
|
158
|
+
expandableHeight.setValue(collapsedHeight);
|
|
159
|
+
}
|
|
160
|
+
translateY.setValue(height);
|
|
161
|
+
backdropOpacity.setValue(0);
|
|
162
|
+
// Report the controlled hidden commit, not native dialog teardown.
|
|
163
|
+
onDismiss?.();
|
|
164
|
+
}
|
|
165
|
+
}, [backdropOpacity, collapsedHeight, expandableHeight, height, isExpandable, onDismiss, onRunOpenAnimation, translateY, visible]);
|
|
166
|
+
|
|
167
|
+
const onClosePress = useCallback(() => onRunCloseAnimation(true), [onRunCloseAnimation]);
|
|
168
|
+
const onCloseAction = useCallback(() => onRunCloseAnimation(true, 'closeAction'), [onRunCloseAnimation]);
|
|
169
|
+
const onBackdropPress = useCallback(() => onRunCloseAnimation(true, 'backdrop'), [onRunCloseAnimation]);
|
|
170
|
+
const onHardwareBack = useCallback(() => onRunCloseAnimation(true, 'hardwareBack'), [onRunCloseAnimation]);
|
|
171
|
+
const onGestureClose = useCallback(() => onRunCloseAnimation(true, 'gesture'), [onRunCloseAnimation]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => () => {
|
|
174
|
+
++animationIdRef.current;
|
|
175
|
+
translateY.stopAnimation();
|
|
176
|
+
backdropOpacity.stopAnimation();
|
|
177
|
+
expandableHeight.stopAnimation();
|
|
178
|
+
}, [backdropOpacity, expandableHeight, translateY]);
|
|
76
179
|
|
|
77
180
|
useEffect(() => {
|
|
78
181
|
if (!closeRef) return;
|
|
@@ -82,23 +185,35 @@ export const useBottomModal = ({
|
|
|
82
185
|
|
|
83
186
|
const panResponder = useMemo(
|
|
84
187
|
() => PanResponder.create({
|
|
85
|
-
onMoveShouldSetPanResponderCapture: (_, gestureState) =>
|
|
86
|
-
|
|
188
|
+
onMoveShouldSetPanResponderCapture: (_, gestureState) => isExpandable
|
|
189
|
+
? isVerticalGesture(gestureState) : isDragDownGesture(gestureState),
|
|
190
|
+
onMoveShouldSetPanResponder: (_, gestureState) => isExpandable
|
|
191
|
+
? isVerticalGesture(gestureState) : isDragDownGesture(gestureState),
|
|
87
192
|
onPanResponderMove: (_, gestureState) => {
|
|
193
|
+
if (isExpandable) return;
|
|
88
194
|
if (gestureState.dy > 0) translateY.setValue(gestureState.dy);
|
|
89
195
|
},
|
|
90
196
|
onPanResponderRelease: (_, gestureState) => {
|
|
197
|
+
if (isExpandable) {
|
|
198
|
+
const swipedUp = -gestureState.dy > CLOSE_DISTANCE || -gestureState.vy > CLOSE_VELOCITY;
|
|
199
|
+
const swipedDown = gestureState.dy > CLOSE_DISTANCE || gestureState.vy > CLOSE_VELOCITY;
|
|
200
|
+
if (expandedRef.current && swipedDown) onResize(false);
|
|
201
|
+
else if (!expandedRef.current && swipedUp) onResize(true);
|
|
202
|
+
else if (!expandedRef.current && swipedDown && dragToDismiss === 'handle') onGestureClose();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
91
205
|
if (gestureState.dy > CLOSE_DISTANCE || gestureState.vy > CLOSE_VELOCITY) {
|
|
92
|
-
|
|
206
|
+
onGestureClose();
|
|
93
207
|
return;
|
|
94
208
|
}
|
|
95
209
|
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0, speed: 18 }).start();
|
|
96
210
|
},
|
|
97
211
|
onPanResponderTerminate: () => {
|
|
212
|
+
if (isExpandable) return;
|
|
98
213
|
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0, speed: 18 }).start();
|
|
99
214
|
},
|
|
100
215
|
}),
|
|
101
|
-
[
|
|
216
|
+
[dragToDismiss, isExpandable, onGestureClose, onResize, translateY],
|
|
102
217
|
);
|
|
103
218
|
|
|
104
219
|
return {
|
|
@@ -107,7 +222,11 @@ export const useBottomModal = ({
|
|
|
107
222
|
bottomInset,
|
|
108
223
|
translateY,
|
|
109
224
|
backdropOpacity,
|
|
110
|
-
|
|
225
|
+
expandableHeight,
|
|
226
|
+
isExpandable,
|
|
227
|
+
onCloseAction,
|
|
228
|
+
onBackdropPress,
|
|
229
|
+
onHardwareBack,
|
|
111
230
|
onShow,
|
|
112
231
|
panHandlers: panResponder.panHandlers,
|
|
113
232
|
};
|
|
@@ -7,9 +7,13 @@ const getAndroidNavigationInset = () => {
|
|
|
7
7
|
return Math.max(0, Dimensions.get('screen').height - Dimensions.get('window').height);
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
export const useBottomModalInsets = () => {
|
|
10
|
+
export const useBottomModalInsets = (requestedTopInset?: number) => {
|
|
11
11
|
const { top, bottom } = useSafeAreaInsets();
|
|
12
|
-
const topInset = useMemo(() =>
|
|
12
|
+
const topInset = useMemo(() => {
|
|
13
|
+
const defaultTopInset = Math.max(top, initialWindowMetrics?.insets.top || 0);
|
|
14
|
+
return requestedTopInset === undefined || !Number.isFinite(requestedTopInset)
|
|
15
|
+
? defaultTopInset : Math.max(0, requestedTopInset);
|
|
16
|
+
}, [requestedTopInset, top]);
|
|
13
17
|
const bottomInset = useMemo(
|
|
14
18
|
() => Math.max(bottom, initialWindowMetrics?.insets.bottom || 0, getAndroidNavigationInset()),
|
|
15
19
|
[bottom],
|
|
@@ -8,6 +8,20 @@ export interface BottomModalCloseAction {
|
|
|
8
8
|
accessibilityHint?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type BottomModalSizing =
|
|
12
|
+
| { mode: 'content' }
|
|
13
|
+
| { mode: 'fixed'; fraction: number }
|
|
14
|
+
| { mode: 'fullscreen' }
|
|
15
|
+
| { mode: 'expandable'; initialFraction: number };
|
|
16
|
+
|
|
17
|
+
export type BottomModalBackdrop = false | {
|
|
18
|
+
color?: string;
|
|
19
|
+
opacity?: number;
|
|
20
|
+
dismissOnPress?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type BottomModalCloseReason = 'closeAction' | 'backdrop' | 'gesture' | 'hardwareBack';
|
|
24
|
+
|
|
11
25
|
export interface BottomModalProps {
|
|
12
26
|
visible: boolean;
|
|
13
27
|
onClose: () => void;
|
|
@@ -20,4 +34,12 @@ export interface BottomModalProps {
|
|
|
20
34
|
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
21
35
|
isFullScreen?: boolean;
|
|
22
36
|
shouldAvoidKeyboard?: boolean;
|
|
37
|
+
sizing?: BottomModalSizing;
|
|
38
|
+
topInset?: number;
|
|
39
|
+
showHeader?: boolean;
|
|
40
|
+
backdrop?: BottomModalBackdrop;
|
|
41
|
+
cornerRadius?: number;
|
|
42
|
+
dragToDismiss?: 'none' | 'handle';
|
|
43
|
+
onCloseRequest?: (reason: BottomModalCloseReason) => void;
|
|
44
|
+
onDismiss?: () => void;
|
|
23
45
|
}
|
|
@@ -20,54 +20,82 @@ export const BottomModal = ({
|
|
|
20
20
|
contentContainerStyle,
|
|
21
21
|
isFullScreen = false,
|
|
22
22
|
shouldAvoidKeyboard = true,
|
|
23
|
+
sizing,
|
|
24
|
+
topInset,
|
|
25
|
+
showHeader = true,
|
|
26
|
+
backdrop,
|
|
27
|
+
cornerRadius,
|
|
28
|
+
dragToDismiss = 'handle',
|
|
29
|
+
onCloseRequest,
|
|
30
|
+
onDismiss,
|
|
23
31
|
}: BottomModalProps) => {
|
|
24
32
|
const { colors, spacing, radius } = useUITheme();
|
|
25
33
|
const scaling = useScaling();
|
|
34
|
+
const backdropOptions = backdrop === false ? undefined : backdrop;
|
|
35
|
+
const backdropColor = backdropOptions?.color ?? colors.text;
|
|
36
|
+
const targetBackdropOpacity = backdropOptions?.opacity !== undefined && Number.isFinite(backdropOptions.opacity)
|
|
37
|
+
? Math.min(1, Math.max(0, backdropOptions.opacity)) : 0.5;
|
|
38
|
+
const dismissOnBackdropPress = backdropOptions?.dismissOnPress ?? true;
|
|
39
|
+
const resolvedCornerRadius = cornerRadius !== undefined && Number.isFinite(cornerRadius)
|
|
40
|
+
? Math.max(0, cornerRadius) : undefined;
|
|
26
41
|
const {
|
|
27
42
|
bottomInset,
|
|
28
43
|
height,
|
|
29
|
-
topInset,
|
|
44
|
+
topInset: resolvedTopInset,
|
|
30
45
|
translateY,
|
|
31
46
|
backdropOpacity,
|
|
32
|
-
|
|
47
|
+
expandableHeight,
|
|
48
|
+
isExpandable,
|
|
49
|
+
onCloseAction,
|
|
50
|
+
onBackdropPress,
|
|
51
|
+
onHardwareBack,
|
|
33
52
|
onShow,
|
|
34
53
|
panHandlers,
|
|
35
|
-
} = useBottomModal({ onClose, closeRef });
|
|
54
|
+
} = useBottomModal({ visible, onClose, onCloseRequest, onDismiss, closeRef, topInset, sizing, dragToDismiss });
|
|
36
55
|
|
|
37
56
|
const {
|
|
38
57
|
modalContentStyle,
|
|
39
58
|
animatedBackdropStyle,
|
|
40
59
|
animatedKeyboardContainerStyle,
|
|
41
60
|
animatedKeyboardBackgroundStyle,
|
|
61
|
+
isBoundedHeight,
|
|
42
62
|
} = useBottomModalAnimatedStyles(
|
|
43
|
-
height,
|
|
63
|
+
height, resolvedTopInset, bottomInset, translateY, backdropOpacity,
|
|
64
|
+
isFullScreen, shouldAvoidKeyboard, sizing, targetBackdropOpacity, expandableHeight,
|
|
44
65
|
);
|
|
45
66
|
|
|
67
|
+
if (__DEV__ && isExpandable && !showHeader) {
|
|
68
|
+
throw new Error('BottomModal expandable sizing requires showHeader=true for its resize gesture.');
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
const styles = useMemo(
|
|
47
|
-
() => getStyles(colors, spacing, radius, scaling, bottomInset),
|
|
48
|
-
[bottomInset, colors, radius, scaling, spacing],
|
|
72
|
+
() => getStyles(colors, spacing, radius, scaling, bottomInset, backdropColor, resolvedCornerRadius),
|
|
73
|
+
[backdropColor, bottomInset, colors, radius, resolvedCornerRadius, scaling, spacing],
|
|
49
74
|
);
|
|
50
75
|
|
|
51
76
|
return (
|
|
52
|
-
<Modal visible={visible} transparent animationType="none" statusBarTranslucent
|
|
77
|
+
<Modal visible={visible} transparent animationType="none" statusBarTranslucent
|
|
78
|
+
onRequestClose={onHardwareBack} onShow={onShow}>
|
|
53
79
|
<View style={styles.modalContainer}>
|
|
54
|
-
|
|
80
|
+
{backdrop !== false ? (
|
|
81
|
+
<>
|
|
82
|
+
<Animated.View pointerEvents="none" style={[styles.backdrop, animatedBackdropStyle]} />
|
|
83
|
+
{dismissOnBackdropPress ? (
|
|
84
|
+
<Pressable accessible={false} importantForAccessibility="no"
|
|
85
|
+
onPress={onBackdropPress} style={StyleSheet.absoluteFill} />
|
|
86
|
+
) : null}
|
|
87
|
+
</>
|
|
88
|
+
) : null}
|
|
55
89
|
<Reanimated.View pointerEvents="none" style={[styles.keyboardBackground, animatedKeyboardBackgroundStyle]} />
|
|
56
|
-
<Pressable
|
|
57
|
-
accessible={false}
|
|
58
|
-
importantForAccessibility="no"
|
|
59
|
-
onPress={onClosePress}
|
|
60
|
-
style={StyleSheet.absoluteFill}
|
|
61
|
-
/>
|
|
62
90
|
|
|
63
91
|
<Reanimated.View style={animatedKeyboardContainerStyle}>
|
|
64
92
|
<Animated.View
|
|
65
93
|
accessibilityViewIsModal
|
|
66
|
-
onAccessibilityEscape={
|
|
94
|
+
onAccessibilityEscape={onCloseAction}
|
|
67
95
|
style={[styles.modalContent, modalContentStyle]}
|
|
68
96
|
>
|
|
69
|
-
<View style={[styles.container,
|
|
70
|
-
<View {...panHandlers}>
|
|
97
|
+
<View style={[styles.container, isBoundedHeight && styles.fullScreenContainer]}>
|
|
98
|
+
{showHeader ? <View {...(isExpandable || dragToDismiss === 'handle' ? panHandlers : {})}>
|
|
71
99
|
<TouchableWithoutFeedback>
|
|
72
100
|
{customHeader ? (
|
|
73
101
|
<View>{customHeader}</View>
|
|
@@ -84,7 +112,7 @@ export const BottomModal = ({
|
|
|
84
112
|
accessibilityRole="button"
|
|
85
113
|
accessibilityLabel={closeAction.accessibilityLabel}
|
|
86
114
|
accessibilityHint={closeAction.accessibilityHint ?? title}
|
|
87
|
-
onPress={
|
|
115
|
+
onPress={onCloseAction}
|
|
88
116
|
style={styles.closeButton}
|
|
89
117
|
>
|
|
90
118
|
{closeAction.icon}
|
|
@@ -93,10 +121,10 @@ export const BottomModal = ({
|
|
|
93
121
|
</View>
|
|
94
122
|
)}
|
|
95
123
|
</TouchableWithoutFeedback>
|
|
96
|
-
</View>
|
|
124
|
+
</View> : null}
|
|
97
125
|
<View style={[
|
|
98
126
|
styles.contentContainer,
|
|
99
|
-
|
|
127
|
+
isBoundedHeight && styles.boundedContentContainer,
|
|
100
128
|
contentContainerStyle,
|
|
101
129
|
]}>
|
|
102
130
|
{children}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
import { Animated, StyleSheet } from 'react-native';
|
|
3
|
-
import type { ViewStyle } from 'react-native';
|
|
4
3
|
import { useAnimatedStyle } from 'react-native-reanimated';
|
|
5
4
|
import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
|
|
6
5
|
import type { UIColors, UIRadius, UISpacing } from '../../../theme';
|
|
7
6
|
import type { ScalingFunctions } from '../../../utils';
|
|
7
|
+
import type { BottomModalSizing } from '../types/types';
|
|
8
8
|
|
|
9
9
|
export const getStyles = (
|
|
10
10
|
colors: UIColors,
|
|
@@ -12,6 +12,8 @@ export const getStyles = (
|
|
|
12
12
|
radius: UIRadius,
|
|
13
13
|
scaling: ScalingFunctions,
|
|
14
14
|
bottomInset: number,
|
|
15
|
+
backdropColor: string,
|
|
16
|
+
cornerRadius?: number,
|
|
15
17
|
) => {
|
|
16
18
|
const styles = StyleSheet.create({
|
|
17
19
|
modalContainer: {
|
|
@@ -24,7 +26,7 @@ export const getStyles = (
|
|
|
24
26
|
right: 0,
|
|
25
27
|
bottom: 0,
|
|
26
28
|
left: 0,
|
|
27
|
-
backgroundColor:
|
|
29
|
+
backgroundColor: backdropColor,
|
|
28
30
|
},
|
|
29
31
|
keyboardBackground: {
|
|
30
32
|
position: 'absolute',
|
|
@@ -35,8 +37,8 @@ export const getStyles = (
|
|
|
35
37
|
},
|
|
36
38
|
modalContent: {
|
|
37
39
|
backgroundColor: colors.background,
|
|
38
|
-
borderTopLeftRadius: scaling.scaleHorizontal(radius.lg),
|
|
39
|
-
borderTopRightRadius: scaling.scaleHorizontal(radius.lg),
|
|
40
|
+
borderTopLeftRadius: scaling.scaleHorizontal(cornerRadius ?? radius.lg),
|
|
41
|
+
borderTopRightRadius: scaling.scaleHorizontal(cornerRadius ?? radius.lg),
|
|
40
42
|
overflow: 'hidden',
|
|
41
43
|
flexShrink: 1,
|
|
42
44
|
},
|
|
@@ -78,7 +80,7 @@ export const getStyles = (
|
|
|
78
80
|
paddingBottom: bottomInset + scaling.scaleVertical(spacing.lg),
|
|
79
81
|
flexShrink: 1,
|
|
80
82
|
},
|
|
81
|
-
|
|
83
|
+
boundedContentContainer: {
|
|
82
84
|
flex: 1,
|
|
83
85
|
minHeight: 0,
|
|
84
86
|
},
|
|
@@ -95,12 +97,26 @@ export const useBottomModalAnimatedStyles = (
|
|
|
95
97
|
backdropOpacity: Animated.Value,
|
|
96
98
|
isFullScreen: boolean,
|
|
97
99
|
shouldAvoidKeyboard: boolean,
|
|
100
|
+
sizing?: BottomModalSizing,
|
|
101
|
+
targetBackdropOpacity = 0.5,
|
|
102
|
+
expandableHeight?: Animated.Value,
|
|
98
103
|
) => {
|
|
99
|
-
const maxHeight = height - topInset;
|
|
104
|
+
const maxHeight = Math.max(0, height - topInset);
|
|
105
|
+
const mode = sizing?.mode ?? (isFullScreen ? 'fullscreen' : 'content');
|
|
106
|
+
const fraction = sizing?.mode === 'fixed' ? sizing.fraction : undefined;
|
|
107
|
+
const validFraction = fraction !== undefined && Number.isFinite(fraction) && fraction > 0 && fraction <= 1;
|
|
108
|
+
if (__DEV__ && sizing?.mode === 'fixed' && !validFraction) {
|
|
109
|
+
throw new Error('BottomModal fixed fraction must be finite and greater than 0 and at most 1.');
|
|
110
|
+
}
|
|
111
|
+
const resolvedFraction = fraction !== undefined && Number.isFinite(fraction) && fraction > 0
|
|
112
|
+
? Math.min(1, fraction) : 0.5;
|
|
113
|
+
const modalHeight = mode === 'fullscreen' ? maxHeight
|
|
114
|
+
: mode === 'fixed' ? maxHeight * resolvedFraction : undefined;
|
|
115
|
+
const isBoundedHeight = modalHeight !== undefined || mode === 'expandable';
|
|
100
116
|
const { height: keyboardHeight } = useAnimatedKeyboard();
|
|
101
117
|
const animatedBackdropStyle = useMemo(
|
|
102
|
-
() => ({ opacity: backdropOpacity.interpolate({ inputRange: [0, 1], outputRange: [0,
|
|
103
|
-
[backdropOpacity],
|
|
118
|
+
() => ({ opacity: backdropOpacity.interpolate({ inputRange: [0, 1], outputRange: [0, targetBackdropOpacity] }) }),
|
|
119
|
+
[backdropOpacity, targetBackdropOpacity],
|
|
104
120
|
);
|
|
105
121
|
const animatedKeyboardContainerStyle = useAnimatedStyle(() => {
|
|
106
122
|
if (!shouldAvoidKeyboard) return { marginBottom: 0, maxHeight };
|
|
@@ -110,10 +126,14 @@ export const useBottomModalAnimatedStyles = (
|
|
|
110
126
|
const animatedKeyboardBackgroundStyle = useAnimatedStyle(() => ({
|
|
111
127
|
height: shouldAvoidKeyboard ? Math.max(keyboardHeight.value - bottomInset, 0) : 0,
|
|
112
128
|
}));
|
|
113
|
-
const modalContentStyle = useMemo
|
|
114
|
-
() => ({
|
|
115
|
-
|
|
129
|
+
const modalContentStyle = useMemo(
|
|
130
|
+
() => ({
|
|
131
|
+
maxHeight,
|
|
132
|
+
height: mode === 'expandable' ? expandableHeight : modalHeight,
|
|
133
|
+
transform: [{ translateY }],
|
|
134
|
+
}),
|
|
135
|
+
[expandableHeight, maxHeight, modalHeight, mode, translateY],
|
|
116
136
|
);
|
|
117
137
|
|
|
118
|
-
return { animatedBackdropStyle, animatedKeyboardContainerStyle, animatedKeyboardBackgroundStyle, modalContentStyle };
|
|
138
|
+
return { animatedBackdropStyle, animatedKeyboardContainerStyle, animatedKeyboardBackgroundStyle, modalContentStyle, isBoundedHeight };
|
|
119
139
|
};
|