diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index e023dda0ce5..752908e1111 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {ReactNode} from 'react'; +import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; import type {IOUAction, IOUType} from '@src/CONST'; import type CONST from '@src/CONST'; import ConfirmationFieldsContext from './context'; @@ -59,6 +60,9 @@ type ProviderProps = { /** Whether the active transaction is a GPS distance request */ isGPSDistanceRequest?: boolean; + /** Scrolls the surface so an inline field's input is not hidden behind the keyboard when focused (new manual expense flow) */ + scrollFocusedInputIntoView?: (input: MeasurableInput) => void; + /** Block components rendered inside the Provider */ children: ReactNode; }; @@ -82,6 +86,7 @@ function Provider({ isManualDistanceRequest = false, isOdometerDistanceRequest = false, isGPSDistanceRequest = false, + scrollFocusedInputIntoView, children, }: ProviderProps) { const value = { @@ -103,6 +108,7 @@ function Provider({ isManualDistanceRequest, isOdometerDistanceRequest, isGPSDistanceRequest, + scrollFocusedInputIntoView, }; return {children}; } diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index 2ece24c6f7a..c5bb8c1fa1b 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -1,4 +1,5 @@ import {createContext, useContext} from 'react'; +import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; import type {IOUAction, IOUType} from '@src/CONST'; import type CONST from '@src/CONST'; @@ -31,6 +32,9 @@ type ConfirmationFieldsContextValue = { isManualDistanceRequest: boolean; isOdometerDistanceRequest: boolean; isGPSDistanceRequest: boolean; + + /** Scrolls the surface so an inline field's input is not hidden behind the keyboard when focused (new manual expense flow). */ + scrollFocusedInputIntoView?: (input: MeasurableInput) => void; }; const ConfirmationFieldsContext = createContext(null); diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index f2bfe1000b3..a165907b10a 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import useAttendees from '@hooks/useAttendees'; @@ -59,6 +59,7 @@ import TaxController from './MoneyRequestConfirmationList/TaxController'; import MoneyRequestConfirmationListFooter from './MoneyRequestConfirmationListFooter'; import BareUserListItem from './SelectionList/ListItem/BareUserListItem'; import SelectionListWithSections from './SelectionList/SelectionListWithSections'; +import type {MeasurableInput, SelectionListWithSectionsHandle} from './SelectionList/SelectionListWithSections/types'; type MoneyRequestConfirmationListProps = { /** Callback to inform parent modal of success */ @@ -234,6 +235,13 @@ function MoneyRequestConfirmationList({ const styles = useThemeStyles(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + const listRef = useRef(null); + + // In the new manual expense flow the inline fields live in the list footer, so they can be hidden behind the keyboard. + // We let those fields ask the list to scroll them into view when focused. + const scrollFocusedInputIntoView = useCallback((input: MeasurableInput) => { + listRef.current?.scrollInputIntoView(input); + }, []); const isDistanceRequest = isDistanceRequestUtil(transaction); const isManualDistanceRequest = isManualDistanceRequestUtil(transaction); @@ -555,6 +563,7 @@ function MoneyRequestConfirmationList({ }} compactControls={{showMoreFields, setShowMoreFields}} onSubmitForm={confirm} + scrollFocusedInputIntoView={scrollFocusedInputIntoView} /> ); @@ -624,6 +633,7 @@ function MoneyRequestConfirmationList({ /> + ref={listRef} sections={sections} ListItem={BareUserListItem} onSelectRow={navigateToParticipantPage} diff --git a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx index ba0c42362b4..d9cb909a57b 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; @@ -49,9 +49,12 @@ function DescriptionField({ policy, onSubmitForm, }: DescriptionFieldProps) { - const {isEditingSplitBill} = useConfirmationFields(); + const {isEditingSplitBill, scrollFocusedInputIntoView} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); + // Ref on the field's outer container (the bordered box), so scrolling brings the whole field — including its + // top border and label — into view rather than just the inner text area. + const fieldContainerRef = useRef(null); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -100,11 +103,15 @@ function DescriptionField({ {isNewManualExpenseFlowEnabled && !isReadOnly ? ( - + scrollFocusedInputIntoView?.(fieldContainerRef.current)} submitBehavior="blurAndSubmit" onSubmitEditing={onSubmitForm} label={translate('common.description')} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 792156c73f5..80dfc8dcfad 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -27,6 +27,7 @@ import DistanceMapSection from './MoneyRequestConfirmationListFooter/sections/Di import InvoiceSenderSection from './MoneyRequestConfirmationListFooter/sections/InvoiceSenderSection'; import PerDiemSection from './MoneyRequestConfirmationListFooter/sections/PerDiemSection'; import ReceiptSection from './MoneyRequestConfirmationListFooter/sections/ReceiptSection'; +import type {MeasurableInput} from './SelectionList/SelectionListWithSections/types'; const noopSetShowMoreFields = () => {}; @@ -108,6 +109,9 @@ type MoneyRequestConfirmationListFooterProps = { /** Triggers submit from inline inputs */ onSubmitForm?: () => void; + + /** Scrolls the surface so an inline field's input is not hidden behind the keyboard when focused (new manual expense flow) */ + scrollFocusedInputIntoView?: (input: MeasurableInput) => void; }; function MoneyRequestConfirmationListFooter({ @@ -137,6 +141,7 @@ function MoneyRequestConfirmationListFooter({ receiptOptions, compactControls, onSubmitForm, + scrollFocusedInputIntoView, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); const isInLandscapeMode = useIsInLandscapeMode(); @@ -167,6 +172,7 @@ function MoneyRequestConfirmationListFooter({ isManualDistanceRequest={distanceFlags.isManualDistanceRequest} isOdometerDistanceRequest={distanceFlags.isOdometerDistanceRequest} isGPSDistanceRequest={distanceFlags.isGPSDistanceRequest} + scrollFocusedInputIntoView={scrollFocusedInputIntoView} > diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index b80df1782a4..c5f82e5e362 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -8,6 +8,7 @@ import Footer from '@components/SelectionList/components/Footer'; import SelectionListEmptyState from '@components/SelectionList/components/SelectionListEmptyState'; import TextInput from '@components/SelectionList/components/TextInput'; import useFlattenedSections, {isItemSelected, shouldTreatItemAsDisabled} from '@components/SelectionList/hooks/useFlattenedSections'; +import useScrollToFocusedInput from '@components/SelectionList/hooks/useScrollToFocusedInput'; import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync'; import useSelectedItemFocusSync from '@components/SelectionList/hooks/useSelectedItemFocusSync'; import useSelectionListKeyboardFocus from '@components/SelectionList/hooks/useSelectionListKeyboardFocus'; @@ -90,6 +91,7 @@ function BaseSelectionListWithSections({ const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey); const listRef = useRef> | null>(null); const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, flattenedData); + const {containerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown); const {focusedIndex, setFocusedIndex, setFocusedIndexFromRowFocus, setFocusedIndexWithoutScrollOnChange, suppressNextFocusScroll, isKeyboardNavigating, setHasKeyBeenPressed} = useSelectionListKeyboardFocus({ @@ -180,8 +182,9 @@ function BaseSelectionListWithSections({ updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedOption: getFocusedItem, + scrollInputIntoView, }), - [focusTextInput, scrollToIndex, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedItem], + [focusTextInput, scrollToIndex, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedItem, scrollInputIntoView], ); const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; @@ -301,6 +304,7 @@ function BaseSelectionListWithSections({ return ( @@ -327,7 +331,8 @@ function BaseSelectionListWithSections({ onEndReachedThreshold={onEndReachedThreshold} onScrollBeginDrag={onScrollBeginDrag} scrollEnabled={scrollEnabled} - onScroll={() => { + onScroll={(event) => { + trackScrollOffset(event); onScroll?.(); triggerScrollEvent(); }} diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index c99aa831fb0..f75fca484ac 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -57,6 +57,8 @@ type SelectionListWithSectionsProps = BaseSelectionListP titleNumberOfLines?: number; }; +type MeasurableInput = unknown; + type SelectionListWithSectionsHandle = { focusTextInput: () => void; scrollToIndex: (index: number) => void; @@ -64,6 +66,9 @@ type SelectionListWithSectionsHandle = { updateAndScrollToFocusedIndex: (index: number, shouldScroll?: boolean) => void; updateExternalTextInputFocus: (isTextInputFocused: boolean) => void; getFocusedOption: () => TItem | undefined; + + /** Scrolls the list so an input rendered inside `listFooterContent` is not hidden behind the keyboard. */ + scrollInputIntoView: (input: MeasurableInput) => void; }; type SectionHeader = { @@ -83,4 +88,4 @@ type SectionListItem = TItem & { type FlattenedItem = SectionListItem | SectionHeader; -export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionListWithSectionsHandle, FlattenedItem}; +export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionListWithSectionsHandle, FlattenedItem, MeasurableInput}; diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts new file mode 100644 index 00000000000..6422ef0bc6f --- /dev/null +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.native.ts @@ -0,0 +1,114 @@ +import {useCallback, useEffect, useRef} from 'react'; +import type {EmitterSubscription, NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; +import {KeyboardEvents} from 'react-native-keyboard-controller'; +import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; +import CONST from '@src/CONST'; +import type {UseScrollToFocusedInput} from './types'; + +/** Extra space (px) left between the focused input and the top of the visible list area after scrolling. */ +const EXTRA_SCROLL_PADDING = 16; + +type MeasureInWindowCallback = (x: number, y: number, width: number, height: number) => void; + +type MeasurableNode = { + measureInWindow: (callback: MeasureInWindowCallback) => void; +}; + +function isMeasurable(node: MeasurableInput): node is MeasurableNode { + return typeof node === 'object' && node !== null && 'measureInWindow' in node && typeof node.measureInWindow === 'function'; +} + +/** + * Scrolls a `FlashList` so that an input rendered inside its footer is not hidden behind the keyboard. + * + * `BaseSelectionList` scrolls *list items* into view via `scrollToIndex`, but footer inputs aren't list items, + * so we instead pull the focused input up toward the top of the list area once the keyboard is shown. + */ +const useScrollToFocusedInput: UseScrollToFocusedInput = (listRef, isKeyboardShown) => { + const containerRef = useRef(null); + const scrollOffsetRef = useRef(0); + const isKeyboardShownRef = useRef(isKeyboardShown); + const keyboardListenerRef = useRef(null); + const scrollTimeoutRef = useRef(null); + + useEffect(() => { + isKeyboardShownRef.current = isKeyboardShown; + }, [isKeyboardShown]); + + const cleanup = useCallback(() => { + keyboardListenerRef.current?.remove(); + keyboardListenerRef.current = null; + if (!scrollTimeoutRef.current) { + return; + } + clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = null; + }, []); + + useEffect(() => cleanup, [cleanup]); + + const trackScrollOffset = useCallback((event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); + + const scrollInputIntoView = useCallback( + (input: MeasurableInput) => { + if (!isMeasurable(input)) { + return; + } + + const performScroll = () => { + const container = containerRef.current; + const list = listRef.current; + if (!container || !list) { + return; + } + // The list container grows to fit its content, so we can't treat its bottom as the visible area. + // Instead we use its top (just below the header) as a stable anchor and pull the focused input up + // toward it. The list viewport is bounded above any sticky footer (which itself sits above the + // keyboard), so anchoring to the top reliably brings the input into view, clamped by the available + // scroll range. + container.measureInWindow((containerX, containerY) => { + input.measureInWindow((inputX, inputY) => { + const target = containerY + EXTRA_SCROLL_PADDING; + const delta = inputY - target; + // The input is already at or above the target anchor, so there's nothing to do. + if (delta <= 0) { + return; + } + list.scrollToOffset({offset: scrollOffsetRef.current + delta, animated: true}); + }); + }); + }; + + cleanup(); + + // The keyboard is already up (e.g. moving focus between fields), so the layout is settled — scroll right away. + if (isKeyboardShownRef.current) { + scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); + return; + } + + // Otherwise wait for the keyboard to appear before measuring. We use react-native-keyboard-controller's + // `keyboardDidShow` (the same source as `withKeyboardState`) rather than RN's `Keyboard`, because RN's + // `keyboardDidShow` does not fire on Android 10 and below under `adjustResize`, which would leave the + // focused field hidden until the safety timeout below. + keyboardListenerRef.current = KeyboardEvents.addListener('keyboardDidShow', () => { + cleanup(); + // Give the layout a moment to settle after the keyboard has fully appeared. + scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING); + }); + + // Safety net in case the keyboard event never fires; long enough that the listener wins under normal conditions. + scrollTimeoutRef.current = setTimeout(() => { + cleanup(); + performScroll(); + }, CONST.MAX_TRANSITION_DURATION_MS); + }, + [cleanup, listRef], + ); + + return {containerRef, trackScrollOffset, scrollInputIntoView}; +}; + +export default useScrollToFocusedInput; diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts new file mode 100644 index 00000000000..608503dbf25 --- /dev/null +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/index.ts @@ -0,0 +1,16 @@ +import {useRef} from 'react'; +import type {View} from 'react-native'; +import type {UseScrollToFocusedInput} from './types'; + +const noop = () => {}; + +/** + * No-op on web: browsers automatically scroll a focused input into view, so there's nothing to do here. + * `containerRef` is still returned so the list can attach it harmlessly. + */ +const useScrollToFocusedInput: UseScrollToFocusedInput = () => { + const containerRef = useRef(null); + return {containerRef, trackScrollOffset: noop, scrollInputIntoView: noop}; +}; + +export default useScrollToFocusedInput; diff --git a/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts new file mode 100644 index 00000000000..452370be48c --- /dev/null +++ b/src/components/SelectionList/hooks/useScrollToFocusedInput/types.ts @@ -0,0 +1,20 @@ +import type {FlashListRef} from '@shopify/flash-list'; +import type {RefObject} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; +import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types'; + +type UseScrollToFocusedInputResult = { + /** Attach to the list's outer container; its top is used as a stable anchor to pull focused inputs up to. */ + containerRef: RefObject; + + /** Wire into the list's `onScroll` so we always know the current content offset. */ + trackScrollOffset: (event: NativeSyntheticEvent) => void; + + /** Scrolls the list so the given input is visible above the keyboard. Safe to call from an input's `onFocus`. */ + scrollInputIntoView: (input: MeasurableInput) => void; +}; + +type UseScrollToFocusedInput = (listRef: RefObject, 'scrollToOffset'> | null>, isKeyboardShown: boolean) => UseScrollToFocusedInputResult; + +// eslint-disable-next-line import/prefer-default-export +export type {UseScrollToFocusedInput};