@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 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 the reference-style slide/drag dismissal and keyboard-height-aware positioning. Supply `children`, `onClose`, and optionally `title`, `customHeader`, and a labeled `closeAction` icon. `Keyboard.dismiss()` and the close animation start together; `closeRef` can trigger the same close path externally. The kit does not render app-specific toasts.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneplatformdev/react-native-uikit",
3
- "version": "1.2.9",
3
+ "version": "1.2.11",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "bin": {
@@ -1,2 +1,8 @@
1
1
  export { BottomModal } from './ui/BottomModal';
2
- export type { BottomModalCloseAction, BottomModalProps } from './types/types';
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<BottomModalProps, 'onClose' | 'closeRef'>;
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 translateY = useMemo(() => new Animated.Value(height), [height]);
23
- const backdropOpacity = useMemo(() => new Animated.Value(0), []);
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 = true) => {
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 && shouldNotify) onClose();
103
+ if (!finished || animationId !== animationIdRef.current) return;
104
+ closingRef.current = false;
105
+ if (shouldNotify) onCloseRef.current();
44
106
  });
45
107
  },
46
- [backdropOpacity, height, onClose, translateY],
108
+ [backdropOpacity, expandableHeight, height, isExpandable, translateY],
47
109
  );
48
- const onCloseAnimated = useCallback(
49
- (shouldNotify = true) => {
50
- Keyboard.dismiss();
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
- translateY.setValue(height);
60
- backdropOpacity.setValue(0);
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) => isDragDownGesture(gestureState),
86
- onMoveShouldSetPanResponder: (_, gestureState) => isDragDownGesture(gestureState),
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
- onCloseAnimated();
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
- [onCloseAnimated, translateY],
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
- onClosePress,
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(() => Math.max(top, initialWindowMetrics?.insets.top || 0), [top]);
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
- onClosePress,
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, topInset, bottomInset, translateY, backdropOpacity, isFullScreen, shouldAvoidKeyboard,
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 onRequestClose={onClosePress} onShow={onShow}>
77
+ <Modal visible={visible} transparent animationType="none" statusBarTranslucent
78
+ onRequestClose={onHardwareBack} onShow={onShow}>
53
79
  <View style={styles.modalContainer}>
54
- <Animated.View pointerEvents="none" style={[styles.backdrop, animatedBackdropStyle]} />
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={onClosePress}
94
+ onAccessibilityEscape={onCloseAction}
67
95
  style={[styles.modalContent, modalContentStyle]}
68
96
  >
69
- <View style={[styles.container, isFullScreen && styles.fullScreenContainer]}>
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={onClosePress}
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
- isFullScreen && styles.fullScreenContentContainer,
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: colors.text,
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
- fullScreenContentContainer: {
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, 0.5] }) }),
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<ViewStyle>(
114
- () => ({ maxHeight, height: isFullScreen ? maxHeight : undefined, transform: [{ translateY }] }),
115
- [isFullScreen, maxHeight, translateY],
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
  };