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
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,6 @@ android {

dependencies {
implementation "com.facebook.react:react-android"
implementation "androidx.activity:activity:1.6.0"
implementation "androidx.dynamicanimation:dynamicanimation:1.0.0"
}
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 All @@ -55,6 +56,8 @@ interface BottomSheetViewListener {
fun onSettle(index: Int)

fun onPositionChange(position: Double, index: Double)

fun onRequestClose()
}

class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScrollingParent3 {
Expand Down Expand Up @@ -84,6 +87,17 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
*/
var interactionListener: ((Boolean) -> Unit)? = null

/**
* Notifies the presentation coordinator whenever the target becomes a valid open or closed
* detent. This deliberately reports the target state rather than a copied detent height: a portal
* Back callback must be re-enabled as soon as React retargets a closed sheet to an open detent.
*/
var requestCloseTargetChangedListener: (() -> Unit)? = null
set(value) {
field = value
value?.invoke()
}

// MARK: - State

private var rawDetentSpecs: List<RawDetentSpec> = emptyList()
Expand Down Expand Up @@ -144,9 +158,12 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
private var pendingInitialContentDetentObserver: ViewTreeObserver? = null
private var pendingInitialContentDetentPreDrawListener: ViewTreeObserver.OnPreDrawListener? = null
private var pendingInitialContentDetentFrames = 0
private var detentResolutionReady = false

private val contentHeightMarkerLayoutListener =
View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> refreshDetentsFromLayout() }
View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
refreshDetentsFromLayout()
}

init {
clipChildren = false
Expand Down Expand Up @@ -222,6 +239,11 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
layoutSheetContainer(width, height)
}
}
private val resolveDetentsAfterAttach = Runnable {
if (isAttachedToWindow && !detentResolutionReady && width > 0 && height > 0) {
resolveHostLayout(width, height)
}
}

