react-native-keyboard-controller
Version:
Keyboard manager which works in identical way on both iOS and Android
261 lines (225 loc) • 7.16 kB
text/typescript
import { useSharedValue } from "react-native-reanimated";
import { useKeyboardHandler } from "../../../hooks";
import useScrollState from "../../hooks/useScrollState";
import {
computeIOSContentOffset,
getEffectiveHeight,
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 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: AnimatedRef<Reanimated.ScrollView>,
options: UseChatKeyboardOptions,
): UseChatKeyboardReturn {
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 };