From 58d7b68b96c723b9bd241782acb92811ca55aa64 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 19 Jun 2025 22:46:12 +0300 Subject: [PATCH] make scrollview automatically adapt to footer height --- src/App.native.tsx | 69 +++++++------- src/App.web.tsx | 103 +++++++++++---------- src/components/Layout/index.tsx | 48 ++++------ src/lib/hooks/useMinimalShellTransform.ts | 6 +- src/screens/PostThread/index.tsx | 34 ++++--- src/state/shell/shell-layout.tsx | 58 ++++++++---- src/view/com/util/List.tsx | 21 ++++- src/view/shell/bottom-bar/BottomBar.tsx | 6 +- src/view/shell/bottom-bar/BottomBarWeb.tsx | 4 +- 9 files changed, 191 insertions(+), 158 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 7bbe72a8d1..b4a5f9dae6 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -232,42 +232,41 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index 178d3b8298..f1110fcc47 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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() { - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -211,37 +212,39 @@ function App() { * that is set up in the InnerApp component above. */ return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 43ae23b52b..be183cae1a 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -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 - contentContainerStyle?: StyleProp +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(function Content( + forwardRef(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 ( - {IS_WEB ? (
- {/* @ts-expect-error web only -esb */} {children}
) : ( children )} -
+ ) }), ) @@ -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 ( @@ -630,29 +632,37 @@ export function PostThread({uri}: {uri: string}) { * Default: 50 */ updateCellsBatchingPeriod={100} + footerExtensionHeight={hasPrompt ? promptHeight : 0} /> )} - {!gtMobile && canReply && hasSession && ( - + {hasPrompt && ( + + 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 ( - + - + ) } diff --git a/src/state/shell/shell-layout.tsx b/src/state/shell/shell-layout.tsx index 9a7e3d3c83..8bcede54a5 100644 --- a/src/state/shell/shell-layout.tsx +++ b/src/state/shell/shell-layout.tsx @@ -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 - footerHeight: SharedValue + footerHeight: number + setFooterHeight: (height: number) => void } -const stateContext = createContext({ +const LayoutContext = createContext({ headerHeight: { value: 0, addListener() {}, @@ -17,34 +23,46 @@ const stateContext = createContext({ }, 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 {children} + return ( + {children} + ) } export function useShellLayout() { - return useContext(stateContext) + return useContext(LayoutContext) +} + +function round4dp(value: number) { + return Math.round(value * 10000) / 10000 } diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index aee397bb79..b65a574520 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -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 = Omit< disableFullWindowScroll?: boolean sideBorders?: boolean progressViewOffset?: number + footerExtensionHeight?: number } export type ListRef = React.RefObject @@ -55,6 +57,8 @@ let List = forwardRef( style, progressViewOffset, automaticallyAdjustsScrollIndicatorInsets = false, + contentContainerStyle, + footerExtensionHeight = 0, ...props }, ref, @@ -63,6 +67,7 @@ let List = forwardRef( 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( 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} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 8c1fb3c52b..3169558a4a 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -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 ? ( <> footerHeight.set(event.nativeEvent.layout.height)}> + onLayout={event => setFooterHeight(event.nativeEvent.layout.height)}> {hasSession ? ( <>