make scrollview automatically adapt to footer height

This commit is contained in:
Samuel Newman
2025-06-19 22:46:12 +03:00
parent c81174e4f0
commit 58d7b68b96
9 changed files with 191 additions and 158 deletions
+34 -35
View File
@@ -232,42 +232,41 @@ function App() {
* that is set up in the InnerApp component above. * that is set up in the InnerApp component above.
*/ */
return ( return (
<Geo.Provider> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<AppConfigProvider> <Geo.Provider>
<A11yProvider> <AppConfigProvider>
<KeyboardControllerProvider preload={false}> <A11yProvider>
<OnboardingProvider> <KeyboardControllerProvider preload={false}>
<AnalyticsContext> <OnboardingProvider>
<SessionProvider> <AnalyticsContext>
<PrefsStateProvider> <SessionProvider>
<I18nProvider> <PrefsStateProvider>
<ShellStateProvider> <I18nProvider>
<ModalStateProvider> <ShellStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<PortalProvider> <LightboxStateProvider>
<BottomSheetProvider> <PortalProvider>
<StarterPackProvider> <BottomSheetProvider>
<SafeAreaProvider <StarterPackProvider>
initialMetrics={initialWindowMetrics}>
<InnerApp /> <InnerApp />
</SafeAreaProvider> </StarterPackProvider>
</StarterPackProvider> </BottomSheetProvider>
</BottomSheetProvider> </PortalProvider>
</PortalProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </ShellStateProvider>
</ShellStateProvider> </I18nProvider>
</I18nProvider> </PrefsStateProvider>
</PrefsStateProvider> </SessionProvider>
</SessionProvider> </AnalyticsContext>
</AnalyticsContext> </OnboardingProvider>
</OnboardingProvider> </KeyboardControllerProvider>
</KeyboardControllerProvider> </A11yProvider>
</A11yProvider> </AppConfigProvider>
</AppConfigProvider> </Geo.Provider>
</Geo.Provider> </SafeAreaProvider>
) )
} }
+53 -50
View File
@@ -4,7 +4,10 @@ import './style.css'
import {Fragment, useEffect, useState} from 'react' import {Fragment, useEffect, useState} from 'react'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller' import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {SafeAreaProvider} from 'react-native-safe-area-context' import {
initialWindowMetrics,
SafeAreaProvider,
} from 'react-native-safe-area-context'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
@@ -149,24 +152,22 @@ function InnerApp() {
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<SafeAreaProvider> <ProgressGuideProvider>
<ProgressGuideProvider> <ServiceConfigProvider>
<ServiceConfigProvider> <EmailVerificationProvider>
<EmailVerificationProvider> <HideBottomBarBorderProvider>
<HideBottomBarBorderProvider> <IntentDialogProvider>
<IntentDialogProvider> <TranslateOnDeviceProvider>
<TranslateOnDeviceProvider> <HotkeysProvider>
<HotkeysProvider> <Shell />
<Shell /> <ToastOutlet />
<ToastOutlet /> </HotkeysProvider>
</HotkeysProvider> </TranslateOnDeviceProvider>
</TranslateOnDeviceProvider> </IntentDialogProvider>
</IntentDialogProvider> </HideBottomBarBorderProvider>
</HideBottomBarBorderProvider> </EmailVerificationProvider>
</EmailVerificationProvider> </ServiceConfigProvider>
</ServiceConfigProvider> </ProgressGuideProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </UnreadNotifsProvider>
@@ -211,37 +212,39 @@ function App() {
* that is set up in the InnerApp component above. * that is set up in the InnerApp component above.
*/ */
return ( return (
<Geo.Provider> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<AppConfigProvider> <Geo.Provider>
<A11yProvider> <AppConfigProvider>
<KeyboardControllerProvider> <A11yProvider>
<OnboardingProvider> <KeyboardControllerProvider>
<AnalyticsContext> <OnboardingProvider>
<SessionProvider> <AnalyticsContext>
<PrefsStateProvider> <SessionProvider>
<I18nProvider> <PrefsStateProvider>
<ShellStateProvider> <I18nProvider>
<ModalStateProvider> <ShellStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<PortalProvider> <LightboxStateProvider>
<StarterPackProvider> <PortalProvider>
<InnerApp /> <StarterPackProvider>
</StarterPackProvider> <InnerApp />
</PortalProvider> </StarterPackProvider>
</LightboxStateProvider> </PortalProvider>
</DialogStateProvider> </LightboxStateProvider>
</ModalStateProvider> </DialogStateProvider>
</ShellStateProvider> </ModalStateProvider>
</I18nProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</SessionProvider> </PrefsStateProvider>
</AnalyticsContext> </SessionProvider>
</OnboardingProvider> </AnalyticsContext>
</KeyboardControllerProvider> </OnboardingProvider>
</A11yProvider> </KeyboardControllerProvider>
</AppConfigProvider> </A11yProvider>
</Geo.Provider> </AppConfigProvider>
</Geo.Provider>
</SafeAreaProvider>
) )
} }
+20 -28
View File
@@ -1,5 +1,7 @@
import {forwardRef, memo, useContext, useMemo} from 'react' import {forwardRef, memo, useContext, useMemo} from 'react'
import { import {
ScrollView,
type ScrollViewProps,
type StyleProp, type StyleProp,
StyleSheet, StyleSheet,
View, View,
@@ -10,16 +12,14 @@ import {
KeyboardAwareScrollView, KeyboardAwareScrollView,
type KeyboardAwareScrollViewProps, type KeyboardAwareScrollViewProps,
} from 'react-native-keyboard-controller' } from 'react-native-keyboard-controller'
import Animated, {
type AnimatedScrollViewProps,
useAnimatedProps,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useEnableMinimalShellModeForScreen} from '#/state/shell' import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import { import {
android,
atoms as a, atoms as a,
ios,
useBreakpoints, useBreakpoints,
useLayoutBreakpoints, useLayoutBreakpoints,
useTheme, useTheme,
@@ -28,7 +28,7 @@ import {
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const' import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context' import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {IS_WEB} from '#/env' import {IS_IOS, IS_WEB} from '#/env'
export * from '#/components/Layout/const' export * from '#/components/Layout/const'
export * as Header from '#/components/Layout/Header' export * as Header from '#/components/Layout/Header'
@@ -63,9 +63,7 @@ export const Screen = memo(function Screen({
) )
}) })
export type ContentProps = AnimatedScrollViewProps & { export type ContentProps = ScrollViewProps & {
style?: StyleProp<ViewStyle>
contentContainerStyle?: StyleProp<ViewStyle>
ignoreTabletLayoutOffset?: boolean ignoreTabletLayoutOffset?: boolean
} }
@@ -73,7 +71,7 @@ export type ContentProps = AnimatedScrollViewProps & {
* Default scroll view for simple pages * Default scroll view for simple pages
*/ */
export const Content = memo( export const Content = memo(
forwardRef<Animated.ScrollView, ContentProps>(function Content( forwardRef<ScrollView, ContentProps>(function Content(
{ {
children, children,
style, style,
@@ -85,39 +83,33 @@ export const Content = memo(
) { ) {
const t = useTheme() const t = useTheme()
const {footerHeight} = useShellLayout() const {footerHeight} = useShellLayout()
const animatedProps = useAnimatedProps(() => {
return {
scrollIndicatorInsets: {
bottom: footerHeight.get(),
top: 0,
right: 1,
},
} satisfies AnimatedScrollViewProps
})
return ( return (
<Animated.ScrollView <ScrollView
ref={ref} ref={ref}
id="content" id="content"
automaticallyAdjustsScrollIndicatorInsets={false} automaticallyAdjustsScrollIndicatorInsets={false}
scrollIndicatorInsets={{
bottom: footerHeight,
top: 0,
right: 1,
}}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'} indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
// sets the scroll inset to the height of the footer
animatedProps={animatedProps}
style={[scrollViewStyles.common, style]} style={[scrollViewStyles.common, style]}
contentInset={ios({top: 0, left: 0, bottom: footerHeight, right: 0})}
contentContainerStyle={[ contentContainerStyle={[
scrollViewStyles.contentContainer, !IS_IOS && {paddingBottom: footerHeight},
contentContainerStyle, contentContainerStyle,
]} ]}
{...props}> {...props}>
{IS_WEB ? ( {IS_WEB ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}> <Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children} {children}
</Center> </Center>
) : ( ) : (
children children
)} )}
</Animated.ScrollView> </ScrollView>
) )
}), }),
) )
@@ -126,9 +118,6 @@ const scrollViewStyles = StyleSheet.create({
common: { common: {
width: '100%', width: '100%',
}, },
contentContainer: {
paddingBottom: 100,
},
}) })
export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & { export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
@@ -147,11 +136,14 @@ export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
contentContainerStyle, contentContainerStyle,
...props ...props
}: KeyboardAwareContentProps) { }: KeyboardAwareContentProps) {
const {footerHeight} = useShellLayout()
return ( return (
<KeyboardAwareScrollView <KeyboardAwareScrollView
style={[scrollViewStyles.common, style]} style={[scrollViewStyles.common, style]}
contentInset={ios({top: 0, left: 0, bottom: footerHeight, right: 0})}
contentContainerStyle={[ contentContainerStyle={[
scrollViewStyles.contentContainer, android({paddingBottom: footerHeight}),
web({paddingBottom: footerHeight}),
contentContainerStyle, contentContainerStyle,
]} ]}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
+1 -5
View File
@@ -68,11 +68,7 @@ export function useMinimalShellFooterTransform() {
opacity: Math.pow(1 - footerModeValue, 2), opacity: Math.pow(1 - footerModeValue, 2),
transform: [ transform: [
{ {
translateY: interpolate( translateY: interpolate(footerModeValue, [0, 1], [0, footerHeight]),
footerModeValue,
[0, 1],
[0, footerHeight.get()],
),
}, },
], ],
} }
+22 -12
View File
@@ -7,7 +7,6 @@ import {
useState, useState,
} from 'react' } from 'react'
import {useWindowDimensions, View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -69,6 +68,8 @@ export function PostThread({uri}: {uri: string}) {
hasSession, hasSession,
) )
const [promptHeight, setPromptHeight] = useState(43)
/* /*
* One query to rule them all * One query to rule them all
*/ */
@@ -543,6 +544,7 @@ export function PostThread({uri}: {uri: string}) {
) )
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
const hasPrompt = !gtMobile && canReply && hasSession
return ( return (
<PostThreadContextProvider context={thread.context}> <PostThreadContextProvider context={thread.context}>
@@ -630,29 +632,37 @@ export function PostThread({uri}: {uri: string}) {
* Default: 50 * Default: 50
*/ */
updateCellsBatchingPeriod={100} updateCellsBatchingPeriod={100}
footerExtensionHeight={hasPrompt ? promptHeight : 0}
/> />
)} )}
{!gtMobile && canReply && hasSession && ( {hasPrompt && (
<MobileComposePrompt onPressReply={onReplyToAnchor} /> <MobileComposePrompt
onPressReply={onReplyToAnchor}
onLayout={evt =>
setPromptHeight(Math.round(evt.nativeEvent.layout.height))
}
/>
)} )}
</PostThreadContextProvider> </PostThreadContextProvider>
) )
} }
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { function MobileComposePrompt({
onPressReply,
onLayout,
}: {
onPressReply: () => unknown
onLayout?: (event: LayoutChangeEvent) => void
}) {
const {footerHeight} = useShellLayout() const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return ( return (
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> <View
style={[a.fixed, a.left_0, a.right_0, {bottom: footerHeight}]}
onLayout={onLayout}>
<ThreadComposePrompt onPressCompose={onPressReply} /> <ThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View> </View>
) )
} }
+38 -20
View File
@@ -1,12 +1,18 @@
import {createContext, useContext, useMemo} from 'react' import {createContext, useContext, useMemo, useState} from 'react'
import {type SharedValue, useSharedValue} from 'react-native-reanimated' import {type SharedValue, useSharedValue} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
type StateContext = { import {clamp} from '#/lib/numbers'
import {isWeb} from '#/platform/detection'
import {atoms as a, platform, useBreakpoints} from '#/alf'
type LayoutContextValue = {
headerHeight: SharedValue<number> headerHeight: SharedValue<number>
footerHeight: SharedValue<number> footerHeight: number
setFooterHeight: (height: number) => void
} }
const stateContext = createContext<StateContext>({ const LayoutContext = createContext<LayoutContextValue>({
headerHeight: { headerHeight: {
value: 0, value: 0,
addListener() {}, addListener() {},
@@ -17,34 +23,46 @@ const stateContext = createContext<StateContext>({
}, },
set() {}, set() {},
}, },
footerHeight: { footerHeight: 0,
value: 0, setFooterHeight: () => {},
addListener() {},
removeListener() {},
modify() {},
get() {
return 0
},
set() {},
},
}) })
stateContext.displayName = 'ShellLayoutContext' LayoutContext.displayName = 'ShellLayoutContext'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const headerHeight = useSharedValue(0) const headerHeight = useSharedValue(0)
const footerHeight = useSharedValue(0) const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const [footerHeight, setFooterHeight] = useState(() =>
platform({
// try and precisely guess the footer height, then round it to 4 decimal places
// to remove floating point imprecision. if we can guess it exactly,
// we get to skip a rerender
native: round4dp(
47 + a.border.borderWidth + clamp(insets.bottom, 15, 60),
),
web: 58,
default: 0,
}),
)
const value = useMemo( const value = useMemo(
() => ({ () => ({
headerHeight, headerHeight,
footerHeight, footerHeight: isWeb && gtMobile ? 0 : footerHeight,
setFooterHeight: (height: number) => setFooterHeight(round4dp(height)),
}), }),
[headerHeight, footerHeight], [headerHeight, footerHeight, setFooterHeight, gtMobile],
) )
return <stateContext.Provider value={value}>{children}</stateContext.Provider> return (
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
)
} }
export function useShellLayout() { export function useShellLayout() {
return useContext(stateContext) return useContext(LayoutContext)
}
function round4dp(value: number) {
return Math.round(value * 10000) / 10000
} }
+19 -2
View File
@@ -12,9 +12,10 @@ import {useDedupe} from '#/lib/hooks/useDedupe'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useScrollHandlers} from '#/lib/ScrollContext' import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles' import {addStyle} from '#/lib/styles'
import {useTheme} from '#/alf' import {useShellLayout} from '#/state/shell/shell-layout'
import {ios, useTheme} from '#/alf'
import {useLightbox} from '#/components/Lightbox/state' import {useLightbox} from '#/components/Lightbox/state'
import {IS_IOS} from '#/env' import {IS_ANDROID, IS_IOS} from '#/env'
import {FlatList_INTERNAL} from './Views' import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL export type ListMethods = FlatList_INTERNAL
@@ -39,6 +40,7 @@ export type ListProps<ItemT = any> = Omit<
disableFullWindowScroll?: boolean disableFullWindowScroll?: boolean
sideBorders?: boolean sideBorders?: boolean
progressViewOffset?: number progressViewOffset?: number
footerExtensionHeight?: number
} }
export type ListRef = React.RefObject<FlatList_INTERNAL | null> export type ListRef = React.RefObject<FlatList_INTERNAL | null>
@@ -55,6 +57,8 @@ let List = forwardRef<ListMethods, ListProps>(
style, style,
progressViewOffset, progressViewOffset,
automaticallyAdjustsScrollIndicatorInsets = false, automaticallyAdjustsScrollIndicatorInsets = false,
contentContainerStyle,
footerExtensionHeight = 0,
...props ...props
}, },
ref, ref,
@@ -63,6 +67,7 @@ let List = forwardRef<ListMethods, ListProps>(
const t = useTheme() const t = useTheme()
const dedupe = useDedupe(400) const dedupe = useDedupe(400)
const scrollsToTop = useAllowScrollToTop() const scrollsToTop = useAllowScrollToTop()
const {footerHeight} = useShellLayout()
const handleScrolledDownChange = useNonReactiveCallback( const handleScrolledDownChange = useNonReactiveCallback(
(didScrollDown: boolean) => { (didScrollDown: boolean) => {
@@ -159,14 +164,26 @@ let List = forwardRef<ListMethods, ListProps>(
onViewableItemsChanged={onViewableItemsChanged} onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig} viewabilityConfig={viewabilityConfig}
{...props} {...props}
contentContainerStyle={[
IS_ANDROID && {paddingBottom: footerHeight + footerExtensionHeight},
contentContainerStyle,
]}
automaticallyAdjustsScrollIndicatorInsets={ automaticallyAdjustsScrollIndicatorInsets={
automaticallyAdjustsScrollIndicatorInsets automaticallyAdjustsScrollIndicatorInsets
} }
scrollIndicatorInsets={{ scrollIndicatorInsets={{
top: headerOffset, top: headerOffset,
right: 1, right: 1,
bottom: footerHeight + footerExtensionHeight,
...props.scrollIndicatorInsets, ...props.scrollIndicatorInsets,
}} }}
contentInset={ios({
top: 0,
left: 0,
right: 0,
bottom: footerHeight + footerExtensionHeight,
...props.contentInset,
})}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'} indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
contentOffset={contentOffset} contentOffset={contentOffset}
refreshControl={refreshControl} refreshControl={refreshControl}
+2 -4
View File
@@ -61,7 +61,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()
const {footerHeight} = useShellLayout() const {setFooterHeight} = useShellLayout()
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile, isAtMessages} = const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile, isAtMessages} =
useNavigationTabState() useNavigationTabState()
const numUnreadNotifications = useUnreadNotifications() const numUnreadNotifications = useUnreadNotifications()
@@ -154,9 +154,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)}, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
footerMinimalShellTransform, footerMinimalShellTransform,
]} ]}
onLayout={e => { onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
footerHeight.set(e.nativeEvent.layout.height)
}}>
{hasSession ? ( {hasSession ? (
<> <>
<Btn <Btn
+2 -2
View File
@@ -53,7 +53,7 @@ export function BottomBarWeb() {
const footerMinimalShellTransform = useMinimalShellFooterTransform() const footerMinimalShellTransform = useMinimalShellFooterTransform()
const {requestSwitchToAccount} = useLoggedOutViewControls() const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const {footerHeight} = useShellLayout() const {setFooterHeight} = useShellLayout()
const hideBorder = useHideBottomBarBorder() const hideBorder = useHideBottomBarBorder()
const accountSwitchControl = useDialogControl() const accountSwitchControl = useDialogControl()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
@@ -93,7 +93,7 @@ export function BottomBarWeb() {
: t.atoms.border_contrast_low, : t.atoms.border_contrast_low,
footerMinimalShellTransform, footerMinimalShellTransform,
]} ]}
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}> onLayout={event => setFooterHeight(event.nativeEvent.layout.height)}>
{hasSession ? ( {hasSession ? (
<> <>
<NavItem routeName="Home" href="/"> <NavItem routeName="Home" href="/">