@oneplatformdev/react-native-uikit 1.2.10 → 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
CHANGED
|
@@ -359,9 +359,9 @@ 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 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,
|
|
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
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`. `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 dragging,
|
|
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
365
|
|
|
366
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.
|
|
367
367
|
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { useBottomModalInsets } from './useBottomModalInsets';
|
|
|
5
5
|
|
|
6
6
|
type UseBottomModalProps = Pick<
|
|
7
7
|
BottomModalProps,
|
|
8
|
-
'visible' | 'onClose' | 'onCloseRequest' | 'onDismiss' | 'closeRef' | 'topInset'
|
|
8
|
+
'visible' | 'onClose' | 'onCloseRequest' | 'onDismiss' | 'closeRef' | 'topInset' | 'sizing' | 'dragToDismiss'
|
|
9
9
|
>;
|
|
10
10
|
|
|
11
11
|
const CLOSE_DISTANCE = 120;
|
|
@@ -15,6 +15,8 @@ const CLOSE_ANIMATION_DURATION = 250;
|
|
|
15
15
|
|
|
16
16
|
const isDragDownGesture = (gestureState: { dx: number; dy: number }) =>
|
|
17
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);
|
|
18
20
|
|
|
19
21
|
export const useBottomModal = ({
|
|
20
22
|
visible,
|
|
@@ -23,11 +25,24 @@ export const useBottomModal = ({
|
|
|
23
25
|
onDismiss,
|
|
24
26
|
closeRef,
|
|
25
27
|
topInset: requestedTopInset,
|
|
28
|
+
sizing,
|
|
29
|
+
dragToDismiss,
|
|
26
30
|
}: UseBottomModalProps) => {
|
|
27
31
|
const { height } = useWindowDimensions();
|
|
28
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);
|
|
29
42
|
const [translateY] = useState(() => new Animated.Value(height));
|
|
30
43
|
const [backdropOpacity] = useState(() => new Animated.Value(0));
|
|
44
|
+
const [expandableHeight] = useState(() => new Animated.Value(collapsedHeight));
|
|
45
|
+
const expandedRef = useRef(false);
|
|
31
46
|
const previousVisibleRef = useRef(visible);
|
|
32
47
|
const visibleRef = useRef(visible);
|
|
33
48
|
const openedRef = useRef(false);
|
|
@@ -39,6 +54,28 @@ export const useBottomModal = ({
|
|
|
39
54
|
onCloseRef.current = onClose;
|
|
40
55
|
onCloseRequestRef.current = onCloseRequest;
|
|
41
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]);
|
|
78
|
+
|
|
42
79
|
const onRunCloseAnimation = useCallback(
|
|
43
80
|
(shouldNotify: boolean, reason?: BottomModalCloseReason) => {
|
|
44
81
|
if (closingRef.current) return;
|
|
@@ -46,6 +83,7 @@ export const useBottomModal = ({
|
|
|
46
83
|
const animationId = ++animationIdRef.current;
|
|
47
84
|
if (reason) onCloseRequestRef.current?.(reason);
|
|
48
85
|
Keyboard.dismiss();
|
|
86
|
+
if (isExpandable) expandableHeight.stopAnimation();
|
|
49
87
|
translateY.stopAnimation();
|
|
50
88
|
backdropOpacity.stopAnimation();
|
|
51
89
|
Animated.parallel([
|
|
@@ -67,7 +105,7 @@ export const useBottomModal = ({
|
|
|
67
105
|
if (shouldNotify) onCloseRef.current();
|
|
68
106
|
});
|
|
69
107
|
},
|
|
70
|
-
[backdropOpacity, height, translateY],
|
|
108
|
+
[backdropOpacity, expandableHeight, height, isExpandable, translateY],
|
|
71
109
|
);
|
|
72
110
|
const onRunOpenAnimation = useCallback((reset: boolean) => {
|
|
73
111
|
++animationIdRef.current;
|
|
@@ -108,16 +146,23 @@ export const useBottomModal = ({
|
|
|
108
146
|
translateY.stopAnimation();
|
|
109
147
|
backdropOpacity.stopAnimation();
|
|
110
148
|
if (visible) {
|
|
149
|
+
expandedRef.current = false;
|
|
150
|
+
if (isExpandable) expandableHeight.setValue(collapsedHeight);
|
|
111
151
|
openedRef.current = true;
|
|
112
152
|
onRunOpenAnimation(false);
|
|
113
153
|
} else {
|
|
114
154
|
openedRef.current = false;
|
|
155
|
+
if (isExpandable) {
|
|
156
|
+
expandableHeight.stopAnimation();
|
|
157
|
+
expandedRef.current = false;
|
|
158
|
+
expandableHeight.setValue(collapsedHeight);
|
|
159
|
+
}
|
|
115
160
|
translateY.setValue(height);
|
|
116
161
|
backdropOpacity.setValue(0);
|
|
117
162
|
// Report the controlled hidden commit, not native dialog teardown.
|
|
118
163
|
onDismiss?.();
|
|
119
164
|
}
|
|
120
|
-
}, [backdropOpacity, height, onDismiss, onRunOpenAnimation, translateY, visible]);
|
|
165
|
+
}, [backdropOpacity, collapsedHeight, expandableHeight, height, isExpandable, onDismiss, onRunOpenAnimation, translateY, visible]);
|
|
121
166
|
|
|
122
167
|
const onClosePress = useCallback(() => onRunCloseAnimation(true), [onRunCloseAnimation]);
|
|
123
168
|
const onCloseAction = useCallback(() => onRunCloseAnimation(true, 'closeAction'), [onRunCloseAnimation]);
|
|
@@ -129,7 +174,8 @@ export const useBottomModal = ({
|
|
|
129
174
|
++animationIdRef.current;
|
|
130
175
|
translateY.stopAnimation();
|
|
131
176
|
backdropOpacity.stopAnimation();
|
|
132
|
-
|
|
177
|
+
expandableHeight.stopAnimation();
|
|
178
|
+
}, [backdropOpacity, expandableHeight, translateY]);
|
|
133
179
|
|
|
134
180
|
useEffect(() => {
|
|
135
181
|
if (!closeRef) return;
|
|
@@ -139,12 +185,23 @@ export const useBottomModal = ({
|
|
|
139
185
|
|
|
140
186
|
const panResponder = useMemo(
|
|
141
187
|
() => PanResponder.create({
|
|
142
|
-
onMoveShouldSetPanResponderCapture: (_, gestureState) =>
|
|
143
|
-
|
|
188
|
+
onMoveShouldSetPanResponderCapture: (_, gestureState) => isExpandable
|
|
189
|
+
? isVerticalGesture(gestureState) : isDragDownGesture(gestureState),
|
|
190
|
+
onMoveShouldSetPanResponder: (_, gestureState) => isExpandable
|
|
191
|
+
? isVerticalGesture(gestureState) : isDragDownGesture(gestureState),
|
|
144
192
|
onPanResponderMove: (_, gestureState) => {
|
|
193
|
+
if (isExpandable) return;
|
|
145
194
|
if (gestureState.dy > 0) translateY.setValue(gestureState.dy);
|
|
146
195
|
},
|
|
147
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
|
+
}
|
|
148
205
|
if (gestureState.dy > CLOSE_DISTANCE || gestureState.vy > CLOSE_VELOCITY) {
|
|
149
206
|
onGestureClose();
|
|
150
207
|
return;
|
|
@@ -152,10 +209,11 @@ export const useBottomModal = ({
|
|
|
152
209
|
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0, speed: 18 }).start();
|
|
153
210
|
},
|
|
154
211
|
onPanResponderTerminate: () => {
|
|
212
|
+
if (isExpandable) return;
|
|
155
213
|
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0, speed: 18 }).start();
|
|
156
214
|
},
|
|
157
215
|
}),
|
|
158
|
-
[onGestureClose, translateY],
|
|
216
|
+
[dragToDismiss, isExpandable, onGestureClose, onResize, translateY],
|
|
159
217
|
);
|
|
160
218
|
|
|
161
219
|
return {
|
|
@@ -164,6 +222,8 @@ export const useBottomModal = ({
|
|
|
164
222
|
bottomInset,
|
|
165
223
|
translateY,
|
|
166
224
|
backdropOpacity,
|
|
225
|
+
expandableHeight,
|
|
226
|
+
isExpandable,
|
|
167
227
|
onCloseAction,
|
|
168
228
|
onBackdropPress,
|
|
169
229
|
onHardwareBack,
|
|
@@ -11,7 +11,8 @@ export interface BottomModalCloseAction {
|
|
|
11
11
|
export type BottomModalSizing =
|
|
12
12
|
| { mode: 'content' }
|
|
13
13
|
| { mode: 'fixed'; fraction: number }
|
|
14
|
-
| { mode: 'fullscreen' }
|
|
14
|
+
| { mode: 'fullscreen' }
|
|
15
|
+
| { mode: 'expandable'; initialFraction: number };
|
|
15
16
|
|
|
16
17
|
export type BottomModalBackdrop = false | {
|
|
17
18
|
color?: string;
|
|
@@ -44,12 +44,14 @@ export const BottomModal = ({
|
|
|
44
44
|
topInset: resolvedTopInset,
|
|
45
45
|
translateY,
|
|
46
46
|
backdropOpacity,
|
|
47
|
+
expandableHeight,
|
|
48
|
+
isExpandable,
|
|
47
49
|
onCloseAction,
|
|
48
50
|
onBackdropPress,
|
|
49
51
|
onHardwareBack,
|
|
50
52
|
onShow,
|
|
51
53
|
panHandlers,
|
|
52
|
-
} = useBottomModal({ visible, onClose, onCloseRequest, onDismiss, closeRef, topInset });
|
|
54
|
+
} = useBottomModal({ visible, onClose, onCloseRequest, onDismiss, closeRef, topInset, sizing, dragToDismiss });
|
|
53
55
|
|
|
54
56
|
const {
|
|
55
57
|
modalContentStyle,
|
|
@@ -59,9 +61,13 @@ export const BottomModal = ({
|
|
|
59
61
|
isBoundedHeight,
|
|
60
62
|
} = useBottomModalAnimatedStyles(
|
|
61
63
|
height, resolvedTopInset, bottomInset, translateY, backdropOpacity,
|
|
62
|
-
isFullScreen, shouldAvoidKeyboard, sizing, targetBackdropOpacity,
|
|
64
|
+
isFullScreen, shouldAvoidKeyboard, sizing, targetBackdropOpacity, expandableHeight,
|
|
63
65
|
);
|
|
64
66
|
|
|
67
|
+
if (__DEV__ && isExpandable && !showHeader) {
|
|
68
|
+
throw new Error('BottomModal expandable sizing requires showHeader=true for its resize gesture.');
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
const styles = useMemo(
|
|
66
72
|
() => getStyles(colors, spacing, radius, scaling, bottomInset, backdropColor, resolvedCornerRadius),
|
|
67
73
|
[backdropColor, bottomInset, colors, radius, resolvedCornerRadius, scaling, spacing],
|
|
@@ -89,7 +95,7 @@ export const BottomModal = ({
|
|
|
89
95
|
style={[styles.modalContent, modalContentStyle]}
|
|
90
96
|
>
|
|
91
97
|
<View style={[styles.container, isBoundedHeight && styles.fullScreenContainer]}>
|
|
92
|
-
{showHeader ? <View {...(dragToDismiss === 'handle' ? panHandlers : {})}>
|
|
98
|
+
{showHeader ? <View {...(isExpandable || dragToDismiss === 'handle' ? panHandlers : {})}>
|
|
93
99
|
<TouchableWithoutFeedback>
|
|
94
100
|
{customHeader ? (
|
|
95
101
|
<View>{customHeader}</View>
|
|
@@ -1,6 +1,5 @@
|
|
|
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';
|
|
@@ -100,6 +99,7 @@ export const useBottomModalAnimatedStyles = (
|
|
|
100
99
|
shouldAvoidKeyboard: boolean,
|
|
101
100
|
sizing?: BottomModalSizing,
|
|
102
101
|
targetBackdropOpacity = 0.5,
|
|
102
|
+
expandableHeight?: Animated.Value,
|
|
103
103
|
) => {
|
|
104
104
|
const maxHeight = Math.max(0, height - topInset);
|
|
105
105
|
const mode = sizing?.mode ?? (isFullScreen ? 'fullscreen' : 'content');
|
|
@@ -112,7 +112,7 @@ export const useBottomModalAnimatedStyles = (
|
|
|
112
112
|
? Math.min(1, fraction) : 0.5;
|
|
113
113
|
const modalHeight = mode === 'fullscreen' ? maxHeight
|
|
114
114
|
: mode === 'fixed' ? maxHeight * resolvedFraction : undefined;
|
|
115
|
-
const isBoundedHeight = modalHeight !== undefined;
|
|
115
|
+
const isBoundedHeight = modalHeight !== undefined || mode === 'expandable';
|
|
116
116
|
const { height: keyboardHeight } = useAnimatedKeyboard();
|
|
117
117
|
const animatedBackdropStyle = useMemo(
|
|
118
118
|
() => ({ opacity: backdropOpacity.interpolate({ inputRange: [0, 1], outputRange: [0, targetBackdropOpacity] }) }),
|
|
@@ -126,9 +126,13 @@ export const useBottomModalAnimatedStyles = (
|
|
|
126
126
|
const animatedKeyboardBackgroundStyle = useAnimatedStyle(() => ({
|
|
127
127
|
height: shouldAvoidKeyboard ? Math.max(keyboardHeight.value - bottomInset, 0) : 0,
|
|
128
128
|
}));
|
|
129
|
-
const modalContentStyle = useMemo
|
|
130
|
-
() => ({
|
|
131
|
-
|
|
129
|
+
const modalContentStyle = useMemo(
|
|
130
|
+
() => ({
|
|
131
|
+
maxHeight,
|
|
132
|
+
height: mode === 'expandable' ? expandableHeight : modalHeight,
|
|
133
|
+
transform: [{ translateY }],
|
|
134
|
+
}),
|
|
135
|
+
[expandableHeight, maxHeight, modalHeight, mode, translateY],
|
|
132
136
|
);
|
|
133
137
|
|
|
134
138
|
return { animatedBackdropStyle, animatedKeyboardContainerStyle, animatedKeyboardBackgroundStyle, modalContentStyle, isBoundedHeight };
|