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( + () => ( +