make scrollview automatically adapt to footer height

This commit is contained in:
Samuel Newman
2025-06-19 22:46:12 +03:00
parent 95f9cc1ed5
commit df975ddcfa
10 changed files with 199 additions and 161 deletions
+32 -33
View File
@@ -227,40 +227,39 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<Geo.Provider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Geo.Provider>
<A11yProvider>
<KeyboardControllerProvider>
<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>
</Geo.Provider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</Geo.Provider>
</SafeAreaProvider>
)
}
+45 -42
View File
@@ -3,7 +3,10 @@ import '#/view/icons'
import './style.css'
import {Fragment, useEffect, useState} from 'react'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {
initialWindowMetrics,
SafeAreaProvider,
} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
@@ -143,20 +146,18 @@ function InnerApp() {
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
@@ -201,33 +202,35 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</Geo.Provider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</Geo.Provider>
</SafeAreaProvider>
)
}
+27 -30
View File
@@ -1,19 +1,24 @@
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import {
ScrollView,
type ScrollViewProps,
type StyleProp,
StyleSheet,
View,
type ViewProps,
type ViewStyle,
} from 'react-native'
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 {useShellLayout} from '#/state/shell/shell-layout'
import {
android,
atoms as a,
ios,
useBreakpoints,
useLayoutBreakpoints,
useTheme,
@@ -22,7 +27,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'
@@ -52,9 +57,7 @@ export const Screen = memo(function Screen({
)
})
export type ContentProps = AnimatedScrollViewProps & {
style?: StyleProp<ViewStyle>
contentContainerStyle?: StyleProp<ViewStyle>
export type ContentProps = ScrollViewProps & {
ignoreTabletLayoutOffset?: boolean
}
@@ -62,7 +65,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,
@@ -74,39 +77,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>
)
}),
)
@@ -115,9 +112,6 @@ const scrollViewStyles = StyleSheet.create({
common: {
width: '100%',
},
contentContainer: {
paddingBottom: 100,
},
})
export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
@@ -136,11 +130,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
@@ -40,11 +40,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]),
},
],
}
@@ -279,14 +279,14 @@ export function MessagesList({
onMove: e => {
'worklet'
keyboardHeight.set(e.height)
if (e.height > footerHeight.get()) {
if (e.height > footerHeight) {
scrollTo(flatListRef, 0, 1e7, false)
}
},
onEnd: e => {
'worklet'
keyboardHeight.set(e.height)
if (e.height > footerHeight.get()) {
if (e.height > footerHeight) {
scrollTo(flatListRef, 0, 1e7, false)
}
keyboardIsOpening.set(false)
@@ -296,13 +296,11 @@ export function MessagesList({
)
const animatedListStyle = useAnimatedStyle(() => ({
marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()),
marginBottom: Math.max(keyboardHeight.get(), footerHeight),
}))
const animatedStickyViewStyle = useAnimatedStyle(() => ({
transform: [
{translateY: -Math.max(keyboardHeight.get(), footerHeight.get())},
],
transform: [{translateY: -Math.max(keyboardHeight.get(), footerHeight)}],
}))
// -- Message sending
@@ -446,6 +444,8 @@ export function MessagesList({
ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} />
}
contentInset={{bottom: 0}}
contentContainerStyle={{paddingBottom: 0}}
/>
</ScrollProvider>
<Animated.View style={animatedStickyViewStyle}>
+23 -13
View File
@@ -1,6 +1,5 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {type LayoutChangeEvent, useWindowDimensions, View} from 'react-native'
import {Trans} from '@lingui/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -60,6 +59,8 @@ export function PostThread({uri}: {uri: string}) {
hasSession,
)
const [promptHeight, setPromptHeight] = useState(43)
/*
* One query to rule them all
*/
@@ -519,6 +520,7 @@ export function PostThread({uri}: {uri: string}) {
)
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
const hasPrompt = !gtMobile && canReply && hasSession
return (
<PostThreadContextProvider context={thread.context}>
@@ -604,29 +606,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>
)
}
+42 -24
View File
@@ -1,12 +1,18 @@
import React 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 = React.createContext<StateContext>({
const LayoutContext = createContext<LayoutContextValue>({
headerHeight: {
value: 0,
addListener() {},
@@ -17,34 +23,46 @@ const stateContext = React.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 value = React.useMemo(
() => ({
headerHeight,
footerHeight,
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,
}),
[headerHeight, footerHeight],
)
return <stateContext.Provider value={value}>{children}</stateContext.Provider>
const value = useMemo(
() => ({
headerHeight,
footerHeight: isWeb && gtMobile ? 0 : footerHeight,
setFooterHeight: (height: number) => setFooterHeight(round4dp(height)),
}),
[headerHeight, footerHeight, setFooterHeight, gtMobile],
)
return (
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
)
}
export function useShellLayout() {
return React.useContext(stateContext)
return useContext(LayoutContext)
}
function round4dp(value: number) {
return Math.round(value * 10000) / 10000
}
+19 -2
View File
@@ -13,8 +13,9 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles'
import {useLightbox} from '#/state/lightbox'
import {useTheme} from '#/alf'
import {IS_IOS} from '#/env'
import {useShellLayout} from '#/state/shell/shell-layout'
import {ios, useTheme} from '#/alf'
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
@@ -59,7 +59,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()
@@ -151,9 +151,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
@@ -51,7 +51,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})
@@ -90,7 +90,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="/">