@@ -7,7 +7,14 @@ import GorhomBottomSheet, {
77import type { BottomSheetBackgroundProps } from '@gorhom/bottom-sheet' ;
88import React from 'react' ;
99import { Portal } from '@gorhom/portal' ;
10- import { Dimensions , AccessibilityInfo , findNodeHandle , View , Keyboard } from 'react-native' ;
10+ import {
11+ Dimensions ,
12+ AccessibilityInfo ,
13+ findNodeHandle ,
14+ View ,
15+ Keyboard ,
16+ useWindowDimensions ,
17+ } from 'react-native' ;
1118import { BottomSheetHeader } from './BottomSheetHeader' ;
1219import { BottomSheetGrabHandle } from './BottomSheetGrabHandle' ;
1320import { BottomSheetBody } from './BottomSheetBody' ;
@@ -53,6 +60,10 @@ const focusOnElement = (element: React.Component<any, any>): void => {
5360 }
5461} ;
5562
63+ // Extra breathing room (home-indicator / safe area) added below the measured
64+ // content when `snapToContentHeight` is on, so the last row is never clipped.
65+ const CONTENT_HEIGHT_SAFE_AREA = 24 ;
66+
5667const _BottomSheet = ( {
5768 children,
5869 snapPoints = [ 0.35 , 0.5 , 0.85 ] ,
@@ -62,6 +73,7 @@ const _BottomSheet = ({
6273 isContentPanningGestureEnabled = true ,
6374 initialFocusRef,
6475 zIndex = componentZIndices . bottomSheet ,
76+ snapToContentHeight = false ,
6577} : BottomSheetProps ) : React . ReactElement => {
6678 const bottomSheetAndDropdownGlue = useBottomSheetAndDropdownGlue ( ) ;
6779 const defaultInitialFocusRef = React . useRef < any > ( null ) ;
@@ -76,7 +88,7 @@ const _BottomSheet = ({
7688 const [ hasBodyPadding , setHasBodyPadding ] = React . useState ( true ) ;
7789 const [ isHeaderEmpty , setIsHeaderEmpty ] = React . useState ( false ) ;
7890 const initialSnapPoint = React . useRef < number > ( 0 ) ;
79- const lastSnappedSnapPointRef = React . useRef < string | null > ( null ) ;
91+ const lastSnappedSnapPointRef = React . useRef < string | number | null > ( null ) ;
8092 const isOpenRef = React . useRef ( Boolean ( _isOpen ) ) ;
8193 isOpenRef . current = Boolean ( _isOpen ) ;
8294 // Gorhom emits `onClose` for programmatic snap/close as well as user dismiss.
@@ -104,7 +116,12 @@ const _BottomSheet = ({
104116
105117 // if bottomSheet height is >35% & <50% then set initial snapPoint to 35%
106118 useIsomorphicLayoutEffect ( ( ) => {
107- if ( bottomSheetAndDropdownGlue ?. hasAutoCompleteInHeader ) {
119+ if ( snapToContentHeight ) {
120+ // Content-driven mode has a single derived snap point, so index 0 is the only
121+ // valid open index. Anything higher would be out of range and crash Gorhom's
122+ // `snapToIndex` invariant (`index <= snapPoints.length - 1`).
123+ initialSnapPoint . current = 0 ;
124+ } else if ( bottomSheetAndDropdownGlue ?. hasAutoCompleteInHeader ) {
108125 // In AutoComplete, we want to open BottomSheet with max height so we set this to last index
109126 initialSnapPoint . current = 2 ;
110127 } else if ( totalHeight > 0 ) {
@@ -119,10 +136,35 @@ const _BottomSheet = ({
119136 initialSnapPoint . current = 0 ;
120137 }
121138 }
122- } , [ snapPoints , totalHeight , bottomSheetAndDropdownGlue ?. hasAutoCompleteInHeader ] ) ;
139+ } , [
140+ snapPoints ,
141+ totalHeight ,
142+ snapToContentHeight ,
143+ bottomSheetAndDropdownGlue ?. hasAutoCompleteInHeader ,
144+ ] ) ;
123145
124- const windowHeight = Dimensions . get ( 'window' ) . height ;
125- const _snapPoints = React . useMemo ( ( ) => {
146+ const { height : windowHeight } = useWindowDimensions ( ) ;
147+ const _snapPoints = React . useMemo < ( string | number ) [ ] > ( ( ) => {
148+ // Content-driven sizing: derive a SINGLE snap point from the measured content
149+ // height (header + body + footer) so the sheet hugs its content. The array is
150+ // always length 1 in this mode (index 0 is the only valid open index) — before
151+ // the first measurement lands we use a sensible default fraction so the sheet
152+ // opens smoothly and never requests an out-of-range snap index.
153+ if ( snapToContentHeight ) {
154+ if ( totalHeight > 0 ) {
155+ // Use an ABSOLUTE pixel snap point (not a percentage). Gorhom resolves a
156+ // percentage against its own container height, which is shorter than the
157+ // window by the top inset (status bar / dynamic island). Dividing the
158+ // measured content by the window height therefore under-sized the sheet and
159+ // clipped the last grid row (forcing a scroll). A pixel value sizes the sheet
160+ // to exactly the measured content regardless of container/window mismatch, so
161+ // every grid view (date / month / year) is fully visible. Capped so very tall
162+ // content still leaves a small gap at the top instead of covering the notch.
163+ const pixelHeight = Math . min ( totalHeight + CONTENT_HEIGHT_SAFE_AREA , windowHeight * 0.95 ) ;
164+ return [ pixelHeight ] ;
165+ }
166+ return [ '60%' ] ;
167+ }
126168 if ( totalHeight > 0 ) {
127169 const fittedHeight = computeMaxContent ( {
128170 maxHeight : windowHeight * snapPoints [ 2 ] ,
@@ -137,7 +179,23 @@ const _BottomSheet = ({
137179 return [ `${ fittedSnap * 100 } %` , `${ snapPoints [ 1 ] * 100 } %` , `${ snapPoints [ 2 ] * 100 } %` ] ;
138180 }
139181 return snapPoints . map ( ( point ) => `${ point * 100 } %` ) ;
140- } , [ snapPoints , totalHeight , headerHeight , footerHeight , contentHeight , windowHeight ] ) ;
182+ } , [
183+ snapToContentHeight ,
184+ snapPoints ,
185+ totalHeight ,
186+ headerHeight ,
187+ footerHeight ,
188+ contentHeight ,
189+ windowHeight ,
190+ ] ) ;
191+
192+ // Always clamp the open index into the CURRENT snap-points range. The snap-points
193+ // array length can change at runtime (e.g. content-driven sizing collapses it to a
194+ // single point), and requesting a now-nonexistent index throws Gorhom's invariant.
195+ const getSafeSnapIndex = React . useCallback (
196+ ( index : number ) => Math . min ( Math . max ( index , 0 ) , _snapPoints . length - 1 ) ,
197+ [ _snapPoints . length ] ,
198+ ) ;
141199
142200 const dismissSheet = React . useCallback ( ( ) => {
143201 if ( ! isDismissible ) return ;
@@ -158,8 +216,8 @@ const _BottomSheet = ({
158216
159217 const handleOnOpen = React . useCallback ( ( ) => {
160218 suppressDismiss ( ) ;
161- sheetRef . current ?. snapToIndex ( initialSnapPoint . current ) ;
162- } , [ suppressDismiss ] ) ;
219+ sheetRef . current ?. snapToIndex ( getSafeSnapIndex ( initialSnapPoint . current ) ) ;
220+ } , [ suppressDismiss , getSafeSnapIndex ] ) ;
163221
164222 const handleOnClose = React . useCallback ( ( ) => {
165223 suppressDismiss ( ) ;
@@ -188,13 +246,13 @@ const _BottomSheet = ({
188246
189247 React . useEffect ( ( ) => {
190248 if ( ! _isOpen || totalHeight === 0 ) return ;
191- const targetIndex = initialSnapPoint . current ;
249+ const targetIndex = getSafeSnapIndex ( initialSnapPoint . current ) ;
192250 const targetSnapPoint = _snapPoints [ targetIndex ] ;
193251 if ( lastSnappedSnapPointRef . current === targetSnapPoint ) return ;
194252 lastSnappedSnapPointRef . current = targetSnapPoint ;
195253 suppressDismiss ( ) ;
196254 sheetRef . current ?. snapToIndex ( targetIndex ) ;
197- } , [ _isOpen , totalHeight , _snapPoints , suppressDismiss ] ) ;
255+ } , [ _isOpen , totalHeight , _snapPoints , suppressDismiss , getSafeSnapIndex ] ) ;
198256
199257 // let the Dropdown component know that it's rendering a bottomsheet
200258 React . useEffect ( ( ) => {
@@ -237,6 +295,9 @@ const _BottomSheet = ({
237295
238296 const renderBackdrop = React . useCallback (
239297 ( props : any ) : React . ReactElement => {
298+ // `isDismissible` MUST be forwarded — the backdrop uses it to decide its
299+ // `pressBehavior` ('close' vs 'none'). Without it, tapping outside the sheet
300+ // does nothing (the tap-outside-to-close affordance is lost).
240301 return (
241302 < BottomSheetBackdrop { ...props } zIndex = { bottomSheetZIndex } isDismissible = { isDismissible } />
242303 ) ;
@@ -357,7 +418,7 @@ const _BottomSheet = ({
357418 ref = { sheetRef }
358419 // on initial render if _isOpen is true we want to render the sheet at initialSnapPoint
359420 // otherwise we want to render it at -1 so that it is not visible
360- index = { _isOpen ? initialSnapPoint . current : - 1 }
421+ index = { _isOpen ? getSafeSnapIndex ( initialSnapPoint . current ) : - 1 }
361422 containerStyle = { { zIndex : bottomSheetZIndex , elevation : bottomSheetZIndex } }
362423 animateOnMount = { true }
363424 handleComponent = { renderHandle }
0 commit comments