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