|
| 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; |
0 commit comments