Skip to content

Commit fead167

Browse files
committed
refactor: remove Reanimated dependency and add resetScrollOnKeyboardHide support with improved keyboard accessory padding logic
1 parent 641449e commit fead167

2 files changed

Lines changed: 108 additions & 69 deletions

File tree

packages/react-native-fieldflow/src/FieldForm.tsx

Lines changed: 101 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
findNodeHandle,
1616
Keyboard,
1717
KeyboardAvoidingView,
18+
LayoutAnimation,
1819
Platform,
1920
ScrollView,
2021
StyleSheet,
@@ -24,47 +25,6 @@ import {
2425
type TextInput,
2526
} from 'react-native';
2627

27-
let AnimatedReanimatedView: any;
28-
let useAnimatedKeyboard: any;
29-
let useAnimatedStyle: any;
30-
let isReanimatedAvailable = false;
31-
32-
try {
33-
const rea = require('react-native-reanimated');
34-
useAnimatedKeyboard = rea.useAnimatedKeyboard;
35-
useAnimatedStyle = rea.useAnimatedStyle;
36-
const createAnimatedComponent = rea.createAnimatedComponent;
37-
// createAnimatedComponent(View) is the canonical way to get an Animated View
38-
// that actually drives Reanimated animated styles on the UI thread.
39-
if (useAnimatedKeyboard && useAnimatedStyle && createAnimatedComponent) {
40-
AnimatedReanimatedView = createAnimatedComponent(View);
41-
isReanimatedAvailable = true;
42-
}
43-
} catch (e) {
44-
// gracefully degrade to Animated.timing if not found
45-
}
46-
47-
// ReanimatedSpacer — used only for the scroll content spacer (not the accessory)
48-
const ReanimatedSpacer = ({
49-
avoidKeyboard,
50-
offset,
51-
extraPad,
52-
}: {
53-
avoidKeyboard: boolean;
54-
offset: number;
55-
extraPad: number;
56-
}) => {
57-
const keyboard = useAnimatedKeyboard();
58-
const animatedStyle = useAnimatedStyle(() => {
59-
const kHeight = avoidKeyboard ? keyboard.height.value : 0;
60-
const finalHeight = kHeight > 0 ? Math.max(kHeight + offset, 0) : 0;
61-
return { height: finalHeight + extraPad };
62-
}, [avoidKeyboard, offset, extraPad]);
63-
64-
const AnimatedView = AnimatedReanimatedView;
65-
return <AnimatedView style={animatedStyle} />;
66-
};
67-
6828
// NOTE: ReanimatedAccessory has been intentionally removed.
6929
// The keyboard accessory view is now animated with a dedicated Animated.timing
7030
// effect (see inside FieldForm) that uses the keyboard event's exact duration
@@ -128,11 +88,14 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
12888
keyboardAccessoryView,
12989
keyboardAccessoryViewMode = 'always',
13090
scrollViewRef: scrollViewRefProp,
91+
resetScrollOnKeyboardHide = false,
13192
} = props;
13293

13394
const fieldsRef = useRef<{ ref: React.MutableRefObject<TextInput | null>; skip?: boolean }[]>([]);
13495
const internalScrollRef = useRef<ScrollView | null>(null);
13596
const [accessoryHeight, setAccessoryHeight] = React.useState(0);
97+
/** Drives bottom padding for accessory when mode is `whenKeyboardOpen` (clears when keyboard closes). */
98+
const [keyboardOpen, setKeyboardOpen] = React.useState(false);
13699

137100
// Ref to the outermost wrapper View — used to measure how far the container
138101
// bottom sits above the real screen bottom (e.g. tab bar height).
@@ -149,12 +112,18 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
149112

