Skip to content
Open
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 @@ -3,12 +3,14 @@ package com.swmansion.reactnativebottomsheet
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Outline
import android.graphics.Paint
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.widget.FrameLayout
Expand Down Expand Up @@ -344,10 +346,13 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr

private fun layoutSheetContainer(viewWidth: Int, viewHeight: Int) {
val maxHeight = resolvedMaxDetentHeight(viewHeight)
val containerTop = (viewHeight - maxHeight).toInt()
// Anchor the container bottom at the floating edge (lifted by bottomInsetPx),
// not the raw view bottom.
val containerTop = (viewHeight - bottomInsetPx - maxHeight).toInt()
lastAppliedMaxDetentHeight = maxHeight
sheetContainer.layout(0, containerTop, viewWidth, containerTop + maxHeight.toInt())
layoutSheetChildren(viewWidth, maxHeight.toInt())
if (bottomInsetPx > 0f) invalidateOutline()
}

// MARK: - Prop setters
Expand Down Expand Up @@ -771,6 +776,61 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
recomputeNativeGeometry()
}

// Floats the sheet up off the bottom edge by this many px — a detached /
// "floating card" sheet. The detent cap shrinks so the sheet stays inside the
// region above the inset, and the floating bottom edge is clipped + rounded
// via `cornerRadiusPx`. Stored in px (the setter converts from dp).
var bottomInsetPx: Float = 0f
private set

fun setBottomInset(dp: Float) {
val px = dp * density
if (bottomInsetPx == px) return
bottomInsetPx = px
updateDetachedClip()
recomputeNativeGeometry()
requestLayout()
}

// Corner radius (px) for the detached sheet's floating bottom corners.
var cornerRadiusPx: Float = 0f
private set

fun setCornerRadius(dp: Float) {
val px = dp * density
if (cornerRadiusPx == px) return
cornerRadiusPx = px
invalidateOutline()
}

// The Y the sheet's bottom edge is anchored to: this view's bottom edge,
// lifted by `bottomInsetPx` for a detached sheet.
private val sheetBottomAnchor: Float
get() = (height - bottomInsetPx).coerceAtLeast(0f)

// Clips the over-sized surface canvas to the floating bottom edge and rounds
// its bottom corners, so a detached sheet reads as a card floating above the
// bottom. A no-op (clip off) when anchored, preserving the anchored sheet's
// slide-off-screen close. Clips this host, so a native scrim (if any) is
// clipped with it — detached sheets should use an external backdrop.
private fun updateDetachedClip() {
val detached = bottomInsetPx > 0f
clipToOutline = detached
if (detached && outlineProvider !is DetachedOutlineProvider) {
outlineProvider = DetachedOutlineProvider()
}
invalidateOutline()
}

private inner class DetachedOutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
val anchor = sheetBottomAnchor.toInt().coerceAtLeast(0)
// Round the bottom corners at the floating edge; the top corners sit at
// y=0 above the (capped) content, where the surface owns the rounding.
outline.setRoundRect(0, 0, view.width, anchor, cornerRadiusPx)
}
}

