Skip to content

Commit e3def01

Browse files
committed
Fix SplitList scroll on focus, CollapsibleHeaderOnKeyboard improvements
1 parent 4a5d2ba commit e3def01

5 files changed

Lines changed: 97 additions & 35 deletions

File tree

src/components/CollapsibleHeaderOnKeyboard/index.native.tsx

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
import {useIsFocused} from '@react-navigation/native';
12
import React, {useEffect, useRef} from 'react';
23
import type {LayoutChangeEvent} from 'react-native';
34
import {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';
56
import useWindowDimensions from '@hooks/useWindowDimensions';
6-
import isInLandscapeMode from '@libs/isInLandscapeMode';
7+
import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
78
import type {CollapsibleHeaderOnKeyboardProps} from './types';
89

9-
const COLLAPSE_DURATION = 200;
10+
const COLLAPSE_DURATION = 100;
1011
const RESTORE_DURATION = 300;
1112
// Assumed vertical space for the focused input field — used to reserve space above the keyboard.
1213
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+
}
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
*/
2230
function 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
);
Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import React, {useCallback, useMemo, useRef} from 'react';
1+
import React, {useMemo, useRef} from 'react';
22
import type {ValueOf} from 'type-fest';
33
import SelectionList from '@components/SelectionList';
44
import SplitListItem from '@components/SelectionList/ListItem/SplitListItem';
55
import type {ListItem, SplitListItemType} from '@components/SelectionList/ListItem/types';
66
import type {SelectionListHandle} from '@components/SelectionList/types';
77
import useThemeStyles from '@hooks/useThemeStyles';
88
import type CONST from '@src/CONST';
9+
import useHandleInputFocus from './useHandleInputFocus';
910

1011
type SplitListProps = {
1112
/** The split expense sections data. */
@@ -38,12 +39,7 @@ function SplitList({data, initiallyFocusedOptionKey, onSelectRow, listFooterCont
3839
const styles = useThemeStyles();
3940
const listRef = useRef<SelectionListHandle<SplitListItemType>>(null);
4041

41-
const handleInputFocus = useCallback((item: SplitListItemType) => {
42-
if (!listRef.current) {
43-
return;
44-
}
45-
listRef.current?.scrollToFocusedInput(item);
46-
}, []);
42+
const handleInputFocus = useHandleInputFocus({listRef});
4743

4844
// Create a wrapper component that adds the onInputFocus handler
4945
const SplitListItemWithInputFocus = useMemo(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type {SplitListItemType} from '@components/SelectionList/ListItem/types';
2+
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
3+
import type UseHandleInputFocusProps from './types';
4+
5+
function useHandleInputFocus({listRef}: UseHandleInputFocusProps) {
6+
const isInLandscapeMode = useIsInLandscapeMode();
7+
8+
return (item: SplitListItemType) => {
9+
if (!listRef.current || isInLandscapeMode) {
10+
return;
11+
}
12+
listRef.current?.scrollToFocusedInput(item);
13+
};
14+
}
15+
16+
export default useHandleInputFocus;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type {SplitListItemType} from '@components/SelectionList/ListItem/types';
2+
import type UseHandleInputFocusProps from './types';
3+
4+
function useHandleInputFocus({listRef}: UseHandleInputFocusProps) {
5+
return (item: SplitListItemType) => {
6+
if (!listRef.current) {
7+
return;
8+
}
9+
listRef.current?.scrollToFocusedInput(item);
10+
};
11+
}
12+
13+
export default useHandleInputFocus;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type {RefObject} from 'react';
2+
import type {SplitListItemType} from '@components/SelectionList/ListItem/types';
3+
import type {SelectionListHandle} from '@components/SelectionList/types';
4+
5+
type UseHandleInputFocusProps = {
6+
listRef: RefObject<SelectionListHandle<SplitListItemType> | null>;
7+
};
8+
9+
export default UseHandleInputFocusProps;

0 commit comments

Comments
 (0)