Skip to content

Commit be6595c

Browse files
authored
Merge pull request Expensify#88178 from software-mansion-labs/@GCygnaek/landscape-mode/hidden-input-fixes
2 parents 7873aac + 3cc82ae commit be6595c

19 files changed

Lines changed: 371 additions & 88 deletions

File tree

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,6 +1892,7 @@ const CONST = {
18921892
NATIVE_ID: 'composer',
18931893
MAX_LINES: 16,
18941894
MAX_LINES_SMALL_SCREEN: 6,
1895+
MAX_LINES_LANDSCAPE_MODE: 2,
18951896
MAX_LINES_FULL: -1,
18961897
// The minimum height needed to enable the full screen composer
18971898
FULL_COMPOSER_MIN_HEIGHT: 60,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import {useIsFocused} from '@react-navigation/native';
2+
import React, {useEffect, useRef} from 'react';
3+
import type {LayoutChangeEvent} from 'react-native';
4+
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller';
5+
import Reanimated, {Easing, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
6+
import useWindowDimensions from '@hooks/useWindowDimensions';
7+
import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
8+
import type {CollapsibleHeaderOnKeyboardProps} from './types';
9+
10+
const COLLAPSE_DURATION = 100;
11+
const RESTORE_DURATION = 300;
12+
// Assumed vertical space for the focused input field — used to reserve space above the keyboard.
13+
const VERTICAL_SPACE_FOR_FOCUSED_INPUT = 120;
14+
const KEYBOARD_OPENING_PROGRESS_THRESHOLDS = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99];
15+
16+
function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboardProgress: number, requiredProgress: number[]): boolean {
17+
'worklet';
18+
19+
return requiredProgress.some((progress) => keyboardProgress > progress && prevKeyboardProgress <= progress);
20+
}
21+
22+
/**
23+
* Wraps a header and collapses it upward when the keyboard is open and there is not enough
24+
* vertical space for a focused input between the header bottom and the keyboard top.
25+
* Restores the header when the keyboard closes.
26+
*
27+
* Intended for landscape mode on phones where the keyboard + header can leave no room for inputs.
28+
* Uses height animation (not translateY) so the freed space is reclaimed by the layout below.
29+
*/
30+
function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: CollapsibleHeaderOnKeyboardProps) {
31+
const isFocused = useIsFocused();
32+
// JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0
33+
const naturalHeightRef = useRef(-1);
34+
// Worklet-accessible mirror of naturalHeightRef. -1 signals "not yet measured".
35+
const naturalHeight = useSharedValue(-1);
36+
// Drives the animated style
37+
const animatedHeight = useSharedValue(0);
38+
39+
const {height: keyboardHeightSV, progress: keyboardProgressSV} = useReanimatedKeyboardAnimation();
40+
41+
const {windowWidth, windowHeight} = useWindowDimensions();
42+
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
43+
// Keep window dimensions and offset accessible on the UI thread. Stable refs, excluded from deps.
44+
const windowHeightSV = useSharedValue(windowHeight);
45+
const collapsibleHeaderOffsetSV = useSharedValue(collapsibleHeaderOffset);
46+
const isFocusedSV = useSharedValue(isFocused);
47+
const isInLandscapeModeSV = useSharedValue(isInLandscapeMode);
48+
useEffect(() => {
49+
windowHeightSV.set(windowHeight);
50+
}, [windowHeight, windowHeightSV]);
51+
useEffect(() => {
52+
collapsibleHeaderOffsetSV.set(collapsibleHeaderOffset);
53+
}, [collapsibleHeaderOffset, collapsibleHeaderOffsetSV]);
54+
useEffect(() => {
55+
isFocusedSV.set(isFocused);
56+
}, [isFocused, isFocusedSV]);
57+
useEffect(() => {
58+
isInLandscapeModeSV.set(isInLandscapeMode);
59+
}, [isInLandscapeMode, isInLandscapeModeSV]);
60+
61+
const onLayout = (e: LayoutChangeEvent) => {
62+
const height = e.nativeEvent.layout.height;
63+
64+
if (height <= 0) {
65+
return;
66+
}
67+
// First measurement, or content changed while header is fully open
68+
// (to skip onLayout calls triggered by our own height animation collapsing the view to 0)
69+
if (naturalHeightRef.current === -1 || animatedHeight.get() >= naturalHeightRef.current) {
70+
naturalHeightRef.current = height;
71+
naturalHeight.set(height);
72+
animatedHeight.set(height);
73+
}
74+
};
75+
76+
// Restores the header when the screen goes from landscape to portrait mode.
77+
useEffect(() => {
78+
const naturalHeightValue = naturalHeightRef.current;
79+
if (!isInLandscapeMode && isFocused && naturalHeightValue !== -1) {
80+
animatedHeight.set(withTiming(naturalHeightValue, {duration: RESTORE_DURATION}));
81+
}
82+
}, [isInLandscapeMode, isFocused, animatedHeight]);
83+
84+
// Runs on the UI thread whenever keyboard state changes.
85+
// Fires at two key moments:
86+
// 1. When keyboard just starts opening: on iOS keyboardHeight is already at its final value
87+
// (set by onKeyboardMoveStart), so the collapse begins before the list scrolls the
88+
// input into place — preventing the input from ending up behind the collapsed header.
89+
// 2. When keyboard is reaching a threshold while opening: on Android keyboardHeight
90+
// reaches its final value when fully open (set by onKeyboardMoveEnd), so we check at thresholds
91+
// to smoothly collapse the header.
92+
useAnimatedReaction(
93+
() => ({
94+
keyboardHeight: keyboardHeightSV.get(),
95+
keyboardProgress: keyboardProgressSV.get(),
96+
windowHeightValue: windowHeightSV.get(),
97+
}),
98+
({keyboardHeight, keyboardProgress, windowHeightValue}, previous) => {
99+
// If the screen is not focused, bail out
100+
if (!isFocusedSV.get() || !isInLandscapeModeSV.get()) {
101+
return;
102+
}
103+
104+
// If the keyboard is closed, restore the header
105+
const isKeyboardClosed = keyboardProgress === 0 && keyboardHeight === 0;
106+
if (isKeyboardClosed) {
107+
animatedHeight.set(withTiming(naturalHeight.get(), {duration: RESTORE_DURATION}));
108+
return;
109+
}
110+
111+
// If the keyboard is closing, bail out
112+
const prevKeyboardProgress = previous?.keyboardProgress ?? 0;
113+
if (prevKeyboardProgress >= keyboardProgress) {
114+
return;
115+
}
116+
117+
// Only act when the keyboard is starting to open or reaching a threshold, not on every intermediate frame.
118+
const isKeyboardStartingOpening = prevKeyboardProgress === 0 && keyboardProgress > 0;
119+
const isKeyboardOpeningAndReachingThreshold = isKeyboardOpeningAtGivenProgress(keyboardProgress, prevKeyboardProgress, KEYBOARD_OPENING_PROGRESS_THRESHOLDS);
120+
121+
if (!isKeyboardStartingOpening && !isKeyboardOpeningAndReachingThreshold) {
122+
return;
123+
}
124+
125+
// keyboardHeight is negative when open (e.g. -291), so keyboardTop = windowHeightValue + keyboardHeight.
126+
// Target header height: give the input exactly the space it needs above the keyboard,
127+
// the header gets what remains. Clamped to [0, naturalHeight].
128+
const keyboardTop = windowHeightValue + keyboardHeight;
129+
const targetHeight = Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get());
130+
const naturalHeightValue = naturalHeight.get();
131+
132+
if (targetHeight >= naturalHeightValue) {
133+
// Enough space for the full header plus the input — restore or keep.
134+
animatedHeight.set(withTiming(naturalHeightValue, {duration: RESTORE_DURATION}));
135+
} else {
136+
animatedHeight.set(withTiming(targetHeight, {duration: COLLAPSE_DURATION, easing: Easing.out(Easing.cubic)}));
137+
}
138+
},
139+
);
140+
141+
// Outer wrapper controls layout space (height collapses to 0, clips overflowing content).
142+
const outerStyle = useAnimatedStyle(() => {
143+
// When fully open, leave height undefined so the view sizes itself naturally.
144+
// This avoids fighting the layout engine during orientation changes.
145+
if (animatedHeight.get() >= naturalHeight.get()) {
146+
return {overflow: 'hidden'};
147+
}
148+
return {height: animatedHeight.get(), overflow: 'hidden'};
149+
});
150+
151+
// Inner wrapper slides the content upward. translateY = animatedHeight - naturalHeight,
152+
// so it goes from 0 (fully open) to -naturalHeight (fully collapsed), making the header
153+
// appear to exit through the top while the outer clip hides it progressively.
154+
const innerStyle = useAnimatedStyle(() => {
155+
if (animatedHeight.get() >= naturalHeight.get()) {
156+
return {};
157+
}
158+
return {transform: [{translateY: animatedHeight.get() - naturalHeight.get()}]};
159+
});
160+
161+
return (
162+
<Reanimated.View style={outerStyle}>
163+
<Reanimated.View
164+
onLayout={onLayout}
165+
style={innerStyle}
166+
>
167+
{children}
168+
</Reanimated.View>
169+
</Reanimated.View>
170+
);
171+
}
172+
173+
export default CollapsibleHeaderOnKeyboard;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type {CollapsibleHeaderOnKeyboardProps} from './types';
2+
3+
/**
4+
* Web no-op — renders children as-is. The collapsing behaviour is only needed on native
5+
* where the software keyboard reduces the visible viewport height.
6+
*/
7+
function CollapsibleHeaderOnKeyboard({children}: CollapsibleHeaderOnKeyboardProps) {
8+
return children;
9+
}
10+
11+
export default CollapsibleHeaderOnKeyboard;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
type CollapsibleHeaderOnKeyboardProps = {
2+
children: React.ReactNode;
3+
/** Additional vertical space (in px) occupied on screen by elements other than the wrapped
4+
* component, keyboard, and focused input — e.g. a tab bar below the list.
5+
* The collapse target is reduced by this amount so those elements are not counted twice. */
6+
collapsibleHeaderOffset?: number;
7+
};
8+
9+
// eslint-disable-next-line import/prefer-default-export
10+
export type {CollapsibleHeaderOnKeyboardProps};