/**
* Re-derives all natively measured geometry from this view's own size, window position, and the
* window's top inset, then pushes it into the shadow tree: the sheet frame (consumed by the
Expand All @@ -793,8 +853,11 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
// sheet inside a container that starts below the status bar keeps its full
// height.
val topOverlap = (topInset - location[1]).coerceAtLeast(0)
// Anchor the cap to the (possibly lifted) floating bottom, not the raw view
// bottom, so a detached sheet stays inside the region above the inset.
val anchor = (height - bottomInsetPx).coerceAtLeast(0f)
val cap =
(if (extendUnderStatusBar) height else height - topOverlap).toFloat().coerceAtLeast(0f)
(if (extendUnderStatusBar) anchor else anchor - topOverlap).coerceAtLeast(0f)

val capChanged = cap != nativeCapPx
nativeCapPx = cap
Expand Down Expand Up @@ -845,7 +908,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr

private fun updateShadowState(translationY: Float) {
val maxDetentHeight = resolvedMaxDetentHeight()
val containerTop = height.toFloat() - maxDetentHeight
val containerTop = (height - bottomInsetPx) - maxDetentHeight
// The content's in-host displacement from its Yoga position: the container
// offset plus the sheet's translation. The content-region inset shrinks
// the content via Yoga BOTTOM padding, keeping the Yoga origin at zero, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ class BottomSheetView(context: Context) : ReactViewGroup(context), LifecycleEven
host.extendUnderStatusBar = value
}

fun setBottomInset(dp: Float) = host.setBottomInset(dp)

fun setCornerRadius(dp: Float) = host.setCornerRadius(dp)

fun setScrimColor(color: Int?) = host.setScrimColor(color)

fun setScrimOpacities(values: List<Float>) = host.setScrimOpacities(values)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ class BottomSheetViewManager :
view.extendUnderStatusBar = value
}

@ReactProp(name = "bottomInset")
override fun setBottomInset(view: BottomSheetView, value: Double) {
view.setBottomInset(value.toFloat())
}

@ReactProp(name = "cornerRadius")
override fun setCornerRadius(view: BottomSheetView, value: Double) {
view.setCornerRadius(value.toFloat())
}

// React Native's borderCurve is iOS-only. Android keeps its platform
// round-rect outline for both accepted values.
@ReactProp(name = "borderCurve")
override fun setBorderCurve(view: BottomSheetView, value: String?) {}

@ReactProp(name = "scrollableExpandNegotiation")
override fun setScrollableExpandNegotiation(view: BottomSheetView, value: Int) {
view.scrollableExpandNegotiation = value
Expand Down
106 changes: 106 additions & 0 deletions docs/content/detached-sheets.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Detached sheets
---

# Detached sheets

By default the sheet is anchored to the bottom of its host: its bottom edge sits
on the screen edge and only the top corners are rounded. A **detached** sheet
floats off the bottom as a card, with rounded corners on all four sides.

Three props drive it:

- `bottomInset` — floats the sheet up off the bottom edge by this many points.
The detent cap shrinks by the same amount, so the sheet stays inside the
region above the inset, and the (now floating) bottom edge is clipped and
rounded.
- `cornerRadius` — the radius (points) applied to the floating bottom corners.
Match it to your surface's top radius for a uniform card. Only used when
`bottomInset` is greater than 0.
- `borderCurve` — the floating bottom corners' curve on iOS: `'circular'` (the
default) or `'continuous'`. Apply the matching `borderCurve` style to the
surface so its top corners use the same curve. Android uses its platform
round-rect curve.

`bottomInset` handles the vertical float; add a horizontal `style` inset for the
side margins. Round the top corners on your `surface` as usual — the top corners
animate with the sheet, so the surface owns them, while `cornerRadius` rounds
the fixed floating bottom.

The `surface` cannot reliably own the floating bottom corners. It fills the
sheet's maximum-height moving canvas so that shorter detents and shrinking
content remain covered. At those heights, the surface's actual bottom edge sits
below the visible floating edge, so bottom radii on the surface would be
offscreen and would move with the canvas. The native host clip knows the fixed
floating edge and clips both the surface and content there; `cornerRadius`
configures that clip.

```tsx
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BottomSheet } from '@swmansion/react-native-bottom-sheet';

const RADIUS = 24;

export function DetachedSheetExample() {
const [index, setIndex] = useState(1);
const insets = useSafeAreaInsets();

return (
<BottomSheet // Or `ModalBottomSheet`.
index={index}
onIndexChange={setIndex}
// Float 12pt above the safe area, 12pt in on each side.
bottomInset={insets.bottom + 12}
cornerRadius={RADIUS}
borderCurve="continuous"
style={{ left: 12, right: 12 }}
surface={
<View
style={[
StyleSheet.absoluteFill,
{
backgroundColor: 'white',
borderTopLeftRadius: RADIUS,
borderTopRightRadius: RADIUS,
borderCurve: 'continuous',
},
]}
/>
}
>
<Text>Sheet content</Text>
</BottomSheet>
);
}
```

`bottomInset` is measured from the bottom of the sheet's host. For a
full-screen, edge-to-edge host, add the safe-area bottom inset to the visual gap
you want, as shown above. `useSafeAreaInsets()` follows Android gesture
navigation, three-button navigation, and other system-bar configurations; do not
hardcode a navigation-bar height. If the host is already laid out above the
system navigation area, pass only the visual gap so the safe area is not counted
twice.

The floating bottom is clipped by masking the sheet host, so a **native scrim**
(`scrimColor` / `scrimOpacities`) is clipped along with it. Detached sheets
should dim the screen with an external backdrop rather than the native scrim. In
development, `ModalBottomSheet` warns when `scrimColor` and a positive
`bottomInset` are both set.

The same mask clips any shadow or elevation that the sheet host or surface draws
beyond the floating bottom edge. A detached sheet therefore cannot currently
cast a native shadow outside the card. If the design requires one, render a
separate shadow shape in the external backdrop layer and keep it aligned with
the sheet (for example, with `onPositionChange`).

When a detached sheet closes to a zero-height detent, its surface and content
slide down behind the fixed floating-edge mask and are progressively clipped at
that edge. The card does not remain visible while moving through the bottom gap.
Anchored sheets keep their existing slide-off-screen close.

`bottomInset` composes with everything else — content (`'content'`) detents,
fixed-point detents, keyboard avoidance, and grow/shrink height animation. The
bottom stays pinned to the floating edge while the top animates.
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const sidebars: SidebarsConfig = {
'inline-sheets',
'modal-sheets',
'surface',
'detached-sheets',
'keyboard-handling',
'scrollable-negotiation',
'detents-and-index',
Expand Down
8 changes: 8 additions & 0 deletions ios/BottomSheetComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
[_sheetView setExtendUnderStatusBar:newViewProps.extendUnderStatusBar];
}

// Always set (like detents): an oldProps diff misses on a recycled instance
// whose retained props still hold the previous sheet's values. The Swift
// setters no-op on an unchanged value, so redundant sets are cheap.
[_sheetView setBottomInset:newViewProps.bottomInset];
[_sheetView setCornerRadius:newViewProps.cornerRadius];
[_sheetView setBorderCurveContinuous:
newViewProps.borderCurve == BottomSheetViewBorderCurve::Continuous];

if (newViewProps.scrollableExpandNegotiation != oldViewProps.scrollableExpandNegotiation) {
_sheetView.scrollableExpandNegotiation = newViewProps.scrollableExpandNegotiation;
}
Expand Down
6 changes: 6 additions & 0 deletions ios/BottomSheetContentView.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ NS_ASSUME_NONNULL_BEGIN
// Whether full-height detents may extend under the status bar; feeds the
// natively computed detent cap.
- (void)setExtendUnderStatusBar:(BOOL)extendUnderStatusBar;
// Floats the sheet up off the bottom edge by this many points (detached sheet).
- (void)setBottomInset:(CGFloat)bottomInset;
// Corner radius for the detached sheet's floating bottom corners.
- (void)setCornerRadius:(CGFloat)cornerRadius;
// Whether the detached sheet's floating bottom corners use a continuous curve.
- (void)setBorderCurveContinuous:(BOOL)continuous;
// The natively measured inset of the content region: the gap between the
// sheet's height and the detent cap.
- (CGFloat)contentRegionInset;
Expand Down
15 changes: 15 additions & 0 deletions ios/BottomSheetContentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ - (void)setExtendUnderStatusBar:(BOOL)extendUnderStatusBar
_impl.extendUnderStatusBar = extendUnderStatusBar;
}

- (void)setBottomInset:(CGFloat)bottomInset
{
_impl.bottomInset = bottomInset;
}

- (void)setCornerRadius:(CGFloat)cornerRadius
{
_impl.cornerRadius = cornerRadius;
}

- (void)setBorderCurveContinuous:(BOOL)continuous
{
_impl.borderCurveContinuous = continuous;
}

- (CGFloat)contentRegionInset
{
return _impl.contentRegionInset;
Expand Down
Loading