diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt index 6e3630d570..a74caa0dd9 100644 --- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt @@ -25,8 +25,8 @@ class BottomSheetModule : Module() { view.dismiss() } - AsyncFunction("updateLayout") { view: BottomSheetView -> - view.updateLayout() + Prop("fullHeight") { view: BottomSheetView, prop: Boolean -> + view.fullHeight = prop } Prop("disableDrag") { view: BottomSheetView, prop: Boolean -> diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt index 300c338f9f..86a9317874 100644 --- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -8,10 +8,7 @@ import android.view.ViewStructure import android.view.Window import android.view.accessibility.AccessibilityEvent 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 @@ -34,11 +31,20 @@ class BottomSheetView( private lateinit var dialogRootViewGroup: DialogRootViewGroup private var eventDispatcher: EventDispatcher? = null - private var isKeyboardVisible: Boolean = false - private val screenHeight = - context.resources.displayMetrics.heightPixels - .toFloat() + // Native content height observation (eliminates JS bridge round-trip) + private var contentLayoutListener: View.OnLayoutChangeListener? = null + private var observedChildren: List = emptyList() + private var lastObservedContentHeight: Float = 0f + private var pendingLayoutUpdate: Boolean = false + + private val screenHeight: Float = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { + context.resources.displayMetrics.heightPixels.toFloat() + } else { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager + wm.currentWindowMetrics.bounds.height().toFloat() + } private fun getNavigationBarHeight(): Int { val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") @@ -64,8 +70,15 @@ class BottomSheetView( set(value) { field = value this.dialog?.setCancelable(!value) + // Full-height sheets have no half-expanded snap point, so any drag + // would dismiss. Disable dragging when dismiss is prevented. + if (fullHeight) { + this.setDraggable(!value && !disableDrag) + } } + var fullHeight = false + var preventExpansion = false var minHeight = 0f @@ -129,6 +142,7 @@ class BottomSheetView( } private fun destroy() { + this.stopObservingContentHeight() this.isClosing = false this.isOpen = false this.dialog = null @@ -193,31 +207,40 @@ class BottomSheetView( val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) bottomSheet?.let { it.setBackgroundColor(0) + it.elevation = 0f val behavior = BottomSheetBehavior.from(it) behavior.state = BottomSheetBehavior.STATE_HIDDEN - behavior.isFitToContents = true - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) behavior.skipCollapsed = true behavior.isDraggable = true behavior.isHideable = true - - if (preventExpansion) { - behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() - } else { - behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt() - } - - val targetHeight = this.getTargetHeight() - val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() - val shouldBeExpanded = targetHeight >= availableHeight - - if (shouldBeExpanded) { + if (fullHeight) { + behavior.isFitToContents = false + behavior.expandedOffset = getStatusBarHeight() behavior.state = BottomSheetBehavior.STATE_EXPANDED this.selectedSnapPoint = 2 - } else { + } else if (preventExpansion) { + behavior.isFitToContents = true + behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) + behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED this.selectedSnapPoint = 1 + } else { + behavior.isFitToContents = false + behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) + behavior.expandedOffset = getStatusBarHeight() + + val targetHeight = this.getTargetHeight() + val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() + val shouldBeExpanded = targetHeight >= availableHeight + + if (shouldBeExpanded) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + this.selectedSnapPoint = 2 + } else { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + this.selectedSnapPoint = 1 + } } behavior.addBottomSheetCallback( @@ -226,12 +249,23 @@ class BottomSheetView( bottomSheet: View, newState: Int, ) { + if (newState == BottomSheetBehavior.STATE_EXPANDED && preventExpansion) { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + return + } when (newState) { BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2 BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1 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( @@ -245,25 +279,14 @@ class BottomSheetView( this.isOpening = true dialog.show() this.dialog = dialog - - ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets -> - val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) - val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) - val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) } - - val wasKeyboardVisible = isKeyboardVisible - isKeyboardVisible = imeVisible - - if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - } else if (!imeVisible && wasKeyboardVisible) { - updateLayout() - } - insets + if (!fullHeight) { + this.startObservingContentHeight() } + } fun updateLayout() { + if (fullHeight) return val dialog = this.dialog ?: return val contentHeight = this.getContentHeight() @@ -274,21 +297,34 @@ class BottomSheetView( val oldRatio = behavior.halfExpandedRatio val newRatio = getHalfExpandedRatio(contentHeight) - behavior.halfExpandedRatio = newRatio - - if (preventExpansion) { - behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() - } val targetHeight = this.getTargetHeight() val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() val shouldBeExpanded = targetHeight >= availableHeight - if (isKeyboardVisible) { - if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) { - behavior.state = BottomSheetBehavior.STATE_EXPANDED + // Don't update during user gestures — defer until the gesture completes. + if (currentState == BottomSheetBehavior.STATE_DRAGGING) { + pendingLayoutUpdate = true + return + } + + behavior.halfExpandedRatio = newRatio + + if (preventExpansion) { + behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() + it.requestLayout() + } + + // During settling (programmatic animation from our own state change), + // redirect the animation to the new position if the ratio changed. + if (currentState == BottomSheetBehavior.STATE_SETTLING) { + if (oldRatio != newRatio) { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED } - } else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) { + return + } + + if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) { behavior.state = BottomSheetBehavior.STATE_EXPANDED } else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED @@ -299,21 +335,77 @@ class BottomSheetView( } fun dismiss() { - this.dialog?.dismiss() + val dialog = this.dialog ?: return + // Mark as closing so the content observer doesn't fight the dismiss + // animation by calling updateLayout() mid-hide. + this.isClosing = true + // Temporarily make cancelable so cancel() works — cancel() gives the + // slide-out animation, while dismiss() does a plain fade. + dialog.setCancelable(true) + dialog.cancel() + } + + // 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 listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom -> + val newHeight = bottom - top + val oldHeight = oldBottom - oldTop + if (newHeight != oldHeight) { + val contentHeight = getContentHeight() + if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) { + lastObservedContentHeight = contentHeight + updateLayout() + } + } + } + + val children = mutableListOf() + for (i in 0 until innerViewGroup.childCount) { + val child = innerViewGroup.getChildAt(i) + child.addOnLayoutChangeListener(listener) + children.add(child) + } + + 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 -> + observedChildren.forEach { it.removeOnLayoutChangeListener(listener) } + } + contentLayoutListener = 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 { diff --git a/modules/bottom-sheet/android/src/main/res/values/styles.xml b/modules/bottom-sheet/android/src/main/res/values/styles.xml index b2a4945da7..6bcdaa55bf 100644 --- a/modules/bottom-sheet/android/src/main/res/values/styles.xml +++ b/modules/bottom-sheet/android/src/main/res/values/styles.xml @@ -1,10 +1,12 @@ - diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift index 2269fbd910..e5f8f2232c 100644 --- a/modules/bottom-sheet/ios/BottomSheetModule.swift +++ b/modules/bottom-sheet/ios/BottomSheetModule.swift @@ -19,8 +19,8 @@ public class BottomSheetModule: Module { view.dismiss() } - AsyncFunction("updateLayout") { (view: SheetView) in - view.updateLayout() + Prop("fullHeight") { (view: SheetView, prop: Bool) in + view.fullHeight = prop } Prop("cornerRadius") { (view: SheetView, prop: Float) in diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift index 360c7a9101..346fe3f413 100644 --- a/modules/bottom-sheet/ios/SheetView.swift +++ b/modules/bottom-sheet/ios/SheetView.swift @@ -8,6 +8,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { private var innerView: UIView? private var touchHandler: RCTTouchHandler? + // Native content height observation (eliminates JS bridge round-trip) + private var contentHeightObservation: NSKeyValueObservation? + // Events private let onAttemptDismiss = EventDispatcher() private let onSnapPointChange = EventDispatcher() @@ -23,6 +26,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { } // React view props + var fullHeight = false var preventDismiss = false var preventExpansion = false var cornerRadius: CGFloat? @@ -68,7 +72,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { } } } - private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier? // MARK: - Lifecycle @@ -106,6 +109,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { } private func destroy() { + self.contentHeightObservation?.invalidate() + self.contentHeightObservation = nil self.isClosing = false self.isOpen = false self.sheetVc = nil @@ -128,7 +133,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { } let sheetVc = SheetViewController() - sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion) + sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion, fullHeight: self.fullHeight) if let sheet = sheetVc.sheetPresentationController { sheet.delegate = self sheet.preferredCornerRadius = self.cornerRadius @@ -147,6 +152,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { self.sheetVc = sheetVc self.isOpening = true + if !self.fullHeight { + self.startObservingContentHeight() + } rvc.present(sheetVc, animated: true) { [weak self] in self?.isOpening = false @@ -154,15 +162,30 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { } } - func updateLayout() { - // Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update) - if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil, - let contentHeight = self.innerView?.subviews.first?.frame.size.height { - self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight), - preventExpansion: self.preventExpansion) + // Observe the content view's bounds via KVO so that height changes are detected + // purely on the native side, without a JS bridge round-trip through onLayout. + // Calls updateDetents directly with the observed height rather than going through + // updateLayout(), which has a prevLayoutDetentIdentifier guard that can block + // legitimate content-driven updates when detent identifiers drift during animations. + private func startObservingContentHeight() { + self.contentHeightObservation?.invalidate() + + guard let contentView = self.innerView?.subviews.first else { return } + + self.contentHeightObservation = contentView.observe( + \.bounds, + options: [.old, .new] + ) { [weak self] _, change in + guard let self = self, + (self.isOpen || self.isOpening) && !self.isClosing, + let oldBounds = change.oldValue, + let newBounds = change.newValue, + oldBounds.height != newBounds.height, + newBounds.height > 0 else { return } + let clampedHeight = self.clampHeight(newBounds.height) + self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion) self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier() } - self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier } func dismiss() { diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift index eaf0a7123a..d8820c1288 100644 --- a/modules/bottom-sheet/ios/SheetViewController.swift +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -20,13 +20,19 @@ class SheetViewController: UIViewController { } } - func setDetents(contentHeight: CGFloat, preventExpansion: Bool) { + func setDetents(contentHeight: CGFloat, preventExpansion: Bool, fullHeight: Bool = false) { guard let sheet = self.sheetPresentationController, let screenHeight = Util.getScreenHeight() else { return } + if fullHeight { + sheet.detents = [.large()] + sheet.selectedDetentIdentifier = .large + return + } + // On iOS 26, the floaty sheet presentation adds the device bottom safe area // on top of the custom detent value, creating visible padding inside the pill. // Subtract it so the pill height matches our actual content. diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts index ab43404536..f865af98f6 100644 --- a/modules/bottom-sheet/src/BottomSheet.types.ts +++ b/modules/bottom-sheet/src/BottomSheet.types.ts @@ -26,6 +26,7 @@ export interface BottomSheetViewProps { disableDrag?: boolean sourceViewTag?: number + fullHeight?: boolean minHeight?: number maxHeight?: number diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index 0fa4c8aa23..2604e0c0b8 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -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, @@ -35,6 +34,10 @@ const IS_IOS15 = Platform.OS === 'ios' && // semvar - can be 3 segments, so can't use Number(Platform.Version) Number(Platform.Version.split('.').at(0)) < 16 +// older android versions (15 and below) aren't naturally edge-to-edge +// and behave a little differently +const IS_NON_E2E_ANDROID = + Platform.OS === 'android' && Number(Platform.Version) < 35 export class BottomSheetNativeComponent extends React.Component< BottomSheetViewProps, @@ -71,10 +74,6 @@ export class BottomSheetNativeComponent extends React.Component< this.props.onStateChange?.(event) } - private updateLayout = () => { - this.ref.current?.updateLayout() - } - static dismissAll = async () => { await NativeModule.dismissAll() } @@ -113,23 +112,14 @@ export class BottomSheetNativeComponent extends React.Component< nativeViewRef={this.ref} onStateChange={this.onStateChange} extraStyles={extraStyles} - onLayout={e => { - if (IS_IOS15) { - const {height} = e.nativeEvent.layout - this.setState({viewHeight: height}) - } - if (Platform.OS === 'android') { - // TEMP HACKFIX: I had to timebox this, but this is Bad. - // On Android, if you run updateLayout() immediately, - // it will take ages to actually run on the native side. - // However, adding literally any delay will fix this, including - // a console.log() - just sending the log to the CLI is enough. - // TODO: Get to the bottom of this and fix it properly! -sfn - setTimeout(() => this.updateLayout()) - } else { - this.updateLayout() - } - }} + onLayout={ + IS_IOS15 + ? e => { + const {height} = e.nativeEvent.layout + this.setState({viewHeight: height}) + } + : undefined + } /> ) @@ -150,13 +140,18 @@ function BottomSheetNativeComponentInner({ event: NativeSyntheticEvent<{state: BottomSheetState}>, ) => void nativeViewRef: React.RefObject - onLayout: (event: LayoutChangeEvent) => void + onLayout?: (event: LayoutChangeEvent) => void }) { const insets = useSafeAreaInsets() const cornerRadius = rest.cornerRadius ?? 0 const {height: screenHeight} = useWindowDimensions() - const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight + // sigh... on older Android versions, screenHeight does not include safe area insets + // on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset + // for the sheet content + const sheetHeight = IS_NON_E2E_ANDROID + ? screenHeight + insets.bottom + : screenHeight - insets.top return ( ) { const themeName = useThemeName() const t = useTheme(themeName) - const ref = React.useRef(null) - const closeCallbacks = React.useRef<(() => void)[]>([]) + const ref = useRef(null) + const closeCallbacks = useRef<(() => void)[]>([]) const {setDialogIsOpen, setFullyExpandedCount} = useDialogStateControlContext() - const prevSnapPoint = React.useRef( + const prevSnapPoint = useRef( BottomSheetSnapPoint.Hidden, ) - const [disableDrag, setDisableDrag] = React.useState(false) - const [snapPoint, setSnapPoint] = React.useState( + const [disableDrag, setDisableDrag] = useState(false) + const [snapPoint, setSnapPoint] = useState( BottomSheetSnapPoint.Partial, ) - const callQueuedCallbacks = React.useCallback(() => { + const callQueuedCallbacks = useCallback(() => { for (const cb of closeCallbacks.current) { try { cb() @@ -84,7 +94,7 @@ export function Outer({ closeCallbacks.current = [] }, []) - const open = React.useCallback(() => { + const open = useCallback(() => { // Run any leftover callbacks that might have been queued up before calling `.open()` callQueuedCallbacks() setDialogIsOpen(control.id, true) @@ -92,7 +102,7 @@ export function Outer({ }, [setDialogIsOpen, control.id, callQueuedCallbacks]) // This is the function that we call when we want to dismiss the dialog. - const close = React.useCallback(cb => { + const close = useCallback(cb => { if (typeof cb === 'function') { closeCallbacks.current.push(cb) } @@ -101,7 +111,7 @@ export function Outer({ // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to // happen before we run this. It is passed to the `BottomSheet` component. - const onCloseAnimationComplete = React.useCallback(() => { + const onCloseAnimationComplete = useCallback(() => { // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this // tells us that we need to toggle the accessibility overlay setting setDialogIsOpen(control.id, false) @@ -147,7 +157,7 @@ export function Outer({ [open, close], ) - const context = React.useMemo( + const context = useMemo( () => ({ close, isNativeDialog: true, @@ -201,25 +211,23 @@ export function Inner({children, style, header}: DialogInnerProps) { ) } -export const ScrollableInner = React.forwardRef( +export const ScrollableInner = forwardRef( function ScrollableInner( {children, contentContainerStyle, header, ...props}, ref, ) { const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() - const insets = useSafeAreaInsets() const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full + const insets = useSafeAreaInsets() + const [keyboardHeight, setKeyboardHeight] = useState(() => + IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0, + ) - 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 keyboardEventHandler = useCallback(e => { + setKeyboardHeight(e.endCoordinates.height) + }, []) + useOnKeyboard('keyboardDidShow', keyboardEventHandler) + useOnKeyboard('keyboardDidHide', keyboardEventHandler) const onScroll = (e: NativeSyntheticEvent) => { if (!IS_ANDROID) { @@ -238,7 +246,12 @@ export const ScrollableInner = React.forwardRef( contentContainerStyle={[ a.pt_2xl, IS_LIQUID_GLASS ? a.px_2xl : a.px_xl, - {paddingBottom}, + platform({ + ios: a.pb_2xl, + android: { + paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl, + }, + }), contentContainerStyle, ]} ref={ref} @@ -250,7 +263,12 @@ export const ScrollableInner = React.forwardRef( {...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 @@ -263,7 +281,7 @@ export const ScrollableInner = React.forwardRef( }, ) -export const InnerFlatList = React.forwardRef< +export const InnerFlatList = forwardRef< ListMethods, ListProps & { webInnerStyle?: StyleProp @@ -293,7 +311,10 @@ export const InnerFlatList = React.forwardRef< } return ( - + void }) { const t = useTheme() - const {top, bottom} = useSafeAreaInsets() + const {bottom} = useSafeAreaInsets() const {height} = useReanimatedKeyboardAnimation() const animatedStyle = useAnimatedStyle(() => { @@ -350,12 +371,7 @@ export function FlatListFooter({ t.atoms.border_contrast_low, a.px_lg, a.pt_md, - { - paddingBottom: platform({ - ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0), - android: tokens.space.md + bottom + top, - }), - }, + {paddingBottom: bottom + tokens.space.md}, // TODO: had to admit defeat here, but we should // try and get this to work for Android as well -sfn ios(animatedStyle), diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 62c3aa4ef8..643a1d018f 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -1,10 +1,5 @@ import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' -import { - TextInput, - useWindowDimensions, - View, - type ViewToken, -} from 'react-native' +import {TextInput, View, type ViewToken} from 'react-native' import {type ModerationOpts} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -72,7 +67,6 @@ export function FollowDialog({ const {_} = useLingui() const control = Dialog.useDialogControl() const {gtPhone} = useBreakpoints() - const {height: minHeight} = useWindowDimensions() return ( <> @@ -89,7 +83,7 @@ export function FollowDialog({ {showArrow && } - + @@ -105,9 +99,8 @@ export function FollowDialogWithoutGuide({ }: { control: Dialog.DialogOuterProps['control'] }) { - const {height: minHeight} = useWindowDimensions() return ( - + diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index 0438e10fcb..a7dd70d8e7 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -151,7 +151,7 @@ export function Content({ }, [items, context.value, valueExtractor, setValue]) return ( - + + diff --git a/src/components/dialogs/LanguageSelectDialog.tsx b/src/components/dialogs/LanguageSelectDialog.tsx index ca13c33b7b..3460a92044 100644 --- a/src/components/dialogs/LanguageSelectDialog.tsx +++ b/src/components/dialogs/LanguageSelectDialog.tsx @@ -1,6 +1,5 @@ import {useCallback, useMemo, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -17,7 +16,7 @@ import {SearchInput} from '#/components/forms/SearchInput' import * as Toggle from '#/components/forms/Toggle' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Text} from '#/components/Typography' -import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' +import {IS_NATIVE, IS_WEB} from '#/env' type FlatListItem = | { @@ -51,20 +50,13 @@ export function LanguageSelectDialog({ onSelectLanguages: (languages: string[]) => void maxLanguages?: number }) { - const {height} = useWindowDimensions() - const insets = useSafeAreaInsets() - const renderErrorBoundary = useCallback( (error: any) => , [], ) return ( - + void }) { const ax = useAnalytics() - const {height} = useWindowDimensions() const formRef = useRef(null) // persist these options between dialog open/close @@ -53,10 +52,7 @@ export function ServerInputDialog({ + nativeOptions={{preventExpansion: true}}> + { @@ -82,7 +81,7 @@ export function CreateOrEditListDialog({ control={control} nativeOptions={{ preventDismiss: dirty, - minHeight: height, + fullHeight: true, }} testID="createOrEditListDialog"> void | undefined }) { return ( - + diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index fe033cbf1a..9c349d0a0d 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -70,7 +70,10 @@ export function NewChat({ accessibilityHint="" /> - + void }) { return ( - + diff --git a/src/components/hooks/useOnKeyboard.ts b/src/components/hooks/useOnKeyboard.ts index 5de681a42a..7d5a39d9bd 100644 --- a/src/components/hooks/useOnKeyboard.ts +++ b/src/components/hooks/useOnKeyboard.ts @@ -1,12 +1,19 @@ -import React from 'react' -import {Keyboard} from 'react-native' +import {useEffect} from 'react' +import { + Keyboard, + type KeyboardEventListener, + type KeyboardEventName, +} from 'react-native' -export function useOnKeyboardDidShow(cb: () => unknown) { - React.useEffect(() => { - const subscription = Keyboard.addListener('keyboardDidShow', cb) +export function useOnKeyboard( + eventName: KeyboardEventName, + cb: KeyboardEventListener, +) { + useEffect(() => { + const subscription = Keyboard.addListener(eventName, cb) return () => { subscription.remove() } - }, [cb]) + }, [eventName, cb]) } diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx index feae9ec3a3..2d0d0d34d3 100644 --- a/src/screens/Profile/Header/EditProfileDialog.tsx +++ b/src/screens/Profile/Header/EditProfileDialog.tsx @@ -1,5 +1,5 @@ import {useCallback, useEffect, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' +import {View} from 'react-native' import {type AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -41,7 +41,6 @@ export function EditProfileDialog({ const {_} = useLingui() const cancelControl = Dialog.useDialogControl() const [dirty, setDirty] = useState(false) - const {height} = useWindowDimensions() const onPressCancel = useCallback(() => { if (dirty) { @@ -56,7 +55,7 @@ export function EditProfileDialog({ control={control} nativeOptions={{ preventDismiss: dirty, - minHeight: height, + fullHeight: true, }} webOptions={{ onBackgroundPress: () => { diff --git a/src/screens/Settings/components/AddAppPasswordDialog.tsx b/src/screens/Settings/components/AddAppPasswordDialog.tsx index 4d379797e0..83a259396b 100644 --- a/src/screens/Settings/components/AddAppPasswordDialog.tsx +++ b/src/screens/Settings/components/AddAppPasswordDialog.tsx @@ -1,5 +1,5 @@ import {useEffect, useMemo, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' +import {View} from 'react-native' import Animated, { FadeIn, FadeOut, @@ -34,9 +34,8 @@ export function AddAppPasswordDialog({ control: Dialog.DialogControlProps passwords: string[] }) { - const {height} = useWindowDimensions() return ( - + diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 3e9c77199b..1b31a7de2a 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -1,5 +1,5 @@ import {useCallback, useMemo, useState} from 'react' -import {useWindowDimensions, View} from 'react-native' +import {View} from 'react-native' import Animated, { FadeIn, FadeOut, @@ -53,10 +53,8 @@ export function ChangeHandleDialog({ }: { control: Dialog.DialogControlProps }) { - const {height} = useWindowDimensions() - return ( - + ) diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index 0359b11582..0d335cdcd6 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -1,5 +1,5 @@ import {useState} from 'react' -import {TouchableOpacity, useWindowDimensions, View} from 'react-native' +import {TouchableOpacity, View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Plural, Trans} from '@lingui/react/macro' @@ -23,7 +23,6 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' import {Text} from '#/components/Typography' -import {IS_ANDROID} from '#/env' import {AltTextReminder} from './photos/Gallery' export function GifAltTextDialog({ @@ -69,7 +68,6 @@ export function GifAltTextDialogLoaded({ const {_} = useLingui() const t = useTheme() const [altTextDraft, setAltTextDraft] = useState(altText || vendorAltText) - const {height: minHeight} = useWindowDimensions() return ( <> { onSubmit(altTextDraft) }} - nativeOptions={{minHeight}}> + nativeOptions={{fullHeight: true}}> - {/* Maybe fix this later -h */} - {IS_ANDROID ? : null} ) } diff --git a/src/view/com/composer/drafts/DraftsListDialog.tsx b/src/view/com/composer/drafts/DraftsListDialog.tsx index 85ac4fd2c7..30525f5cbc 100644 --- a/src/view/com/composer/drafts/DraftsListDialog.tsx +++ b/src/view/com/composer/drafts/DraftsListDialog.tsx @@ -167,7 +167,7 @@ export function DraftsListDialog({ ) return ( - + {/* We really really need to figure out a nice, consistent API for doing a header cross-platform -sfn */} {IS_NATIVE && header} { - const {height: minHeight} = useWindowDimensions() const [altText, setAltText] = useState(image.alt) return ( @@ -44,7 +42,7 @@ export const ImageAltTextDialog = ({ alt: enforceLen(altText, MAX_ALT_TEXT, true), }) }} - nativeOptions={{minHeight, sourceViewTag}}> + nativeOptions={{fullHeight: true, sourceViewTag}}> (() => { const maxWidth = IS_WEB ? 450 @@ -179,8 +175,6 @@ const ImageAltTextInner = ({ - {/* Maybe fix this later -h */} - {IS_ANDROID && isKeyboardVisible ? : null} ) }