150113
const setMergedScrollRef = useCallback(
151114
(node: ScrollView | null) => {
152-
internalScrollRef.current = node;
115+
const next = node ?? null;
116+
internalScrollRef.current = next;
153117
if (!scrollViewRefProp) return;
154-
if (typeof scrollViewRefProp === 'function') {
155-
scrollViewRefProp(node);
156-
} else {
157-
(scrollViewRefProp as React.MutableRefObject<ScrollView | null>).current = node;
118+
try {
119+
if (typeof scrollViewRefProp === 'function') {
120+
scrollViewRefProp(next);
121+
} else {
122+
(scrollViewRefProp as React.MutableRefObject<ScrollView | null>).current =
123+
next;
124+
}
125+
} catch {
126+
// Parent passed a read-only / frozen ref (e.g. some RN + Reanimated paths).
158127
}
159128
},
160129
[scrollViewRefProp],
@@ -383,30 +352,82 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
383352
keyboardHideEvent ?? (Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide');
384353

385354
const showSub = Keyboard.addListener(showEvent, e => {
355+
setKeyboardOpen(true);
386356
const h = e.endCoordinates.height;
387357
const offset = resolveKeyboardVerticalOffset(keyboardVerticalOffset);
388358
const finalHeight = Math.max(h + offset, 0);
389359

390-
// Only needed when Reanimated is NOT available (content spacer fallback)
391-
if (!isReanimatedAvailable && avoidKeyboard) {
392-
Animated.timing(animatedMargin, {
393-
toValue: finalHeight,
394-
duration: e.duration || 250,
395-
easing: Easing.out(Easing.poly(4)),
396-
useNativeDriver: false,
397-
}).start();
360+
// Scroll bottom spacer (non‑iOS, or iOS when not using the static spacer below).
361+
// Avoid useAnimatedKeyboard here — it can throw on New Architecture (frozen ref).
362+
if (avoidKeyboard) {
363+
if (Platform.OS === 'ios') {
364+
// Spacer height is fixed on iOS; animating this value still costs JS frames.
365+
animatedMargin.setValue(0);
366+
} else {
367+
Animated.timing(animatedMargin, {
368+
toValue: finalHeight,
369+
duration: e.duration || 250,
370+
easing: Easing.out(Easing.poly(4)),
371+
useNativeDriver: false,
372+
}).start();
373+
}
398374
}
399375
onKeyboardShow?.({ height: h, event: e });
400376
});
401377

402378
const hideSub = Keyboard.addListener(hideEvent, e => {
403-
if (!isReanimatedAvailable && avoidKeyboard) {
404-
Animated.timing(animatedMargin, {
405-
toValue: 0,
406-
duration: e?.duration || 250,
407-
easing: Easing.out(Easing.poly(4)),
408-
useNativeDriver: false,
409-
}).start();
379+
const hideDuration = e?.duration || 250;
380+
const hideEasing =
381+
Platform.OS === 'ios'
382+
? Easing.bezier(0.42, 0, 1, 1)
383+
: Easing.out(Easing.cubic);
384+
385+
const deferAccessoryPadClear =
386+
Boolean(keyboardAccessoryView) &&
387+
keyboardAccessoryViewMode === 'whenKeyboardOpen';
388+
389+
const applyKeyboardClosedLayout = () => {
390+
if (Platform.OS === 'ios' && deferAccessoryPadClear) {
391+
LayoutAnimation.configureNext({
392+
...LayoutAnimation.Presets.easeInEaseOut,
393+
duration: Math.min(hideDuration, 280),
394+
});
395+
}
396+
setKeyboardOpen(false);
397+
};
398+
399+
if (resetScrollOnKeyboardHide && scrollable) {
400+
const scroll = internalScrollRef.current;
401+
if (scroll) {
402+
// Same frame as keyboard hide — avoids an extra rAF of latency.
403+
scroll.scrollTo({ x: 0, y: 0, animated: false });
404+
}
405+
}
406+
407+
if (!deferAccessoryPadClear) {
408+
applyKeyboardClosedLayout();
409+
}
410+
411+
if (avoidKeyboard) {
412+
if (Platform.OS === 'ios') {
413+
animatedMargin.setValue(0);
414+
if (deferAccessoryPadClear) {
415+
applyKeyboardClosedLayout();
416+
}
417+
} else {
418+
Animated.timing(animatedMargin, {
419+
toValue: 0,
420+
duration: hideDuration,
421+
easing: hideEasing,
422+
useNativeDriver: false,
423+
}).start(() => {
424+
if (deferAccessoryPadClear) {
425+
applyKeyboardClosedLayout();
426+
}
427+
});
428+
}
429+
} else if (deferAccessoryPadClear) {
430+
applyKeyboardClosedLayout();
410431
}
411432
onKeyboardHide?.();
412433
});
@@ -422,6 +443,10 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
422443
keyboardHideEvent,
423444
onKeyboardShow,
424445
onKeyboardHide,
446+
resetScrollOnKeyboardHide,
447+
scrollable,
448+
keyboardAccessoryView,
449+
keyboardAccessoryViewMode,
425450
]);
426451

427452
const ctx = useMemo(
@@ -466,11 +491,17 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
466491
...kavRest
467492
} = keyboardAvoidingViewProps ?? {};
468493

494+
const useAccessoryBottomPad =
495+
Boolean(keyboardAccessoryView) &&
496+
(keyboardAccessoryViewMode === 'always' || keyboardOpen);
497+
469498
const mergedContentContainerStyle = [
470499
applyDefaultScrollContentFlexGrow ? styles.scrollContent : null,
471500
contentContainerStyle,
472501
scrollContentFromProps,
473-
keyboardAccessoryView ? { paddingBottom: accessoryHeight } : null,
502+
useAccessoryBottomPad && accessoryHeight > 0
503+
? { paddingBottom: accessoryHeight }
504+
: null,
474505
];
475506

476507
const kavBehavior =
@@ -494,14 +525,15 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
494525
{children}
495526
{Platform.OS === 'ios' && avoidKeyboard ? (
496527
<View style={{ height: extraScrollPadding }} />
497-
) : isReanimatedAvailable ? (
498-
<ReanimatedSpacer
499-
avoidKeyboard={avoidKeyboard}
500-
offset={kavOffset}
501-
extraPad={typeof extraScrollPadding === 'number' ? extraScrollPadding : 0}
502-
/>
503528
) : (
504-
<Animated.View style={{ height: Animated.add(extraScrollPadding, animatedMargin) }} />
529+
<Animated.View
530+
style={{
531+
height: Animated.add(
532+
typeof extraScrollPadding === 'number' ? extraScrollPadding : 0,
533+
animatedMargin,
534+
),
535+
}}
536+
/>
505537
)}
506538
</>
507539
),

packages/react-native-fieldflow/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ export interface FieldFormProps {
132132
* (e.g. when `ScrollViewComponent` manages ref externally).
133133
*/
134134
scrollViewRef?: Ref<ScrollView | null>;
135+
136+
/**
137+
* After the keyboard hides, scroll the form `ScrollView` back to the top (`y: 0`).
138+
* Use for short forms; leave false for long scrollable content.
139+
* @default false
140+
*/
141+
resetScrollOnKeyboardHide?: boolean;
135142
}
136143

137144
export interface FieldInputProps extends TextInputProps {

0 commit comments

Comments
 (0)