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};