Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
142 changes: 142 additions & 0 deletions src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
Original file line number Diff line number Diff line change
@@ -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:
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'};
});

// 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
33 changes: 20 additions & 13 deletions src/components/InteractiveStepWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +69,8 @@ type InteractiveStepWrapperProps = {
ref?: ForwardedRef<View>;
};

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

function InteractiveStepWrapper({
children,
wrapperID,
Expand Down Expand Up @@ -101,19 +105,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
8 changes: 6 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,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) {
Comment on lines +19 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Distinguish keyboard opening before forcing refocus

The new blur proxy now treats isKeyboardAnimatingRef.current && isKeyboardActive as “keyboard opening”, but that condition can also be true during keyboard-dismiss transitions on Android (depending on event timing), so a normal blur (e.g. tapping outside or dismissing the keyboard) can immediately schedule focus() again and reopen the keyboard. Because this hook wraps all RNTextInput/RNMarkdownTextInput blur handlers in landscape, the regression can make dismissing focus unreliable across many inputs; please gate refocus on an actual open transition rather than any active animation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to https://github.com/Expensify/App/blob/main/src/components/withKeyboardState.tsx keyboardWillHide sets isKeyboardActive to false, I also tested both cases already and this doesn't happen. isKeyboardActive is false during close animation

setTimeout(() => inputRef.current?.focus?.(), ROTATION_REFOCUS_DELAY_MS);
}
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