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.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.core.view.allViews
|
|
||||||
import com.facebook.react.bridge.LifecycleEventListener
|
import com.facebook.react.bridge.LifecycleEventListener
|
||||||
import com.facebook.react.bridge.ReactContext
|
import com.facebook.react.bridge.ReactContext
|
||||||
import com.facebook.react.bridge.UiThreadUtil
|
import com.facebook.react.bridge.UiThreadUtil
|
||||||
@@ -38,7 +37,9 @@ class BottomSheetView(
|
|||||||
|
|
||||||
// Native content height observation (eliminates JS bridge round-trip)
|
// Native content height observation (eliminates JS bridge round-trip)
|
||||||
private var contentLayoutListener: View.OnLayoutChangeListener? = null
|
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 =
|
private val screenHeight =
|
||||||
context.resources.displayMetrics.heightPixels
|
context.resources.displayMetrics.heightPixels
|
||||||
@@ -237,6 +238,13 @@ class BottomSheetView(
|
|||||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
||||||
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
|
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(
|
override fun onSlide(
|
||||||
@@ -298,6 +306,15 @@ class BottomSheetView(
|
|||||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||||
val shouldBeExpanded = targetHeight >= availableHeight
|
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 (isKeyboardVisible) {
|
||||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
@@ -316,53 +333,67 @@ class BottomSheetView(
|
|||||||
this.dialog?.dismiss()
|
this.dialog?.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe the content view's layout changes so that height updates are detected
|
// Observe each direct child of innerView via OnLayoutChangeListener so that
|
||||||
// purely on the native side, without a JS bridge round-trip through onLayout.
|
// 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() {
|
private fun startObservingContentHeight() {
|
||||||
stopObservingContentHeight()
|
stopObservingContentHeight()
|
||||||
|
|
||||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
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 newHeight = bottom - top
|
||||||
val oldHeight = oldBottom - oldTop
|
val oldHeight = oldBottom - oldTop
|
||||||
if (newHeight != oldHeight && newHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
if (newHeight != oldHeight) {
|
||||||
updateLayout()
|
val contentHeight = getContentHeight()
|
||||||
|
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||||
|
lastObservedContentHeight = contentHeight
|
||||||
|
updateLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contentView.addOnLayoutChangeListener(listener)
|
val children = mutableListOf<View>()
|
||||||
this.contentLayoutListener = listener
|
for (i in 0 until innerViewGroup.childCount) {
|
||||||
this.observedContentView = contentView
|
val child = innerViewGroup.getChildAt(i)
|
||||||
|
child.addOnLayoutChangeListener(listener)
|
||||||
|
children.add(child)
|
||||||
|
}
|
||||||
|
|
||||||
// The listener only fires on future changes. If content already laid out
|
this.contentLayoutListener = listener
|
||||||
// (e.g. dialog.show() triggered layout synchronously), pick up that height now.
|
this.observedChildren = children
|
||||||
if (contentView.height > 0) {
|
|
||||||
|
// Pick up current height if content is already laid out
|
||||||
|
val contentHeight = getContentHeight()
|
||||||
|
if (contentHeight > 0 && contentHeight != lastObservedContentHeight) {
|
||||||
|
lastObservedContentHeight = contentHeight
|
||||||
updateLayout()
|
updateLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopObservingContentHeight() {
|
private fun stopObservingContentHeight() {
|
||||||
contentLayoutListener?.let { listener ->
|
contentLayoutListener?.let { listener ->
|
||||||
observedContentView?.removeOnLayoutChangeListener(listener)
|
observedChildren.forEach { it.removeOnLayoutChangeListener(listener) }
|
||||||
}
|
}
|
||||||
contentLayoutListener = null
|
contentLayoutListener = null
|
||||||
observedContentView = null
|
observedChildren = emptyList()
|
||||||
|
lastObservedContentHeight = 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Util
|
// Util
|
||||||
|
|
||||||
private fun getContentHeight(): Float {
|
private fun getContentHeight(): Float {
|
||||||
val innerView = this.innerView ?: return 0f
|
val innerView = this.innerView as? ViewGroup ?: return 0f
|
||||||
var index = 0
|
// Use the tallest direct child's height. The handle is absolutely positioned
|
||||||
innerView.allViews.forEach {
|
// (overlaps the content), so summing would double-count its height as padding.
|
||||||
if (index == 1) {
|
var maxChildHeight = 0f
|
||||||
return it.height.toFloat()
|
for (i in 0 until innerView.childCount) {
|
||||||
}
|
val h = innerView.getChildAt(i).height.toFloat()
|
||||||
index++
|
if (h > maxChildHeight) maxChildHeight = h
|
||||||
}
|
}
|
||||||
return 0f
|
return maxChildHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTargetHeight(): Float {
|
private fun getTargetHeight(): Float {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||||
|
|
||||||
import {IS_IOS} from '#/env'
|
|
||||||
import {
|
import {
|
||||||
type BottomSheetState,
|
type BottomSheetState,
|
||||||
type BottomSheetViewProps,
|
type BottomSheetViewProps,
|
||||||
@@ -145,8 +144,6 @@ function BottomSheetNativeComponentInner({
|
|||||||
const cornerRadius = rest.cornerRadius ?? 0
|
const cornerRadius = rest.cornerRadius ?? 0
|
||||||
const {height: screenHeight} = useWindowDimensions()
|
const {height: screenHeight} = useWindowDimensions()
|
||||||
|
|
||||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NativeView
|
<NativeView
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -154,7 +151,7 @@ function BottomSheetNativeComponentInner({
|
|||||||
ref={nativeViewRef}
|
ref={nativeViewRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
height: sheetHeight,
|
height: screenHeight - insets.top,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
containerBackgroundColor={backgroundColor}>
|
containerBackgroundColor={backgroundColor}>
|
||||||
|
|||||||
@@ -207,19 +207,8 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||||
const insets = useSafeAreaInsets()
|
|
||||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
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 onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
if (!IS_ANDROID) {
|
if (!IS_ANDROID) {
|
||||||
@@ -238,8 +227,14 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
a.pt_2xl,
|
a.pt_2xl,
|
||||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||||
{paddingBottom},
|
platform({
|
||||||
|
ios: a.pb_2xl,
|
||||||
|
android: {
|
||||||
|
paddingBottom: insets.bottom + tokens.space.xl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
|
a.debug,
|
||||||
]}
|
]}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||||
@@ -250,14 +245,19 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
bounces={isAtMaxSnapPoint}
|
bounces={isAtMaxSnapPoint}
|
||||||
scrollEventThrottle={50}
|
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"
|
keyboardShouldPersistTaps="handled"
|
||||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
// 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
|
// on Android. fine to disable for now, because we don't have any
|
||||||
// dialogs that use this that actually scroll -sfn
|
// dialogs that use this that actually scroll -sfn
|
||||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||||
{header}
|
{header}
|
||||||
{children}
|
<View style={a.debug}>{children}</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -293,7 +293,10 @@ export const InnerFlatList = React.forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollProvider onScroll={onScroll}>
|
<ScrollProvider
|
||||||
|
onScroll={onScroll}
|
||||||
|
onEndDrag={onScroll}
|
||||||
|
onMomentumEnd={onScroll}>
|
||||||
<List
|
<List
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentInsetAdjustmentBehavior={
|
contentInsetAdjustmentBehavior={
|
||||||
|
|||||||
Reference in New Issue
Block a user