src/components/Composer/implementation/index.native.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ function Composer({
114114
[onPasteFile],
115115
);
116116

117-
const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]);
117+
const maxHeightStyle = useMemo(
118+
() => StyleUtils.getComposerMaxHeightStyle(isInLandscapeMode ? CONST.COMPOSER.MAX_LINES_LANDSCAPE_MODE : maxLines, isComposerFullSize),
119+
[StyleUtils, isComposerFullSize, maxLines, isInLandscapeMode],
120+
);
118121
const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]);
119122

120123
return (

src/components/InteractiveStepWrapper.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import React from 'react';
33
import type {StyleProp, ViewStyle} from 'react-native';
44
import {View} from 'react-native';
55
import useThemeStyles from '@hooks/useThemeStyles';
6+
import variables from '@styles/variables';
67
import CONST from '@src/CONST';
8+
import CollapsibleHeaderOnKeyboard from './CollapsibleHeaderOnKeyboard';
79
import HeaderWithBackButton from './HeaderWithBackButton';
810
import InteractiveStepSubHeader from './InteractiveStepSubHeader';
911
import ScreenWrapper from './ScreenWrapper';
@@ -67,6 +69,8 @@ type InteractiveStepWrapperProps = {
6769
ref?: ForwardedRef<View>;
6870
};
6971

72+
const INPUT_HEADER_HEIGHT = variables.lineHeightXXLarge;
73+
7074
function InteractiveStepWrapper({
7175
children,
7276
wrapperID,
@@ -101,19 +105,22 @@ function InteractiveStepWrapper({
101105
shouldKeyboardOffsetBottomSafeAreaPadding={shouldKeyboardOffsetBottomSafeAreaPadding}
102106
onEntryTransitionEnd={onEntryTransitionEnd}
103107
>
104-
<HeaderWithBackButton
105-
title={headerTitle}
106-
subtitle={headerSubtitle}
107-
onBackButtonPress={handleBackButtonPress}
108-
/>
109-
{!!stepNames && (
110-
<View style={[styles.ph5, styles.mb5, styles.mt3, {height: CONST.BANK_ACCOUNT.STEPS_HEADER_HEIGHT}]}>
111-
<InteractiveStepSubHeader
112-
startStepIndex={startStepIndex}
113-
stepNames={stepNames}
114-
/>
115-
</View>
116-
)}
108+
<CollapsibleHeaderOnKeyboard collapsibleHeaderOffset={INPUT_HEADER_HEIGHT}>
109+
<HeaderWithBackButton
110+
title={headerTitle}
111+
subtitle={headerSubtitle}
112+
onBackButtonPress={handleBackButtonPress}
113+
/>
114+
{!!stepNames && (
115+
<View style={[styles.ph5, styles.mb5, styles.mt3, {height: CONST.BANK_ACCOUNT.STEPS_HEADER_HEIGHT}]}>
116+
<InteractiveStepSubHeader
117+
startStepIndex={startStepIndex}
118+
stepNames={stepNames}
119+
/>
120+
</View>
121+
)}
122+
</CollapsibleHeaderOnKeyboard>
123+
117124
{children}
118125
</ScreenWrapper>
119126
);

src/components/MoneyRequestReportView/MoneyRequestReportView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React, {useCallback, useEffect, useMemo} from 'react';
66
import {Animated, InteractionManager, ScrollView, View} from 'react-native';
77
import type {LayoutChangeEvent} from 'react-native';
88
import type {OnyxEntry} from 'react-native-onyx';
9+
import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
910
import MoneyReportHeader from '@components/MoneyReportHeader';
1011
import MoneyRequestHeader from '@components/MoneyRequestHeader';
1112
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -246,7 +247,7 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport
246247
needsOffscreenAlphaCompositing
247248
shouldShowErrorMessages={false}
248249
>
249-
{reportHeaderView}
250+
<CollapsibleHeaderOnKeyboard>{reportHeaderView}</CollapsibleHeaderOnKeyboard>
250251
</OfflineWithFeedback>
251252
<OfflineWithFeedback
252253
pendingAction={reportPendingAction}

src/hooks/useLandscapeOnBlurProxy/index.android.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
2+
import useKeyboardState from '@hooks/useKeyboardState';
23
import usePrevious from '@hooks/usePrevious';
34
import type {UseLandscapeOnBlurProxy} from './types';
45

5-
// During a portrait → landscape rotation the input briefly ends up behind the keyboard
6+
// During a portrait → landscape rotation and sometimes when the keyboard is
7+
// opening in the landscape mode, the input briefly ends up behind the keyboard/header
68
// while KeyboardAvoidingView catches up, and native blurs it as a result. When that blur
79
// fires we re-focus the input after a short delay — long enough for KAV to reposition so
810
// 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;
1113
const useLandscapeOnBlurProxy: UseLandscapeOnBlurProxy = (inputRef, onBlur) => {
1214
const isInLandscapeMode = useIsInLandscapeMode();
1315
const prevIsInLandscapeMode = usePrevious(isInLandscapeMode);
16+
const {isKeyboardAnimatingRef, isKeyboardActive} = useKeyboardState();
1417

1518
return (e) => {
16-
if (prevIsInLandscapeMode !== isInLandscapeMode && isInLandscapeMode) {
19+
const isKeyboardOpening = isKeyboardAnimatingRef.current && isKeyboardActive;
20+
if ((prevIsInLandscapeMode !== isInLandscapeMode || isKeyboardOpening) && isInLandscapeMode) {
1721
setTimeout(() => inputRef.current?.focus?.(), ROTATION_REFOCUS_DELAY_MS);
1822
}
1923
onBlur?.(e);

src/pages/inbox/ReportScreen.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {PortalHost} from '@gorhom/portal';
22
import React from 'react';
33
import type {ViewStyle} from 'react-native';
44
import {View} from 'react-native';
5+
import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
56
import OfflineWithFeedback from '@components/OfflineWithFeedback';
67
import ScreenWrapper from '@components/ScreenWrapper';
78
import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper';
@@ -104,8 +105,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) {
104105
<LinkedActionNotFoundGuard>
105106
<ReportDragAndDropProvider>
106107
{!shouldDeferNonEssentials && <ReportLifecycleHandler reportID={reportIDFromRoute} />}
107-
<ReportHeader />
108-
{!shouldDeferNonEssentials && <AccountManagerBanner reportID={reportIDFromRoute} />}
108+
<CollapsibleHeaderOnKeyboard>
109+
<ReportHeader />
110+
{!shouldDeferNonEssentials && <AccountManagerBanner reportID={reportIDFromRoute} />}
111+
</CollapsibleHeaderOnKeyboard>
109112
<OfflineWithFeedback
110113
pendingAction={reportPendingAction}
111114
errors={reportErrors}

0 commit comments

Comments
 (0)