-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Fix input hidden behind keyboard/header in landscape mode #88178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
GCyganek
wants to merge
9
commits into
Expensify:main
Choose a base branch
from
software-mansion-labs:@GCygnaek/landscape-mode/hidden-input-fixes
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
48ad3b3
Fix input hidden behind keyboard/header in landscape mode
GCyganek 7761f21
Fix types
GCyganek ff65fdb
Show max 2 lines in composer in landscape mode
GCyganek de178e8
Fix import
GCyganek 43f3c67
Fix naturalHeight updates
GCyganek 7bc92e3
console.logs
GCyganek 1035e04
Refocus only when opening the keyboard if it gets onBlur
GCyganek be06128
Feedback
GCyganek d979cec
Fix React Compiler check
GCyganek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| }; | ||
|
|
||
| // Runs on the UI thread whenever keyboard state changes. | ||
| // Fires at two key moments: | ||
|
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; | ||
|
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'}; | ||
|
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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.