Skip to content

Commit

Permalink
feat: added dynamic sizing (#1513)(with @Eli-Nathan & @ororsatti)
Browse files Browse the repository at this point in the history
* feat: added dynamic sizing

* chore: updated dynamic sizing example

* chore: removed commented code

* chore: added deprecated tag to useBottomSheetDynamicSnapPoints

* chore: added extra description for snap points prop
gorhom authored Sep 10, 2023
1 parent 43de6d7 commit 7330c7c
Showing 12 changed files with 178 additions and 93 deletions.
21 changes: 3 additions & 18 deletions example/app/src/screens/advanced/DynamicSnapPointExample.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import BottomSheet, {
BottomSheetView,
useBottomSheetDynamicSnapPoints,
} from '@gorhom/bottom-sheet';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button } from '../../components/button';

const DynamicSnapPointExample = () => {
// state
const [count, setCount] = useState(0);
const initialSnapPoints = useMemo(() => ['CONTENT_HEIGHT'], []);

// hooks
const { bottom: safeBottomArea } = useSafeAreaInsets();
const bottomSheetRef = useRef<BottomSheet>(null);
const {
animatedHandleHeight,
animatedSnapPoints,
animatedContentHeight,
handleContentLayout,
} = useBottomSheetDynamicSnapPoints(initialSnapPoints);

// callbacks
const handleIncreaseContentPress = useCallback(() => {
@@ -59,16 +49,11 @@ const DynamicSnapPointExample = () => {
<Button label="Close" onPress={handleClosePress} />
<BottomSheet
ref={bottomSheetRef}
snapPoints={animatedSnapPoints}
handleHeight={animatedHandleHeight}
contentHeight={animatedContentHeight}
enableDynamicSizing={true}
enablePanDownToClose={true}
animateOnMount={true}
>
<BottomSheetView
style={contentContainerStyle}
onLayout={handleContentLayout}
>
<BottomSheetView style={contentContainerStyle}>
<Text style={styles.message}>
Could this sheet resize to its content height ?
</Text>
22 changes: 3 additions & 19 deletions example/app/src/screens/modal/DynamicSnapPointExample.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import {
BottomSheetModal,
BottomSheetView,
useBottomSheetDynamicSnapPoints,
} from '@gorhom/bottom-sheet';
import { BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button } from '../../components/button';
import { withModalProvider } from './withModalProvider';

const DynamicSnapPointExample = () => {
// state
const [count, setCount] = useState(0);
const initialSnapPoints = useMemo(() => ['CONTENT_HEIGHT'], []);

// hooks
const { bottom: safeBottomArea } = useSafeAreaInsets();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const {
animatedHandleHeight,
animatedSnapPoints,
animatedContentHeight,
handleContentLayout,
} = useBottomSheetDynamicSnapPoints(initialSnapPoints);

// callbacks
const handleIncreaseContentPress = useCallback(() => {
@@ -62,15 +51,10 @@ const DynamicSnapPointExample = () => {
<Button label="Dismiss" onPress={handleDismissPress} />
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={animatedSnapPoints}
handleHeight={animatedHandleHeight}
contentHeight={animatedContentHeight}
enableDynamicSizing={true}
enablePanDownToClose={true}
>
<BottomSheetView
style={contentContainerStyle}
onLayout={handleContentLayout}
>
<BottomSheetView style={contentContainerStyle}>
<Text style={styles.message}>
Could this sheet modal resize to its content height ?
</Text>
46 changes: 31 additions & 15 deletions src/components/bottomSheet/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -74,6 +74,7 @@ import {
DEFAULT_ENABLE_PAN_DOWN_TO_CLOSE,
INITIAL_CONTAINER_OFFSET,
INITIAL_VALUE,
DEFAULT_DYNAMIC_SIZING,
} from './constants';
import type { BottomSheetMethods, Insets } from '../../types';
import type { BottomSheetProps, AnimateToPositionType } from './types';
@@ -104,6 +105,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
enableHandlePanningGesture = DEFAULT_ENABLE_HANDLE_PANNING_GESTURE,
enableOverDrag = DEFAULT_ENABLE_OVER_DRAG,
enablePanDownToClose = DEFAULT_ENABLE_PAN_DOWN_TO_CLOSE,
enableDynamicSizing = DEFAULT_DYNAMIC_SIZING,
overDragResistanceFactor = DEFAULT_OVER_DRAG_RESISTANCE_FACTOR,

// styles
@@ -128,6 +130,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
containerOffset: _providedContainerOffset,
topInset = 0,
bottomInset = 0,
maxDynamicContentSize,

// animated callback shared values
animatedPosition: _providedAnimatedPosition,
@@ -185,12 +188,14 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
_providedHandleHeight ?? INITIAL_HANDLE_HEIGHT
);
const animatedFooterHeight = useSharedValue(0);
const animatedContentHeight = useSharedValue(INITIAL_CONTAINER_HEIGHT);
const animatedSnapPoints = useNormalizedSnapPoints(
_providedSnapPoints,
animatedContainerHeight,
topInset,
bottomInset,
$modal
animatedContentHeight,
animatedHandleHeight,
enableDynamicSizing,
maxDynamicContentSize
);
const animatedHighestSnapPoint = useDerivedValue(
() => animatedSnapPoints.value[animatedSnapPoints.value.length - 1]
@@ -388,7 +393,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
return SCROLLABLE_STATE.LOCKED;
});
// dynamic
const animatedContentHeight = useDerivedValue(() => {
const animatedContentHeightMax = useDerivedValue(() => {
const keyboardHeightInContainer = animatedKeyboardHeightInContainer.value;
const handleHeight = Math.max(0, animatedHandleHeight.value);
let contentHeight = animatedSheetHeight.value - handleHeight;
@@ -807,9 +812,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
*/
const nextPosition = normalizeSnapPoint(
position,
animatedContainerHeight.value,
topInset,
bottomInset
animatedContainerHeight.value
);

/**
@@ -1054,6 +1057,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
const internalContextVariables = useMemo(
() => ({
enableContentPanningGesture,
enableDynamicSizing,
overDragResistanceFactor,
enableOverDrag,
enablePanDownToClose,
@@ -1121,6 +1125,7 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
overDragResistanceFactor,
enableOverDrag,
enablePanDownToClose,
enableDynamicSizing,
_providedSimultaneousHandlers,
_providedWaitFor,
_providedActiveOffsetX,
@@ -1175,6 +1180,17 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
[_providedStyle, containerAnimatedStyle]
);
const contentContainerAnimatedStyle = useAnimatedStyle(() => {
/**
* if dynamic sizing is enabled, and content height
* is still not set, then we exit method.
*/
if (
enableDynamicSizing &&
animatedContentHeight.value === INITIAL_CONTAINER_HEIGHT
) {
return {};
}

/**
* if content height was provided, then we skip setting
* calculated height.
@@ -1185,11 +1201,11 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(

return {
height: animate({
point: animatedContentHeight.value,
point: animatedContentHeightMax.value,
configs: _providedAnimationConfigs,
}),
};
}, [animatedContentHeight, _providedContentHeight]);
}, [animatedContentHeightMax, enableDynamicSizing, animatedContentHeight]);
const contentContainerStyle = useMemo(
() => [styles.contentContainer, contentContainerAnimatedStyle],
[contentContainerAnimatedStyle]
@@ -1664,18 +1680,18 @@ const BottomSheetComponent = forwardRef<BottomSheet, BottomSheetProps>(
// topInset,
// bottomInset,
animatedSheetState,
animatedScrollableState,
animatedScrollableOverrideState,
// animatedScrollableState,
// animatedScrollableOverrideState,
// isScrollableRefreshable,
// animatedScrollableContentOffsetY,
// keyboardState,
// animatedIndex,
// animatedCurrentIndex,
// animatedPosition,
// animatedContainerHeight,
// animatedSheetHeight,
// animatedHandleHeight,
// animatedContentHeight,
animatedContainerHeight,
animatedSheetHeight,
animatedHandleHeight,
animatedContentHeight,
// // keyboardHeight,
// isLayoutCalculated,
// isContentHeightFixed,
2 changes: 2 additions & 0 deletions src/components/bottomSheet/constants.ts
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ const DEFAULT_ENABLE_HANDLE_PANNING_GESTURE = true;
const DEFAULT_ENABLE_OVER_DRAG = true;
const DEFAULT_ENABLE_PAN_DOWN_TO_CLOSE = false;
const DEFAULT_ANIMATE_ON_MOUNT = true;
const DEFAULT_DYNAMIC_SIZING = false;

// keyboard
const DEFAULT_KEYBOARD_BEHAVIOR = KEYBOARD_BEHAVIOR.interactive;
@@ -39,6 +40,7 @@ export {
DEFAULT_ENABLE_HANDLE_PANNING_GESTURE,
DEFAULT_ENABLE_OVER_DRAG,
DEFAULT_ENABLE_PAN_DOWN_TO_CLOSE,
DEFAULT_DYNAMIC_SIZING,
DEFAULT_ANIMATE_ON_MOUNT,
// keyboard
DEFAULT_KEYBOARD_BEHAVIOR,
18 changes: 17 additions & 1 deletion src/components/bottomSheet/types.d.ts
Original file line number Diff line number Diff line change
@@ -42,13 +42,15 @@ export interface BottomSheetProps
/**
* Points for the bottom sheet to snap to. It accepts array of number, string or mix.
* String values should be a percentage.
*
* ⚠️ This prop is required unless you set `enableDynamicSizing` to `true`.
* @example
* snapPoints={[200, 500]}
* snapPoints={[200, '%50']}
* snapPoints={['%100']}
* @type Array<string | number>
*/
snapPoints: Array<string | number> | SharedValue<Array<string | number>>;
snapPoints?: Array<string | number> | SharedValue<Array<string | number>>;
/**
* Defines how violently sheet has to be stopped while over dragging.
* @type number
@@ -85,6 +87,13 @@ export interface BottomSheetProps
* @default false
*/
enablePanDownToClose?: boolean;
/**
* Enable dynamic sizing for content view and scrollable
* content size.
* @type boolean
* @default false
*/
enableDynamicSizing?: boolean;
/**
* To start the sheet closed and snap to initial index when it's mounted.
* @type boolean
@@ -133,6 +142,13 @@ export interface BottomSheetProps
* @default 0
*/
bottomInset?: number;
/**
* Max dynamic content size height to limit the bottom sheet height
* from exceeding a provided size.
* @type number
* @default container height
*/
maxDynamicContentSize?: number;
//#endregion

//#region keyboard
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import {
useScrollHandler,
useScrollableSetter,
useBottomSheetInternal,
useStableCallback,
} from '../../hooks';
import {
GESTURE_SOURCE,
@@ -41,6 +42,7 @@ export function createBottomSheetScrollableComponent<T, P>(
onScroll,
onScrollBeginDrag,
onScrollEndDrag,
onContentSizeChange,
...rest
}: any = props;

@@ -61,6 +63,7 @@ export function createBottomSheetScrollableComponent<T, P>(
enableContentPanningGesture,
animatedFooterHeight,
animatedScrollableState,
animatedContentHeight,
} = useBottomSheetInternal();
//#endregion

@@ -77,6 +80,18 @@ export function createBottomSheetScrollableComponent<T, P>(
);
//#endregion

//#region callbacks
const handleContentSizeChange = useStableCallback(
(contentWidth: number, contentHeight: number) => {
animatedContentHeight.value = contentHeight;

if (onContentSizeChange) {
onContentSizeChange(contentWidth, contentHeight);
}
}
);
//#endregion

//#region styles
const containerAnimatedStyle = useAnimatedStyle(
() => ({
@@ -124,6 +139,7 @@ export function createBottomSheetScrollableComponent<T, P>(
overScrollMode={overScrollMode}
keyboardDismissMode={keyboardDismissMode}
onScroll={scrollHandler}
onContentSizeChange={handleContentSizeChange}
style={containerStyle}
/>
</NativeViewGestureHandler>
@@ -174,6 +190,7 @@ export function createBottomSheetScrollableComponent<T, P>(
progressViewOffset={progressViewOffset}
refreshControl={refreshControl}
onScroll={scrollHandler}
onContentSizeChange={handleContentSizeChange}
style={containerStyle}
/>
</NativeViewGestureHandler>
37 changes: 32 additions & 5 deletions src/components/bottomSheetView/BottomSheetView.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import React, { memo, useEffect, useCallback, useMemo } from 'react';
import { StyleSheet } from 'react-native';
import { LayoutChangeEvent, StyleSheet } from 'react-native';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
import { SCROLLABLE_TYPE } from '../../constants';
import { useBottomSheetInternal } from '../../hooks';
import type { BottomSheetViewProps } from './types';
import { print } from '../../utilities';

function BottomSheetViewComponent({
focusHook: useFocusHook = useEffect,
enableFooterMarginAdjustment = false,
onLayout,
style,
children,
...rest
}: BottomSheetViewProps) {
// hooks
//#region hooks
const {
animatedScrollableContentOffsetY,
animatedScrollableType,
animatedFooterHeight,
enableDynamicSizing,
animatedContentHeight,
} = useBottomSheetInternal();
//#endregion

// styles
//#region styles
const containerStylePaddingBottom = useMemo(() => {
const flattenStyle = StyleSheet.flatten(style);
const paddingBottom =
@@ -40,19 +45,41 @@ function BottomSheetViewComponent({
() => [style, containerAnimatedStyle],
[style, containerAnimatedStyle]
);
//#endregion

// callback
//#region callbacks
const handleSettingScrollable = useCallback(() => {
animatedScrollableContentOffsetY.value = 0;
animatedScrollableType.value = SCROLLABLE_TYPE.VIEW;
}, [animatedScrollableContentOffsetY, animatedScrollableType]);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
if (enableDynamicSizing) {
animatedContentHeight.value = event.nativeEvent.layout.height;
}

if (onLayout) {
onLayout(event);
}

print({
component: BottomSheetView.displayName,
method: 'handleLayout',
params: {
height: event.nativeEvent.layout.height,
},
});
},
[onLayout, animatedContentHeight, enableDynamicSizing]
);
//#endregion

// effects
useFocusHook(handleSettingScrollable);

//render
return (
<Animated.View style={containerStyle} {...rest}>
<Animated.View onLayout={handleLayout} style={containerStyle} {...rest}>
{children}
</Animated.View>
);
1 change: 1 addition & 0 deletions src/contexts/internal.ts
Original file line number Diff line number Diff line change
@@ -33,6 +33,7 @@ export interface BottomSheetInternalContextType
| 'enableContentPanningGesture'
| 'enableOverDrag'
| 'enablePanDownToClose'
| 'enableDynamicSizing'
| 'overDragResistanceFactor'
>
> {
10 changes: 9 additions & 1 deletion src/hooks/useBottomSheetDynamicSnapPoints.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useDerivedValue, useSharedValue } from 'react-native-reanimated';
import {
INITIAL_HANDLE_HEIGHT,
@@ -17,6 +17,7 @@ import {
* - animatedContentHeight: an animated content height callback node to be set on `BottomSheet` or `BottomSheetModal`.
* - handleContentLayout: a `onLayout` callback method to be set on `BottomSheetView` component.
* }
* @deprecated will be deprecated in the next major release! please use the new introduce prop `enableDynamicSizing`.
*/
export const useBottomSheetDynamicSnapPoints = (
initialSnapPoints: Array<string | number>
@@ -56,6 +57,13 @@ export const useBottomSheetDynamicSnapPoints = (
[animatedContentHeight]
);

//#region effects
useEffect(() => {
console.warn(
'`useBottomSheetDynamicSnapPoints` will be deprecated in the next major release! please use the new introduce prop `enableDynamicSizing`.'
);
}, []);
//#endregion
return {
animatedSnapPoints,
animatedHandleHeight,
72 changes: 49 additions & 23 deletions src/hooks/useNormalizedSnapPoints.ts
Original file line number Diff line number Diff line change
@@ -3,44 +3,70 @@ import { normalizeSnapPoint } from '../utilities';
import type { BottomSheetProps } from '../components/bottomSheet';
import {
INITIAL_CONTAINER_HEIGHT,
INITIAL_HANDLE_HEIGHT,
INITIAL_SNAP_POINT,
} from '../components/bottomSheet/constants';

/**
* Convert percentage snap points to pixels in screen and calculate
* the accurate snap points positions.
* @param providedSnapPoints provided snap points.
* @param snapPoints provided snap points.
* @param containerHeight BottomSheetContainer height.
* @param topInset top inset.
* @param bottomInset bottom inset.
* @param $modal is sheet in a modal.
* @param contentHeight content size.
* @param handleHeight handle size.
* @param enableDynamicSizing
* @param maxDynamicContentSize
* @returns {Animated.SharedValue<number[]>}
*/
export const useNormalizedSnapPoints = (
providedSnapPoints: BottomSheetProps['snapPoints'],
snapPoints: BottomSheetProps['snapPoints'],
containerHeight: Animated.SharedValue<number>,
topInset: number,
bottomInset: number,
$modal: boolean
contentHeight: Animated.SharedValue<number>,
handleHeight: Animated.SharedValue<number>,
enableDynamicSizing: BottomSheetProps['enableDynamicSizing'],
maxDynamicContentSize: BottomSheetProps['maxDynamicContentSize']
) => {
const normalizedSnapPoints = useDerivedValue(() =>
('value' in providedSnapPoints
? providedSnapPoints.value
: providedSnapPoints
).map(snapPoint => {
if (containerHeight.value === INITIAL_CONTAINER_HEIGHT) {
return INITIAL_SNAP_POINT;
const normalizedSnapPoints = useDerivedValue(() => {
// early exit, if container layout is not ready
const isContainerLayoutReady =
containerHeight.value !== INITIAL_CONTAINER_HEIGHT;
if (!isContainerLayoutReady) {
return [INITIAL_SNAP_POINT];
}

const _snapPoints = snapPoints
? 'value' in snapPoints
? snapPoints.value
: snapPoints
: [];

let _normalizedSnapPoints = _snapPoints.map(snapPoint =>
normalizeSnapPoint(snapPoint, containerHeight.value)
) as number[];

if (enableDynamicSizing) {
if (handleHeight.value === INITIAL_HANDLE_HEIGHT) {
return [INITIAL_SNAP_POINT];
}

return normalizeSnapPoint(
snapPoint,
containerHeight.value,
topInset,
bottomInset,
$modal
if (contentHeight.value === INITIAL_CONTAINER_HEIGHT) {
return [INITIAL_SNAP_POINT];
}

_normalizedSnapPoints.push(
containerHeight.value -
Math.min(
contentHeight.value + handleHeight.value,
maxDynamicContentSize !== undefined
? maxDynamicContentSize
: containerHeight.value
)
);
})
);

_normalizedSnapPoints = _normalizedSnapPoints.sort((a, b) => b - a);
}
return _normalizedSnapPoints;
}, [snapPoints]);

return normalizedSnapPoints;
};
20 changes: 13 additions & 7 deletions src/hooks/usePropsValidator.ts
Original file line number Diff line number Diff line change
@@ -11,14 +11,19 @@ import type { BottomSheetProps } from '../components/bottomSheet';
export const usePropsValidator = ({
index,
snapPoints,
enableDynamicSizing,
topInset,
bottomInset,
}: BottomSheetProps) => {
useMemo(() => {
//#region snap points
const _snapPoints = 'value' in snapPoints ? snapPoints.value : snapPoints;
const _snapPoints = snapPoints
? 'value' in snapPoints
? snapPoints.value
: snapPoints
: [];
invariant(
_snapPoints,
_snapPoints || enableDynamicSizing,
`'snapPoints' was not provided! please provide at least one snap point.`
);

@@ -35,7 +40,7 @@ export const usePropsValidator = ({
});

invariant(
'value' in _snapPoints || _snapPoints.length > 0,
'value' in _snapPoints || _snapPoints.length > 0 || enableDynamicSizing,
`'snapPoints' was provided with no points! please provide at least one snap point.`
);
//#endregion
@@ -47,9 +52,10 @@ export const usePropsValidator = ({
);

invariant(
typeof index === 'number'
? index >= -1 && index <= _snapPoints.length - 1
: true,
enableDynamicSizing ||
(typeof index === 'number'
? index >= -1 && index <= _snapPoints.length - 1
: true),
`'index' was provided but out of the provided snap points range! expected value to be between -1, ${
_snapPoints.length - 1
}`
@@ -68,5 +74,5 @@ export const usePropsValidator = ({
//#endregion

// animations
}, [index, snapPoints, topInset, bottomInset]);
}, [index, snapPoints, topInset, bottomInset, enableDynamicSizing]);
};
5 changes: 1 addition & 4 deletions src/utilities/normalizeSnapPoint.ts
Original file line number Diff line number Diff line change
@@ -3,10 +3,7 @@
*/
export const normalizeSnapPoint = (
snapPoint: number | string,
containerHeight: number,
_topInset: number,
_bottomInset: number,
_$modal: boolean = false
containerHeight: number
) => {
'worklet';
let normalizedSnapPoint = snapPoint;

0 comments on commit 7330c7c

Please sign in to comment.