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