diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt index a9e1e0e..b77b650 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt @@ -31,6 +31,7 @@ import kotlin.math.roundToInt private enum class DetentKind { POINTS, + PERCENTAGE, CONTENT, } @@ -146,7 +147,9 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr private var pendingInitialContentDetentFrames = 0 private val contentHeightMarkerLayoutListener = - View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> refreshDetentsFromLayout() } + View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + refreshDetentsFromLayout() + } init { clipChildren = false @@ -353,17 +356,19 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr // MARK: - Prop setters fun setDetents(raw: List>) { - rawDetentSpecs = - raw.mapNotNull { dict -> - val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null - val kind = - when ((dict["kind"] as? String)?.lowercase()) { - "content" -> DetentKind.CONTENT - else -> DetentKind.POINTS - } - val programmatic = dict["programmatic"] as? Boolean ?: false - RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic) - } + rawDetentSpecs = raw.mapNotNull { dict -> + val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null + val kind = + when (dict["kind"] as? String) { + "content" -> DetentKind.CONTENT + "percentage" -> DetentKind.PERCENTAGE + else -> DetentKind.POINTS + } + val programmatic = dict["programmatic"] as? Boolean ?: false + val nativeValue = + if (kind == DetentKind.POINTS) (value * density).toFloat() else value.toFloat() + RawDetentSpec(value = nativeValue, kind = kind, programmatic = programmatic) + } refreshDetentsFromLayout() } @@ -428,6 +433,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr val height = when (spec.kind) { DetentKind.POINTS -> spec.value + DetentKind.PERCENTAGE -> maxHeight * spec.value DetentKind.CONTENT -> measuredContentHeight ?: unresolvedContentDetentHeight(index, maxHeight) }.coerceIn(0f, maxHeight) @@ -444,9 +450,16 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr } private fun unresolvedContentDetentHeight(index: Int, maxHeight: Float): Float { - val nextPointHeight = - rawDetentSpecs.drop(index + 1).firstOrNull { it.kind == DetentKind.POINTS }?.value - return (nextPointHeight ?: maxHeight).coerceIn(0f, maxHeight) + val nextBound = + rawDetentSpecs.drop(index + 1).firstOrNull { it.kind != DetentKind.CONTENT } + ?: return maxHeight + val nextBoundHeight = + when (nextBound.kind) { + DetentKind.POINTS -> nextBound.value + DetentKind.PERCENTAGE -> maxHeight * nextBound.value + DetentKind.CONTENT -> maxHeight + } + return nextBoundHeight.coerceIn(0f, maxHeight) } private fun refreshDetentsFromLayout() { @@ -591,23 +604,22 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr if (!observer.isAlive) return pendingInitialContentDetentFrames = 0 - val listener = - ViewTreeObserver.OnPreDrawListener { - refreshContentHeightMarker() - refreshDetentsFromLayout() - // refreshDetentsFromLayout() completes and stops observing via - // trySnapPendingInitialContentDetent() once the target is measurable. - // If it is still pending, this frame was unproductive: bound how many - // such frames we spend so a content detent that never becomes - // measurable cannot keep us redrawing forever. - if ( - pendingInitialContentDetentSnap && - ++pendingInitialContentDetentFrames >= MAX_PENDING_INITIAL_CONTENT_DETENT_FRAMES - ) { - removePendingInitialContentDetentObserver() - } - true + val listener = ViewTreeObserver.OnPreDrawListener { + refreshContentHeightMarker() + refreshDetentsFromLayout() + // refreshDetentsFromLayout() completes and stops observing via + // trySnapPendingInitialContentDetent() once the target is measurable. + // If it is still pending, this frame was unproductive: bound how many + // such frames we spend so a content detent that never becomes + // measurable cannot keep us redrawing forever. + if ( + pendingInitialContentDetentSnap && + ++pendingInitialContentDetentFrames >= MAX_PENDING_INITIAL_CONTENT_DETENT_FRAMES + ) { + removePendingInitialContentDetentObserver() } + true + } // Keep the exact observer instance used for registration. Android can // replace a ViewTreeObserver across attach/detach boundaries, and listeners diff --git a/docs/content/detents-and-index.mdx b/docs/content/detents-and-index.mdx index 5bf8e9d..c28af1d 100644 --- a/docs/content/detents-and-index.mdx +++ b/docs/content/detents-and-index.mdx @@ -4,17 +4,35 @@ title: Detents and index # Detents and index -Detents are the points to which the sheet snaps. Each detent is either a number -(a fixed height in pixels) or `'content'` (the sheet’s content height, capped by -the available screen height). The default detents are `[0, 'content']`. Pass -detents in ascending order, from shortest to tallest. Fixed detents can be -taller than the measured content height, so `[0, 'content', 600]` lets a compact -content-sized sheet expand to a larger surface. - -Sheet children are laid out in a flex container. For a full-height -sheet, apply `flex: 1` to your content and use the `'content'` detent. -`surface` is sized by the library, so `flex: 1` only ever belongs on your -content, never on the surface: +Detents are the heights to which the sheet snaps. Each detent is a number (a +fixed height in React Native density-independent layout units—points on iOS and +dp on Android), a percentage string such as `'30%'`, or `'content'` (the +measured content height, capped by the available sheet height). The default +detents are `[0, 'content']`. + +For example, `[0, 'content']` creates a content-sized sheet, `[0, 300]` keeps +the open height fixed, and `['0%', '30%', '80%']` creates responsive heights. + +Percentage values from `0%` through `100%`, including decimals such as `12.5%`, +resolve against the available sheet height and update when that height changes. +The percentage syntax is unsigned and does not allow whitespace, so values such +as `'-10%'` are invalid. + +`detents` must contain at least one item. Numeric detents must be finite and +non-negative; the library throws an `Error` for values such as `-10`, `NaN`, or +`Infinity` instead of correcting them. A numeric detent may be taller than the +measured content or exceed the available height; native layout caps its resolved +height to the available geometry. + +Pass detents in ascending order, from shortest to tallest. Detent forms can be +mixed, but they must remain ordered by their resolved heights at every layout +size your app supports. The library validates the resolved order during native +layout. + +Sheet children are laid out in a flex container. For a full-height sheet, apply +`flex: 1` to your content and use the `'content'` detent. `surface` is sized by +the library, so `flex: 1` only ever belongs on your content, never on the +surface: ```tsx + { const [index, setIndex] = useState(0); const [middleDetent, setMiddleDetent] = useState(200); + const [usesShortDetents, setUsesShortDetents] = useState(false); const [position, setPosition] = useState(0); const sheetBottomPadding = useSheetBottomPadding(0); - const detents = useMemo( - () => [0, middleDetent, 'content'] as const, - [middleDetent] + const detents = useMemo( + () => (usesShortDetents ? [0, middleDetent] : [0, middleDetent, 'content']), + [middleDetent, usesShortDetents] ); + const shortenDetents = () => { + setIndex(1); + setUsesShortDetents(true); + }; + + const restoreContentDetent = () => { + setIndex(2); + setUsesShortDetents(false); + }; + return ( { >