Skip to content

Commit 90a0c48

Browse files
feat(core): implement advanced keyboard management and fix monorepo b… (#3)
* feat(core): implement advanced keyboard management and fix monorepo bundling This commit introduces several new props to FieldForm (autoScroll, chainEnabled, autoReturnKeyType, dismissKeyboardOnTap, etc.) and resolves bundling issues in the monorepo showcase app by hoisting runtime polyfills to the project root. * feat: add field skipping, Reanimated keyboard support, and optional submit-on-done behavior to FieldForm * docs: update README with performance details, default padding, and new field skip/submit props
1 parent ee59eea commit 90a0c48

5 files changed

Lines changed: 138 additions & 30 deletions

File tree

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ At the same time, every `FieldInput` registers itself into an ordered focus chai
161161

162162
Nothing about this requires native modules. It is entirely JS-side and works on Expo, bare RN, and the New Architecture.
163163

164+
### ⚡ 60fps Native Performance
165+
**FieldFlow** strictly requires [`react-native-reanimated`](https://docs.swmansion.com/react-native-reanimated/) as a peer dependency. By mandating Reanimated, the library delegates all keyboard layout tracking to a high-performance C++ worklet via `useAnimatedKeyboard()`. This completely bypasses the JavaScript bridge during animations, resulting in maximum UI thread performance (smooth 60fps) during heavy keyboard pan gestures that would otherwise stutter on the JS thread.
166+
164167
---
165168

166169
## API
@@ -170,9 +173,10 @@ Nothing about this requires native modules. It is entirely JS-side and works on
170173
| Prop | Type | Default | Description |
171174
|------|------|---------|-------------|
172175
| `onSubmit` | `() => void` || Called when the last field is submitted |
173-
| `extraScrollPadding` | `number` | `100` | Space between the active field and the keyboard top edge |
176+
| `extraScrollPadding` | `number` | `40` | Space between the active field and the keyboard top edge |
174177
| `scrollable` | `boolean` | `true` | Wrap children in a managed ScrollView |
175178
| `avoidKeyboard` | `boolean` | `true` | Enable the animated keyboard spacer |
179+
| `submitOnLastFieldDone` | `boolean` | `false` | Submit the whole form / call `onSubmit` when "Done" is pressed on the final field |
176180
| `style` | `ViewStyle` || Container style |
177181
| `scrollViewProps` | `ScrollViewProps` || Forwarded to the internal ScrollView |
178182
| `onKeyboardShow` | `(height: number) => void` || Called when keyboard appears |
@@ -184,7 +188,7 @@ Accepts every `TextInput` prop, plus:
184188

185189
| Prop | Type | Default | Description |
186190
|------|------|---------|-------------|
187-
| `shouldIgnoreChain` | `boolean` | `false` | Exclude this field from the focus chain |
191+
| `skip` | `boolean` | `false` | Exclude this field from the auto-focus chain entirely |
188192
| `nextRef` | `RefObject<TextInput>` || Override: focus this ref instead of the auto-detected next field |
189193
| `onFormSubmit` | `() => void` || Override: called when this field is the last and Done is tapped |
190194

@@ -304,7 +308,7 @@ const MyInput = forwardRef<TextInput, MyInputProps>((props, ref) => (
304308
<summary><b>Can I skip a field in the chain?</b></summary>
305309
<br/>
306310

307-
Yes. Add `shouldIgnoreChain` to any `FieldInput` and the focus chain skips over it as if it doesn't exist. The field is still fully functional — it just doesn't participate in Next/Done handling.
311+
Yes. Add `skip={true}` to any `FieldInput` and the focus chain skips over it dynamically. The field is still fully functional — it just doesn't participate in Next/Done handling.
308312
</details>
309313

310314
<details>

example/app/(tabs)/advanced.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,12 @@ export default function AdvancedScreen() {
169169
icon="play-outline"
170170
label="Start here"
171171
placeholder='Press "Next" on keyboard →'
172-
nextRef={customTargetRef}
173172
/>
174173
<StyledInput
175174
icon="play-skip-forward-outline"
176175
label="Skipped"
177176
placeholder="This field is skipped"
177+
skip={true}
178178
/>
179179
<View>
180180
<View style={styles.targetLabelRow}>

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

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,45 @@ import {
2323
type TextInput,
2424
} from 'react-native';
2525

26+
let AnimatedReanimatedView: any;
27+
let useAnimatedKeyboard: any;
28+
let useAnimatedStyle: any;
29+
let isReanimatedAvailable = false;
30+
31+
try {
32+
const rea = require('react-native-reanimated');
33+
// Handle different ESM/CJS export artifacts in React Native versions
34+
AnimatedReanimatedView = rea.default?.View || rea.Animated?.View || rea.View;
35+
useAnimatedKeyboard = rea.useAnimatedKeyboard;
36+
useAnimatedStyle = rea.useAnimatedStyle;
37+
if (AnimatedReanimatedView && useAnimatedKeyboard && useAnimatedStyle) {
38+
isReanimatedAvailable = true;
39+
}
40+
} catch (e) {
41+
// gracefully degrade to Animated.timing if not found
42+
}
43+
44+
const ReanimatedSpacer = ({
45+
avoidKeyboard,
46+
offset,
47+
extraPad,
48+
}: {
49+
avoidKeyboard: boolean;
50+
offset: number;
51+
extraPad: number;
52+
}) => {
53+
// We can safely call hooks here because this component is only rendered if isReanimatedAvailable = true
54+
const keyboard = useAnimatedKeyboard();
55+
const animatedStyle = useAnimatedStyle(() => {
56+
const kHeight = avoidKeyboard ? keyboard.height.value : 0;
57+
const finalHeight = Math.max(kHeight + offset, 0);
58+
return { height: finalHeight + extraPad };
59+
}, [avoidKeyboard, offset, extraPad]);
60+
61+
const AnimatedView = AnimatedReanimatedView;
62+
return <AnimatedView style={animatedStyle} />;
63+
};
64+
2665
import { FieldFlowContext } from './context';
2766
import type {
2867
FieldFormContextValue,
@@ -31,7 +70,7 @@ import type {
3170
KeyboardPlatformOs,
3271
} from './types';
3372

34-
const defaultExtraScrollPadding = 10;
73+
const defaultExtraScrollPadding = 50;
3574

3675
function resolveKeyboardVerticalOffset(
3776
value: FieldFormProps['keyboardVerticalOffset'],
@@ -76,10 +115,11 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
76115
chainEnabled = true,
77116
autoReturnKeyType = true,
78117
dismissKeyboardOnTap = false,
118+
submitOnLastFieldDone = false,
79119
scrollViewRef: scrollViewRefProp,
80120
} = props;
81121

82-
const fieldsRef = useRef<React.MutableRefObject<TextInput | null>[]>([]);
122+
const fieldsRef = useRef<{ ref: React.MutableRefObject<TextInput | null>; skip?: boolean }[]>([]);
83123
const internalScrollRef = useRef<ScrollView | null>(null);
84124

85125
const setMergedScrollRef = useCallback(
@@ -95,22 +135,40 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
95135
[scrollViewRefProp],
96136
);
97137

98-
const register = useCallback((inputRef: React.MutableRefObject<TextInput | null>): number => {
138+
const register = useCallback((inputRef: React.MutableRefObject<TextInput | null>, skip?: boolean): number => {
99139
const fields = fieldsRef.current;
100-
if (!fields.includes(inputRef)) {
101-
fields.push(inputRef);
140+
const existingIdx = fields.findIndex(f => f.ref === inputRef);
141+
if (existingIdx === -1) {
142+
fields.push({ ref: inputRef, skip });
143+
return fields.length - 1;
102144
}
103-
return fields.indexOf(inputRef);
145+
return existingIdx;
104146
}, []);
105147

106148
const unregister = useCallback((inputRef: React.MutableRefObject<TextInput | null>) => {
107149
const fields = fieldsRef.current;
108-
const idx = fields.indexOf(inputRef);
150+
const idx = fields.findIndex(f => f.ref === inputRef);
109151
if (idx > -1) fields.splice(idx, 1);
110152
}, []);
111153

154+
const updateField = useCallback((inputRef: React.MutableRefObject<TextInput | null>, skip?: boolean) => {
155+
const fields = fieldsRef.current;
156+
const idx = fields.findIndex(f => f.ref === inputRef);
157+
if (idx > -1) {
158+
fields[idx].skip = skip;
159+
}
160+
}, []);
161+
112162
const count = useCallback(() => fieldsRef.current.length, []);
113163

164+
const hasNext = useCallback((currentIndex: number) => {
165+
const fields = fieldsRef.current;
166+
for (let i = currentIndex + 1; i < fields.length; i++) {
167+
if (!fields[i].skip) return true;
168+
}
169+
return false;
170+
}, []);
171+
114172
const scrollInputIntoView = useCallback(
115173
(input: TextInput | null, padding?: number) => {
116174
const scroll = internalScrollRef.current;
@@ -133,10 +191,18 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
133191
const focusNext = useCallback(
134192
(currentIndex: number) => {
135193
const fields = fieldsRef.current;
136-
const next = fields[currentIndex + 1];
137-
if (next?.current) {
138-
next.current.focus();
139-
scrollInputIntoView(next.current);
194+
let nextField = null;
195+
196+
for (let i = currentIndex + 1; i < fields.length; i++) {
197+
if (!fields[i].skip) {
198+
nextField = fields[i].ref;
199+
break;
200+
}
201+
}
202+
203+
if (nextField?.current) {
204+
nextField.current.focus();
205+
scrollInputIntoView(nextField.current);
140206
} else if (dismissKeyboardOnFocusPastLast) {
141207
Keyboard.dismiss();
142208
}
@@ -186,7 +252,7 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
186252
const offset = resolveKeyboardVerticalOffset(keyboardVerticalOffset);
187253
const finalHeight = Math.max(h + offset, 0);
188254

189-
if (avoidKeyboard) {
255+
if (avoidKeyboard && !isReanimatedAvailable) {
190256
Animated.timing(animatedMargin, {
191257
toValue: finalHeight,
192258
duration: e.duration || 250,
@@ -198,12 +264,14 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
198264
});
199265

200266
const hideSub = Keyboard.addListener(hideEvent, e => {
201-
Animated.timing(animatedMargin, {
202-
toValue: 0,
203-
duration: e?.duration || 250,
204-
easing: Easing.out(Easing.quad),
205-
useNativeDriver: false,
206-
}).start();
267+
if (!isReanimatedAvailable) {
268+
Animated.timing(animatedMargin, {
269+
toValue: 0,
270+
duration: e?.duration || 250,
271+
easing: Easing.out(Easing.quad),
272+
useNativeDriver: false,
273+
}).start();
274+
}
207275
onKeyboardHide?.();
208276
});
209277

@@ -224,24 +292,30 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
224292
(): FieldFormContextValue => ({
225293
register,
226294
unregister,
295+
updateField,
227296
focusNext,
228297
scrollInputIntoView,
229298
submitForm,
230299
count,
300+
hasNext,
231301
autoScroll,
232302
chainEnabled,
233303
autoReturnKeyType,
304+
submitOnLastFieldDone,
234305
}),
235306
[
236307
register,
237308
unregister,
309+
updateField,
238310
focusNext,
239311
scrollInputIntoView,
240312
submitForm,
241313
count,
314+
hasNext,
242315
autoScroll,
243316
chainEnabled,
244317
autoReturnKeyType,
318+
submitOnLastFieldDone,
245319
],
246320
);
247321

@@ -277,10 +351,21 @@ export const FieldForm = forwardRef<FieldFormHandle, FieldFormProps>((props, ref
277351
showsVerticalScrollIndicator,
278352
style: scrollStyleFromProps,
279353
contentContainerStyle: mergedContentContainerStyle,
354+
automaticallyAdjustKeyboardInsets: Platform.OS === 'ios' ? avoidKeyboard : undefined,
280355
children: (
281356
<>
282357
{children}
283-
<Animated.View style={{ height: Animated.add(extraScrollPadding, animatedMargin) }} />
358+
{Platform.OS === 'ios' && avoidKeyboard ? (
359+
<View style={{ height: extraScrollPadding }} />
360+
) : isReanimatedAvailable ? (
361+
<ReanimatedSpacer
362+
avoidKeyboard={avoidKeyboard}
363+
offset={kavOffset}
364+
extraPad={typeof extraScrollPadding === 'number' ? extraScrollPadding : 0}
365+
/>
366+
) : (
367+
<Animated.View style={{ height: Animated.add(extraScrollPadding, animatedMargin) }} />
368+
)}
284369
</>
285370
),
286371
...scrollRest,

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
22
import type { NativeSyntheticEvent, TextInputSubmitEditingEventData } from 'react-native';
3-
import { TextInput } from 'react-native';
3+
import { Keyboard, TextInput } from 'react-native';
44

55
import { useFieldFlow } from './context';
66
import type { FieldInputProps } from './types';
@@ -14,6 +14,7 @@ export const FieldInput = forwardRef<TextInput, FieldInputProps>((props, forward
1414
returnKeyType,
1515
registerWithForm = true,
1616
blurOnSubmit: blurOnSubmitProp,
17+
skip,
1718
...rest
1819
} = props;
1920

@@ -26,14 +27,21 @@ export const FieldInput = forwardRef<TextInput, FieldInputProps>((props, forward
2627
if (!registerWithForm || !ctx) {
2728
return undefined;
2829
}
29-
indexRef.current = ctx.register(internalRef);
30+
indexRef.current = ctx.register(internalRef, skip);
3031
return () => ctx.unregister(internalRef);
31-
}, [ctx, registerWithForm]);
32+
}, [ctx, registerWithForm, skip]);
33+
34+
useEffect(() => {
35+
if (registerWithForm && ctx) {
36+
ctx.updateField(internalRef, skip);
37+
}
38+
}, [skip, ctx, registerWithForm]);
3239

3340
const isLast = useCallback((): boolean => {
3441
if (!ctx) return true;
3542
const idx = chainIndex ?? indexRef.current;
36-
return idx === ctx.count() - 1;
43+
if (idx === -1) return true;
44+
return !ctx.hasNext(idx);
3745
}, [ctx, chainIndex]);
3846

3947
const handleSubmitEditing = useCallback(
@@ -46,8 +54,12 @@ export const FieldInput = forwardRef<TextInput, FieldInputProps>((props, forward
4654
if (nextRef?.current) {
4755
nextRef.current.focus();
4856
} else if (isLast()) {
49-
onFormSubmit?.();
50-
ctx.submitForm();
57+
if (ctx.submitOnLastFieldDone) {
58+
onFormSubmit?.();
59+
ctx.submitForm();
60+
} else {
61+
Keyboard.dismiss();
62+
}
5163
} else if (ctx.chainEnabled) {
5264
ctx.focusNext(idx);
5365
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,20 @@ import type {
1111

1212
/** Public context API for field registration and chained focus (used by `KeyboardInput`). */
1313
export interface FieldFormContextValue {
14-
register: (inputRef: MutableRefObject<TextInput | null>) => number;
14+
register: (inputRef: MutableRefObject<TextInput | null>, skip?: boolean) => number;
1515
unregister: (inputRef: MutableRefObject<TextInput | null>) => void;
16+
updateField: (inputRef: MutableRefObject<TextInput | null>, skip?: boolean) => void;
1617
focusNext: (currentIndex: number) => void;
1718
/** Scrolls the form scroll view to ensure the given input is visible above the keyboard. */
1819
scrollInputIntoView: (input: TextInput | null, padding?: number) => void;
1920
submitForm: () => void;
2021
count: () => number;
22+
hasNext: (currentIndex: number) => boolean;
2123
/** @internal settings from FieldForm */
2224
autoScroll: boolean;
2325
chainEnabled: boolean;
2426
autoReturnKeyType: boolean;
27+
submitOnLastFieldDone: boolean;
2528
}
2629

2730
/** Imperative API when `KeyboardForm` is used with `ref`. */
@@ -107,6 +110,8 @@ export interface FieldFormProps {
107110
dismissKeyboardOnSubmit?: boolean;
108111
/** When moving past the last field via `focusNext`. @default true */
109112
dismissKeyboardOnFocusPastLast?: boolean;
113+
/** Submit the whole form / call `onSubmit` when the "Done" key is pressed on the last field. @default false */
114+
submitOnLastFieldDone?: boolean;
110115

111116
/**
112117
* Use your own ref for the scroll view instead of an internal one
@@ -124,6 +129,8 @@ export interface FieldInputProps extends TextInputProps {
124129
nextRef?: MutableRefObject<TextInput | null>;
125130
/** Set false to use `KeyboardInput` outside a `KeyboardForm`. @default true */
126131
registerWithForm?: boolean;
132+
/** Skip this field during standard keyboard "Next" navigation. @default false */
133+
skip?: boolean;
127134
}
128135

129136
export type FieldInputSubmitEditingEvent =

0 commit comments

Comments
 (0)