Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,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,
Expand Down
138 changes: 138 additions & 0 deletions src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
collapsibleHeaderOffsetSV.set(collapsibleHeaderOffset);
}, [collapsibleHeaderOffset]); // eslint-disable-line react-hooks/exhaustive-deps

// Measure natural height exactly once. Subsequent onLayout fires (triggered by our own height
// animation collapsing the view to 0) are ignored via the naturalHeightRef guard.
const onLayout = (e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
if (naturalHeightRef.current === -1 && h > 0) {
naturalHeightRef.current = h;
naturalHeight.set(h);
animatedHeight.set(h);
Comment thread
GCyganek marked this conversation as resolved.
Outdated
}
};

// Runs on the UI thread whenever keyboard state changes.
// Fires at two key moments:
Comment thread
GCyganek marked this conversation as resolved.
// 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;
Comment thread
GCyganek marked this conversation as resolved.
}

// 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'};
Comment thread
GCyganek marked this conversation as resolved.
Outdated
});

// 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 (
<Reanimated.View
style={outerStyle}
onLayout={onLayout}
>
<Reanimated.View style={innerStyle}>{children}</Reanimated.View>
</Reanimated.View>
);
}

export default CollapsibleHeaderOnKeyboard;
11 changes: 11 additions & 0 deletions src/components/CollapsibleHeaderOnKeyboard/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/components/CollapsibleHeaderOnKeyboard/types.ts
Original file line number Diff line number Diff line change
@@ -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};
5 changes: 4 additions & 1 deletion src/components/Composer/implementation/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
32 changes: 19 additions & 13 deletions src/components/InteractiveStepWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import CollapsibleHeaderOnKeyboard from './CollapsibleHeaderOnKeyboard';
import HeaderWithBackButton from './HeaderWithBackButton';
import InteractiveStepSubHeader from './InteractiveStepSubHeader';
import ScreenWrapper from './ScreenWrapper';
Expand Down Expand Up @@ -67,6 +68,8 @@ type InteractiveStepWrapperProps = {
ref?: ForwardedRef<View>;
};

Comment thread
GCyganek marked this conversation as resolved.
const INPUT_HEADER_HEIGHT = 27;

function InteractiveStepWrapper({
children,
wrapperID,
Expand Down Expand Up @@ -101,19 +104,22 @@ function InteractiveStepWrapper({
shouldKeyboardOffsetBottomSafeAreaPadding={shouldKeyboardOffsetBottomSafeAreaPadding}
onEntryTransitionEnd={onEntryTransitionEnd}
>
<HeaderWithBackButton
title={headerTitle}
subtitle={headerSubtitle}
onBackButtonPress={handleBackButtonPress}
/>
{!!stepNames && (
<View style={[styles.ph5, styles.mb5, styles.mt3, {height: CONST.BANK_ACCOUNT.STEPS_HEADER_HEIGHT}]}>
<InteractiveStepSubHeader
startStepIndex={startStepIndex}
stepNames={stepNames}
/>
</View>
)}
<CollapsibleHeaderOnKeyboard collapsibleHeaderOffset={INPUT_HEADER_HEIGHT}>
<HeaderWithBackButton
title={headerTitle}
subtitle={headerSubtitle}
onBackButtonPress={handleBackButtonPress}
/>
{!!stepNames && (
<View style={[styles.ph5, styles.mb5, styles.mt3, {height: CONST.BANK_ACCOUNT.STEPS_HEADER_HEIGHT}]}>
<InteractiveStepSubHeader
startStepIndex={startStepIndex}
stepNames={stepNames}
/>
</View>
)}
</CollapsibleHeaderOnKeyboard>

{children}
</ScreenWrapper>
);
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useLandscapeOnBlurProxy/index.android.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,9 +13,10 @@ const ROTATION_REFOCUS_DELAY_MS = 100;
const useLandscapeOnBlurProxy: UseLandscapeOnBlurProxy = (inputRef, onBlur) => {
const isInLandscapeMode = useIsInLandscapeMode();
const prevIsInLandscapeMode = usePrevious(isInLandscapeMode);
const {isKeyboardAnimatingRef} = useKeyboardState();

return (e) => {
if (prevIsInLandscapeMode !== isInLandscapeMode && isInLandscapeMode) {
if ((prevIsInLandscapeMode !== isInLandscapeMode || isKeyboardAnimatingRef.current) && isInLandscapeMode) {
setTimeout(() => inputRef.current?.focus?.(), ROTATION_REFOCUS_DELAY_MS);
Comment thread
GCyganek marked this conversation as resolved.
Outdated
}
onBlur?.(e);
Expand Down
7 changes: 5 additions & 2 deletions src/pages/inbox/ReportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {PortalHost} from '@gorhom/portal';
import React from 'react';
import type {ViewStyle} from 'react-native';
import {View} from 'react-native';
import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
import ScreenWrapper from '@components/ScreenWrapper';
import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper';
import useActionListContextValue from '@hooks/useActionListContextValue';
Expand Down Expand Up @@ -73,8 +74,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
<LinkedActionNotFoundGuard>
<ReportDragAndDropProvider>
<ReportLifecycleHandler reportID={reportIDFromRoute} />
<ReportHeader />
<AccountManagerBanner reportID={reportIDFromRoute} />
<CollapsibleHeaderOnKeyboard>
<ReportHeader />
<AccountManagerBanner reportID={reportIDFromRoute} />
</CollapsibleHeaderOnKeyboard>
<View style={[styles.flex1, styles.flexRow]}>
<WideRHPReceiptPanel />
<AgentZeroStatusProvider reportID={reportIDFromRoute}>
Expand Down
Loading
Loading