Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/content/nitro-input-props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ Focused. The event carries `text`, `eventCount` and `target`.
Blurred. Same event as `onFocus`.
</Prop>

<Prop name="focusedValue" type="SharedValue<boolean>">
A shared value the field keeps equal to whether it has focus, set on the UI thread the moment focus changes (`autoFocus` included), while `onFocus` / `onBlur` still run on JS. For focus styling that must not wait for JS, such as a border around the field and the icons or buttons beside it. Needs `react-native-worklets`.
</Prop>

<Prop name="onSubmitEditing" type="(event: NitroInputTextEvent) => void">
The return key was pressed.
</Prop>
Expand Down
82 changes: 82 additions & 0 deletions example/__tests__/nitro-input-keyboard.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,88 @@ describe('NitroInput keyboard handoff', () => {
})
}

// The other side of the handoff: back to a screen where no field takes the
// keyboard (a list whose search field was never focused), the keyboard goes
// with the pop instead of staying up over it for keyboardHandoffMs. iOS
// only: the handoff at the end of a pop is UIKit's.
if (Platform.OS === 'ios') {
it('lets the keyboard go after a pop back to a screen whose fields do not take it', async () => {
const Stack = createNativeStackNavigator()
const navigation = createNavigationContainerRef<Record<string, undefined>>()
const next = createRef<NitroInputHandle>()
function List() {
return <NitroInput defaultValue="search" keyboardHandoffMs={3000} style={{ margin: 20 }} />
}
function Amount() {
return <NitroInput ref={next} autoFocus defaultValue="amount" keyboardHandoffMs={3000} style={{ margin: 20 }} />
}
await render(
<View style={{ flex: 1, height: 500 }}>
<NavigationContainer ref={navigation}>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="list" component={List} />
<Stack.Screen name="amount" component={Amount} />
</Stack.Navigator>
</NavigationContainer>
</View>
)
try {
await closeKeyboard()
navigation.navigate('amount')
await sleep(1200)
if (!Keyboard.isVisible()) return
navigation.goBack()
// The pop takes ~0.5 s; the 3 s hold would still have the keyboard up.
await sleep(1500)
expect(Keyboard.isVisible()).toBe(false)
} finally {
await closeKeyboard()
}
})
}

// The same pop, back to a field that had the keyboard when it was covered:
// that one takes it back (no refocus hook, UIKit's own return), so the
// hold stays for it and the keyboard never closes.
if (Platform.OS === 'ios') {
it('keeps the keyboard for the covered field taking it back after a pop', async () => {
const Stack = createNativeStackNavigator()
const navigation = createNavigationContainerRef<Record<string, undefined>>()
const home = createRef<NitroInputHandle>()
const log = keyboardRecorder()
function Home() {
return <NitroInput ref={home} defaultValue="home" keyboardHandoffMs={3000} style={{ margin: 20 }} />
}
function Next() {
return <NitroInput autoFocus defaultValue="next" keyboardHandoffMs={3000} style={{ margin: 20 }} />
}
await render(
<View style={{ flex: 1, height: 500 }}>
<NavigationContainer ref={navigation}>
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="home" component={Home} />
<Stack.Screen name="next" component={Next} />
</Stack.Navigator>
</NavigationContainer>
</View>
)
try {
await waitFor(() => expect(home.current).not.toBeNull())
if (!(await openKeyboard(home.current))) return
navigation.navigate('next')
await sleep(1200)
const from = Date.now()
navigation.goBack()
await sleep(1500)
expect(log.hidesSince(from)).toEqual([])
expect(home.current!.isFocused()).toBe(true)
} finally {
log.stop()
await closeKeyboard()
}
})
}

// What the platform apps do when you come back to a screen whose field had the
// keyboard: iOS (UIKit) gives the field its focus and the keyboard back, with
// the pop; Android leaves the keyboard down. Native-stack gets both for free as
Expand Down
22 changes: 22 additions & 0 deletions packages/react-native-nitro-input/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 0.3.5

