make scrollview automatically adapt to footer height

This commit is contained in:
Samuel Newman
2025-06-19 22:46:12 +03:00
parent 3c92714e4e
commit 3ecfe20c28
11 changed files with 195 additions and 162 deletions
+30 -31
View File
@@ -204,38 +204,37 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<GeolocationProvider>
<A11yProvider>
<KeyboardControllerProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<GeolocationProvider>
<A11yProvider>
<KeyboardControllerProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardControllerProvider>
</A11yProvider>
</GeolocationProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardControllerProvider>
</A11yProvider>
</GeolocationProvider>
</SafeAreaProvider>
)
}
+41 -38
View File
@@ -4,7 +4,10 @@ import './style.css'
import React, {useEffect, useState} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
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'
@@ -129,18 +132,16 @@ function InnerApp() {
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
@@ -183,31 +184,33 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<GeolocationProvider>
<A11yProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</A11yProvider>
</GeolocationProvider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<GeolocationProvider>
<A11yProvider>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</A11yProvider>
</GeolocationProvider>
</SafeAreaProvider>
)
}
+26 -29
View File
@@ -1,20 +1,25 @@
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {
ScrollView,
type ScrollViewProps,
StyleSheet,
View,
type ViewProps,
type ViewStyle,
} from 'react-native'
import {type StyleProp} 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 {isWeb} from '#/platform/detection'
import {isIOS, isWeb} from '#/platform/detection'
import {useShellLayout} from '#/state/shell/shell-layout'
import {
android,
atoms as a,
ios,
useBreakpoints,
useLayoutBreakpoints,
useTheme,
@@ -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,
!isIOS && {paddingBottom: footerHeight},
contentContainerStyle,
]}
{...props}>
{isWeb ? (
<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, 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'
@@ -50,6 +49,8 @@ export function PostThread({uri}: {uri: string}) {
const anchorPostSource = useUnstablePostSource(uri)
const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
const [promptHeight, setPromptHeight] = useState(43)
/*
* One query to rule them all
*/
@@ -485,6 +486,7 @@ export function PostThread({uri}: {uri: string}) {
)
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
const hasPrompt = !gtMobile && canReply && hasSession
return (
<>
@@ -553,29 +555,37 @@ export function PostThread({uri}: {uri: string}) {
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
footerExtensionHeight={hasPrompt ? promptHeight : 0}
/>
)}
{!gtMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onReplyToAnchor} />
{hasPrompt && (
<MobileComposePrompt
onPressReply={onReplyToAnchor}
onLayout={evt =>
setPromptHeight(Math.round(evt.nativeEvent.layout.height))
}
/>
)}
</>
)
}
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}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
</View>
)
}
+42 -24
View File
@@ -1,12 +1,18 @@
import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
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,33 +23,45 @@ const stateContext = React.createContext<StateContext>({
},
set() {},
},
footerHeight: {
value: 0,
addListener() {},
removeListener() {},
modify() {},
get() {
return 0
},
set() {},
},
footerHeight: 0,
setFooterHeight: () => {},
})
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
}
+3 -8
View File
@@ -1,6 +1,6 @@
import React, {memo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {
AppBskyFeedDefs,
@@ -735,14 +735,9 @@ ThreadMenu = memo(ThreadMenu)
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return (
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<Animated.View
style={[a.fixed, a.left_0, a.right_0, {bottom: footerHeight}]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
+19 -2
View File
@@ -11,9 +11,10 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {isAndroid, isIOS} from '#/platform/detection'
import {useLightbox} from '#/state/lightbox'
import {useTheme} from '#/alf'
import {useShellLayout} from '#/state/shell/shell-layout'
import {ios, useTheme} from '#/alf'
import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL
@@ -38,6 +39,7 @@ export type ListProps<ItemT = any> = Omit<
disableFullWindowScroll?: boolean
sideBorders?: boolean
progressViewOffset?: number
footerExtensionHeight?: number
}
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
@@ -54,6 +56,8 @@ let List = React.forwardRef<ListMethods, ListProps>(
style,
progressViewOffset,
automaticallyAdjustsScrollIndicatorInsets = false,
contentContainerStyle,
footerExtensionHeight = 0,
...props
},
ref,
@@ -62,6 +66,7 @@ let List = React.forwardRef<ListMethods, ListProps>(
const t = useTheme()
const dedupe = useDedupe(400)
const {activeLightbox} = useLightbox()
const {footerHeight} = useShellLayout()
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@@ -156,14 +161,26 @@ let List = React.forwardRef<ListMethods, ListProps>(
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
{...props}
contentContainerStyle={[
isAndroid && {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
@@ -60,7 +60,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const pal = usePalette('default')
const {_} = useLingui()
const safeAreaInsets = useSafeAreaInsets()
const {footerHeight} = useShellLayout()
const {setFooterHeight} = useShellLayout()
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile, isAtMessages} =
useNavigationTabState()
const numUnreadNotifications = useUnreadNotifications()
@@ -152,9 +152,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 iconWidth = 26
@@ -83,7 +83,7 @@ export function BottomBarWeb() {
: t.atoms.border_contrast_low,
footerMinimalShellTransform,
]}
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
{hasSession ? (
<>
<NavItem