react-native-keyboard-controller
Version:
Keyboard manager which works in identical way on both iOS and Android
143 lines (135 loc) • 5.83 kB
JavaScript
import { useSharedValue } from "react-native-reanimated";
import { useKeyboardHandler } from "../../../hooks";
import useScrollState from "../../hooks/useScrollState";
import { computeIOSContentOffset, getEffectiveHeight, getScrollEffective, getVisibleMinimumPaddingFraction, isScrollAtEnd, shouldShiftContent } from "./helpers";
/**
* Hook that manages keyboard-driven scrolling for chat-style scroll views.
* Calculates padding (extra scrollable space) and content shift values,
* using iOS-specific strategy (contentOffset set once in onStart).
*
* @param scrollViewRef - Animated ref to the scroll view.
* @param options - Configuration for inverted and keyboardLiftBehavior.
* @returns Shared values for padding and contentOffsetY.
* @example
* ```tsx
* const { padding, contentOffsetY } = useChatKeyboard(ref, {
* inverted: false,
* keyboardLiftBehavior: "always",
* });
* ```
*/
function useChatKeyboard(scrollViewRef, options) {
const {
inverted,
keyboardLiftBehavior,
freeze,
offset,
blankSpace,
extraContentPadding
} = options;
const padding = useSharedValue(0);
const currentHeight = useSharedValue(0);
const contentOffsetY = useSharedValue(0);
const targetKeyboardHeight = useSharedValue(0);
const prevAbsorption = useSharedValue(0);
const {
layout,
size,
offset: scroll,
onLayout,
onContentSizeChange
} = useScrollState(scrollViewRef);
useKeyboardHandler({
onStart: e => {
"worklet";
if (freeze.value) {
return;
}
if (e.height > 0) {
// eslint-disable-next-line react-compiler/react-compiler
targetKeyboardHeight.value = e.height;
}
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 visiblePadding = visibleFraction * blankSpace.value;
const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);
const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
// persistent mode: when keyboard shrinks, clamp to valid range
if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
padding.value = effective;
prevAbsorption.value = minimumPaddingAbsorbed;
if (inverted) {
const maxScroll = Math.max(size.value.height - layout.value.height, 0);
contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
} else {
const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
}
return;
}
// never mode: when keyboard shrinks, clamp to valid range
// to avoid ghost padding
if (keyboardLiftBehavior === "never" && effective < padding.value && atEnd) {
padding.value = effective;
prevAbsorption.value = minimumPaddingAbsorbed;
if (inverted) {
const maxScroll = Math.max(size.value.height - layout.value.height, 0);
contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll));
} else {
const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0);
contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll));
}
return;
}
// Undo only the scroll displacement that was actually applied
// (not the full padding, which includes the absorbed portion).
// Use the stored absorption from the previous event so that
// the unwind matches the shift that was originally applied.
const prevScrollEffective = getScrollEffective(padding.value, prevAbsorption.value);
const relativeScroll = inverted ? scroll.value + prevScrollEffective : scroll.value - prevScrollEffective;
padding.value = effective;
prevAbsorption.value = minimumPaddingAbsorbed;
if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) {
// Preserve current scroll position so animated props
// don't re-apply a stale contentOffset when padding changes
contentOffsetY.value = scroll.value;
return;
}
// When blankSpace fully absorbs the keyboard opening, preserve current scroll position
// (only when keyboard is open — effective > 0 — not when closing)
if (scrollEffective === 0 && minimumPaddingAbsorbed > 0 && effective > 0) {
contentOffsetY.value = scroll.value;
return;
}
contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding);
},
onMove: () => {
"worklet";
// iOS doesn't need per-frame updates (contentOffset handles it)
},
onEnd: e => {
"worklet";
if (freeze.value) {
return;
}
const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
padding.value = effective;
}
}, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
return {
padding,
currentHeight,
contentOffsetY,
scroll,
layout,
size,
onLayout,
onContentSizeChange
};
}
export { useChatKeyboard };
//# sourceMappingURL=index.ios.js.map