fix android bottom sheet height observation and content clipping
- getContentHeight: use max of children heights instead of DFS index 1 (old approach measured the drag handle, not the content) - observe height via OnLayoutChangeListener on each direct child instead of OnGlobalLayoutListener (RN bypasses requestLayout, so global listener never fires for RN-driven layout changes) - defer state changes during DRAGGING/SETTLING to avoid interrupting dismiss swipes, apply when gesture settles - subtract insets.top from NativeView height on android (matching iOS) to prevent scroll content clipping at bottom of full-height sheets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+55
-24
@@ -11,7 +11,6 @@ import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.allViews
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
@@ -38,7 +37,9 @@ class BottomSheetView(
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentLayoutListener: View.OnLayoutChangeListener? = null
|
||||
private var observedContentView: View? = null
|
||||
private var observedChildren: List<View> = emptyList()
|
||||
private var lastObservedContentHeight: Float = 0f
|
||||
private var pendingLayoutUpdate: Boolean = false
|
||||
|
||||
private val screenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
@@ -237,6 +238,13 @@ class BottomSheetView(
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
|
||||
}
|
||||
// Apply deferred layout update after gesture completes
|
||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate) {
|
||||
pendingLayoutUpdate = false
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(
|
||||
@@ -298,6 +306,15 @@ class BottomSheetView(
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
// Don't force state changes during user gestures — the ratio and maxHeight
|
||||
// are already updated above, so when the gesture settles the sheet will land
|
||||
// at the correct position. Forcing a state change mid-drag interrupts
|
||||
// dismiss swipes and causes visual glitches.
|
||||
if (currentState == BottomSheetBehavior.STATE_DRAGGING || currentState == BottomSheetBehavior.STATE_SETTLING) {
|
||||
pendingLayoutUpdate = true
|
||||
return
|
||||
}
|
||||
|
||||
if (isKeyboardVisible) {
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
@@ -316,53 +333,67 @@ class BottomSheetView(
|
||||
this.dialog?.dismiss()
|
||||
}
|
||||
|
||||
// Observe the content view's layout changes so that height updates are detected
|
||||
// purely on the native side, without a JS bridge round-trip through onLayout.
|
||||
// Observe each direct child of innerView via OnLayoutChangeListener so that
|
||||
// height updates are detected purely on the native side. We use OnLayoutChangeListener
|
||||
// (not OnGlobalLayoutListener) because React Native calls view.layout() directly
|
||||
// via Yoga, bypassing requestLayout()/performTraversals(). OnLayoutChangeListener
|
||||
// fires from setFrame() which IS called by layout(), so it catches RN updates.
|
||||
private fun startObservingContentHeight() {
|
||||
stopObservingContentHeight()
|
||||
|
||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||
val contentView = innerViewGroup.getChildAt(0) ?: return
|
||||
|
||||
val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
|
||||
val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight && newHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
updateLayout()
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentView.addOnLayoutChangeListener(listener)
|
||||
this.contentLayoutListener = listener
|
||||
this.observedContentView = contentView
|
||||
val children = mutableListOf<View>()
|
||||
for (i in 0 until innerViewGroup.childCount) {
|
||||
val child = innerViewGroup.getChildAt(i)
|
||||
child.addOnLayoutChangeListener(listener)
|
||||
children.add(child)
|
||||
}
|
||||
|
||||
// The listener only fires on future changes. If content already laid out
|
||||
// (e.g. dialog.show() triggered layout synchronously), pick up that height now.
|
||||
if (contentView.height > 0) {
|
||||
this.contentLayoutListener = listener
|
||||
this.observedChildren = children
|
||||
|
||||
// Pick up current height if content is already laid out
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight > 0 && contentHeight != lastObservedContentHeight) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopObservingContentHeight() {
|
||||
contentLayoutListener?.let { listener ->
|
||||
observedContentView?.removeOnLayoutChangeListener(listener)
|
||||
observedChildren.forEach { it.removeOnLayoutChangeListener(listener) }
|
||||
}
|
||||
contentLayoutListener = null
|
||||
observedContentView = null
|
||||
observedChildren = emptyList()
|
||||
lastObservedContentHeight = 0f
|
||||
}
|
||||
|
||||
// Util
|
||||
|
||||
private fun getContentHeight(): Float {
|
||||
val innerView = this.innerView ?: return 0f
|
||||
var index = 0
|
||||
innerView.allViews.forEach {
|
||||
if (index == 1) {
|
||||
return it.height.toFloat()
|
||||
}
|
||||
index++
|
||||
val innerView = this.innerView as? ViewGroup ?: return 0f
|
||||
// Use the tallest direct child's height. The handle is absolutely positioned
|
||||
// (overlaps the content), so summing would double-count its height as padding.
|
||||
var maxChildHeight = 0f
|
||||
for (i in 0 until innerView.childCount) {
|
||||
val h = innerView.getChildAt(i).height.toFloat()
|
||||
if (h > maxChildHeight) maxChildHeight = h
|
||||
}
|
||||
return 0f
|
||||
return maxChildHeight
|
||||
}
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {IS_IOS} from '#/env'
|
||||
import {
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
@@ -145,8 +144,6 @@ function BottomSheetNativeComponentInner({
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
|
||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...rest}
|
||||
@@ -154,7 +151,7 @@ function BottomSheetNativeComponentInner({
|
||||
ref={nativeViewRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: sheetHeight,
|
||||
height: screenHeight - insets.top,
|
||||
width: '100%',
|
||||
}}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
|
||||
@@ -207,19 +207,8 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
|
||||
let paddingBottom = 0
|
||||
if (IS_IOS) {
|
||||
paddingBottom = tokens.space._2xl
|
||||
} else {
|
||||
paddingBottom =
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
if (isAtMaxSnapPoint) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
}
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
@@ -238,8 +227,14 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
{paddingBottom},
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
a.debug,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
@@ -250,14 +245,19 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={IS_ANDROID ? onScroll : undefined}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
<View style={a.debug}>{children}</View>
|
||||
</ScrollView>
|
||||
)
|
||||
},
|
||||
@@ -293,7 +293,10 @@ export const InnerFlatList = React.forwardRef<
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<ScrollProvider
|
||||
onScroll={onScroll}
|
||||
onEndDrag={onScroll}
|
||||
onMomentumEnd={onScroll}>
|
||||
<List
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior={
|
||||
|
||||
Reference in New Issue
Block a user