Fix iOS sheet keyboard handling using native scrollview features (#9959)
This commit is contained in:
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user