Bluesky app fork with some witchin' additions 💫

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