- **`focusedValue`**: pass a shared value (Reanimated's `useSharedValue(false)`)
and the field keeps it equal to its focus, set on the UI thread the moment
focus changes, while `onFocus` / `onBlur` still run on JS. For focus styling
that must not wait for JS and that wraps more than the field: a border
around a row holding an icon or a country picker in front and a clear
button behind. Needs `react-native-worklets`.
- **Worklet focus handlers see the first focus of an `autoFocus` field.** They
were registered in an effect, which can run after the view has attached and
taken focus, so a worklet `onFocus` (and `useNitroInputState`'s `focused`)
missed it. They are registered while rendering now.
- **iOS: no keyboard left over a screen popped back to.** Going back from a
screen whose field had the keyboard, with `keyboardHandoffMs`, to a screen
where no field takes it (a list whose search field was never focused), the
keyboard stayed up over that screen for the whole `keyboardHandoffMs` after
the pop. React unmounts the popped screen's field before the pop starts, so
the handoff could not tell a pop from a field swapped in place. The hold now
ends as the screen starts to leave when no field is waiting for the
keyboard; a field there that had it when it was covered, or one focused on
return, still takes it over.

## 0.3.4

- **iOS: a focused field that took no typing** (with
Expand Down
28 changes: 28 additions & 0 deletions packages/react-native-nitro-input/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,33 @@ function Field() {
values; `field.handlers` are the worklets that keep them current. Typing in
that field runs nothing on the JS thread and re-renders nothing.

#### Focus styling around a field and what sits beside it

`field.handlers` replace your JS `onFocus` / `onBlur`. When you only need the
focus state, and keep your handlers, pass a shared value as `focusedValue`: the
field sets it on the UI thread the moment focus changes, `autoFocus` included,
and `onFocus` / `onBlur` still run. The row that draws the border can then hold
anything, the way a form field often has an icon or a country picker in front
and a clear button behind:

```tsx
function SearchField({ onFocus }: { onFocus: () => void }) {
const focused = useSharedValue(false)
const ring = useAnimatedStyle(() => ({
borderColor: focused.value ? '#2563eb' : 'transparent',
}))
return (
<Animated.View style={[styles.row, ring]}>
<SearchIcon />
<NitroInput style={{ flex: 1 }} focusedValue={focused} onFocus={onFocus} />
<ClearButton />
</Animated.View>
)
}
```

The border changes in the frame the caret appears, with no JS round trip.

A worklet can only reach what it closes over, unless worklets run in
[Bundle Mode](https://docs.swmansion.com/react-native-worklets/docs/bundleMode/),
which gives them the whole bundle. That is what lets a `transform` use a real
Expand Down Expand Up @@ -549,6 +576,7 @@ screen behaves like theirs with these settings:
| `onChangeMask` | `(formatted, extracted, tail, complete) => void` | – | `mask` mode: the formatted text, the characters the user contributed, what is still missing, and whether every mandatory slot is filled. |
| `signPlacement` | `'beforeAffix' \| 'afterAffix'` | `'beforeAffix'` | Where a negative amount's sign sits relative to `prefix`: `-$1,234.56` or `$-1,234.56`. Reflow only — a plain field's affixes are accessory views outside the text. |
| `onFocus` / `onBlur` | `(event) => void` | – | Carries `text`, `eventCount` and `target`. |
| `focusedValue` | `SharedValue<boolean>` | – | Kept equal to the field's focus on the UI thread, while `onFocus` / `onBlur` still run. Needs `react-native-worklets`. |
| `onSubmitEditing` | `(event) => void` | – | Return key pressed; what happens next is `submitBehavior`. |
| `onEndEditing` | `(event) => void` | – | Editing finished. |
| `onSelectionChange` | `(event) => void` | – | The caret or selection moved, in code points. |
Expand Down
68 changes: 65 additions & 3 deletions packages/react-native-nitro-input/ios/NitroInputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,8 @@ final class NitroInputView: UIView {
/// Set while a NitroInput (or the keyboard handoff's stand-in) takes first
/// responder, which must never be refused.
fileprivate static var claimInProgress = false
/// Every live field, to ask whether one is about to take the keyboard.
private static let liveFields = NSHashTable<NitroInputView>.weakObjects()
private var everInWindow = false
/// When the field last stopped editing while on screen (media time; 0 = never).
private var endedEditingAt: CFTimeInterval = 0
Expand Down Expand Up @@ -705,6 +707,7 @@ final class NitroInputView: UIView {
super.init(frame: frame)
// Starts it tracking the keyboard before any field needs to ask.
_ = KeyboardHandoff.shared
Self.liveFields.add(self)
isOpaque = false
backgroundColor = .clear
clipsToBounds = false
Expand Down Expand Up @@ -914,7 +917,61 @@ final class NitroInputView: UIView {
func handOffKeyboardIfFocused() {
guard traits.keyboardHandoffMs > 0, traits.showSoftInputOnFocus,
let window, editor.isFirstResponder else { return }
KeyboardHandoff.shared.hold(like: editor, in: window, for: traits.keyboardHandoffMs)
let hold = KeyboardHandoff.shared.hold(like: editor, in: window, for: traits.keyboardHandoffMs)
if let hold, let host = hostViewController {
Self.letGo(hold, ifLeaving: host, until: CACurrentMediaTime() + traits.keyboardHandoffMs / 1000)
}
}

/// Ends a hold as soon as the leaving field's screen turns out to be going
/// itself (back, a sheet closing) with no field waiting to take the
/// keyboard: held on, it only stays up over a screen that has no use for it
/// until the time runs out. React unmounts a popped screen's fields before
/// react-native-screens starts the pop, so at the handoff a pop still looks
/// like a view swapped in place; the screen starts leaving a moment later.
/// A field on the screen underneath that takes the keyboard back ends the
/// hold itself.
private static func letGo(_ hold: Int, ifLeaving host: UIViewController, until deadline: CFTimeInterval) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak host] in
let handoff = KeyboardHandoff.shared
guard let host, handoff.isHolding, handoff.currentHold == hold else { return }
if isLeaving(host) {
// Once more a moment later: the screen underneath comes back into the
// window as the pop starts, and a field there that had the keyboard
// asks for it back only then.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
guard handoff.isHolding, handoff.currentHold == hold, !fieldWaitsForKeyboard() else { return }
handoff.letGo()
}
return
}
if CACurrentMediaTime() < deadline { letGo(hold, ifLeaving: host, until: deadline) }
}
}

/// `controller`'s screen is being popped or dismissed. react-native-screens
/// takes a popped screen out of its navigation controller as the pop
/// starts, while its views are still on screen: a controller in no
/// container, presented by nothing and not the window's root is on its way
/// out.
private static func isLeaving(_ controller: UIViewController) -> Bool {
var current: UIViewController? = controller
while let each = current {
if each.isMovingFromParent || each.isBeingDismissed { return true }
if each.parent == nil, each.presentingViewController == nil,
each.viewIfLoaded?.window?.rootViewController !== each { return true }
current = each.parent
}
return false
}

/// A field is about to take first responder: a return waiting for the
/// transition to end, or a focus() held until its view is back in a window.
private static func fieldWaitsForKeyboard() -> Bool {
let now = CACurrentMediaTime()
return liveFields.allObjects.contains { field in
(field.focusAfterTransition && field.window != nil) || field.pendingFocusUntil > now
}
}

/// `becomeFirstResponder`, retried next turn if UIKit declines because the
Expand Down Expand Up @@ -3287,6 +3344,8 @@ final class KeyboardHandoff {
private var holder: UITextField?
private var release: DispatchWorkItem?
private var endObserver: NSObjectProtocol?
/// Which hold is running, so a check on an older one leaves this one alone.
private(set) var currentHold = 0
/// Whether the system keyboard is up or on its way up.
private(set) var keyboardVisible = false

Expand All @@ -3304,7 +3363,8 @@ final class KeyboardHandoff {

/// Takes the keyboard from `editor` (the leaving field's system field) for up
/// to `milliseconds`.
func hold(like editor: UIView, in window: UIWindow, for milliseconds: Double) {
@discardableResult
func hold(like editor: UIView, in window: UIWindow, for milliseconds: Double) -> Int? {
letGo()
let box = UIView(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
box.tag = -1
Expand Down Expand Up @@ -3339,8 +3399,9 @@ final class KeyboardHandoff {
NitroInputView.claimInProgress = false
guard held else {
box.removeFromSuperview()
return
return nil
}
currentHold += 1
container = box
holder = field
// Another field took the keyboard: the hold is over.
Expand All @@ -3353,6 +3414,7 @@ final class KeyboardHandoff {
let item = DispatchWorkItem { [weak self] in self?.letGo() }
release = item
DispatchQueue.main.asyncAfter(deadline: .now() + milliseconds / 1000, execute: item)
return currentHold
}

/// Nothing claimed the keyboard in time, or `Keyboard.dismiss()`: let it hide.
Expand Down
51 changes: 40 additions & 11 deletions packages/react-native-nitro-input/src/NitroInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ import {
unregisterWorklet,
type NitroInputSelection,
type NitroInputTransform,
type WorkletFocusEvent,
} from './worklets'
import type { SharedValueLike } from './useNitroInputState'
import { toNumericWeight, toProcessedColor } from './styleHelpers'
import { formatProps } from './formatProps'
import type { NumberFormat } from './NumberFormat'
Expand Down Expand Up @@ -644,6 +646,22 @@ export interface NitroInputProps extends Omit<ViewProps, 'children' | 'onFocus'
* `({ text }) => …` reads better than `(e) => e.nativeEvent.text`.
*/
onFocus?: (event: NitroInputFocusEvent) => void
/**
* A shared value (Reanimated's `useSharedValue(false)`) the field keeps equal
* to whether it has focus, set on the UI thread the moment focus changes.
* `onFocus` / `onBlur` still run as usual. For focus styling that must not
* wait for JS: a border around the field and whatever sits beside it, an
* icon's tint, a label's colour. Needs `react-native-worklets`.
*
* ```tsx
* const focused = useSharedValue(false)
* const ring = useAnimatedStyle(() => ({ borderColor: focused.value ? '#2563eb' : 'transparent' }))
* <Animated.View style={[styles.row, ring]}>
* <Icon /><NitroInput focusedValue={focused} onFocus={track} /><ClearButton />
* </Animated.View>
* ```
*/
focusedValue?: SharedValueLike<boolean>
/** Blurred. Same event as {@link NitroInputProps.onFocus}. */
onBlur?: (event: NitroInputFocusEvent) => void
/** The return key was pressed. */
Expand Down Expand Up @@ -822,6 +840,7 @@ export const NitroInput = forwardRef<NitroInputHandle, NitroInputProps>(
onChangeMask,
onFocus,
onBlur,
focusedValue,
onSubmitEditing,
onNativeRef,
style,
Expand Down Expand Up @@ -902,10 +921,10 @@ export const NitroInput = forwardRef<NitroInputHandle, NitroInputProps>(
const onChangeValueId = useWorkletId(onChangeValueWorklet, registerCallback)
// Marking any of these `'worklet'` moves it to the UI thread; the JS
// handler is then skipped, exactly as `onChangeText` already worked.
const onFocusId = useWorkletPairId(
const onFocusId = useFocusWorkletId(
isWorklet(onFocus) ? (onFocus as never) : undefined,
isWorklet(onBlur) ? (onBlur as never) : undefined,
registerFocusChange
focusedValue
)
const onSelectionChangeId = useWorkletId(
isWorklet(onSelectionChange) ? (onSelectionChange as never) : undefined,
Expand Down Expand Up @@ -1537,19 +1556,29 @@ function useWorkletId<T extends (...args: never[]) => unknown>(
}

/**
* The same, for the focus pair: native reports one focus change, so the two
* handlers share an id and the wrapper picks between them.
* The same, for focus: native reports one focus change, so the two handlers
* and `focusedValue` share an id and the wrapper picks between them.
*
* Registered while rendering, not in an effect: an `autoFocus` field takes
* focus as its view attaches, which can come before this component's effects
* run, and a focus change for an id that is not registered yet is dropped -
* `focusedValue` would miss the field's first focus. The registration is
* synchronous on the UI runtime, so it is there before the view is.
*/
function useWorkletPairId<T extends (...args: never[]) => unknown>(
onFocus: T | undefined,
onBlur: T | undefined,
register: (a: T | undefined, b: T | undefined, id: number) => void
function useFocusWorkletId(
onFocus: ((event: WorkletFocusEvent) => void) | undefined,
onBlur: ((event: WorkletFocusEvent) => void) | undefined,
focusedValue: SharedValueLike<boolean> | undefined
): number {
const id = useMemo(() => (onFocus || onBlur ? allocateWorkletId() : 0), [onFocus, onBlur])
const id = useMemo(() => {
if (!onFocus && !onBlur && !focusedValue) return 0
const allocated = allocateWorkletId()
registerFocusChange(onFocus, onBlur, allocated, focusedValue)
return allocated
}, [onFocus, onBlur, focusedValue])
useEffect(() => {
if (id === 0) return
register(onFocus, onBlur, id)
return () => unregisterWorklet(id)
}, [id, onFocus, onBlur, register])
}, [id])
return id
}
Loading
Loading