1+ import { useIsFocused } from '@react-navigation/native' ;
12import React , { useEffect , useRef } from 'react' ;
23import type { LayoutChangeEvent } from 'react-native' ;
34import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' ;
4- import Reanimated , { useAnimatedReaction , useAnimatedStyle , useSharedValue , withTiming } from 'react-native-reanimated' ;
5+ import Reanimated , { Easing , useAnimatedReaction , useAnimatedStyle , useSharedValue , withTiming } from 'react-native-reanimated' ;
56import useWindowDimensions from '@hooks/useWindowDimensions' ;
6- import isInLandscapeMode from '@libs/isInLandscapeMode' ;
7+ import isInLandscapeModeUtil from '@libs/isInLandscapeMode' ;
78import type { CollapsibleHeaderOnKeyboardProps } from './types' ;
89
9- const COLLAPSE_DURATION = 200 ;
10+ const COLLAPSE_DURATION = 100 ;
1011const RESTORE_DURATION = 300 ;
1112// Assumed vertical space for the focused input field — used to reserve space above the keyboard.
1213const 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+ }
1321
1422/**
1523 * Wraps a header and collapses it upward when the keyboard is open and there is not enough
@@ -20,6 +28,7 @@ const VERTICAL_SPACE_FOR_FOCUSED_INPUT = 120;
2028 * Uses height animation (not translateY) so the freed space is reclaimed by the layout below.
2129 */
2230function CollapsibleHeaderOnKeyboard ( { children, collapsibleHeaderOffset = 0 } : CollapsibleHeaderOnKeyboardProps ) {
31+ const isFocused = useIsFocused ( ) ;
2332 // JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0
2433 const naturalHeightRef = useRef ( - 1 ) ;
2534 // Worklet-accessible mirror of naturalHeightRef. -1 signals "not yet measured".
@@ -30,17 +39,24 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
3039 const { height : keyboardHeightSV , progress : keyboardProgressSV } = useReanimatedKeyboardAnimation ( ) ;
3140
3241 const { windowWidth, windowHeight} = useWindowDimensions ( ) ;
42+ const isInLandscapeMode = isInLandscapeModeUtil ( windowWidth , windowHeight ) ;
3343 // Keep window dimensions and offset accessible on the UI thread. Stable refs, excluded from deps.
3444 const windowHeightSV = useSharedValue ( windowHeight ) ;
35- const isLandscapeSV = useSharedValue ( isInLandscapeMode ( windowWidth , windowHeight ) ) ;
3645 const collapsibleHeaderOffsetSV = useSharedValue ( collapsibleHeaderOffset ) ;
46+ const isFocusedSV = useSharedValue ( isFocused ) ;
47+ const isInLandscapeModeSV = useSharedValue ( isInLandscapeMode ) ;
3748 useEffect ( ( ) => {
3849 windowHeightSV . set ( windowHeight ) ;
39- isLandscapeSV . set ( isInLandscapeMode ( windowWidth , windowHeight ) ) ;
40- } , [ windowWidth , windowHeight , isLandscapeSV , windowHeightSV ] ) ;
50+ } , [ windowHeight , windowHeightSV ] ) ;
4151 useEffect ( ( ) => {
4252 collapsibleHeaderOffsetSV . set ( collapsibleHeaderOffset ) ;
4353 } , [ collapsibleHeaderOffset , collapsibleHeaderOffsetSV ] ) ;
54+ useEffect ( ( ) => {
55+ isFocusedSV . set ( isFocused ) ;
56+ } , [ isFocused , isFocusedSV ] ) ;
57+ useEffect ( ( ) => {
58+ isInLandscapeModeSV . set ( isInLandscapeMode ) ;
59+ } , [ isInLandscapeMode , isInLandscapeModeSV ] ) ;
4460
4561 const onLayout = ( e : LayoutChangeEvent ) => {
4662 const height = e . nativeEvent . layout . height ;
@@ -57,41 +73,52 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
5773 }
5874 } ;
5975
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+
6084 // Runs on the UI thread whenever keyboard state changes.
6185 // Fires at two key moments:
6286 // 1. When keyboard just starts opening: on iOS keyboardHeight is already at its final value
6387 // (set by onKeyboardMoveStart), so the collapse begins before the list scrolls the
6488 // input into place — preventing the input from ending up behind the collapsed header.
65- // 2. When keyboard is fully open: on Android keyboardHeight only reaches its final value at
66- // this point (set by onKeyboardMoveEnd), so this is the earliest we can act correctly.
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.
6792 useAnimatedReaction (
68- ( ) => ( { keyboardHeight : keyboardHeightSV . get ( ) , keyboardProgress : keyboardProgressSV . get ( ) , isLandscape : isLandscapeSV . get ( ) , windowHeightValue : windowHeightSV . get ( ) } ) ,
69- ( { keyboardHeight, keyboardProgress, isLandscape, windowHeightValue} , previous ) => {
70- const prevKeyboardProgress = previous ?. keyboardProgress ?? 0 ;
71- const naturalHeightValue = naturalHeight . get ( ) ;
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+ }
72103
73- const isKeyboardFullyClosed = keyboardProgress < 0.01 ;
74- // Keyboard fully closed — restore header (guard avoids redundant withTiming calls
75- // during the first few frames of keyboard opening when keyboardProgress is still < 0.01).
76- if ( isKeyboardFullyClosed ) {
77- if ( animatedHeight . get ( ) < naturalHeightValue ) {
78- animatedHeight . set ( withTiming ( naturalHeightValue , { duration : RESTORE_DURATION } ) ) ;
79- }
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 } ) ) ;
80108 return ;
81109 }
82110
83- // Portrait mode — no collapse needed. Snap to full height in case orientation
84- // changed while the header was collapsed, then bail out.
85- if ( ! isLandscape ) {
86- animatedHeight . set ( withTiming ( naturalHeightValue , { duration : RESTORE_DURATION } ) ) ;
111+ // If the keyboard is closing, bail out
112+ const prevKeyboardProgress = previous ?. keyboardProgress ?? 0 ;
113+ if ( prevKeyboardProgress >= keyboardProgress ) {
87114 return ;
88115 }
89116
90- // Only act at the two transition points described above , not on every intermediate frame.
91- const isKeyboardStartingOpening = prevKeyboardProgress < 0.01 ;
92- const isKeyboardFullyOpened = keyboardProgress > 0.99 && prevKeyboardProgress <= 0.99 ;
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 ) ;
93120
94- if ( ! isKeyboardStartingOpening && ! isKeyboardFullyOpened ) {
121+ if ( ! isKeyboardStartingOpening && ! isKeyboardOpeningAndReachingThreshold ) {
95122 return ;
96123 }
97124
@@ -100,12 +127,13 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
100127 // the header gets what remains. Clamped to [0, naturalHeight].
101128 const keyboardTop = windowHeightValue + keyboardHeight ;
102129 const targetHeight = Math . max ( 0 , keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV . get ( ) ) ;
130+ const naturalHeightValue = naturalHeight . get ( ) ;
103131
104132 if ( targetHeight >= naturalHeightValue ) {
105133 // Enough space for the full header plus the input — restore or keep.
106134 animatedHeight . set ( withTiming ( naturalHeightValue , { duration : RESTORE_DURATION } ) ) ;
107135 } else {
108- animatedHeight . set ( withTiming ( targetHeight , { duration : COLLAPSE_DURATION } ) ) ;
136+ animatedHeight . set ( withTiming ( targetHeight , { duration : COLLAPSE_DURATION , easing : Easing . out ( Easing . cubic ) } ) ) ;
109137 }
110138 } ,
111139 ) ;
0 commit comments