Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import kotlin.math.roundToInt

private enum class DetentKind {
POINTS,
PERCENTAGE,
CONTENT,
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -353,17 +356,19 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
// MARK: - Prop setters

fun setDetents(raw: List<Map<String, Any>>) {
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()
}

Expand Down Expand Up @@ -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)
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
55 changes: 39 additions & 16 deletions docs/content/detents-and-index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<BottomSheet
Expand Down Expand Up @@ -46,8 +64,11 @@ screen height:

## Index events

The `index` prop is a zero-based index into the `detents` array.
`onIndexChange` and `onSettle` have different responsibilities:
The `index` prop must be a finite, zero-based integer in the range
`0..(detents.length - 1)`. Invalid indices and empty `detents` arrays throw an
`Error` synchronously in JavaScript for both `BottomSheet` and
`ModalBottomSheet`. `onIndexChange` and `onSettle` have different
responsibilities:

- `onIndexChange` fires when a user-triggered snap is _initiated_: the
moment a drag commits to a detent, before the animation settles. It does not
Expand All @@ -65,7 +86,7 @@ const [index, setIndex] = useState(0);

```tsx
<BottomSheet // Or `ModalBottomSheet`.
detents={[0, 300, 'content']} // Collapsed, 300 px, content height.
detents={[0, 300, 600]} // Collapsed, 300 layout units, 600 layout units.
index={index}
onIndexChange={setIndex} // Fires when a drag commits; keep state in sync.
surface={/* ... */}
Expand All @@ -78,7 +99,9 @@ const [index, setIndex] = useState(0);
```

Detents can also change over time. When you update `detents`, the sheet keeps
the current index and animates to the updated detent height when needed.
the current index and animates to the updated detent height when needed. If an
update shortens the array, set a matching in-range `index` in the same render;
the library does not clamp a now-out-of-range index.

## Programmatic-only detents

Expand All @@ -89,7 +112,7 @@ detent is programmatic-only, tapping the scrim does not dismiss the sheet.

```tsx
<BottomSheet
detents={[0, programmatic(300), 'content']}
detents={[0, programmatic(300), 600]}
index={index}
onIndexChange={setIndex}
surface={/* ... */}
Expand Down
9 changes: 8 additions & 1 deletion example/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export default function RootLayout() {
/>
<Stack.Screen
name="invalid-detents"
options={{ title: 'Invalid detents', headerShown: false }}
options={{ title: 'Invalid props', headerShown: false }}
/>
<Stack.Screen
name="scrollable-negotiation"
Expand All @@ -124,6 +124,13 @@ export default function RootLayout() {
headerShown: false,
}}
/>
<Stack.Screen
name="percentage-detents"
options={{
title: 'Numeric and percentage detents',
headerShown: false,
}}
/>
<Stack.Screen
name="dynamic-detents"
options={{ title: 'Dynamic detent updates', headerShown: false }}
Expand Down
1 change: 1 addition & 0 deletions example/app/percentage-detents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PercentageDetentsScreen as default } from '../src/demos/PercentageDetentsScreen';
8 changes: 7 additions & 1 deletion example/src/demoCases.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type CaseKey =
| 'invalid-detents'
| 'scrollable-negotiation'
| 'programmatic-detent-drag'
| 'percentage-detents'
| 'dynamic-detents'
| 'dynamic-content-height'
| 'snap-callbacks'
Expand Down Expand Up @@ -115,7 +116,7 @@ export const DEMO_CASES: DemoCase[] = [
},
{
key: 'invalid-detents',
title: 'Invalid detents',
title: 'Invalid props',
href: '/invalid-detents',
throws: true,
},
Expand All @@ -129,6 +130,11 @@ export const DEMO_CASES: DemoCase[] = [
title: 'Programmatic detent drag',
href: '/programmatic-detent-drag',
},
{
key: 'percentage-detents',
title: 'Numeric and percentage detents',
href: '/percentage-detents',
},
{
key: 'dynamic-detents',
title: 'Dynamic detent updates',
Expand Down
37 changes: 31 additions & 6 deletions example/src/demos/DynamicDetentsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { BottomSheet } from '@swmansion/react-native-bottom-sheet';
import { BottomSheet, type Detent } from '@swmansion/react-native-bottom-sheet';

import {
DemoScreen,
Expand All @@ -12,13 +12,24 @@ import {
export const DynamicDetentsScreen = () => {
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<Detent[]>(
() => (usesShortDetents ? [0, middleDetent] : [0, middleDetent, 'content']),
[middleDetent, usesShortDetents]
);

const shortenDetents = () => {
setIndex(1);
setUsesShortDetents(true);
};

const restoreContentDetent = () => {
setIndex(2);
setUsesShortDetents(false);
};

return (
<DemoScreen
title="Dynamic detent updates"
Expand Down Expand Up @@ -57,7 +68,7 @@ export const DynamicDetentsScreen = () => {
>
<View style={{ gap: 12 }}>
<Button title="Open at index 1" onPress={() => setIndex(1)} />
<Button title="Expand to content" onPress={() => setIndex(2)} />
<Button title="Expand to content" onPress={restoreContentDetent} />
<Button title="Collapse" onPress={() => setIndex(0)} />
</View>
<View style={{ gap: 12 }}>
Expand All @@ -69,6 +80,14 @@ export const DynamicDetentsScreen = () => {
title="Use 300pt middle detent"
onPress={() => setMiddleDetent(300)}
/>
<Button
title="Shorten detents and select last index"
onPress={shortenDetents}
/>
<Button
title="Restore content detent and select it"
onPress={restoreContentDetent}
/>
</View>
<View
style={{
Expand All @@ -79,9 +98,15 @@ export const DynamicDetentsScreen = () => {
}}
>
<Text style={{ fontWeight: '600' }}>Current state</Text>
<Text>detents: [{`0, ${middleDetent}, 'content'`}]</Text>
<Text>
detents: [0, {middleDetent}
{usesShortDetents ? '' : ", 'content'"}]
</Text>
<Text>index: {index}</Text>
<Text>position: {position.toFixed(0)}pt</Text>
<Text>
Shortening and restoring update detents and index in the same render.
</Text>
</View>
</DemoScreen>
);
Expand Down
Loading