Skip to content

Commit 1d5652d

Browse files
committed
feat: v1.3.0 introduce autofocus API, useFieldFlowController, and major performance + stability improvements
1 parent 5c884fc commit 1d5652d

12 files changed

Lines changed: 351 additions & 123 deletions

File tree

README.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
npm install react-native-fieldflow
8282
```
8383

84-
> **Requirements:** React Native ≥ 0.68 · [`react-native-reanimated`](https://docs.swmansion.com/react-native-reanimated/) · Expo & bare RN supported · Zero native modules · No `pod install`
84+
> **Requirements:** React Native ≥ 0.68 · Expo & bare RN supported · Zero native modules · No `pod install`
8585
8686
---
8787

@@ -127,7 +127,8 @@ export default function SignUpScreen() {
127127
| | |
128128
| ------------------------- | ------------------------------------------------------------------- |
129129
| 🔗 **Focus chaining** | Fields 1–4 get `returnKeyType="next"`, the last field gets `"done"` |
130-
| ⌨️ **Keyboard avoidance** | Smooth layout shift via Reanimated worklet — no jumps |
130+
| ⌨️ **Keyboard avoidance** | Smooth animated layout shift — no jumps, no native modules |
131+
| 🛠️ **Accessory views** | Cross-platform floating toolbars, perfectly synced with animations |
131132
| 📜 **Auto scroll** | Focused field is always scrolled into view above the keyboard |
132133
| 📱 **Cross-platform** | Identical behavior on iOS and Android, no `Platform.OS` switches |
133134

@@ -213,7 +214,7 @@ import { FieldForm, FieldInput } from "react-native-fieldflow";
213214

214215
| Layer | What happens |
215216
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
216-
| **Keyboard tracking** | `useAnimatedKeyboard()` runs on the UI thread via a C++ Reanimated worklet — zero JS bridge involvement during keyboard animation |
217+
| **Keyboard tracking** | Uses native keyboard listeners to track frame changes — zero native modules required |
217218
| **Spacer** | An `Animated.View` at the bottom of the scroll content grows to match the keyboard frame, pushing content up in sync |
218219
| **Focus chain** | Every `FieldInput` registers itself into an ordered list; tapping Next calls `focus()` on the next ref and scrolls it into view above the keyboard |
219220
| **Submit** | The last field's Done button calls `onSubmit` and dismisses the keyboard |
@@ -246,6 +247,8 @@ The wrapper component that manages keyboard avoidance, scroll behavior, and the
246247
| `keyboardVerticalOffset` | `number \| (platform) => number` | `0 / 25` | Static offset or per-platform resolver function |
247248
| `onKeyboardShow` | `(payload) => void` || Fired when keyboard appears |
248249
| `onKeyboardHide` | `() => void` || Fired when keyboard dismisses |
250+
| `autofocusFirst` | `boolean` | `false` | Automatically focus the first field on mount (keyboard safe) |
251+
| `autofocusDelay` | `number` | `500` | Delay in ms before autofocusing (waits for screen transitions) |
249252

250253
---
251254

@@ -256,7 +259,7 @@ A drop-in replacement for `TextInput`. Accepts **all** standard `TextInput` prop
256259
| Prop | Type | Default | Description |
257260
| ------------------ | ---------------------- | ------- | ---------------------------------------------------------------------------------------------- |
258261
| `skip` | `boolean` | `false` | Exclude this field from the auto-focus chain |
259-
| `nextRef` | `RefObject<TextInput>` || Override: focus a specific ref instead of the next detected field |
262+
| `nextRef` | `RefObject<Focusable>` || Override: focus a specific ref instead of the next detected field |
260263
| `onFormSubmit` | `() => void` || Override: called when this is the last field and Done is tapped |
261264
| `isAccessoryField` | `boolean` | `false` | Set to `true` if this input lives inside `keyboardAccessoryView` to bypass scroll measurements |
262265

@@ -303,22 +306,27 @@ const visible = useKeyboardVisible(); // boolean
303306

304307
Both hooks use `keyboardWillShow` / `keyboardWillHide` on iOS and `keyboardDidShow` / `keyboardDidHide` on Android. No polling, no timers.
305308

306-
**Example — a submit button that lifts above the keyboard:**
309+
### `useFieldFlowController`
310+
311+
Exposes imperative actions to control the form from header buttons, tabs, or custom UI outside the field tree.
307312

308313
```tsx
309-
function SubmitButton() {
310-
const height = useKeyboardHeight();
314+
import { useFieldFlowController } from "react-native-fieldflow";
311315

312-
return (
313-
<Animated.View style={{ marginBottom: height }}>
314-
<TouchableOpacity onPress={handleSubmit}>
315-
<Text>Continue</Text>
316-
</TouchableOpacity>
317-
</Animated.View>
318-
);
316+
function HeaderSubmit() {
317+
const { submit, focusFirst } = useFieldFlowController();
318+
319+
return <Button title="Submit" onPress={submit} />;
319320
}
320321
```
321322

323+
| Method | Description |
324+
| --------------- | ---------------------------------------------------------------------- |
325+
| `focusFirst()` | Focuses the very first registered field in the form. |
326+
| `submit()` | Triggers `onSubmit` and optionally dismisses the keyboard. |
327+
| `scrollTo(ref)` | Manually scroll the form to keep a specific `Focusable` field visible. |
328+
| `dismiss()` | Helper for `Keyboard.dismiss()`. |
329+
322330
---
323331

324332
## 📊 Comparison

example/app/demos/01-login.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export default function LoginDemo() {
2929
/>
3030

3131
<FieldForm
32+
autofocusFirst
3233
onSubmit={handleSubmit}
3334
extraScrollPadding={140}
3435
keyboardVerticalOffset={0}

example/app/demos/02-signup.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ShowcaseColors as C, ShowcaseRadius, ShowcaseSpacing } from '../../cons
1010

1111
export default function SignupDemo() {
1212
const router = useRouter();
13-
const [activeIndex, setActiveIndex] = useState(0);
13+
const [activeIndex, setActiveIndex] = useState(-1);
1414

1515
const handleSubmit = () => {
1616
Alert.alert('Success', 'Sign up form submitted! Focus chain worked perfectly.');
@@ -52,6 +52,7 @@ export default function SignupDemo() {
5252
/>
5353

5454
<FieldForm
55+
autofocusFirst
5556
onSubmit={handleSubmit}
5657
extraScrollPadding={140}
5758
keyboardVerticalOffset={0}

example/app/demos/04-profile.tsx

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import React, { useRef, forwardRef } from 'react';
2-
import { StyleSheet, Text, View, TextInput, TouchableOpacity, ScrollView } from 'react-native';
3-
import { Stack, useRouter } from 'expo-router';
41
import { Ionicons } from '@expo/vector-icons';
5-
import {
6-
FieldForm,
7-
FieldInput
2+
import { Stack, useRouter } from 'expo-router';
3+
import React, { forwardRef, useRef } from 'react';
4+
import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
5+
import {
6+
FieldForm,
7+
FieldInput
88
} from '../../../packages/react-native-fieldflow/src';
9-
import { ShowcaseColors as C, ShowcaseSpacing, ShowcaseRadius } from '../../constants/showcase-theme';
109
import { IconButton } from '../../components/showcase';
10+
import { ShowcaseColors as C, ShowcaseRadius, ShowcaseSpacing } from '../../constants/showcase-theme';
1111

1212
/**
1313
* Reusable LabeledInput component using forwardRef.
@@ -17,7 +17,7 @@ import { IconButton } from '../../components/showcase';
1717
const LabeledInput = forwardRef<TextInput, any>(({ label, style, ...props }, ref) => (
1818
<View style={[styles.labeledInputWrapper, style]}>
1919
<Text style={styles.label}>{label}</Text>
20-
<FieldInput
20+
<FieldInput
2121
ref={ref}
2222
style={styles.fieldInput}
2323
placeholderTextColor={C.textTertiary}
@@ -30,30 +30,31 @@ LabeledInput.displayName = 'LabeledInput';
3030

3131
export default function ProfileDemo() {
3232
const router = useRouter();
33-
33+
3434
// Create a ref for the Username field to demonstrate nextRef override.
3535
const usernameRef = useRef<TextInput>(null);
3636

3737
return (
3838
<View style={styles.container}>
39-
<Stack.Screen
40-
options={{
39+
<Stack.Screen
40+
options={{
4141
headerRight: () => (
4242
<TouchableOpacity onPress={() => router.back()} style={{ marginRight: 16 }}>
4343
<Text style={styles.doneText}>Save</Text>
4444
</TouchableOpacity>
4545
),
4646
headerLeft: () => (
47-
<IconButton
48-
icon="close"
49-
onPress={() => router.back()}
47+
<IconButton
48+
icon="close"
49+
onPress={() => router.back()}
5050
color={C.textPrimary}
5151
/>
5252
),
53-
}}
53+
}}
5454
/>
5555

56-
<FieldForm
56+
<FieldForm
57+
autofocusFirst
5758
extraScrollPadding={100}
5859
keyboardVerticalOffset={0}
5960
scrollViewProps={{
@@ -73,35 +74,35 @@ export default function ProfileDemo() {
7374

7475
<View style={styles.section}>
7576
<View style={styles.row}>
76-
<LabeledInput
77-
label="First Name"
77+
<LabeledInput
78+
label="First Name"
7879
placeholder="Zuck"
7980
style={{ flex: 1 }}
8081
nextRef={usernameRef} // OVERRIDE: Focus Username after this field
8182
/>
82-
<LabeledInput
83-
label="Last Name"
83+
<LabeledInput
84+
label="Last Name"
8485
placeholder="Berg"
8586
style={{ flex: 1 }}
8687
/>
8788
</View>
88-
89+
8990
<View style={styles.annotation}>
9091
<Ionicons name="git-branch-outline" size={14} color={C.accent} />
9192
<Text style={styles.annotationText}>
9293
nextRef skips Last Name in the focus chain — fill it freely without Next/Done pressure.
9394
</Text>
9495
</View>
9596

96-
<LabeledInput
97+
<LabeledInput
9798
ref={usernameRef}
98-
label="Username"
99+
label="Username"
99100
placeholder="zuck_official"
100101
autoCapitalize="none"
101102
/>
102103

103-
<LabeledInput
104-
label="Bio"
104+
<LabeledInput
105+
label="Bio"
105106
placeholder="Building the future..."
106107
multiline
107108
numberOfLines={3}
@@ -110,8 +111,8 @@ export default function ProfileDemo() {
110111
/>
111112
<Text style={styles.bioNote}>Bio is skipped from focus chain for free typing.</Text>
112113

113-
<LabeledInput
114-
label="Website"
114+
<LabeledInput
115+
label="Website"
115116
placeholder="https://meta.com"
116117
keyboardType="url"
117118
autoCapitalize="none"

example/app/demos/11-accessory-view.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export default function ChatComposerDemo() {
6767
/>
6868

6969
<FieldForm
70+
autofocusFirst
7071
keyboardAccessoryView={renderFullAccessory()}
7172
keyboardAccessoryViewMode="always"
7273
extraScrollPadding={20}

packages/react-native-fieldflow/README.md

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
npm install react-native-fieldflow
8282
```
8383

84-
> **Requirements:** React Native ≥ 0.68 · [`react-native-reanimated`](https://docs.swmansion.com/react-native-reanimated/) · Expo & bare RN supported · Zero native modules · No `pod install`
84+
> **Requirements:** React Native ≥ 0.68 · Expo & bare RN supported · Zero native modules · No `pod install`
8585
8686
---
8787

@@ -127,7 +127,8 @@ export default function SignUpScreen() {
127127
| | |
128128
| ------------------------- | ------------------------------------------------------------------- |
129129
| 🔗 **Focus chaining** | Fields 1–4 get `returnKeyType="next"`, the last field gets `"done"` |
130-
| ⌨️ **Keyboard avoidance** | Smooth layout shift via Reanimated worklet — no jumps |
130+
| ⌨️ **Keyboard avoidance** | Smooth animated layout shift — no jumps, no native modules |
131+
| 🛠️ **Accessory views** | Cross-platform floating toolbars, perfectly synced with animations |
131132
| 📜 **Auto scroll** | Focused field is always scrolled into view above the keyboard |
132133
| 📱 **Cross-platform** | Identical behavior on iOS and Android, no `Platform.OS` switches |
133134

@@ -213,7 +214,7 @@ import { FieldForm, FieldInput } from "react-native-fieldflow";
213214

214215
| Layer | What happens |
215216
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
216-
| **Keyboard tracking** | `useAnimatedKeyboard()` runs on the UI thread via a C++ Reanimated worklet — zero JS bridge involvement during keyboard animation |
217+
| **Keyboard tracking** | Uses native keyboard listeners to track frame changes — zero native modules required |
217218
| **Spacer** | An `Animated.View` at the bottom of the scroll content grows to match the keyboard frame, pushing content up in sync |
218219
| **Focus chain** | Every `FieldInput` registers itself into an ordered list; tapping Next calls `focus()` on the next ref and scrolls it into view above the keyboard |
219220
| **Submit** | The last field's Done button calls `onSubmit` and dismisses the keyboard |
@@ -222,6 +223,55 @@ import { FieldForm, FieldInput } from "react-native-fieldflow";
222223
223224
---
224225

226+
## 📋 Using with React Hook Form
227+
228+
`FieldInput` works as a drop-in inside RHF's `Controller`. Because we correctly forward refs, RHF's **focus-on-error** feature works automatically.
229+
230+
```tsx
231+
import { useForm, Controller } from "react-hook-form";
232+
import { FieldForm, FieldInput } from "react-native-fieldflow";
233+
234+
const { control, handleSubmit } = useForm();
235+
236+
<FieldForm onSubmit={handleSubmit(onSubmit)}>
237+
<Controller
238+
control={control}
239+
name="email"
240+
rules={{ required: true }}
241+
render={({ field: { onChange, onBlur, value, ref } }) => (
242+
<FieldInput
243+
ref={ref} // 👈 Ref forwarded for validation focus
244+
value={value}
245+
onChangeText={onChange}
246+
onBlur={onBlur}
247+
placeholder="Email"
248+
/>
249+
)}
250+
/>
251+
252+
<Controller
253+
control={control}
254+
name="password"
255+
render={({ field: { onChange, onBlur, value, ref } }) => (
256+
<FieldInput
257+
ref={ref}
258+
value={value}
259+
onChangeText={onChange}
260+
onBlur={onBlur}
261+
placeholder="Password"
262+
secureTextEntry
263+
/>
264+
)}
265+
/>
266+
</FieldForm>
267+
```
268+
269+
**Separation of Concerns:**
270+
- **FieldFlow owns:** Keyboard avoidance, focus chaining, scroll-to-field, automatic return keys, and accessory views.
271+
- **React Hook Form owns:** Field values, validation rules, error state, and form submission payload.
272+
273+
---
274+
225275
## 📖 API Reference
226276

227277
### `<FieldForm>`
@@ -246,6 +296,8 @@ The wrapper component that manages keyboard avoidance, scroll behavior, and the
246296
| `keyboardVerticalOffset` | `number \| (platform) => number` | `0 / 25` | Static offset or per-platform resolver function |
247297
| `onKeyboardShow` | `(payload) => void` || Fired when keyboard appears |
248298
| `onKeyboardHide` | `() => void` || Fired when keyboard dismisses |
299+
| `autofocusFirst` | `boolean` | `false` | Automatically focus the first field on mount (keyboard safe) |
300+
| `autofocusDelay` | `number` | `500` | Delay in ms before autofocusing (waits for screen transitions) |
249301

250302
---
251303

@@ -256,7 +308,7 @@ A drop-in replacement for `TextInput`. Accepts **all** standard `TextInput` prop
256308
| Prop | Type | Default | Description |
257309
| ------------------ | ---------------------- | ------- | ---------------------------------------------------------------------------------------------- |
258310
| `skip` | `boolean` | `false` | Exclude this field from the auto-focus chain |
259-
| `nextRef` | `RefObject<TextInput>` || Override: focus a specific ref instead of the next detected field |
311+
| `nextRef` | `RefObject<Focusable>` || Override: focus a specific ref instead of the next detected field |
260312
| `onFormSubmit` | `() => void` || Override: called when this is the last field and Done is tapped |
261313
| `isAccessoryField` | `boolean` | `false` | Set to `true` if this input lives inside `keyboardAccessoryView` to bypass scroll measurements |
262314

@@ -303,22 +355,42 @@ const visible = useKeyboardVisible(); // boolean
303355

304356
Both hooks use `keyboardWillShow` / `keyboardWillHide` on iOS and `keyboardDidShow` / `keyboardDidHide` on Android. No polling, no timers.
305357

306-
**Example — a submit button that lifts above the keyboard:**
358+
### `useFieldFlowController`
359+
360+
Exposes imperative actions to control the form from header buttons, tabs, or custom UI outside the field tree.
307361

308362
```tsx
309-
function SubmitButton() {
310-
const height = useKeyboardHeight();
363+
import { useFieldFlowController } from "react-native-fieldflow";
311364

312-
return (
313-
<Animated.View style={{ marginBottom: height }}>
314-
<TouchableOpacity onPress={handleSubmit}>
315-
<Text>Continue</Text>
316-
</TouchableOpacity>
317-
</Animated.View>
318-
);
365+
function HeaderSubmit() {
366+
const { submit, focusFirst } = useFieldFlowController();
367+
368+
return <Button title="Submit" onPress={submit} />;
319369
}
320370
```
321371

372+
| Method | Description |
373+
| -------------- | ---------------------------------------------------------------------- |
374+
| `focusFirst()` | Focuses the very first registered field in the form. |
375+
| `submit()` | Triggers `onSubmit` and optionally dismisses the keyboard. |
376+
| `scrollTo(ref)` | Manually scroll the form to keep a specific `Focusable` field visible. |
377+
| `dismiss()` | Helper for `Keyboard.dismiss()`. |
378+
379+
---
380+
381+
## 🪟 Modals & Bottom Sheets
382+
383+
When using `FieldForm` inside a `Modal` (especially on Android) or a bottom sheet library, you may need to adjust the avoidance offset.
384+
385+
```tsx
386+
<FieldForm
387+
// Offset varies by navigation header and modal type
388+
keyboardVerticalOffset={(platform) => platform === 'ios' ? 44 : 0}
389+
>
390+
...
391+
</FieldForm>
392+
```
393+
322394
---
323395

324396
## 📊 Comparison

0 commit comments

Comments
 (0)