Fix iOS sheet keyboard handling using native scrollview features (#9959)

This commit is contained in:
Samuel Newman
2026-02-27 21:59:24 +00:00
committed by GitHub
parent 576761a645
commit d4357b2cb8
7 changed files with 91 additions and 61 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
export const Context = createContext<DialogContextProps>({
close: () => {},
IS_NATIVEDialog: false,
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
+45 -39
View File
@@ -1,20 +1,16 @@
import React, {useImperativeHandle} from 'react'
import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
type ScrollView,
ScrollView,
type StyleProp,
TextInput,
View,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewRef,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
import Animated, {
runOnJS,
type ScrollEvent,
@@ -29,7 +25,7 @@ import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
import {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
@@ -154,7 +150,7 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
IS_NATIVEDialog: true,
isNativeDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
@@ -212,33 +208,17 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
useKeyboardHandler(
{
onEnd: e => {
'worklet'
runOnJS(setKeyboardHeight)(e.height)
},
},
[],
)
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
let paddingBottom = 0
if (IS_IOS) {
paddingBottom += keyboardHeight / 4
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.bottom + tokens.space.md
}
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
paddingBottom = tokens.space._2xl
} else {
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom =
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
if (isAtMaxSnapPoint) {
paddingBottom += insets.top
}
paddingBottom +=
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
}
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -254,18 +234,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
}
return (
<KeyboardAwareScrollView
<ScrollView
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
{paddingBottom},
contentContainerStyle,
]}
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
onScroll={IS_ANDROID ? onScroll : undefined}
keyboardShouldPersistTaps="handled"
@@ -275,7 +258,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
stickyHeaderIndices={ios(header ? [0] : undefined)}>
{header}
{children}
</KeyboardAwareScrollView>
</ScrollView>
)
},
)
@@ -287,10 +270,15 @@ export const InnerFlatList = React.forwardRef<
webInnerContentContainerStyle?: StyleProp<ViewStyle>
footer?: React.ReactNode
}
>(function InnerFlatList({footer, style, ...props}, ref) {
>(function InnerFlatList(
{headerOffset, footer, style, contentContainerStyle, ...props},
ref,
) {
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!IS_ANDROID) {
@@ -308,19 +296,36 @@ export const InnerFlatList = React.forwardRef<
<ScrollProvider onScroll={onScroll}>
<List
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
scrollIndicatorInsets={{top: headerOffset}}
bounces={isAtMaxSnapPoint}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
style={[a.h_full, style]}
contentContainerStyle={[
{paddingTop: headerOffset},
android({
paddingBottom: insets.top + insets.bottom + tokens.space.xl,
}),
contentContainerStyle,
]}
/>
{footer}
</ScrollProvider>
)
})
export function FlatListFooter({children}: {children: React.ReactNode}) {
export function FlatListFooter({
children,
onLayout,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
}) {
const t = useTheme()
const {top, bottom} = useSafeAreaInsets()
const {height} = useReanimatedKeyboardAnimation()
@@ -334,6 +339,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
return (
<Animated.View
onLayout={onLayout}
style={[
a.absolute,
a.bottom_0,
+10 -2
View File
@@ -3,6 +3,7 @@ import {
FlatList,
type FlatListProps,
type GestureResponderEvent,
type LayoutChangeEvent,
Pressable,
type StyleProp,
View,
@@ -98,7 +99,7 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
IS_NATIVEDialog: false,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
@@ -253,11 +254,18 @@ export const InnerFlatList = React.forwardRef<
)
})
export function FlatListFooter({children}: {children: React.ReactNode}) {
export function FlatListFooter({
children,
onLayout,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
}) {
const t = useTheme()
return (
<View
onLayout={onLayout}
style={[
a.absolute,
a.bottom_0,
+1 -1
View File
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
IS_NATIVEDialog: boolean
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
@@ -10,7 +10,7 @@ import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useTheme, web} from '#/alf'
import {atoms as a, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {SearchInput} from '#/components/forms/SearchInput'
@@ -84,6 +84,7 @@ export function DialogInner({
}) {
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0)
const allowedLanguages = useMemo(() => {
const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce(
@@ -249,6 +250,8 @@ export function DialogInner({
...displayedLanguages.all.map(lang => ({type: 'item', lang})),
]
const numItems = flatListData.length
return (
<Toggle.Group
values={checkedLanguagesCode2}
@@ -261,9 +264,12 @@ export function DialogInner({
data={flatListData}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
contentContainerStyle={[a.gap_0]}
style={[IS_NATIVE && a.px_lg, web({paddingBottom: 120})]}
scrollIndicatorInsets={{top: headerHeight}}
contentContainerStyle={[
a.gap_0,
IS_NATIVE && {paddingBottom: footerHeight + tokens.space.xl},
]}
style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]}
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
renderItem={({item, index}) => {
if (item.type === 'header') {
return (
@@ -283,6 +289,8 @@ export function DialogInner({
}
const lang = item.lang
const isLastItem = index === numItems - 1
return (
<Toggle.Item
key={lang.code2}
@@ -290,7 +298,7 @@ export function DialogInner({
label={languageName(lang, langPrefs.appLanguage)}
style={[
t.atoms.border_contrast_low,
a.border_b,
!isLastItem && a.border_b,
a.rounded_0,
a.px_0,
a.py_md,
@@ -303,7 +311,8 @@ export function DialogInner({
)
}}
footer={
<Dialog.FlatListFooter>
<Dialog.FlatListFooter
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
<Button
label={_(msg`Close dialog`)}
onPress={handleClose}
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo, useState} from 'react'
import {type ImageStyle, useWindowDimensions, View} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro'
@@ -10,14 +10,14 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {enforceLen} from '#/lib/strings/helpers'
import {type ComposerImage} from '#/state/gallery'
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Text} from '#/components/Typography'
import {IS_ANDROID, IS_WEB} from '#/env'
import {IS_ANDROID, IS_LIQUID_GLASS, IS_WEB} from '#/env'
type Props = {
control: Dialog.DialogOuterProps['control']
@@ -31,7 +31,7 @@ export const ImageAltTextDialog = ({
onChange,
}: Props): React.ReactNode => {
const {height: minHeight} = useWindowDimensions()
const [altText, setAltText] = React.useState(image.alt)
const [altText, setAltText] = useState(image.alt)
return (
<Dialog.Outer
@@ -67,12 +67,15 @@ const ImageAltTextInner = ({
}): React.ReactNode => {
const {_, i18n} = useLingui()
const t = useTheme()
const windim = useWindowDimensions()
const {width: screenWidth} = useWindowDimensions()
const [isKeyboardVisible] = useIsKeyboardVisible()
const imageStyle = React.useMemo<ImageStyle>(() => {
const maxWidth = IS_WEB ? 450 : windim.width
const imageStyle = useMemo<ImageStyle>(() => {
const maxWidth = IS_WEB
? 450
: screenWidth - // account for dialog padding
2 * (IS_LIQUID_GLASS ? tokens.space._2xl : tokens.space.xl)
const source = image.transformed ?? image.source
if (source.height > source.width) {
@@ -88,16 +91,20 @@ const ImageAltTextInner = ({
height: (maxWidth / source.width) * source.height,
borderRadius: 8,
}
}, [image, windim])
}, [image, screenWidth])
return (
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
<Dialog.Close />
<View>
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_tight, a.pb_sm]}>
<Trans>Add alt text</Trans>
</Text>
{/* vertical space is too precious - gets scrolled out of the way anyway */}
{IS_WEB && (
<Text
style={[a.text_2xl, a.font_semi_bold, a.leading_tight, a.pb_sm]}>
<Trans>Add alt text</Trans>
</Text>
)}
<View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
<Image
@@ -60,7 +60,7 @@ export function SubtitleDialogBtn(props: Props) {
)}
</ButtonText>
</Button>
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<SubtitleDialogInner {...props} />
</Dialog.Outer>