From 3ecfe20c28d35286a3c962c21027d49e78839799 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 | 61 +++++++------- src/App.web.tsx | 79 ++++++++++--------- src/components/Layout/index.tsx | 55 ++++++------- src/lib/hooks/useMinimalShellTransform.ts | 6 +- .../Messages/components/MessagesList.tsx | 12 +-- src/screens/PostThread/index.tsx | 36 ++++++--- src/state/shell/shell-layout.tsx | 66 ++++++++++------ src/view/com/post-thread/PostThread.tsx | 11 +-- src/view/com/util/List.tsx | 21 ++++- src/view/shell/bottom-bar/BottomBar.tsx | 6 +- src/view/shell/bottom-bar/BottomBarWeb.tsx | 4 +- 11 files changed, 195 insertions(+), 162 deletions(-) diff --git a/src/App.native.tsx b/src/App.native.tsx index 81d4a870e9..fccb2fd414 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -204,38 +204,37 @@ 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 b706774fdc..6b2535411a 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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() { - - - - - - - - - - - - + + + + + + + + + + @@ -183,31 +184,33 @@ 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 5891ca863a..79b3d5e14a 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -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 - contentContainerStyle?: StyleProp +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(function Content( + forwardRef(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 ( - {isWeb ? (
- {/* @ts-expect-error web only -esb */} {children}
) : ( children )} -
+ ) }), ) @@ -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 ( { '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={ } + contentInset={{bottom: 0}} + contentContainerStyle={{paddingBottom: 0}} /> diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 350fc41aee..7b975d970a 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -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 && ( - + {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 39f69cfff9..5279c8cf1d 100644 --- a/src/state/shell/shell-layout.tsx +++ b/src/state/shell/shell-layout.tsx @@ -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 - footerHeight: SharedValue + footerHeight: number + setFooterHeight: (height: number) => void } -const stateContext = React.createContext({ +const LayoutContext = createContext({ headerHeight: { value: 0, addListener() {}, @@ -17,33 +23,45 @@ const stateContext = React.createContext({ }, 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 {children} + const value = useMemo( + () => ({ + headerHeight, + footerHeight: isWeb && gtMobile ? 0 : footerHeight, + setFooterHeight: (height: number) => setFooterHeight(round4dp(height)), + }), + [headerHeight, footerHeight, setFooterHeight, gtMobile], + ) + + return ( + {children} + ) } export function useShellLayout() { - return React.useContext(stateContext) + return useContext(LayoutContext) +} + +function round4dp(value: number) { + return Math.round(value * 10000) / 10000 } diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 94cc04f542..63ea673d29 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -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 ( - + ) diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 7dd04bab3a..180925d58b 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -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 = Omit< disableFullWindowScroll?: boolean sideBorders?: boolean progressViewOffset?: number + footerExtensionHeight?: number } export type ListRef = React.MutableRefObject @@ -54,6 +56,8 @@ let List = React.forwardRef( style, progressViewOffset, automaticallyAdjustsScrollIndicatorInsets = false, + contentContainerStyle, + footerExtensionHeight = 0, ...props }, ref, @@ -62,6 +66,7 @@ let List = React.forwardRef( 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( 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} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 01aa4afc42..8d83c3c12b 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -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 ? ( <> footerHeight.set(event.nativeEvent.layout.height)}> + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> {hasSession ? ( <>