diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 5ece2222e909..4eefcbfbb006 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1831,6 +1831,7 @@ const CONST = {
NATIVE_ID: 'composer',
MAX_LINES: 16,
MAX_LINES_SMALL_SCREEN: 6,
+ MAX_LINES_LANDSCAPE_MODE: 2,
MAX_LINES_FULL: -1,
// The minimum height needed to enable the full screen composer
FULL_COMPOSER_MIN_HEIGHT: 60,
diff --git a/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx b/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
new file mode 100644
index 000000000000..30c51c1f7df2
--- /dev/null
+++ b/src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
@@ -0,0 +1,142 @@
+import React, {useEffect, useRef} from 'react';
+import type {LayoutChangeEvent} from 'react-native';
+import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller';
+import Reanimated, {useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import isInLandscapeMode from '@libs/isInLandscapeMode';
+import type {CollapsibleHeaderOnKeyboardProps} from './types';
+
+const COLLAPSE_DURATION = 200;
+const RESTORE_DURATION = 300;
+// Assumed vertical space for the focused input field — used to reserve space above the keyboard.
+const VERTICAL_SPACE_FOR_FOCUSED_INPUT = 120;
+
+/**
+ * Wraps a header and collapses it upward when the keyboard is open and there is not enough
+ * vertical space for a focused input between the header bottom and the keyboard top.
+ * Restores the header when the keyboard closes.
+ *
+ * Intended for landscape mode on phones where the keyboard + header can leave no room for inputs.
+ * Uses height animation (not translateY) so the freed space is reclaimed by the layout below.
+ */
+function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: CollapsibleHeaderOnKeyboardProps) {
+ // JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0
+ const naturalHeightRef = useRef(-1);
+ // Worklet-accessible mirror of naturalHeightRef. -1 signals "not yet measured".
+ const naturalHeight = useSharedValue(-1);
+ // Drives the animated style
+ const animatedHeight = useSharedValue(0);
+
+ const {height: keyboardHeightSV, progress: keyboardProgressSV} = useReanimatedKeyboardAnimation();
+
+ const {windowWidth, windowHeight} = useWindowDimensions();
+ // Keep window dimensions and offset accessible on the UI thread. Stable refs, excluded from deps.
+ const windowHeightSV = useSharedValue(windowHeight);
+ const isLandscapeSV = useSharedValue(isInLandscapeMode(windowWidth, windowHeight));
+ const collapsibleHeaderOffsetSV = useSharedValue(collapsibleHeaderOffset);
+ useEffect(() => {
+ windowHeightSV.set(windowHeight);
+ isLandscapeSV.set(isInLandscapeMode(windowWidth, windowHeight));
+ }, [windowWidth, windowHeight, isLandscapeSV, windowHeightSV]);
+ useEffect(() => {
+ collapsibleHeaderOffsetSV.set(collapsibleHeaderOffset);
+ }, [collapsibleHeaderOffset, collapsibleHeaderOffsetSV]);
+
+ const onLayout = (e: LayoutChangeEvent) => {
+ const height = e.nativeEvent.layout.height;
+
+ if (height <= 0) {
+ return;
+ }
+ // First measurement, or content changed while header is fully open
+ // (to skip onLayout calls triggered by our own height animation collapsing the view to 0)
+ if (naturalHeightRef.current === -1 || animatedHeight.get() >= naturalHeightRef.current) {
+ naturalHeightRef.current = height;
+ naturalHeight.set(height);
+ animatedHeight.set(height);
+ }
+ };
+
+ // Runs on the UI thread whenever keyboard state changes.
+ // Fires at two key moments:
+ // 1. When keyboard just starts opening: on iOS keyboardHeight is already at its final value
+ // (set by onKeyboardMoveStart), so the collapse begins before the list scrolls the
+ // input into place — preventing the input from ending up behind the collapsed header.
+ // 2. When keyboard is fully open: on Android keyboardHeight only reaches its final value at
+ // this point (set by onKeyboardMoveEnd), so this is the earliest we can act correctly.
+ useAnimatedReaction(
+ () => ({keyboardHeight: keyboardHeightSV.get(), keyboardProgress: keyboardProgressSV.get(), isLandscape: isLandscapeSV.get(), windowHeightValue: windowHeightSV.get()}),
+ ({keyboardHeight, keyboardProgress, isLandscape, windowHeightValue}, previous) => {
+ const prevKeyboardProgress = previous?.keyboardProgress ?? 0;
+ const naturalHeightValue = naturalHeight.get();
+
+ // Keyboard fully closed — restore header (guard avoids redundant withTiming calls
+ // during the first few frames of keyboard opening when keyboardProgress is still < 0.01).
+ if (keyboardProgress < 0.01) {
+ if (animatedHeight.get() < naturalHeightValue) {
+ animatedHeight.set(withTiming(naturalHeightValue, {duration: RESTORE_DURATION}));
+ }
+ return;
+ }
+
+ // Portrait mode — no collapse needed. Snap to full height in case orientation
+ // changed while the header was collapsed, then bail out.
+ if (!isLandscape) {
+ animatedHeight.set(naturalHeightValue);
+ return;
+ }
+
+ // Only act at the two transition points described above, not on every intermediate frame.
+ const keyboardJustStartedOpening = prevKeyboardProgress < 0.01;
+ const keyboardJustFullyOpened = keyboardProgress > 0.99 && prevKeyboardProgress <= 0.99;
+
+ if (!keyboardJustStartedOpening && !keyboardJustFullyOpened) {
+ return;
+ }
+
+ // keyboardHeight is negative when open (e.g. -291), so keyboardTop = windowHeightValue + keyboardHeight.
+ // Target header height: give the input exactly the space it needs above the keyboard,
+ // the header gets what remains. Clamped to [0, naturalHeight].
+ const keyboardTop = windowHeightValue + keyboardHeight;
+ const targetHeight = Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get());
+
+ if (targetHeight >= naturalHeightValue) {
+ // Enough space for the full header plus the input — restore or keep.
+ animatedHeight.set(withTiming(naturalHeightValue, {duration: RESTORE_DURATION}));
+ } else {
+ animatedHeight.set(withTiming(targetHeight, {duration: COLLAPSE_DURATION}));
+ }
+ },
+ );
+
+ // Outer wrapper controls layout space (height collapses to 0, clips overflowing content).
+ const outerStyle = useAnimatedStyle(() => {
+ // When fully open, leave height undefined so the view sizes itself naturally.
+ // This avoids fighting the layout engine during orientation changes.
+ if (animatedHeight.get() >= naturalHeight.get()) {
+ return {overflow: 'hidden'};
+ }
+ return {height: animatedHeight.get(), overflow: 'hidden'};
+ });
+
+ // Inner wrapper slides the content upward. translateY = animatedHeight - naturalHeight,
+ // so it goes from 0 (fully open) to -naturalHeight (fully collapsed), making the header
+ // appear to exit through the top while the outer clip hides it progressively.
+ const innerStyle = useAnimatedStyle(() => {
+ if (animatedHeight.get() >= naturalHeight.get()) {
+ return {};
+ }
+ return {transform: [{translateY: animatedHeight.get() - naturalHeight.get()}]};
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default CollapsibleHeaderOnKeyboard;
diff --git a/src/components/CollapsibleHeaderOnKeyboard/index.tsx b/src/components/CollapsibleHeaderOnKeyboard/index.tsx
new file mode 100644
index 000000000000..e3ed2ec67e6a
--- /dev/null
+++ b/src/components/CollapsibleHeaderOnKeyboard/index.tsx
@@ -0,0 +1,11 @@
+import type {CollapsibleHeaderOnKeyboardProps} from './types';
+
+/**
+ * Web no-op — renders children as-is. The collapsing behaviour is only needed on native
+ * where the software keyboard reduces the visible viewport height.
+ */
+function CollapsibleHeaderOnKeyboard({children}: CollapsibleHeaderOnKeyboardProps) {
+ return children;
+}
+
+export default CollapsibleHeaderOnKeyboard;
diff --git a/src/components/CollapsibleHeaderOnKeyboard/types.ts b/src/components/CollapsibleHeaderOnKeyboard/types.ts
new file mode 100644
index 000000000000..d5281b0d2d6e
--- /dev/null
+++ b/src/components/CollapsibleHeaderOnKeyboard/types.ts
@@ -0,0 +1,10 @@
+type CollapsibleHeaderOnKeyboardProps = {
+ children: React.ReactNode;
+ /** Additional vertical space (in px) occupied on screen by elements other than the wrapped
+ * component, keyboard, and focused input — e.g. a tab bar below the list.
+ * The collapse target is reduced by this amount so those elements are not counted twice. */
+ collapsibleHeaderOffset?: number;
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export type {CollapsibleHeaderOnKeyboardProps};
diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx
index d1bf042c16a4..af914146a3d8 100644
--- a/src/components/Composer/implementation/index.native.tsx
+++ b/src/components/Composer/implementation/index.native.tsx
@@ -114,7 +114,10 @@ function Composer({
[onPasteFile],
);
- const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]);
+ const maxHeightStyle = useMemo(
+ () => StyleUtils.getComposerMaxHeightStyle(isInLandscapeMode ? CONST.COMPOSER.MAX_LINES_LANDSCAPE_MODE : maxLines, isComposerFullSize),
+ [StyleUtils, isComposerFullSize, maxLines, isInLandscapeMode],
+ );
const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]);
return (
diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx
index f1d3fc7b55ce..29f6b29ab722 100644
--- a/src/components/InteractiveStepWrapper.tsx
+++ b/src/components/InteractiveStepWrapper.tsx
@@ -3,7 +3,9 @@ import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
+import CollapsibleHeaderOnKeyboard from './CollapsibleHeaderOnKeyboard';
import HeaderWithBackButton from './HeaderWithBackButton';
import InteractiveStepSubHeader from './InteractiveStepSubHeader';
import ScreenWrapper from './ScreenWrapper';
@@ -67,6 +69,8 @@ type InteractiveStepWrapperProps = {
ref?: ForwardedRef;
};
+const INPUT_HEADER_HEIGHT = variables.lineHeightXXLarge;
+
function InteractiveStepWrapper({
children,
wrapperID,
@@ -101,19 +105,22 @@ function InteractiveStepWrapper({
shouldKeyboardOffsetBottomSafeAreaPadding={shouldKeyboardOffsetBottomSafeAreaPadding}
onEntryTransitionEnd={onEntryTransitionEnd}
>
-
- {!!stepNames && (
-
-
-
- )}
+
+
+ {!!stepNames && (
+
+
+
+ )}
+
+
{children}
);
diff --git a/src/hooks/useLandscapeOnBlurProxy/index.android.ts b/src/hooks/useLandscapeOnBlurProxy/index.android.ts
index 105b1e920b65..d6323df43c2a 100644
--- a/src/hooks/useLandscapeOnBlurProxy/index.android.ts
+++ b/src/hooks/useLandscapeOnBlurProxy/index.android.ts
@@ -1,8 +1,10 @@
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
+import useKeyboardState from '@hooks/useKeyboardState';
import usePrevious from '@hooks/usePrevious';
import type {UseLandscapeOnBlurProxy} from './types';
-// During a portrait → landscape rotation the input briefly ends up behind the keyboard
+// During a portrait → landscape rotation and sometimes when the keyboard is
+// opening in the landscape mode, the input briefly ends up behind the keyboard/header
// while KeyboardAvoidingView catches up, and native blurs it as a result. When that blur
// fires we re-focus the input after a short delay — long enough for KAV to reposition so
// the input is on-screen again, otherwise the re-focus gets clobbered by the same issue.
@@ -11,9 +13,11 @@ const ROTATION_REFOCUS_DELAY_MS = 100;
const useLandscapeOnBlurProxy: UseLandscapeOnBlurProxy = (inputRef, onBlur) => {
const isInLandscapeMode = useIsInLandscapeMode();
const prevIsInLandscapeMode = usePrevious(isInLandscapeMode);
+ const {isKeyboardAnimatingRef, isKeyboardActive} = useKeyboardState();
return (e) => {
- if (prevIsInLandscapeMode !== isInLandscapeMode && isInLandscapeMode) {
+ const isKeyboardOpening = isKeyboardAnimatingRef.current && isKeyboardActive;
+ if ((prevIsInLandscapeMode !== isInLandscapeMode || isKeyboardOpening) && isInLandscapeMode) {
setTimeout(() => inputRef.current?.focus?.(), ROTATION_REFOCUS_DELAY_MS);
}
onBlur?.(e);
diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx
index c543b3e91f65..4b6828ab1e86 100644
--- a/src/pages/inbox/ReportScreen.tsx
+++ b/src/pages/inbox/ReportScreen.tsx
@@ -3,6 +3,7 @@ import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import type {ViewStyle} from 'react-native';
import {InteractionManager, View} from 'react-native';
+import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
import ScreenWrapper from '@components/ScreenWrapper';
import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper';
import useActionListContextValue from '@hooks/useActionListContextValue';
@@ -138,8 +139,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
{!shouldDeferNonEssentials && }
-
- {!shouldDeferNonEssentials && }
+
+
+ {!shouldDeferNonEssentials && }
+
{!shouldDeferNonEssentials && }
diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx
index 0dc8cbaf4bbc..7fc7fbe53d39 100644
--- a/src/pages/iou/SplitExpensePage.tsx
+++ b/src/pages/iou/SplitExpensePage.tsx
@@ -4,6 +4,7 @@ import {InteractionManager, Keyboard, View} from 'react-native';
import type {ValueOf} from 'type-fest';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import Button from '@components/Button';
+import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
import FormHelpMessage from '@components/FormHelpMessage';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -56,6 +57,7 @@ import {computeSplitSaveErrorMessage, computeSplitWarningMessage} from '@libs/Sp
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils';
import {getChildTransactions, getExpenseTypeTranslationKey, getTransactionType, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -67,13 +69,15 @@ import SplitList from './SplitList';
type SplitExpensePageProps = PlatformStackScreenProps;
+const TAB_NAVIGATOR_HEIGHT_LANDSCAPE = variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding;
+
function SplitExpensePage({route}: SplitExpensePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {reportID, transactionID, splitExpenseTransactionID, backTo} = route.params;
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout();
const {showConfirmModal} = useConfirmModal();
const {isOffline} = useNetwork();
@@ -376,27 +380,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
};
});
- const listFooterContent = (
-
-
- {isInitialSplit && (
-
- )}
-
- );
-
const warningMessage = computeSplitWarningMessage({
splitExpenses,
transactionDetailsAmount,
@@ -427,6 +410,30 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
);
+ const listFooterContent = (
+ <>
+
+
+ {isInitialSplit && (
+
+ )}
+
+ {isInLandscapeMode && footerContent}
+ >
+ );
+
const splitStartDate = draftTransaction?.comment?.splitsStartDate;
const splitEndDate = draftTransaction?.comment?.splitsEndDate;
const splitDatesTitle = DateUtils.getFormattedSplitDateRange(translate, splitStartDate, splitEndDate);
@@ -493,6 +500,8 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
);
}
+ const collapsibleHeaderOffset = isInitialSplit ? TAB_NAVIGATOR_HEIGHT_LANDSCAPE : 0;
+
return (
- Navigation.goBack(backTo)}
- />
+
+ Navigation.goBack(backTo)}
+ />
+
{isInitialSplit ? (
@@ -525,7 +536,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
listFooterContent={listFooterContent}
mode={CONST.TAB.SPLIT.AMOUNT}
/>
- {footerContent}
+ {!isInLandscapeMode && footerContent}
)}
@@ -541,7 +552,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
listFooterContent={listFooterContent}
mode={CONST.TAB.SPLIT.PERCENTAGE}
/>
- {footerContent}
+ {!isInLandscapeMode && footerContent}
)}
@@ -550,15 +561,16 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
{() => (
- {headerDateContent}
+ {!isInLandscapeMode && headerDateContent}
- {footerContent}
+ {!isInLandscapeMode && footerContent}
)}
@@ -574,7 +586,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
listFooterContent={listFooterContent}
mode={CONST.TAB.SPLIT.AMOUNT}
/>
- {footerContent}
+ {!isInLandscapeMode && footerContent}
)}
diff --git a/src/pages/iou/SplitList.tsx b/src/pages/iou/SplitList.tsx
index b4fc5826d985..e194d787e323 100644
--- a/src/pages/iou/SplitList.tsx
+++ b/src/pages/iou/SplitList.tsx
@@ -20,6 +20,9 @@ type SplitListProps = {
/** Footer content to render at the bottom of the list. */
listFooterContent?: React.JSX.Element | null;
+ /** Header content to render at the top of the list. */
+ listHeaderContent?: React.JSX.Element | null;
+
/** The split mode to use (amount, percentage, or date). */
mode: ValueOf;
};
@@ -31,7 +34,7 @@ type SplitListItemProps = React.ComponentProps;
* Renders split items with the appropriate input type based on mode,
* managing its own scroll/height state.
*/
-function SplitList({data, initiallyFocusedOptionKey, onSelectRow, listFooterContent, mode}: SplitListProps) {
+function SplitList({data, initiallyFocusedOptionKey, onSelectRow, listFooterContent, listHeaderContent, mode}: SplitListProps) {
const styles = useThemeStyles();
const listRef = useRef>(null);
@@ -65,6 +68,7 @@ function SplitList({data, initiallyFocusedOptionKey, onSelectRow, listFooterCont
initiallyFocusedItemKey={initiallyFocusedOptionKey}
ListItem={SplitListItemWithInputFocus}
style={{containerStyle: styles.flexBasisAuto}}
+ customListHeaderContent={listHeaderContent}
listFooterContent={listFooterContent}
shouldPreventDefaultFocusOnSelectRow
shouldScrollToFocusedIndex={false}
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx
index e0b24bb7616d..7c173d4d2b50 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx
@@ -51,7 +51,7 @@ function StatusPage() {
// distinguish between large and small screens, so we rely on isSmallScreenWidth
// to accurately detect the screen size.
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
- const {isSmallScreenWidth} = useResponsiveLayout();
+ const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout();
const [draftStatus] = useOnyx(ONYXKEYS.CUSTOM_STATUS_DRAFT);
const [formState] = useOnyx(ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM);
@@ -188,6 +188,22 @@ function StatusPage() {
const {inputCallbackRef, inputRef} = useAutoFocusInput();
+ const saveButton = useMemo(
+ () => (
+