react-native-keyboard-controller
Version:
Keyboard manager which works in identical way on both iOS and Android
377 lines (325 loc) • 11.6 kB
text/typescript
import { scrollTo, useSharedValue } from "react-native-reanimated";
import { useKeyboardHandler } from "../../../hooks";
import useScrollState from "../../hooks/useScrollState";
import {
clampedScrollTarget,
getEffectiveHeight,
getMinimumPaddingAbsorbed,
getScrollEffective,
getVisibleMinimumPaddingFraction,
isScrollAtEnd,
shouldShiftContent,
} from "./helpers";
import type { UseChatKeyboardOptions, UseChatKeyboardReturn } from "./types";
import type { AnimatedRef } from "react-native-reanimated";
import type Reanimated from "react-native-reanimated";
/**
* Hook that manages keyboard-driven scrolling for chat-style scroll views.
* Calculates padding (extra scrollable space) and content shift values,
* using per-frame scrollTo updates (Android and other platforms).
*
* @param scrollViewRef - Animated ref to the scroll view.
* @param options - Configuration for inverted and keyboardLiftBehavior.
* @returns Shared values for padding and contentOffsetY (always `undefined`).
* @example
* ```tsx
* const { padding } = useChatKeyboard(ref, { inverted: false, keyboardLiftBehavior: "always" });
* ```
*/
function useChatKeyboard(
scrollViewRef: AnimatedRef<Reanimated.ScrollView>,
options: UseChatKeyboardOptions,
): UseChatKeyboardReturn {
const {
inverted,
keyboardLiftBehavior,
freeze,
offset,
blankSpace,
extraContentPadding,
} = options;
const padding = useSharedValue(0);
const currentHeight = useSharedValue(0);
const offsetBeforeScroll = useSharedValue(0);
const targetKeyboardHeight = useSharedValue(0);
const closing = useSharedValue(false);
const minimumPaddingFractionOnOpen = useSharedValue(0);
const actualOpenShift = useSharedValue(0);
const {
layout,
size,
offset: scroll,
onLayout,
onContentSizeChange,
} = useScrollState(scrollViewRef);
const clampScrollIfNeeded = (
effective: number,
totalPaddingForMaxScroll?: number,
) => {
"worklet";
const paddingForMax =
totalPaddingForMaxScroll !== undefined
? totalPaddingForMaxScroll
: effective;
const maxScroll = Math.max(
size.value.height - layout.value.height + paddingForMax,
0,
);
if (scroll.value > maxScroll) {
scrollTo(scrollViewRef, 0, maxScroll, false);
}
};
useKeyboardHandler(
{
onStart: (e) => {
"worklet";
if (freeze.value) {
return;
}
if (e.height > 0) {
// eslint-disable-next-line react-compiler/react-compiler
targetKeyboardHeight.value = e.height;
closing.value = false;
} else {
closing.value = true;
}
const effective = getEffectiveHeight(
e.height,
targetKeyboardHeight.value,
offset,
);
const atEnd = isScrollAtEnd(
scroll.value,
layout.value.height,
size.value.height,
inverted,
);
// Scale minimum padding absorption by how much of it is visible.
// Fully visible → full absorption; fully off-screen → no absorption.
const visibleFraction = getVisibleMinimumPaddingFraction(
scroll.value,
layout.value.height,
size.value.height,
blankSpace.value,
inverted,
);
const minimumPaddingAbsorbed =
visibleFraction >= 1
? getMinimumPaddingAbsorbed(
blankSpace.value,
extraContentPadding.value,
)
: 0;
const scrollEffective = getScrollEffective(
effective,
minimumPaddingAbsorbed,
);
if (inverted && e.duration === -1) {
// Android inverted: skip post-interactive snap-back events
// (duration === -1 means the keyboard is re-establishing its
// position after an interactive gesture, not a real animation)
return;
} else if (e.height > 0) {
// Android: keyboard opening — set padding + capture scroll position
minimumPaddingFractionOnOpen.value = visibleFraction >= 1 ? 1 : 0;
padding.value = effective;
offsetBeforeScroll.value = scroll.value;
if (!inverted && keyboardLiftBehavior === "whenAtEnd" && !atEnd) {
// Sentinel: don't scroll in onMove (non-inverted only)
offsetBeforeScroll.value = -1;
} else if (!inverted && scrollEffective === 0) {
// blankSpace fully absorbs the keyboard — prevent scroll
offsetBeforeScroll.value = -1;
} else if (inverted && scrollEffective === 0) {
// blankSpace fully absorbs the keyboard — guard for inverted
offsetBeforeScroll.value = scroll.value;
}
} else {
// Android: keyboard closing — re-capture scroll position
if (inverted) {
offsetBeforeScroll.value = scroll.value;
} else {
// Preserve "whenAtEnd" sentinel: if open didn't shift, close shouldn't either
if (offsetBeforeScroll.value !== -1) {
// Use the actual displacement recorded at end of open animation
// (not the theoretical value) so close is symmetric with open
offsetBeforeScroll.value = scroll.value - actualOpenShift.value;
}
}
}
},
onMove: (e) => {
"worklet";
if (freeze.value) {
return;
}
currentHeight.value = e.height;
if (inverted) {
// Skip post-interactive snap-back (duration === -1)
if (e.duration === -1) {
return;
}
const effective = getEffectiveHeight(
e.height,
targetKeyboardHeight.value,
offset,
);
const minimumPaddingAbsorbed =
getMinimumPaddingAbsorbed(
blankSpace.value,
extraContentPadding.value,
) * minimumPaddingFractionOnOpen.value;
const scrollEffective = getScrollEffective(
effective,
minimumPaddingAbsorbed,
);
const actualTotalPadding = Math.max(
blankSpace.value,
effective + extraContentPadding.value,
);
// Check if we should shift content based on position when keyboard started
const wasAtEnd = isScrollAtEnd(
offsetBeforeScroll.value,
layout.value.height,
size.value.height,
inverted,
);
// "never" at end: scroll along when keyboard closes to avoid jump
if (
keyboardLiftBehavior === "never" &&
wasAtEnd &&
effective < padding.value
) {
padding.value = effective;
if (scrollEffective === 0 && minimumPaddingAbsorbed > 0) {
return;
}
scrollTo(scrollViewRef, 0, 0, false);
return;
}
if (!shouldShiftContent(keyboardLiftBehavior, wasAtEnd)) {
// Closing, not shifting: reduce padding to avoid gap
if (closing.value && effective < padding.value) {
padding.value = effective;
clampScrollIfNeeded(effective, actualTotalPadding);
}
return;
}
// When blankSpace fully absorbs the keyboard, skip scroll
if (scrollEffective === 0 && minimumPaddingAbsorbed > 0) {
return;
}
// Persistent: don't let shift decrease
if (keyboardLiftBehavior === "persistent") {
const currentShift =
offsetBeforeScroll.value + padding.value - scroll.value;
if (effective < currentShift) {
// When at end, allow scrolling back (snap to end + reduce padding)
if (wasAtEnd) {
padding.value = effective;
scrollTo(scrollViewRef, 0, 0, false);
} else if (closing.value) {
// Not at end: reduce padding to avoid gap
padding.value = effective;
clampScrollIfNeeded(effective, actualTotalPadding);
}
return;
}
}
const target =
offsetBeforeScroll.value + padding.value - scrollEffective;
scrollTo(scrollViewRef, 0, target, false);
} else {
const effective = getEffectiveHeight(
e.height,
targetKeyboardHeight.value,
offset,
);
const minimumPaddingAbsorbed =
getMinimumPaddingAbsorbed(
blankSpace.value,
extraContentPadding.value,
) * minimumPaddingFractionOnOpen.value;
const scrollEffective = getScrollEffective(
effective,
minimumPaddingAbsorbed,
);
const actualTotalPadding = Math.max(
blankSpace.value,
effective + extraContentPadding.value,
);
// "never" closing: clamp scroll to valid range as inset shrinks
if (
keyboardLiftBehavior === "never" &&
closing.value &&
effective < padding.value
) {
clampScrollIfNeeded(effective, actualTotalPadding);
return;
}
if (!shouldShiftContent(keyboardLiftBehavior, true)) {
return;
}
// "whenAtEnd" sentinel check (also used for blankSpace full absorption)
if (offsetBeforeScroll.value === -1) {
if (closing.value) {
// Keyboard didn't shift on open; ensure valid position on close
clampScrollIfNeeded(effective, actualTotalPadding);
}
return;
}
// "persistent" closing: maintain position, clamped to valid range
if (keyboardLiftBehavior === "persistent" && closing.value) {
const keepAt = offsetBeforeScroll.value + padding.value;
const maxScroll = Math.max(
size.value.height - layout.value.height + actualTotalPadding,
0,
);
scrollTo(scrollViewRef, 0, Math.min(keepAt, maxScroll), false);
return;
}
const target = clampedScrollTarget(
offsetBeforeScroll.value,
scrollEffective,
size.value.height,
layout.value.height,
actualTotalPadding,
);
scrollTo(scrollViewRef, 0, target, false);
// Track actual (clamped) displacement during open for symmetric close
if (!closing.value) {
actualOpenShift.value = target - offsetBeforeScroll.value;
}
}
},
onEnd: (e) => {
"worklet";
if (freeze.value) {
return;
}
const effective = getEffectiveHeight(
e.height,
targetKeyboardHeight.value,
offset,
);
padding.value = effective;
// Record actual scroll displacement so close can be symmetric
if (effective > 0 && offsetBeforeScroll.value !== -1) {
actualOpenShift.value = scroll.value - offsetBeforeScroll.value;
}
},
},
[inverted, keyboardLiftBehavior, offset],
);
return {
padding,
currentHeight,
contentOffsetY: undefined,
scroll,
layout,
size,
onLayout,
onContentSizeChange,
};
}
export { useChatKeyboard };