/**
* useKeyboardAvoidance — keep an element clear of the software keyboard.
*
* `KeyboardAvoidingView` shifts or pads an entire subtree by the full keyboard
* height regardless of where the element actually sits, which over-scrolls
* short forms and does nothing useful for an element already above the fold.
* This works from the element's own position instead.
*
* Two modes, because "get out of the keyboard's way" and "ride the keyboard"
* are different jobs:
*
* - **`lift`** (default) — for a field sitting in the page's flow. The element
* is measured every frame while the keyboard is up and moved by exactly the
* current overlap, so it follows the scroll: scroll it clear and the lift
* decays to nothing, scroll it back under and the lift returns. Measuring
* once and holding the result is what leaves a field hanging out of its own
* slot the moment the page moves underneath it.
* - **`dock`** — for an absolutely-positioned composer, toolbar or search bar
* pinned near the bottom edge. There is nothing to measure: the element
* simply travels with the keyboard, less whatever bottom inset it is already
* sitting above.
*
* ## Install the keyboard controller
*
* ```sh
* npx expo install react-native-keyboard-controller
* ```
*
* It is an optional peer, but on Android it is close to required. Reanimated's
* own `useAnimatedKeyboard` — the fallback used when the controller is absent —
* is deprecated in Reanimated 4, and merely *calling* it switches Android out
* of `adjustResize` into manual inset handling for the whole app. That is a
* global side effect from a local hook, and it is why keyboard avoidance built
* on it tends to work on iOS and break on Android.
*
* With the controller installed, wrap your app in its `KeyboardProvider` —
* `PanelUIProvider` does this for you when the package is present.
*
* ```tsx
* const { ref, onLayout, animatedStyle } = useKeyboardAvoidance();
*
*
*
*
* ```
*/
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
import {
TurboModuleRegistry,
useWindowDimensions,
type LayoutChangeEvent,
type View,
type ViewStyle,
} from 'react-native';
import {
measure,
runOnJS,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
withTiming,
type AnimatedRef,
type SharedValue,
} from 'react-native-reanimated';
/** How long a lifted element takes to settle back after it stops being active. */
const SETTLE_DURATION = 200;
/**
* Movement below this is dropped. The element's measured position already
* includes the translation applied on the previous frame, so the loop reads
* its own output — a dead band keeps sub-pixel rounding from making it hum.
*/
const EPSILON = 0.5;
export type KeyboardAvoidanceMode = 'lift' | 'dock';
export interface UseKeyboardAvoidanceOptions {
/** Set false to leave the element where it is. */
enabled?: boolean;
/**
* Whether this element is the one that should get out of the keyboard's way.
*
* Defaults to true, which suits a composer or a toolbar: it rides the
* keyboard whatever is focused. A *field* wants the opposite — pass its own
* focus state, or every field on the screen lifts whenever any one of them
* is tapped, and they all arrive at the same place on top of each other.
*/
active?: boolean;
/**
* `lift` moves an in-flow element by its overlap with the keyboard and
* tracks it as the page scrolls. `dock` travels with the keyboard outright,
* for an element already pinned to the bottom edge.
*/
mode?: KeyboardAvoidanceMode;
/** Gap to keep between the element's bottom edge and the keyboard. `lift` only. */
offset?: number;
/**
* How far above the bottom edge the element already sits — usually the safe
* area inset it is offset by. Subtracted from the travel, since the keyboard
* covers that strip too. `dock` only.
*/
bottomInset?: number;
}
export interface UseKeyboardAvoidanceResult {
/** Attach to the element that should stay visible. */
ref: AnimatedRef;
/** Attach to the same element, so a re-layout at rest cannot leave it offset. */
onLayout: (event: LayoutChangeEvent) => void;
/** Apply to the same element. */
animatedStyle: ReturnType>;
}
type KeyboardHeightHook = () => SharedValue;
/**
* Whether the controller's native module is actually in this client.
*
* Resolving the package is not the same as being able to use it: in Expo Go the
* JavaScript is in `node_modules` and requires cleanly, and every call into it
* throws from a proxy that reports the package as unlinked. A `try`/`catch`
* around the require never sees that, because it happens later.
*/
function nativeControllerPresent(): boolean {
try {
return TurboModuleRegistry.get('KeyboardController') !== null;
} catch {
return false;
}
}
/**
* Resolved on the first render rather than at module load, and then never
* again.
*
* The resolution asks the native module registry a question, and this module is
* reachable from the package's root entry — so at module scope that question
* was being asked while a consuming app was still evaluating its imports,
* before the runtime had finished standing up. By the first render it is up.
*
* Caching it is what keeps the rules of hooks: `useKeyboardHeight` always calls
* the same underlying hook, because the implementation is chosen once and
* cannot change afterwards.
*/
let keyboardHeightImpl: KeyboardHeightHook | undefined;
function useKeyboardHeight(): SharedValue {
if (!keyboardHeightImpl) keyboardHeightImpl = resolveKeyboardHeight();
return keyboardHeightImpl();
}
const resolveKeyboardHeight = (): KeyboardHeightHook => {
try {
if (!nativeControllerPresent()) return () => useAnimatedKeyboard().height;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const controller = require('react-native-keyboard-controller');
if (typeof controller?.useReanimatedKeyboardAnimation === 'function') {
return () => {
// The controller reports height as a negative offset, matching the
// translation you would apply. Normalise it to a positive height so
// the arithmetic below reads the same either way.
const { height } = controller.useReanimatedKeyboardAnimation();
return height as SharedValue;
};
}
} catch {
// Not installed — fall through.
}
return () => useAnimatedKeyboard().height;
};
/** True when the keyboard controller is driving this, rather than the fallback. */
export function hasKeyboardController(): boolean {
try {
if (!nativeControllerPresent()) return false;
// eslint-disable-next-line @typescript-eslint/no-require-imports
return typeof require('react-native-keyboard-controller')
?.useReanimatedKeyboardAnimation === 'function';
} catch {
return false;
}
}
export function useKeyboardAvoidance({
enabled = true,
active = true,
mode = 'lift',
offset = 16,
bottomInset = 0,
}: UseKeyboardAvoidanceOptions = {}): UseKeyboardAvoidanceResult {
const ref = useAnimatedRef();
const rawHeight = useKeyboardHeight();
const { height: screenHeight } = useWindowDimensions();
/** The translation currently applied, in pixels. Zero or negative. */
const translation = useSharedValue(0);
/*
* Whether the element can be measured: laid out at least once, and still
* mounted.
*
* `measure` warns when it is handed a view the layout engine has no metrics
* for — every frame between a mount and its first layout, and every frame
* after an unmount that the callback has not been stopped for yet. Neither
* is a fault, and neither can be answered by the `null` it returns, because
* the warning is printed before that null comes back. Running once a frame,
* one of these fills the log on its own.
*
* The layout pass arms it. Two things disarm it, because one is not enough:
*
* - the teardown, in a *layout* effect. A plain effect's cleanup is written
* from the JS thread after the commit, which is one or more frames after
* the view it is protecting has gone — long enough for the loop to measure
* a detached view and log. This is the case an overlay hits every time:
* a dialog whose content unmounts on close takes the element with it while
* the keyboard is still on its way down.
* - the worklet itself, whenever a measurement comes back null. That write
* belongs to the UI thread, so it lands whatever order the teardown
* arrives in, and a view that has gone costs one warning rather than one
* per frame.
*
* A disarm that was only a race is put back by the next layout pass, or by
* the keyboard opening again — `everLaidOut` is what makes the second one
* safe, since arming before the first layout is the other half of the bug.
*/
const laidOut = useSharedValue(false);
const everLaidOut = useSharedValue(false);
useLayoutEffect(
() => () => {
laidOut.value = false;
},
[laidOut]
);
// `active` is a plain prop and the worklets below cannot read props, so it is
// mirrored — written in an effect, because touching a shared value during
// render is a Reanimated strict-mode violation.
const isActive = useSharedValue(active && enabled);
useEffect(() => {
isActive.value = active && enabled;
}, [active, enabled, isActive]);
/*
* The whole of `lift` is this callback, and it runs only while the element is
* the active one *and* the keyboard is up — see the reaction below.
*
* The element is re-measured every frame rather than once, because every
* interesting thing that moves it happens after the keyboard opens: the page
* scrolls, a sheet settles, content above it grows. A position captured at
* the moment of focus is right for exactly one frame, and the element spends
* the rest of the time holding an offset that belongs to where it used to be.
*
* What is measured already includes the translation applied on the previous
* frame, so that is subtracted back out to recover the honest resting edge.
* Without it the callback would chase its own output down the screen.
*/
const track = useFrameCallback(() => {
'worklet';
const keyboardHeight = Math.abs(rawHeight.value);
if (keyboardHeight === 0) return;
if (!laidOut.value) return;
const frame = measure(ref);
if (!frame) {
laidOut.value = false;
return;
}
if (frame.height <= 0) return;
const restingBottom = frame.pageY + frame.height - translation.value;
const keyboardTop = screenHeight - keyboardHeight;
const overlap = restingBottom + offset - keyboardTop;
const next = overlap > 0 ? -overlap : 0;
if (Math.abs(next - translation.value) > EPSILON) translation.value = next;
}, false);
/*
* Tracking is switched on the transition rather than left running, so a
* screen full of fields costs nothing until one of them is being typed into.
* `setActive` lives on the JS side, hence the hop.
*/
const [tracking, setTracking] = useState(false);
useAnimatedReaction(
() => isActive.value && Math.abs(rawHeight.value) > 0,
(shouldTrack, wasTracking) => {
if (shouldTrack === wasTracking) return;
runOnJS(setTracking)(shouldTrack);
// A keyboard opening is the other place the guard is put back, for the
// element that was disarmed by a measurement that raced a re-layout.
if (shouldTrack && everLaidOut.value) laidOut.value = true;
// Moving straight from one field to another never closes the keyboard,
// so the field being left has nothing to follow back down — it is sent
// home explicitly, or it stays hanging where the keyboard left it.
if (!shouldTrack && translation.value !== 0) {
translation.value = withTiming(0, { duration: SETTLE_DURATION });
}
}
);
const { setActive } = track;
useLayoutEffect(() => {
setActive(tracking && mode === 'lift');
return () => setActive(false);
}, [tracking, mode, setActive]);
const onLayout = useCallback(
(_event: LayoutChangeEvent) => {
laidOut.value = true;
everLaidOut.value = true;
// A layout pass while the element is at rest means its slot moved for
// some reason other than the keyboard. Anything left over from the last
// lift belongs to the old slot.
if (!tracking && translation.value !== 0) translation.value = 0;
},
[everLaidOut, laidOut, tracking, translation]
);
const animatedStyle = useAnimatedStyle(() => {
if (mode === 'dock') {
if (!isActive.value) return { transform: [{ translateY: 0 }] };
// Both sources are normalised to a positive height here.
const keyboardHeight = Math.abs(rawHeight.value);
const travel = Math.max(keyboardHeight - bottomInset, 0);
return { transform: [{ translateY: -travel }] };
}
return { transform: [{ translateY: translation.value }] };
});
return { ref, onLayout, animatedStyle };
}