diff --git a/jest/setup.ts b/jest/setup.ts index 41626ee3bb97..e366fbffacb7 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -161,6 +161,8 @@ jest.mock('react-native-nitro-sqlite', () => ({ open: jest.fn(), })); +jest.mock('@src/hooks/useKeyboardDismissibleFlatListValues/index.ts'); + // Provide a default global fetch mock for tests that do not explicitly set it up // This avoids ReferenceError: fetch is not defined in CI when coverage is enabled const globalWithOptionalFetch: typeof globalThis & {fetch?: unknown} = globalThis as typeof globalThis & {fetch?: unknown}; diff --git a/src/App.tsx b/src/App.tsx index 683f6772a9bb..19655c4b5471 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import FullScreenLoaderContextProvider from './components/FullScreenLoaderContex import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; import {InputBlurContextProvider} from './components/InputBlurContext'; +import {KeyboardDismissibleFlatListContextProvider} from './components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext'; import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import NavigationBar from './components/NavigationBar'; @@ -115,6 +116,7 @@ function App() { VideoPopoverMenuContextProvider, KeyboardProvider, KeyboardStateProvider, + KeyboardDismissibleFlatListContextProvider, SearchRouterContextProvider, ProductTrainingContextProvider, InputBlurContextProvider, diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0eb13e95ef83..860aeb2ab81d 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2045,6 +2045,8 @@ const CONST = { BIG_SCREEN_SUGGESTION_WIDTH: 300, }, COMPOSER_MAX_HEIGHT: 125, + // Starts with this value to avoid a big jump while the container height is being calculated in case the screen is first rendered w/ a full size composer. It's based on the perceived concierge header height on the iPhone 16 Pro. + CHAT_HEADER_BASE_HEIGHT: 73, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, CHAT_FOOTER_SECONDARY_ROW_PADDING: 5, CHAT_FOOTER_MIN_HEIGHT: 65, @@ -7261,6 +7263,11 @@ const CONST = { REVIEW_WORKSPACE_SETTINGS: 'reviewWorkspaceSettings', INVITE_ACCOUNTANT: 'inviteAccountant', }, + + LIST_BEHAVIOR: { + REGULAR: 'regular', + INVERTED: 'inverted', + }, } as const; const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.native.tsx similarity index 73% rename from src/components/ActionSheetAwareScrollView/index.ios.tsx rename to src/components/ActionSheetAwareScrollView/index.native.tsx index 6fead4946aea..3ed5a21c7e17 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.native.tsx @@ -1,12 +1,13 @@ import React, {forwardRef, useCallback} from 'react'; // eslint-disable-next-line no-restricted-imports -import type {ScrollView} from 'react-native'; import Reanimated, {useAnimatedRef, useAnimatedStyle} from 'react-native-reanimated'; +import type {AnimatedScrollView} from 'react-native-reanimated/lib/typescript/component/ScrollView'; import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; -import type {ActionSheetAwareScrollViewProps, RenderActionSheetAwareScrollViewComponent} from './types'; +import type {ActionSheetAwareScrollViewProps} from './types'; import useActionSheetKeyboardSpacing from './useActionSheetKeyboardSpacing'; +import usePreventScrollOnKeyboardInteraction from './usePreventScrollOnKeyboardInteraction'; -const ActionSheetAwareScrollView = forwardRef(({style, children, ...props}, ref) => { +const ActionSheetAwareScrollView = forwardRef(({style, children, onScroll: onScrollProp, ...props}, ref) => { const scrollViewAnimatedRef = useAnimatedRef(); const onRef = useCallback( @@ -28,11 +29,14 @@ const ActionSheetAwareScrollView = forwardRef {children} @@ -47,7 +51,7 @@ export default ActionSheetAwareScrollView; * @param props - props that will be passed to the ScrollView from FlatList * @returns - ActionSheetAwareScrollView */ -const renderScrollComponent: RenderActionSheetAwareScrollViewComponent = (props) => { +const renderScrollComponent = (props: ActionSheetAwareScrollViewProps) => { // eslint-disable-next-line react/jsx-props-no-spreading return ; }; diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx index f2fc64cda8bd..09dc827c8e20 100644 --- a/src/components/ActionSheetAwareScrollView/index.tsx +++ b/src/components/ActionSheetAwareScrollView/index.tsx @@ -1,30 +1,45 @@ -// this whole file is just for other platforms -// iOS version has everything implemented import React, {forwardRef} from 'react'; // eslint-disable-next-line no-restricted-imports -import {ScrollView} from 'react-native'; +import type {AnimatedRef} from 'react-native-reanimated'; +import Animated, {useAnimatedRef} from 'react-native-reanimated'; +import type Reanimated from 'react-native-reanimated'; +import type {AnimatedScrollView} from 'react-native-reanimated/lib/typescript/component/ScrollView'; import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; -import type {ActionSheetAwareScrollViewProps, RenderActionSheetAwareScrollViewComponent} from './types'; +import type {ActionSheetAwareScrollViewProps} from './types'; +import usePreventScrollOnKeyboardInteraction from './usePreventScrollOnKeyboardInteraction'; -const ActionSheetAwareScrollView = forwardRef((props, ref) => ( - - {props.children} - -)); +const ActionSheetAwareScrollView = forwardRef(({onScroll: onScrollProp, ...props}, ref) => { + const fallbackRef = useAnimatedRef(); + const combinedRef = ref ?? fallbackRef; + + const {onScroll} = usePreventScrollOnKeyboardInteraction({scrollViewRef: combinedRef as AnimatedRef, onScroll: onScrollProp}); + + return ( + + {props.children} + + ); +}); export default ActionSheetAwareScrollView; /** - * This is only used on iOS. On other platforms it's just undefined to be pass a prop to FlatList + * The bottom spacing config for this action sheet is only used on Android and iOS. On other platforms, + * this component will be a default Animated.ScrollView, because the onScroll handler used is from Reanimated. * * This function should be used as renderScrollComponent prop for FlatList * @param {Object} props - props that will be passed to the ScrollView from FlatList * @returns {React.ReactElement} - ActionSheetAwareScrollView */ -const renderScrollComponent: RenderActionSheetAwareScrollViewComponent = undefined; + +function renderScrollComponent(props: ActionSheetAwareScrollViewProps) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/ActionSheetAwareScrollView/types.ts b/src/components/ActionSheetAwareScrollView/types.ts index 9d7f7f765fef..98ecf5c13055 100644 --- a/src/components/ActionSheetAwareScrollView/types.ts +++ b/src/components/ActionSheetAwareScrollView/types.ts @@ -1,6 +1,10 @@ -import type {PropsWithChildren} from 'react'; -import type {ScrollViewProps} from 'react-native'; +import type {ReactNode} from 'react'; +import type {AnimatedScrollViewProps, ScrollHandlerProcessed, SharedValue} from 'react-native-reanimated'; -type ActionSheetAwareScrollViewProps = PropsWithChildren; -type RenderActionSheetAwareScrollViewComponent = ((props: ActionSheetAwareScrollViewProps) => React.ReactElement) | undefined; -export type {ActionSheetAwareScrollViewProps, RenderActionSheetAwareScrollViewComponent}; +type ActionSheetAwareScrollViewProps = Omit & { + children?: ReactNode | SharedValue; + onScroll?: ScrollHandlerProcessed>; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {ActionSheetAwareScrollViewProps}; diff --git a/src/components/ActionSheetAwareScrollView/usePreventScrollOnKeyboardInteraction.ts b/src/components/ActionSheetAwareScrollView/usePreventScrollOnKeyboardInteraction.ts new file mode 100644 index 000000000000..5ee0a9ad289a --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/usePreventScrollOnKeyboardInteraction.ts @@ -0,0 +1,34 @@ +import {useKeyboardHandler} from 'react-native-keyboard-controller'; +import type {AnimatedRef, ScrollHandlerProcessed} from 'react-native-reanimated'; +import {scrollTo, useAnimatedScrollHandler, useComposedEventHandler, useSharedValue} from 'react-native-reanimated'; +import type Reanimated from 'react-native-reanimated'; + +function usePreventScrollOnKeyboardInteraction({ + scrollViewRef, + onScroll: onScrollProp, +}: { + scrollViewRef: AnimatedRef; + onScroll?: ScrollHandlerProcessed>; +}) { + // Receive the latest scroll position whenever the content is scrolled + const scroll = useSharedValue(0); + const onScrollInternal = useAnimatedScrollHandler({ + onScroll: (e) => { + scroll.set(e.contentOffset.y); + }, + }); + + // Scroll to the latest scroll position whenever the keyboard is interacted with, + // to prevent additional scrolling when the keyboard is interactively dismissed. + useKeyboardHandler({ + onInteractive: () => { + scrollTo(scrollViewRef, 0, scroll.get(), false); + }, + }); + + const onScroll = useComposedEventHandler([onScrollInternal, onScrollProp ?? null]); + + return {onScroll}; +} + +export default usePreventScrollOnKeyboardInteraction; diff --git a/src/components/AnimatedFlatListWithCellRenderer.tsx b/src/components/AnimatedFlatListWithCellRenderer.tsx new file mode 100644 index 000000000000..74aa7a1aa3f3 --- /dev/null +++ b/src/components/AnimatedFlatListWithCellRenderer.tsx @@ -0,0 +1,122 @@ +/** + * This is a copy of the FlatList implementation from 'react-native-reanimated' in order to implement a custom CellRendererComponent. + * This should be updated when the original implementation updates + * Taken from: https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/src/component/FlatList.tsx + */ +import React, {forwardRef, useRef} from 'react'; +import type {FlatListProps, NativeScrollEvent, NativeSyntheticEvent, CellRendererProps as RNCellRendererProps} from 'react-native'; +import {FlatList} from 'react-native'; +import type {AnimatedProps, ILayoutAnimationBuilder, ScrollHandlerProcessed} from 'react-native-reanimated'; +import Animated, {LayoutAnimationConfig} from 'react-native-reanimated'; + +// eslint-disable-next-line deprecation/deprecation +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + +type CellRendererComponentProps = React.ComponentType> | null | undefined; + +const createCellRendererComponent = (CellRendererComponentProp?: CellRendererComponentProps, itemLayoutAnimationRef?: React.RefObject) => { + // Make CellRendererComponent specifically use the 'Item' type from its parent scope + function CellRendererComponent(props: RNCellRendererProps) { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + {CellRendererComponentProp ? {props.children} : props.children} + + ); + } + + return CellRendererComponent; +}; +type ReanimatedFlatListPropsWithLayout = { + /** + * Lets you pass layout animation directly to the FlatList item. + */ + itemLayoutAnimation?: ILayoutAnimationBuilder; + /** + * Lets you skip entering and exiting animations of FlatList items when on FlatList mount or unmount. + */ + skipEnteringExitingAnimations?: boolean; +} & AnimatedProps>; + +// Since createAnimatedComponent return type is ComponentClass that has the props of the argument, +// but not things like NativeMethods, etc. we need to add them manually by extending the type. +type AnimatedFlatListComplement = { + getNode(): FlatList; +} & FlatList; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnimatedFlatListWithCellRendererProps = Omit, 'CellRendererComponent' | 'onScroll'> & { + CellRendererComponent?: CellRendererComponentProps; + onScroll?: ScrollHandlerProcessed>; + additionalOnScrollHandler?: (event: NativeSyntheticEvent) => void; +}; + +// We need explicit any here, because this is the exact same type that is used in React Native types. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function FlatListForwardRefRender(props: AnimatedFlatListWithCellRendererProps, ref: React.ForwardedRef) { + const {itemLayoutAnimation, skipEnteringExitingAnimations, ...restProps} = props; + + // Set default scrollEventThrottle, because user expects + // to have continuous scroll events and + // react-native defaults it to 50 for FlatLists. + // We set it to 1, so we have peace until + // there are 960 fps screens. + if (!('scrollEventThrottle' in restProps)) { + // eslint-disable-next-line react-compiler/react-compiler + restProps.scrollEventThrottle = 1; + } + + const itemLayoutAnimationRef = useRef(itemLayoutAnimation); + itemLayoutAnimationRef.current = itemLayoutAnimation; + + const CellRendererComponent = React.useMemo( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => createCellRendererComponent(props.CellRendererComponent, itemLayoutAnimationRef), + [itemLayoutAnimationRef, props.CellRendererComponent], + ); + + const animatedFlatList = ( + // @ts-expect-error In its current type state, createAnimatedComponent cannot create generic components. + + ); + + if (skipEnteringExitingAnimations === undefined) { + return animatedFlatList; + } + + return ( + + {animatedFlatList} + + ); +} + +const AnimatedFlatListWithCellRenderer = forwardRef(FlatListForwardRefRender) as < + // We need explicit any here, because this is the exact same type that is used in React Native types. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ItemT = any, +>( + props: AnimatedFlatListWithCellRendererProps & { + ref?: React.ForwardedRef; + }, +) => React.ReactElement; + +type ReanimatedFlatList = typeof AnimatedFlatList & AnimatedFlatListComplement; + +export type {ReanimatedFlatList, AnimatedFlatListWithCellRendererProps}; + +export default AnimatedFlatListWithCellRenderer; diff --git a/src/components/FlatList/index.android.tsx b/src/components/FlatList/index.android.tsx index edc9f28cb5f8..b504d7167686 100644 --- a/src/components/FlatList/index.android.tsx +++ b/src/components/FlatList/index.android.tsx @@ -2,11 +2,12 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useRef} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {FlatList} from 'react-native'; -import type {CustomFlatListProps} from './index'; +import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList'; +import type {CustomFlatListProps} from './types'; // FlatList wrapped with the freeze component will lose its scroll state when frozen (only for Android). // CustomFlatList saves the offset and use it for scrollToOffset() when unfrozen. -function CustomFlatList({ref, ...props}: CustomFlatListProps) { +function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, ...props}: CustomFlatListProps) { const lastScrollOffsetRef = useRef(0); const onScreenFocus = useCallback(() => { @@ -32,6 +33,17 @@ function CustomFlatList({ref, ...props}: CustomFlatListProps) { }, [onScreenFocus]), ); + if (enableAnimatedKeyboardDismissal) { + return ( + + ); + } + return ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/FlatList/index.ios.tsx b/src/components/FlatList/index.ios.tsx index 4c73b4417d18..def414df826b 100644 --- a/src/components/FlatList/index.ios.tsx +++ b/src/components/FlatList/index.ios.tsx @@ -1,10 +1,11 @@ import React, {useCallback, useState} from 'react'; import {FlatList} from 'react-native'; -import type {CustomFlatListProps} from './index'; +import {useComposedEventHandler} from 'react-native-reanimated'; +import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList'; +import type {CustomFlatListProps} from './types'; // On iOS, we have to unset maintainVisibleContentPosition while the user is scrolling to prevent jumping to the beginning issue -function CustomFlatList({ref, ...props}: CustomFlatListProps) { - const {maintainVisibleContentPosition: originalMaintainVisibleContentPosition, ...rest} = props; +function CustomFlatList({ref, maintainVisibleContentPosition: maintainVisibleContentPositionProp, enableAnimatedKeyboardDismissal = false, ...restProps}: CustomFlatListProps) { const [isScrolling, setIsScrolling] = useState(false); const handleScrollBegin = useCallback(() => { @@ -15,12 +16,25 @@ function CustomFlatList({ref, ...props}: CustomFlatListProps) { setIsScrolling(false); }, []); - const maintainVisibleContentPosition = isScrolling ? undefined : originalMaintainVisibleContentPosition; + const maintainVisibleContentPosition = isScrolling ? undefined : maintainVisibleContentPositionProp; + + if (enableAnimatedKeyboardDismissal) { + return ( + + ); + } return ( // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} + {...restProps} ref={ref} maintainVisibleContentPosition={maintainVisibleContentPosition} onMomentumScrollBegin={handleScrollBegin} @@ -29,5 +43,5 @@ function CustomFlatList({ref, ...props}: CustomFlatListProps) { ); } -CustomFlatList.displayName = 'CustomFlatListWithRef'; +CustomFlatList.displayName = 'CustomFlatList'; export default CustomFlatList; diff --git a/src/components/FlatList/index.tsx b/src/components/FlatList/index.tsx index 267f127600f9..b5d8f650d310 100644 --- a/src/components/FlatList/index.tsx +++ b/src/components/FlatList/index.tsx @@ -1,9 +1,10 @@ /* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */ import type {ForwardedRef, RefObject} from 'react'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import type {FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {FlatList} from 'react-native'; import {isMobileSafari} from '@libs/Browser'; +import type {CustomFlatListProps} from './types'; // Changing the scroll position during a momentum scroll does not work on mobile Safari. // We do a best effort to avoid content jumping by using some hacks on mobile Safari only. @@ -41,11 +42,7 @@ function getScrollableNode(flatList: FlatList | null): HTMLElement | undefined { return flatList?.getScrollableNode() as HTMLElement | undefined; } -type CustomFlatListProps = FlatListProps & { - ref?: ForwardedRef; -}; - -function MVCPFlatList({maintainVisibleContentPosition, horizontal = false, onScroll, ref, ...props}: CustomFlatListProps) { +function MVCPFlatList({maintainVisibleContentPosition, horizontal = false, onScroll, additionalOnScrollHandler, ref, ...props}: CustomFlatListProps) { const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {}; const scrollRef = useRef(null); const prevFirstVisibleOffsetRef = useRef(0); @@ -228,10 +225,9 @@ function MVCPFlatList({maintainVisibleContentPosition, horizontal = false const onScrollInternal = useCallback( (event: NativeSyntheticEvent) => { prepareForMaintainVisibleContentPosition(); - - onScroll?.(event); + additionalOnScrollHandler?.(event); }, - [prepareForMaintainVisibleContentPosition, onScroll], + [prepareForMaintainVisibleContentPosition, additionalOnScrollHandler], ); return ( @@ -258,5 +254,3 @@ function MVCPFlatList({maintainVisibleContentPosition, horizontal = false MVCPFlatList.displayName = 'MVCPFlatList'; export default MVCPFlatList; - -export type {CustomFlatListProps}; diff --git a/src/components/FlatList/types.ts b/src/components/FlatList/types.ts new file mode 100644 index 000000000000..de656a6bf660 --- /dev/null +++ b/src/components/FlatList/types.ts @@ -0,0 +1,35 @@ +import type {ForwardedRef} from 'react'; +import type {CellRendererProps, FlatList, FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {AnimatedFlatListWithCellRendererProps} from '@components/AnimatedFlatListWithCellRenderer'; + +type CustomFlatListProps = Omit, 'CellRendererComponent' | 'onScroll'> & { + /** + * Whether to use the animated keyboard handler capabilities on native (iOS and Android) + * Allows for interactive keyboard dimissal when the user drags the keyboard down + */ + enableAnimatedKeyboardDismissal?: boolean; + + /** + * Custom cell renderer component + */ + CellRendererComponent?: React.ComponentType> | null; + + /** + * Ref to the FlatList component + */ + ref?: ForwardedRef; + + additionalOnScrollHandler?: (event: NativeSyntheticEvent) => void; +} & ( + | { + enableAnimatedKeyboardDismissal: true; + onScroll?: AnimatedFlatListWithCellRendererProps['onScroll']; + } + | { + enableAnimatedKeyboardDismissal?: false; + onScroll?: FlatListProps['onScroll']; + } + ); + +// eslint-disable-next-line import/prefer-default-export +export type {CustomFlatListProps}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx index 35184f5320ef..4a1e92bf1dd0 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.e2e.tsx @@ -1,16 +1,12 @@ import React, {useMemo} from 'react'; -import type {FlatListProps, ScrollViewProps, ViewToken} from 'react-native'; +import type {ScrollViewProps, ViewToken} from 'react-native'; import {DeviceEventEmitter, FlatList} from 'react-native'; import type {ReportAction} from '@src/types/onyx'; - -type BaseInvertedFlatListProps = FlatListProps & { - shouldEnableAutoScrollToTopThreshold?: boolean; - ref?: React.ForwardedRef>; -}; +import type {BaseInvertedFlatListProps} from './types'; const AUTOSCROLL_TO_TOP_THRESHOLD = 128; -function BaseInvertedFlatListE2e({ref, ...props}: BaseInvertedFlatListProps) { +function BaseInvertedFlatListE2E({ref, ...props}: BaseInvertedFlatListProps) { const {shouldEnableAutoScrollToTopThreshold, ...rest} = props; const handleViewableItemsChanged = useMemo( @@ -46,6 +42,6 @@ function BaseInvertedFlatListE2e({ref, ...props}: BaseInvertedFlatListProps) { ); } -BaseInvertedFlatListE2e.displayName = 'BaseInvertedFlatListE2e'; +BaseInvertedFlatListE2E.displayName = 'BaseInvertedFlatListE2E'; -export default BaseInvertedFlatListE2e; +export default BaseInvertedFlatListE2E; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx index 47274b133c6f..ffa017aafc64 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/index.tsx @@ -1,10 +1,10 @@ -import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {FlatListProps, ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; +import type {ListRenderItemInfo, FlatList as RNFlatList, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; import usePrevious from '@hooks/usePrevious'; import getInitialPaginationSize from './getInitialPaginationSize'; import RenderTaskQueue from './RenderTaskQueue'; +import type {BaseInvertedFlatListProps} from './types'; // Adapted from https://github.com/facebook/react-native/blob/29a0d7c3b201318a873db0d1b62923f4ce720049/packages/virtualized-lists/Lists/VirtualizeUtils.js#L237 function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: number): string { @@ -19,18 +19,19 @@ function defaultKeyExtractor(item: T | {key: string} | {id: string}, index: n return String(index); } -type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' | 'initialScrollIndex'> & { - shouldEnableAutoScrollToTopThreshold?: boolean; - data: T[]; - renderItem: ListRenderItem; - initialScrollKey?: string | null; - ref?: ForwardedRef; -}; - const AUTOSCROLL_TO_TOP_THRESHOLD = 250; -function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) { - const {shouldEnableAutoScrollToTopThreshold, initialScrollKey, data, onStartReached, renderItem, keyExtractor = defaultKeyExtractor, ...rest} = props; +function BaseInvertedFlatList({ + ref, + shouldEnableAutoScrollToTopThreshold, + initialScrollKey, + data, + onStartReached, + additionalOnScrollHandler, + renderItem, + keyExtractor = defaultKeyExtractor, + ...restProps +}: BaseInvertedFlatListProps) { // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more // previous items, until everything is rendered. We also progressively render new data that is added at the start of the @@ -126,16 +127,17 @@ function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) }); return ( - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} + {...restProps} ref={listRef} maintainVisibleContentPosition={maintainVisibleContentPosition} inverted data={displayedData} - onStartReached={handleStartReached} renderItem={handleRenderItem} keyExtractor={keyExtractor} + onStartReached={handleStartReached} + additionalOnScrollHandler={additionalOnScrollHandler} /> ); } @@ -143,7 +145,4 @@ function BaseInvertedFlatList({ref, ...props}: BaseInvertedFlatListProps) BaseInvertedFlatList.displayName = 'BaseInvertedFlatList'; export default BaseInvertedFlatList; - export {AUTOSCROLL_TO_TOP_THRESHOLD}; - -export type {BaseInvertedFlatListProps}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList/types.ts b/src/components/InvertedFlatList/BaseInvertedFlatList/types.ts new file mode 100644 index 000000000000..4b801a254034 --- /dev/null +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/types.ts @@ -0,0 +1,14 @@ +import type {ForwardedRef} from 'react'; +import type {ListRenderItem, FlatList as RNFlatList} from 'react-native'; +import type {CustomFlatListProps} from '@components/FlatList/types'; + +type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' | 'initialScrollIndex'> & { + shouldEnableAutoScrollToTopThreshold?: boolean; + data: T[]; + renderItem: ListRenderItem; + initialScrollKey?: string | null; + ref?: ForwardedRef; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {BaseInvertedFlatListProps}; diff --git a/src/components/InvertedFlatList/index.native.tsx b/src/components/InvertedFlatList/index.native.tsx index 50e6fb1d5cc1..8c2cc19a898c 100644 --- a/src/components/InvertedFlatList/index.native.tsx +++ b/src/components/InvertedFlatList/index.native.tsx @@ -1,6 +1,6 @@ import React from 'react'; import BaseInvertedFlatList from './BaseInvertedFlatList'; -import type {BaseInvertedFlatListProps} from './BaseInvertedFlatList'; +import type {BaseInvertedFlatListProps} from './BaseInvertedFlatList/types'; import CellRendererComponent from './CellRendererComponent'; function BaseInvertedFlatListWithRef({ref, ...props}: BaseInvertedFlatListProps) { diff --git a/src/components/InvertedFlatList/index.tsx b/src/components/InvertedFlatList/index.tsx index 40b1451e1fed..72d7267b0d9c 100644 --- a/src/components/InvertedFlatList/index.tsx +++ b/src/components/InvertedFlatList/index.tsx @@ -1,14 +1,13 @@ import React, {useEffect, useRef} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter} from 'react-native'; import CONST from '@src/CONST'; import BaseInvertedFlatList from './BaseInvertedFlatList'; -import type {BaseInvertedFlatListProps} from './BaseInvertedFlatList'; +import type {BaseInvertedFlatListProps} from './BaseInvertedFlatList/types'; import CellRendererComponent from './CellRendererComponent'; // This is adapted from https://codesandbox.io/s/react-native-dsyse // It's a HACK alert since FlatList has inverted scrolling on web -function InvertedFlatList({onScroll: onScrollProp = () => {}, ref, ...props}: BaseInvertedFlatListProps) { +function InvertedFlatList({ref, onScroll, ...props}: BaseInvertedFlatListProps) { const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); const updateInProgress = useRef(false); @@ -26,22 +25,20 @@ function InvertedFlatList({onScroll: onScrollProp = () => {}, ref, ...props}: /** * Emits when the scrolling is in progress. Also, * invokes the onScroll callback function from props. - * - * @param event - The onScroll event from the FlatList */ - const onScroll = (event: NativeSyntheticEvent) => { - onScrollProp(event); - - if (!updateInProgress.current) { - updateInProgress.current = true; - DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true); + const emitOnScroll = () => { + if (updateInProgress.current) { + return; } + + updateInProgress.current = true; + DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true); }; /** * Emits when the scrolling has ended. */ - const onScrollEnd = () => { + const emitOnScrollEnd = () => { DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, false); updateInProgress.current = false; }; @@ -60,8 +57,9 @@ function InvertedFlatList({onScroll: onScrollProp = () => {}, ref, ...props}: * https://github.com/necolas/react-native-web/issues/1021#issuecomment-984151185 * */ - const handleScroll = (event: NativeSyntheticEvent) => { - onScroll(event); + const emitScrollEvents: BaseInvertedFlatListProps['additionalOnScrollHandler'] = () => { + emitOnScroll(); + const timestamp = Date.now(); if (scrollEndTimeout.current) { @@ -75,7 +73,7 @@ function InvertedFlatList({onScroll: onScrollProp = () => {}, ref, ...props}: } // Scroll has ended lastScrollEvent.current = null; - onScrollEnd(); + emitOnScrollEnd(); }, 250); } @@ -87,7 +85,8 @@ function InvertedFlatList({onScroll: onScrollProp = () => {}, ref, ...props}: // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} - onScroll={handleScroll} + onScroll={onScroll} + additionalOnScrollHandler={emitScrollEvents} CellRendererComponent={CellRendererComponent} /> ); diff --git a/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx b/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx new file mode 100644 index 000000000000..3a6d7a14a887 --- /dev/null +++ b/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx @@ -0,0 +1,152 @@ +import type {PropsWithChildren} from 'react'; +import React, {useMemo, useState} from 'react'; +import {useKeyboardHandler} from 'react-native-keyboard-controller'; +import {useAnimatedScrollHandler, useSharedValue} from 'react-native-reanimated'; +import type {ScrollHandlerProcessed, SharedValue} from 'react-native-reanimated'; +import type {ValueOf} from 'type-fest'; +import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type ListBehavior = ValueOf; + +type KeyboardDismissibleFlatListContextValues = { + keyboardHeight: SharedValue; + keyboardOffset: SharedValue; + contentSizeHeight: SharedValue; + layoutMeasurementHeight: SharedValue; + onScroll: ScrollHandlerProcessed>; + scrollY: SharedValue; + setListBehavior: React.Dispatch>; +}; + +const createDummySharedValue = (): SharedValue => + ({ + value: 0, + addListener: () => 0, + removeListener: () => {}, + modify: () => {}, + }) as unknown as SharedValue; + +const KeyboardDismissibleFlatListContext = React.createContext({ + keyboardHeight: createDummySharedValue(), + keyboardOffset: createDummySharedValue(), + scrollY: createDummySharedValue(), + onScroll: () => {}, + contentSizeHeight: createDummySharedValue(), + layoutMeasurementHeight: createDummySharedValue(), + setListBehavior: () => {}, +}); + +function KeyboardDismissibleFlatListContextProvider({children}: PropsWithChildren) { + const [modal] = useOnyx(ONYXKEYS.MODAL, {canBeMissing: false}); + const isModalVisible = useMemo(() => modal?.isPopover, [modal?.isPopover]); + + const [listBehavior, setListBehavior] = useState(CONST.LIST_BEHAVIOR.INVERTED); + + const height = useSharedValue(0); + const offset = useSharedValue(0); + const scrollY = useSharedValue(0); + const keyboardOpenedHeight = useSharedValue(0); + const contentSizeHeight = useSharedValue(0); + const layoutMeasurementHeight = useSharedValue(0); + + useKeyboardHandler({ + onStart: (e) => { + 'worklet'; + + const scrollYValueAtStart = scrollY.get(); + const prevHeight = height.get(); + + height.set(e.height); + + const willKeyboardOpen = e.progress === 1; + + if (willKeyboardOpen) { + if (e.height > 0) { + keyboardOpenedHeight.set(e.height); + } + + // Do nothing when the keyboard opens again after the modal closes, since the current position is preserved + if (isModalVisible) { + return; + } + } + + if (isModalVisible) { + // Since the keyboard will close immediately when a modal opens, this is to preserve the current scroll position before it closes + offset.set(scrollYValueAtStart + keyboardOpenedHeight.get()); + return; + } + + if (listBehavior === CONST.LIST_BEHAVIOR.REGULAR) { + const isAtTop = scrollY.get() <= 0; + + if (!willKeyboardOpen && isAtTop) { + return offset.set(0); + } + + return offset.set(scrollYValueAtStart - prevHeight); + } + + const invertedListVisualTop = contentSizeHeight.get() - layoutMeasurementHeight.get(); + + const isAtTop = scrollY.get() >= invertedListVisualTop; + + if (!willKeyboardOpen && isAtTop) { + return offset.set(invertedListVisualTop); + } + + // Preserve the current scroll position the the keyboard starts its movement + offset.set(scrollYValueAtStart + prevHeight); + }, + onInteractive: (e) => { + 'worklet'; + + height.set(e.height); + + if (listBehavior === CONST.LIST_BEHAVIOR.REGULAR) { + return offset.set(scrollY.get() - e.height); + } + + offset.set(scrollY.get() + e.height); + }, + onMove: (e) => { + 'worklet'; + + height.set(e.height); + }, + onEnd: (e) => { + 'worklet'; + + height.set(e.height); + }, + }); + + const onScroll = useAnimatedScrollHandler({ + onScroll: (e) => { + scrollY.set(e.contentOffset.y); + contentSizeHeight.set(e.contentSize.height); + layoutMeasurementHeight.set(e.layoutMeasurement.height); + }, + }); + + const value = useMemo( + () => ({ + keyboardHeight: height, + keyboardOffset: offset, + onScroll, + scrollY, + contentSizeHeight, + layoutMeasurementHeight, + setListBehavior, + }), + [height, offset, onScroll, scrollY, contentSizeHeight, layoutMeasurementHeight], + ); + + return {children}; +} + +KeyboardDismissibleFlatListContextProvider.displayName = 'KeyboardDismissibleFlatListContextProvider'; + +export {KeyboardDismissibleFlatListContext, KeyboardDismissibleFlatListContextProvider}; diff --git a/src/components/KeyboardDismissibleFlatList/index.ios.tsx b/src/components/KeyboardDismissibleFlatList/index.ios.tsx new file mode 100644 index 000000000000..f8c783fc902a --- /dev/null +++ b/src/components/KeyboardDismissibleFlatList/index.ios.tsx @@ -0,0 +1,61 @@ +import type {ForwardedRef} from 'react'; +import {forwardRef, useEffect} from 'react'; +import type {FlatList} from 'react-native'; +import {useAnimatedProps, useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-reanimated'; +import type {AnimatedFlatListWithCellRendererProps} from '@components/AnimatedFlatListWithCellRenderer'; +import AnimatedFlatListWithCellRenderer from '@components/AnimatedFlatListWithCellRenderer'; +import useKeyboardDismissibleFlatListValues from './useKeyboardDismissibleFlatListValues'; + +function KeyboardDismissibleFlatList({onScroll: onScrollProp, additionalOnScrollHandler, ...restProps}: AnimatedFlatListWithCellRendererProps, ref: ForwardedRef) { + const {keyboardHeight, keyboardOffset, onScroll: onScrollHandleKeyboard, setListBehavior} = useKeyboardDismissibleFlatListValues(); + + const additionalOnScroll = useAnimatedScrollHandler({ + onScroll: (event) => { + additionalOnScrollHandler?.(event); + }, + }); + + const onScroll = useComposedEventHandler([onScrollHandleKeyboard, additionalOnScroll, onScrollProp ?? null]); + + const invertedListAnimatedProps = useAnimatedProps(() => { + return { + contentInset: { + top: keyboardHeight.get(), + }, + contentOffset: { + x: 0, + y: -keyboardHeight.get() + keyboardOffset.get(), + }, + }; + }); + + const regularListAnimatedProps = useAnimatedProps(() => { + return { + contentInset: { + bottom: keyboardHeight.get(), + }, + contentOffset: { + x: 0, + y: keyboardHeight.get() + keyboardOffset.get(), + }, + }; + }); + + useEffect(() => { + setListBehavior(restProps.inverted ? 'inverted' : 'regular'); + }, [restProps.inverted, setListBehavior]); + + return ( + + ); +} + +export default forwardRef(KeyboardDismissibleFlatList); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx new file mode 100644 index 000000000000..f6022c9428c0 --- /dev/null +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -0,0 +1,30 @@ +import type {ForwardedRef} from 'react'; +import React from 'react'; +import type {FlatList} from 'react-native'; +import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-reanimated'; +import type {AnimatedFlatListWithCellRendererProps} from '@components/AnimatedFlatListWithCellRenderer'; +import AnimatedFlatListWithCellRenderer from '@components/AnimatedFlatListWithCellRenderer'; +import useKeyboardDismissibleFlatListValues from './useKeyboardDismissibleFlatListValues'; + +function KeyboardDismissibleFlatList({onScroll: onScrollProp, additionalOnScrollHandler, ...restProps}: AnimatedFlatListWithCellRendererProps, ref: ForwardedRef) { + const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListValues(); + + const additionalOnScroll = useAnimatedScrollHandler({ + onScroll: (event) => { + additionalOnScrollHandler?.(event); + }, + }); + + const onScroll = useComposedEventHandler([onScrollHandleKeyboard, additionalOnScroll, onScrollProp ?? null]); + + return ( + + ); +} + +export default React.forwardRef(KeyboardDismissibleFlatList); diff --git a/src/components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues/__mocks__/index.ts b/src/components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues/__mocks__/index.ts new file mode 100644 index 000000000000..36c0f946118b --- /dev/null +++ b/src/components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues/__mocks__/index.ts @@ -0,0 +1,29 @@ +function useKeyboardDismissibleFlatListValues() { + const createMockSharedValue = (initialValue = 0) => ({ + value: initialValue, + get: jest.fn().mockReturnValue(initialValue), + set: jest.fn(), + addListener: jest.fn().mockReturnValue(0), + removeListener: jest.fn(), + modify: jest.fn(), + }); + + const mockScrollHandler = jest.fn(); + const mockSetListBehavior = jest.fn(); + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: jest.fn().mockReturnValue({ + keyboardHeight: createMockSharedValue(0), + keyboardOffset: createMockSharedValue(0), + scrollY: createMockSharedValue(0), + onScroll: mockScrollHandler, + contentSizeHeight: createMockSharedValue(0), + layoutMeasurementHeight: createMockSharedValue(0), + setListBehavior: mockSetListBehavior, + }), + }; +} + +export default useKeyboardDismissibleFlatListValues; diff --git a/src/components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues/index.ts b/src/components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues/index.ts new file mode 100644 index 000000000000..436f1303b70a --- /dev/null +++ b/src/components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues/index.ts @@ -0,0 +1,6 @@ +import {useContext} from 'react'; +import {KeyboardDismissibleFlatListContext} from '@components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext'; + +export default function useKeyboardDismissibleFlatListValues() { + return useContext(KeyboardDismissibleFlatListContext); +} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index a9ca147df1b2..67399b714e6a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -5,19 +5,21 @@ import {isUserValidatedSelector} from '@selectors/Account'; import {accountIDSelector} from '@selectors/Session'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {useAnimatedReaction} from 'react-native-reanimated'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import FlatList from '@components/FlatList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; +import useKeyboardDismissibleFlatListValues from '@components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import useKeyboardState from '@hooks/useKeyboardState'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -28,7 +30,9 @@ import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; import DateUtils from '@libs/DateUtils'; @@ -105,8 +109,16 @@ type MoneyRequestReportListProps = { /** Whether report actions are still loading and we load the report for the first time, since the last sign in */ showReportActionsLoadingState?: boolean; + + /** The current composer height */ + composerHeight: number; + + /** Whether the composer is in full size */ + isComposerFullSize?: boolean; }; +const ON_SCROLL_TO_LIMITS_THRESHOLD = 0.75; + function MoneyRequestReportActionsList({ report, policy, @@ -117,6 +129,8 @@ function MoneyRequestReportActionsList({ hasNewerActions, hasOlderActions, showReportActionsLoadingState, + composerHeight, + isComposerFullSize, }: MoneyRequestReportListProps) { const styles = useThemeStyles(); const {translate, getLocalDateFromDatetime} = useLocalize(); @@ -126,6 +140,10 @@ function MoneyRequestReportActionsList({ const didLayout = useRef(false); const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); + const {unmodifiedPaddings} = useSafeAreaPaddings(); + const {isKeyboardActive} = useKeyboardState(); + const StyleUtils = useStyleUtils(); + const {contentSizeHeight, layoutMeasurementHeight, keyboardHeight, scrollY} = useKeyboardDismissibleFlatListValues(); const route = useRoute>(); // wrapped in useMemo to avoid unnecessary re-renders and improve performance const reportTransactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); @@ -261,6 +279,28 @@ function MoneyRequestReportActionsList({ loadNewerChats(false); }, [loadNewerChats]); + // The previous scroll tracking implementation was made via ref. This is + // to ensure it will behave the same as before. + useAnimatedReaction( + () => { + return { + offsetY: scrollY.get(), + csHeight: contentSizeHeight.get(), + lmHeight: layoutMeasurementHeight.get(), + }; + }, + ({offsetY, csHeight, lmHeight}) => { + /** + * Count the diff between current scroll position and the bottom of the list. + * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) + */ + scrollingVerticalBottomOffset.current = csHeight - lmHeight - offsetY; + + // We additionally track the top offset to be able to scroll to the new transaction when it's added + scrollingVerticalTopOffset.current = offsetY; + }, + ); + const prevUnreadMarkerReportActionID = useRef(null); const visibleActionsMap = useMemo(() => { @@ -399,25 +439,14 @@ function MoneyRequestReportActionsList({ }, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, - currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, + currentVerticalScrollingOffset: scrollY, readActionSkippedRef: readActionSkipped, + hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, + keyboardHeight, unreadMarkerReportActionIndex, isInverted: false, - onTrackScrolling: (event: NativeSyntheticEvent) => { - const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; - const fullContentHeight = contentSize.height; - - /** - * Count the diff between current scroll position and the bottom of the list. - * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) - */ - scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; - - // We additionally track the top offset to be able to scroll to the new transaction when it's added - scrollingVerticalTopOffset.current = contentOffset.y; - }, }); useEffect(() => { @@ -633,6 +662,9 @@ function MoneyRequestReportActionsList({ const isSelectAllChecked = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === transactionsWithoutPendingDelete.length; // Wrapped into useCallback to stabilize children re-renders const keyExtractor = useCallback((item: OnyxTypes.ReportAction) => item.reportActionID, []); + + const paddingBottom = StyleUtils.getReportPaddingBottom({composerHeight, isKeyboardActive, safePaddingBottom: unmodifiedPaddings.bottom ?? 0, isComposerFullSize}); + return ( )} - + ) : ( } + keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt2]} ref={reportScrollManager.ref} ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 23d40bb69786..fc102b4d8a34 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -1,6 +1,8 @@ import {PortalHost} from '@gorhom/portal'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {InteractionManager, View} from 'react-native'; +import {KeyboardGestureArea} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; import HeaderGap from '@components/HeaderGap'; import MoneyReportHeader from '@components/MoneyReportHeader'; @@ -87,6 +89,10 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const [composerHeight, setComposerHeight] = useState(CONST.CHAT_FOOTER_MIN_HEIGHT); + // Starts with this value to avoid a big jump while the container height is being calculated in case the screen is first rendered w/ a full size composer. It's based on the perceived concierge header height on the iPhone 16 Pro. + const [headerHeight, setHeaderHeight] = useState(73); + const reportID = report?.reportID; const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`, {canBeMissing: true}); @@ -130,6 +136,11 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe const isEmptyTransactionReport = visibleTransactions && visibleTransactions.length === 0 && transactionThreadReportID === undefined; const shouldDisplayMoneyRequestActionsList = !!isEmptyTransactionReport || shouldDisplayReportTableView(report, visibleTransactions ?? []); + const onComposerLayout = useCallback((height: number) => setComposerHeight(height), []); + const onHeaderLayout = useCallback((e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height); + }, []); + const reportHeaderView = useMemo( () => isTransactionThreadView ? ( @@ -191,6 +202,8 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} lastReportAction={lastReportAction} + onLayout={onComposerLayout} + headerHeight={headerHeight} // If the report is from the 'Send Money' flow, we add the comment to the `iou` report because for these we don't combine reportActions even if there is a single transaction (they always have a single transaction) transactionThreadReportID={isSentMoneyReport ? undefined : transactionThreadReportID} /> @@ -200,7 +213,13 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe } return ( - + - {reportHeaderView} + {reportHeaderView} {shouldDisplayMoneyRequestActionsList ? ( ) : ( )} {shouldDisplayReportFooter ? ( @@ -246,6 +267,8 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe isComposerFullSize={!!isComposerFullSize} lastReportAction={lastReportAction} reportTransactions={transactions} + onLayout={onComposerLayout} + headerHeight={headerHeight} // If the report is from the 'Send Money' flow, we add the comment to the `iou` report because for these we don't combine reportActions even if there is a single transaction (they always have a single transaction) transactionThreadReportID={isSentMoneyReport ? undefined : transactionThreadReportID} /> @@ -254,7 +277,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe ) : null} - + ); } diff --git a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx index 61f901f9c00c..8830ddfde803 100644 --- a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx @@ -1,7 +1,7 @@ import type {ReactNode} from 'react'; import React, {useMemo} from 'react'; -import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; +import Animated from 'react-native-reanimated'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; import OfflineIndicator from '@components/OfflineIndicator'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -10,6 +10,7 @@ import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import useOfflineIndicatoKeyboardHandlingStyles from './useOfflineIndicatoKeyboardHandlingStyles'; type ScreenWrapperOfflineIndicatorsProps = { /** Styles for the offline indicator */ @@ -62,7 +63,7 @@ function ScreenWrapperOfflineIndicators({ * By default, the background color of the small screen offline indicator is translucent. * If `isOfflineIndicatorTranslucent` is set to true, an opaque background color is applied. */ - const smallScreenOfflineIndicatorBackgroundStyle = useMemo(() => { + const smallScreenBackgroundStyle = useMemo(() => { const showOfflineIndicatorBackground = !extraContent && (isSoftKeyNavigation || isOffline); if (!showOfflineIndicatorBackground) { return undefined; @@ -78,7 +79,7 @@ function ScreenWrapperOfflineIndicators({ * two overlapping layers of translucent background. * If the device does not have soft keys, the bottom safe area padding is applied as `paddingBottom`. */ - const smallScreenOfflineIndicatorBottomSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ + const smallScreenBottomSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding, addOfflineIndicatorBottomSafeAreaPadding: false, styleProperty: isSoftKeyNavigation ? 'bottom' : 'paddingBottom', @@ -89,19 +90,9 @@ function ScreenWrapperOfflineIndicators({ * It always applies the bottom safe area padding as well as the background style, if the device has soft keys. * In this case, we want the whole container (including the bottom safe area padding) to have translucent/opaque background. */ - const smallScreenOfflineIndicatorContainerStyle = useMemo( - () => [ - smallScreenOfflineIndicatorBottomSafeAreaStyle, - shouldSmallScreenOfflineIndicatorStickToBottom && styles.stickToBottom, - !isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, - ], - [ - smallScreenOfflineIndicatorBottomSafeAreaStyle, - shouldSmallScreenOfflineIndicatorStickToBottom, - styles.stickToBottom, - isSoftKeyNavigation, - smallScreenOfflineIndicatorBackgroundStyle, - ], + const smallScreenContainerStyle = useMemo( + () => [smallScreenBottomSafeAreaStyle, shouldSmallScreenOfflineIndicatorStickToBottom && styles.stickToBottom, !isSoftKeyNavigation && smallScreenBackgroundStyle], + [smallScreenBottomSafeAreaStyle, shouldSmallScreenOfflineIndicatorStickToBottom, styles.stickToBottom, isSoftKeyNavigation, smallScreenBackgroundStyle], ); /** @@ -109,20 +100,25 @@ function ScreenWrapperOfflineIndicators({ * If the device has soft keys, we only want to apply the background style to the small screen offline indicator component, * rather than the whole container, because otherwise the navigation bar would be extra opaque, since it already has a translucent background. */ - const smallScreenOfflineIndicatorStyle = useMemo( - () => [styles.pl5, isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle], - [isSoftKeyNavigation, smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle, styles.pl5], + const smallScreenStyle = useMemo( + () => [styles.pl5, isSoftKeyNavigation && smallScreenBackgroundStyle, offlineIndicatorStyle], + [isSoftKeyNavigation, smallScreenBackgroundStyle, offlineIndicatorStyle, styles.pl5], ); + /** + * This style includes the styles applied to the small screen offline indicator component when the keyboard is interacted with. + */ + const keyboardHandlingStyles = useOfflineIndicatoKeyboardHandlingStyles(); + return ( <> {shouldShowSmallScreenOfflineIndicator && ( <> {isOffline && ( - - + + {/* Since import state is tightly coupled to the offline state, it is safe to display it when showing offline indicator */} - + )} diff --git a/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ios.ts b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ios.ts new file mode 100644 index 000000000000..b052c5e26609 --- /dev/null +++ b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ios.ts @@ -0,0 +1,15 @@ +import type {ViewStyle} from 'react-native'; +import {useAnimatedStyle} from 'react-native-reanimated'; +import useKeyboardDismissibleFlatListValues from '@components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useStyleUtils from '@hooks/useStyleUtils'; + +function useOfflineIndicatoKeyboardHandlingStyles(): ViewStyle { + const StyleUtils = useStyleUtils(); + const {keyboardHeight} = useKeyboardDismissibleFlatListValues(); + const {paddingBottom} = useSafeAreaPaddings(true); + + return useAnimatedStyle(() => StyleUtils.getOfflineIndicatorKeyboardHandlingStyles(keyboardHeight, paddingBottom)); +} + +export default useOfflineIndicatoKeyboardHandlingStyles; diff --git a/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts new file mode 100644 index 000000000000..7c8386eab4b4 --- /dev/null +++ b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts @@ -0,0 +1,7 @@ +import type {ViewStyle} from 'react-native'; + +function useOfflineIndicatoKeyboardHandlingStyles(): ViewStyle { + return {}; +} + +export default useOfflineIndicatoKeyboardHandlingStyles; diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx deleted file mode 100644 index 4376585c6f0a..000000000000 --- a/src/components/SwipeableView/index.native.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, {useRef} from 'react'; -import {PanResponder, View} from 'react-native'; -import CONST from '@src/CONST'; -import type SwipeableViewProps from './types'; - -function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { - const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; - const oldYRef = useRef(0); - const panResponder = useRef( - // eslint-disable-next-line react-compiler/react-compiler - PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards - onMoveShouldSetPanResponderCapture: (_event, gestureState) => { - if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { - return true; - } - oldYRef.current = gestureState.dy; - return false; - }, - - // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: onSwipeDown, - }), - ).current; - - // eslint-disable-next-line react/jsx-props-no-spreading, react-compiler/react-compiler - return {children}; -} - -SwipeableView.displayName = 'SwipeableView'; - -export default SwipeableView; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx deleted file mode 100644 index d3881d2efd21..000000000000 --- a/src/components/SwipeableView/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import type SwipeableViewProps from './types'; - -// Swipeable View is available just on Android/iOS for now. -export default ({children}: SwipeableViewProps) => children; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts deleted file mode 100644 index 738e21bb73ee..000000000000 --- a/src/components/SwipeableView/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {ReactNode} from 'react'; - -type SwipeableViewProps = { - /** The content to be rendered within the SwipeableView */ - children: ReactNode; - - /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: () => void; -}; - -export default SwipeableViewProps; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts new file mode 100644 index 000000000000..f1e3578720a6 --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts @@ -0,0 +1,25 @@ +import type {ScrollToBottomHandlerParams} from '@hooks/useReportScrollManager/types'; + +/** + * The iOS interactive keyboard implementation uses the keyboard height in order + * to animate the Report FlatList insets and offsets. This is to avoid content to be + * covered by the keyboard when it is visible. + * */ + +function scrollToBottomHandler({flatListRef, keyboardHeight, isKeyboardActive, setScrollPosition}: ScrollToBottomHandlerParams) { + if (!flatListRef?.current) { + return; + } + + if (isKeyboardActive) { + setScrollPosition({offset: -keyboardHeight}); + flatListRef.current?.scrollToOffset({animated: false, offset: -keyboardHeight}); + return; + } + + setScrollPosition({offset: 0}); + + flatListRef.current?.scrollToOffset({animated: false, offset: 0}); +} + +export default scrollToBottomHandler; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts new file mode 100644 index 000000000000..f0d7c744845a --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts @@ -0,0 +1,13 @@ +import type {ScrollToBottomHandlerParams} from '@hooks/useReportScrollManager/types'; + +function scrollToBottomHandler({flatListRef, setScrollPosition}: ScrollToBottomHandlerParams) { + if (!flatListRef?.current) { + return; + } + + setScrollPosition({offset: 0}); + + flatListRef.current?.scrollToOffset({animated: false, offset: 0}); +} + +export default scrollToBottomHandler; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts new file mode 100644 index 000000000000..e9cffce46353 --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts @@ -0,0 +1,22 @@ +import type {ScrollToOffsetHandlerParams} from '@hooks/useReportScrollManager/types'; + +/** + * The iOS interactive keyboard implementation uses the keyboard height in order + * to animate the Report FlatList insets and offsets. This is to avoid content to be + * covered by the keyboard when it is visible. + * */ + +function scrollToOffsetHandler({flatListRef, offset, isKeyboardActive, keyboardHeight}: ScrollToOffsetHandlerParams) { + if (!flatListRef?.current) { + return; + } + + if (isKeyboardActive) { + flatListRef.current?.scrollToOffset({animated: false, offset: offset - keyboardHeight}); + return; + } + + flatListRef.current.scrollToOffset({offset, animated: false}); +} + +export default scrollToOffsetHandler; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts new file mode 100644 index 000000000000..453c945a2db6 --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts @@ -0,0 +1,11 @@ +import type {ScrollToOffsetHandlerParams} from '@hooks/useReportScrollManager/types'; + +function scrollToOffsetHandler({flatListRef, offset}: ScrollToOffsetHandlerParams) { + if (!flatListRef?.current) { + return; + } + + flatListRef.current.scrollToOffset({offset, animated: false}); +} + +export default scrollToOffsetHandler; diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index fa0042a3bf26..1eae25bc6bf2 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,11 +1,15 @@ import {useCallback, useContext} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; +import useKeyboardState from '@hooks/useKeyboardState'; import {ActionListContext} from '@pages/home/ReportScreenContext'; +import scrollToBottomHandler from './handlers/scrollToBottom'; +import scrollToOffsetHandler from './handlers/scrollToOffset'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { const {flatListRef, setScrollPosition} = useContext(ActionListContext); + const {isKeyboardActive, keyboardHeight} = useKeyboardState(); /** * Scroll to the provided index. @@ -25,15 +29,10 @@ function useReportScrollManager(): ReportScrollManagerData { * Scroll to the bottom of the inverted FlatList. * When FlatList is inverted it's "bottom" is really it's top */ - const scrollToBottom = useCallback(() => { - if (!flatListRef?.current) { - return; - } - - setScrollPosition({offset: 0}); - - flatListRef.current?.scrollToOffset({animated: false, offset: 0}); - }, [flatListRef, setScrollPosition]); + const scrollToBottom = useCallback( + () => scrollToBottomHandler({flatListRef, isKeyboardActive, keyboardHeight, setScrollPosition}), + [flatListRef, setScrollPosition, isKeyboardActive, keyboardHeight], + ); /** * Scroll to the end of the FlatList. @@ -53,16 +52,7 @@ function useReportScrollManager(): ReportScrollManagerData { flatListRef.current.scrollToEnd({animated: false}); }, [flatListRef]); - const scrollToOffset = useCallback( - (offset: number) => { - if (!flatListRef?.current) { - return; - } - - flatListRef.current.scrollToOffset({offset, animated: false}); - }, - [flatListRef], - ); + const scrollToOffset = useCallback((offset: number) => scrollToOffsetHandler({flatListRef, isKeyboardActive, keyboardHeight, offset}), [flatListRef, isKeyboardActive, keyboardHeight]); return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; } diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index 6706f00e1744..907275201eda 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -8,4 +8,19 @@ type ReportScrollManagerData = { scrollToOffset: (offset: number) => void; }; +type ScrollToCommonParams = { + flatListRef: FlatListRefType; + isKeyboardActive: boolean; + keyboardHeight: number; +}; + +type ScrollToOffsetHandlerParams = ScrollToCommonParams & { + offset: number; +}; + +type ScrollToBottomHandlerParams = ScrollToCommonParams & { + setScrollPosition: (position: {offset: number}) => void; +}; + +export type {ScrollToBottomHandlerParams, ScrollToOffsetHandlerParams}; export default ReportScrollManagerData; diff --git a/src/libs/includeSafeAreaBottom/index.ios.ts b/src/libs/includeSafeAreaBottom/index.ios.ts new file mode 100644 index 000000000000..33136544dba2 --- /dev/null +++ b/src/libs/includeSafeAreaBottom/index.ios.ts @@ -0,0 +1 @@ +export default false; diff --git a/src/libs/includeSafeAreaBottom/index.ts b/src/libs/includeSafeAreaBottom/index.ts new file mode 100644 index 000000000000..ff3177babdde --- /dev/null +++ b/src/libs/includeSafeAreaBottom/index.ts @@ -0,0 +1 @@ +export default true; diff --git a/src/libs/shouldEnableKeyboardAvoidingView/index.ios.ts b/src/libs/shouldEnableKeyboardAvoidingView/index.ios.ts new file mode 100644 index 000000000000..4eda884865f6 --- /dev/null +++ b/src/libs/shouldEnableKeyboardAvoidingView/index.ios.ts @@ -0,0 +1,5 @@ +const shouldEnableKeyboardAvoidingView = () => { + return false; +}; + +export default shouldEnableKeyboardAvoidingView; diff --git a/src/libs/shouldEnableKeyboardAvoidingView/index.ts b/src/libs/shouldEnableKeyboardAvoidingView/index.ts new file mode 100644 index 000000000000..de986f8e04f2 --- /dev/null +++ b/src/libs/shouldEnableKeyboardAvoidingView/index.ts @@ -0,0 +1,7 @@ +import type ShouldEnableKeyboardAvoidingViewParams from './types'; + +const shouldEnableKeyboardAvoidingView = ({isInNarrowPaneModal, isTopMostReportId}: ShouldEnableKeyboardAvoidingViewParams) => { + return isInNarrowPaneModal || isTopMostReportId; +}; + +export default shouldEnableKeyboardAvoidingView; diff --git a/src/libs/shouldEnableKeyboardAvoidingView/types.ts b/src/libs/shouldEnableKeyboardAvoidingView/types.ts new file mode 100644 index 000000000000..d6a389d01301 --- /dev/null +++ b/src/libs/shouldEnableKeyboardAvoidingView/types.ts @@ -0,0 +1,6 @@ +type ShouldEnableKeyboardAvoidingViewParams = { + isTopMostReportId: boolean; + isInNarrowPaneModal: boolean; +}; + +export default ShouldEnableKeyboardAvoidingViewParams; diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 17ffd1d7e9aa..6958c078738a 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -16,6 +16,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import includeSafeAreaBottom from '@libs/includeSafeAreaBottom'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; @@ -31,6 +32,7 @@ import {ActionListContext} from '@src/pages/home/ReportScreenContext'; import type SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import shouldEnableKeyboardAvoidingView from './shouldEnableKeyboardAvoidingView'; type SearchMoneyRequestPageProps = PlatformStackScreenProps; @@ -119,6 +121,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} headerGapStyles={styles.searchHeaderGap} + shouldEnableKeyboardAvoidingView={shouldEnableKeyboardAvoidingView} + includeSafeAreaPaddingBottom={includeSafeAreaBottom} > { + return () => { + KeyboardController.dismiss(); + }; + }, []), + ); + useEffect(() => { // Don't update if there is a reportID in the params already if (route.params.reportID) { @@ -284,6 +293,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [isBannerVisible, setIsBannerVisible] = useState(true); const [scrollPosition, setScrollPosition] = useState({}); + const [composerHeight, setComposerHeight] = useState(CONST.CHAT_FOOTER_MIN_HEIGHT); + const [headerHeight, setHeaderHeight] = useState(CONST.CHAT_HEADER_BASE_HEIGHT); const wasReportAccessibleRef = useRef(false); @@ -377,6 +388,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [isInNarrowPaneModal, backTo], ); + const onHeaderLayout = useCallback((e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height); + }, []); + let headerView = ( setComposerHeight(height), []); + + const shouldEnableKeyboardAvoidingViewResult = shouldEnableKeyboardAvoidingView({isInNarrowPaneModal, isTopMostReportId}); + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. // We aim to display a loader first, then fetch relevant reportActions, and finally show them. @@ -822,98 +841,113 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, visibleTransactions ?? []); return ( - - - - + + + - - {headerView} - - {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( - - )} - - - {(!report || shouldWaitForTransactions) && } - {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {isCurrentReportLoadedFromOnyx ? ( - - ) : null} - + {headerView} + + {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( + + )} + + + {(!report || shouldWaitForTransactions) && } + {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {isCurrentReportLoadedFromOnyx ? ( + + ) : null} + + - - - - - + + + + + ); } -ReportScreen.displayName = 'ReportScreen'; export default memo(ReportScreen, (prevProps, nextProps) => deepEqual(prevProps.route, nextProps.route)); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index e07482bd3b39..a78a495cfede 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -145,6 +145,9 @@ type ComposerWithSuggestionsProps = Partial & { /** Whether the main composer was hidden */ didHideComposerInput?: boolean; + /** The native ID for this component */ + nativeID?: string; + /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -225,6 +228,7 @@ function ComposerWithSuggestions({ raiseIsScrollLikelyLayoutTriggered, onCleared = () => {}, onLayout: onLayoutProps, + nativeID, // Refs suggestionsRef, @@ -826,6 +830,7 @@ function ComposerWithSuggestions({ onScroll={hideSuggestionMenu} shouldContainScroll={isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} + nativeID={nativeID} /> diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 4074ba0413ff..8dad40841908 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -111,6 +111,12 @@ type ReportActionComposeProps = Pick