Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

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

authored by samuel.fm and committed by

GitHub d4357b2c 576761a6

+91 -61
+1 -1
src/components/Dialog/context.ts
··· 18 18 19 19 export const Context = createContext<DialogContextProps>({ 20 20 close: () => {}, 21 - IS_NATIVEDialog: false, 21 + isNativeDialog: false, 22 22 nativeSnapPoint: BottomSheetSnapPoint.Hidden, 23 23 disableDrag: false, 24 24 setDisableDrag: () => {},
+45 -39
src/components/Dialog/index.tsx
··· 1 1 import React, {useImperativeHandle} from 'react' 2 2 import { 3 + type LayoutChangeEvent, 3 4 type NativeScrollEvent, 4 5 type NativeSyntheticEvent, 5 6 Pressable, 6 - type ScrollView, 7 + ScrollView, 7 8 type StyleProp, 8 9 TextInput, 9 10 View, 10 11 type ViewStyle, 11 12 } from 'react-native' 12 - import { 13 - KeyboardAwareScrollView, 14 - type KeyboardAwareScrollViewRef, 15 - useKeyboardHandler, 16 - useReanimatedKeyboardAnimation, 17 - } from 'react-native-keyboard-controller' 13 + import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller' 18 14 import Animated, { 19 15 runOnJS, 20 16 type ScrollEvent, ··· 29 25 import {useA11y} from '#/state/a11y' 30 26 import {useDialogStateControlContext} from '#/state/dialogs' 31 27 import {List, type ListMethods, type ListProps} from '#/view/com/util/List' 32 - import {atoms as a, ios, platform, tokens, useTheme} from '#/alf' 28 + import {android, atoms as a, ios, platform, tokens, useTheme} from '#/alf' 33 29 import {useThemeName} from '#/alf/util/useColorModeTheme' 34 30 import {Context, useDialogContext} from '#/components/Dialog/context' 35 31 import { ··· 154 150 const context = React.useMemo( 155 151 () => ({ 156 152 close, 157 - IS_NATIVEDialog: true, 153 + isNativeDialog: true, 158 154 nativeSnapPoint: snapPoint, 159 155 disableDrag, 160 156 setDisableDrag, ··· 212 208 ) { 213 209 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 214 210 const insets = useSafeAreaInsets() 215 - 216 - const [keyboardHeight, setKeyboardHeight] = React.useState(0) 217 - 218 - // note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android 219 - useKeyboardHandler( 220 - { 221 - onEnd: e => { 222 - 'worklet' 223 - runOnJS(setKeyboardHeight)(e.height) 224 - }, 225 - }, 226 - [], 227 - ) 211 + const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 228 212 229 213 let paddingBottom = 0 230 214 if (IS_IOS) { 231 - paddingBottom += keyboardHeight / 4 232 - if (nativeSnapPoint === BottomSheetSnapPoint.Full) { 233 - paddingBottom += insets.bottom + tokens.space.md 234 - } 235 - paddingBottom = Math.max(paddingBottom, tokens.space._2xl) 215 + paddingBottom = tokens.space._2xl 236 216 } else { 237 - if (nativeSnapPoint === BottomSheetSnapPoint.Full) { 217 + paddingBottom = 218 + Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl 219 + if (isAtMaxSnapPoint) { 238 220 paddingBottom += insets.top 239 221 } 240 - paddingBottom += 241 - Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl 242 222 } 243 223 244 224 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => { ··· 254 234 } 255 235 256 236 return ( 257 - <KeyboardAwareScrollView 237 + <ScrollView 258 238 contentContainerStyle={[ 259 239 a.pt_2xl, 260 240 IS_LIQUID_GLASS ? a.px_2xl : a.px_xl, 261 241 {paddingBottom}, 262 242 contentContainerStyle, 263 243 ]} 264 - ref={ref as React.Ref<KeyboardAwareScrollViewRef>} 244 + ref={ref} 265 245 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined} 246 + contentInsetAdjustmentBehavior={ 247 + isAtMaxSnapPoint ? 'automatic' : 'never' 248 + } 249 + automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint} 266 250 {...props} 267 - bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 268 - bottomOffset={30} 251 + bounces={isAtMaxSnapPoint} 269 252 scrollEventThrottle={50} 270 253 onScroll={IS_ANDROID ? onScroll : undefined} 271 254 keyboardShouldPersistTaps="handled" ··· 275 258 stickyHeaderIndices={ios(header ? [0] : undefined)}> 276 259 {header} 277 260 {children} 278 - </KeyboardAwareScrollView> 261 + </ScrollView> 279 262 ) 280 263 }, 281 264 ) ··· 287 270 webInnerContentContainerStyle?: StyleProp<ViewStyle> 288 271 footer?: React.ReactNode 289 272 } 290 - >(function InnerFlatList({footer, style, ...props}, ref) { 273 + >(function InnerFlatList( 274 + {headerOffset, footer, style, contentContainerStyle, ...props}, 275 + ref, 276 + ) { 291 277 const insets = useSafeAreaInsets() 292 278 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 279 + 280 + const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 293 281 294 282 const onScroll = (e: ScrollEvent) => { 295 283 'worklet' ··· 308 296 <ScrollProvider onScroll={onScroll}> 309 297 <List 310 298 keyboardShouldPersistTaps="handled" 311 - bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 312 - ListFooterComponent={<View style={{height: insets.bottom + 100}} />} 299 + contentInsetAdjustmentBehavior={ 300 + isAtMaxSnapPoint ? 'automatic' : 'never' 301 + } 302 + automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint} 303 + scrollIndicatorInsets={{top: headerOffset}} 304 + bounces={isAtMaxSnapPoint} 313 305 ref={ref} 314 306 showsVerticalScrollIndicator={IS_ANDROID ? false : undefined} 315 307 {...props} 316 308 style={[a.h_full, style]} 309 + contentContainerStyle={[ 310 + {paddingTop: headerOffset}, 311 + android({ 312 + paddingBottom: insets.top + insets.bottom + tokens.space.xl, 313 + }), 314 + contentContainerStyle, 315 + ]} 317 316 /> 318 317 {footer} 319 318 </ScrollProvider> 320 319 ) 321 320 }) 322 321 323 - export function FlatListFooter({children}: {children: React.ReactNode}) { 322 + export function FlatListFooter({ 323 + children, 324 + onLayout, 325 + }: { 326 + children: React.ReactNode 327 + onLayout?: (event: LayoutChangeEvent) => void 328 + }) { 324 329 const t = useTheme() 325 330 const {top, bottom} = useSafeAreaInsets() 326 331 const {height} = useReanimatedKeyboardAnimation() ··· 334 339 335 340 return ( 336 341 <Animated.View 342 + onLayout={onLayout} 337 343 style={[ 338 344 a.absolute, 339 345 a.bottom_0,
+10 -2
src/components/Dialog/index.web.tsx
··· 3 3 FlatList, 4 4 type FlatListProps, 5 5 type GestureResponderEvent, 6 + type LayoutChangeEvent, 6 7 Pressable, 7 8 type StyleProp, 8 9 View, ··· 98 99 const context = React.useMemo( 99 100 () => ({ 100 101 close, 101 - IS_NATIVEDialog: false, 102 + isNativeDialog: false, 102 103 nativeSnapPoint: 0, 103 104 disableDrag: false, 104 105 setDisableDrag: () => {}, ··· 253 254 ) 254 255 }) 255 256 256 - export function FlatListFooter({children}: {children: React.ReactNode}) { 257 + export function FlatListFooter({ 258 + children, 259 + onLayout, 260 + }: { 261 + children: React.ReactNode 262 + onLayout?: (event: LayoutChangeEvent) => void 263 + }) { 257 264 const t = useTheme() 258 265 259 266 return ( 260 267 <View 268 + onLayout={onLayout} 261 269 style={[ 262 270 a.absolute, 263 271 a.bottom_0,
+1 -1
src/components/Dialog/types.ts
··· 39 39 40 40 export type DialogContextProps = { 41 41 close: DialogControlProps['close'] 42 - IS_NATIVEDialog: boolean 42 + isNativeDialog: boolean 43 43 nativeSnapPoint: BottomSheetSnapPoint 44 44 disableDrag: boolean 45 45 setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
+15 -6
src/components/dialogs/LanguageSelectDialog.tsx
··· 10 10 import {useLanguagePrefs} from '#/state/preferences/languages' 11 11 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 12 12 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 13 - import {atoms as a, useTheme, web} from '#/alf' 13 + import {atoms as a, tokens, useTheme, web} from '#/alf' 14 14 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 15 import * as Dialog from '#/components/Dialog' 16 16 import {SearchInput} from '#/components/forms/SearchInput' ··· 84 84 }) { 85 85 const control = Dialog.useDialogContext() 86 86 const [headerHeight, setHeaderHeight] = useState(0) 87 + const [footerHeight, setFooterHeight] = useState(0) 87 88 88 89 const allowedLanguages = useMemo(() => { 89 90 const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce( ··· 249 250 ...displayedLanguages.all.map(lang => ({type: 'item', lang})), 250 251 ] 251 252 253 + const numItems = flatListData.length 254 + 252 255 return ( 253 256 <Toggle.Group 254 257 values={checkedLanguagesCode2} ··· 261 264 data={flatListData} 262 265 ListHeaderComponent={listHeader} 263 266 stickyHeaderIndices={[0]} 264 - contentContainerStyle={[a.gap_0]} 265 - style={[IS_NATIVE && a.px_lg, web({paddingBottom: 120})]} 266 - scrollIndicatorInsets={{top: headerHeight}} 267 + contentContainerStyle={[ 268 + a.gap_0, 269 + IS_NATIVE && {paddingBottom: footerHeight + tokens.space.xl}, 270 + ]} 271 + style={[IS_NATIVE && a.px_lg, IS_WEB && {paddingBottom: 120}]} 272 + scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} 267 273 renderItem={({item, index}) => { 268 274 if (item.type === 'header') { 269 275 return ( ··· 283 289 } 284 290 const lang = item.lang 285 291 292 + const isLastItem = index === numItems - 1 293 + 286 294 return ( 287 295 <Toggle.Item 288 296 key={lang.code2} ··· 290 298 label={languageName(lang, langPrefs.appLanguage)} 291 299 style={[ 292 300 t.atoms.border_contrast_low, 293 - a.border_b, 301 + !isLastItem && a.border_b, 294 302 a.rounded_0, 295 303 a.px_0, 296 304 a.py_md, ··· 303 311 ) 304 312 }} 305 313 footer={ 306 - <Dialog.FlatListFooter> 314 + <Dialog.FlatListFooter 315 + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> 307 316 <Button 308 317 label={_(msg`Close dialog`)} 309 318 onPress={handleClose}
+18 -11
src/view/com/composer/photos/ImageAltTextDialog.tsx
··· 1 - import React from 'react' 1 + import {useMemo, useState} from 'react' 2 2 import {type ImageStyle, useWindowDimensions, View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {msg} from '@lingui/core/macro' ··· 10 10 import {enforceLen} from '#/lib/strings/helpers' 11 11 import {type ComposerImage} from '#/state/gallery' 12 12 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' 13 - import {atoms as a, useTheme} from '#/alf' 13 + import {atoms as a, tokens, useTheme} from '#/alf' 14 14 import {Button, ButtonText} from '#/components/Button' 15 15 import * as Dialog from '#/components/Dialog' 16 16 import {type DialogControlProps} from '#/components/Dialog' 17 17 import * as TextField from '#/components/forms/TextField' 18 18 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 19 19 import {Text} from '#/components/Typography' 20 - import {IS_ANDROID, IS_WEB} from '#/env' 20 + import {IS_ANDROID, IS_LIQUID_GLASS, IS_WEB} from '#/env' 21 21 22 22 type Props = { 23 23 control: Dialog.DialogOuterProps['control'] ··· 31 31 onChange, 32 32 }: Props): React.ReactNode => { 33 33 const {height: minHeight} = useWindowDimensions() 34 - const [altText, setAltText] = React.useState(image.alt) 34 + const [altText, setAltText] = useState(image.alt) 35 35 36 36 return ( 37 37 <Dialog.Outer ··· 67 67 }): React.ReactNode => { 68 68 const {_, i18n} = useLingui() 69 69 const t = useTheme() 70 - const windim = useWindowDimensions() 70 + const {width: screenWidth} = useWindowDimensions() 71 71 72 72 const [isKeyboardVisible] = useIsKeyboardVisible() 73 73 74 - const imageStyle = React.useMemo<ImageStyle>(() => { 75 - const maxWidth = IS_WEB ? 450 : windim.width 74 + const imageStyle = useMemo<ImageStyle>(() => { 75 + const maxWidth = IS_WEB 76 + ? 450 77 + : screenWidth - // account for dialog padding 78 + 2 * (IS_LIQUID_GLASS ? tokens.space._2xl : tokens.space.xl) 76 79 const source = image.transformed ?? image.source 77 80 78 81 if (source.height > source.width) { ··· 88 91 height: (maxWidth / source.width) * source.height, 89 92 borderRadius: 8, 90 93 } 91 - }, [image, windim]) 94 + }, [image, screenWidth]) 92 95 93 96 return ( 94 97 <Dialog.ScrollableInner label={_(msg`Add alt text`)}> 95 98 <Dialog.Close /> 96 99 97 100 <View> 98 - <Text style={[a.text_2xl, a.font_semi_bold, a.leading_tight, a.pb_sm]}> 99 - <Trans>Add alt text</Trans> 100 - </Text> 101 + {/* vertical space is too precious - gets scrolled out of the way anyway */} 102 + {IS_WEB && ( 103 + <Text 104 + style={[a.text_2xl, a.font_semi_bold, a.leading_tight, a.pb_sm]}> 105 + <Trans>Add alt text</Trans> 106 + </Text> 107 + )} 101 108 102 109 <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}> 103 110 <Image
+1 -1
src/view/com/composer/videos/SubtitleDialog.tsx
··· 60 60 )} 61 61 </ButtonText> 62 62 </Button> 63 - <Dialog.Outer control={control}> 63 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 64 64 <Dialog.Handle /> 65 65 <SubtitleDialogInner {...props} /> 66 66 </Dialog.Outer>