override fun requestLayout() {
super.requestLayout()
Expand All @@ -236,11 +258,18 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr

override fun onAttachedToWindow() {
super.onAttachedToWindow()
detentResolutionReady = false
notifyRequestCloseTargetChanged()
// Native geometry (cap, frame) is derived from the window; recompute on
// (re)attach — including the inline<->overlay reparent — and ask for a
// fresh insets pass.
// fresh insets pass. Fabric may assign unchanged bounds before attaching
// the view, in which case Android does not owe us another onSizeChanged or
// onLayout callback. Schedule the same resolution pass explicitly so
// close-request eligibility cannot remain invalid after the attach.
requestApplyInsets()
recomputeNativeGeometry()
removeCallbacks(resolveDetentsAfterAttach)
post(resolveDetentsAfterAttach)
// A re-attach gives us a fresh, live ViewTreeObserver; the previous one was
// dropped on detach. Resume observing if the initial snap is still pending.
if (pendingInitialContentDetentSnap) {
Expand All @@ -260,6 +289,9 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
}

override fun onDetachedFromWindow() {
removeCallbacks(resolveDetentsAfterAttach)
detentResolutionReady = false
notifyRequestCloseTargetChanged()
// Release the listener from the soon-to-be-replaced observer and clear our
// references so a later re-attach registers on the new live observer.
removePendingInitialContentDetentObserver()
Expand All @@ -272,11 +304,17 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
val h = bottom - top
if (w <= 0 || h <= 0) return

resolveHostLayout(w, h)
}

private fun resolveHostLayout(w: Int, h: Int) {
// The cap depends on this view's window position (top-inset overlap),
// which can change without a resize.
recomputeNativeGeometry()
refreshContentHeightMarker()
refreshDetentsFromLayout()
detentResolutionReady = true
notifyRequestCloseTargetChanged()
layoutSheetContainer(w, h)

if (!hasLaidOut && detentSpecs.isNotEmpty()) {
Expand Down Expand Up @@ -353,17 +391,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 All @@ -373,6 +413,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
if (!hasLaidOut) {
pendingIndex = newIndex
targetIndex = newIndex
notifyRequestCloseTargetChanged()
return
}

Expand All @@ -382,6 +423,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
// reflected, then either complete it now (e.g. a points detent that is
// already resolvable) or keep waiting for the content to measure.
targetIndex = newIndex.coerceIn(0, detentSpecs.size - 1)
notifyRequestCloseTargetChanged()
if (!trySnapPendingInitialContentDetent()) {
observePendingInitialContentDetent()
}
Expand Down Expand Up @@ -428,6 +470,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 +487,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 All @@ -455,12 +505,14 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
activeDragDetentSpecs = null
}
if (hasLaidOut && isInvalidContentDetentTarget(targetIndex)) {
notifyRequestCloseTargetChanged()
updateScrim()
return
}

val resolvedDetents = resolveDetentSpecs()
if (resolvedDetents == detentSpecs && resolvedMaxDetentHeight() == lastAppliedMaxDetentHeight) {
notifyRequestCloseTargetChanged()
if (trySnapPendingInitialContentDetent()) {
return
}
Expand All @@ -487,6 +539,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr

if (hasLaidOut && !isPanning) {
targetIndex = targetIndex.coerceIn(0, detentSpecs.size - 1)
notifyRequestCloseTargetChanged()
val newMaxHeight = resolvedMaxDetentHeight()
val targetTy = translationY(targetIndex)
if (trySnapPendingInitialContentDetent()) {
Expand Down Expand Up @@ -548,6 +601,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
}

requestLayout()
notifyRequestCloseTargetChanged()
updateScrim()
}

Expand Down Expand Up @@ -591,23 +645,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 Expand Up @@ -683,6 +736,29 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
private val isTargetingClosedDetent: Boolean
get() = closedIndex?.let { targetIndex == it } == true

// Close-request eligibility follows this resolved target—not the transient
// animated position—so targeting zero disables input immediately while a
// closing animation is still visible. Keep this as a host-owned computed
// value instead of mirroring the height in BottomSheetView: the latter can
// miss the closed -> open retarget during a portal update.
val isRequestCloseTargetOpen: Boolean
get() {
if (
!detentResolutionReady ||
!isAttachedToWindow ||
width <= 0 ||
height <= 0 ||
isInvalidContentDetentTarget(targetIndex)
) {
return false
}
return detentSpecs.getOrNull(targetIndex)?.height?.let { it > 0f } == true
}

private fun notifyRequestCloseTargetChanged() {
requestCloseTargetChangedListener?.invoke()
}

private fun snapCandidateIndices(includeIndex: Int? = null): List<Int> {
val indices = detentSpecs.indices.filter { !detentSpecs[it].programmatic }.toMutableList()
if (
Expand Down Expand Up @@ -867,6 +943,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
) {
if (index < 0 || index >= detentSpecs.size) return
targetIndex = index
notifyRequestCloseTargetChanged()
if (!isTargetingClosedDetent) {
suppressScrimForClosingTarget = false
}
Expand Down Expand Up @@ -1551,6 +1628,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
velocityTracker = null
removeCallbacks(sheetChildrenLayoutPass)
sheetChildrenLayoutEnqueued = false
removeCallbacks(resolveDetentsAfterAttach)
nativeCapPx = Float.NaN
lastAppliedMaxDetentHeight = Float.NaN
lastGeometryStateWidth = 0
Expand All @@ -1563,6 +1641,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
rawDetentSpecs = emptyList()
detentSpecs = emptyList()
targetIndex = 0
detentResolutionReady = false
notifyRequestCloseTargetChanged()
pendingIndex = null
hasLaidOut = false
isPanning = false
Expand All @@ -1580,6 +1660,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context), NestedScr
sheetContainer.removeAllViews()
stateWrapper = null
lastShadowOffsetY = Float.NaN
requestCloseTargetChangedListener = null
listener = null
}

private fun updateScrim(position: Float = currentSheetHeight()) {
Expand Down
Loading