react-native-keyboard-controller
Version:
Keyboard manager which works in identical way on both iOS and Android
155 lines (136 loc) • 4.67 kB
text/typescript
import { useCallback } from "react";
import { Platform } from "react-native";
import { scrollTo, useAnimatedReaction } from "react-native-reanimated";
import { IS_FABRIC } from "../../../architecture";
import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers";
import type { KeyboardLiftBehavior } from "../useChatKeyboard/types";
import type { AnimatedRef, SharedValue } from "react-native-reanimated";
import type Reanimated from "react-native-reanimated";
type UseExtraContentPaddingOptions = {
scrollViewRef: AnimatedRef<Reanimated.ScrollView>;
extraContentPadding: SharedValue<number>;
/** Keyboard-only padding from useChatKeyboard — used to compute total padding for clamping. */
keyboardPadding: SharedValue<number>;
/** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
blankSpace: SharedValue<number>;
/** Current vertical scroll offset. */
scroll: SharedValue<number>;
/** Visible viewport dimensions. */
layout: SharedValue<{ width: number; height: number }>;
/** Total content dimensions. */
size: SharedValue<{ width: number; height: number }>;
/** IOS only — when provided, sets contentOffset atomically with contentInset. */
contentOffsetY?: SharedValue<number>;
inverted: boolean;
keyboardLiftBehavior: KeyboardLiftBehavior;
freeze: SharedValue<boolean>;
};
/**
* Hook that reacts to `extraContentPadding` changes and conditionally
* adjusts the scroll position using `scrollTo` on both iOS and Android.
*
* Padding extension (scrollable range) is handled externally via a
* `useDerivedValue` that sums keyboard padding + extra content padding.
* This hook only handles the scroll correction.
*
* @param options - Configuration and shared values.
* @example
* ```tsx
* useExtraContentPadding({ scrollViewRef, extraContentPadding, ... });
* ```
*/
function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
const {
scrollViewRef,
extraContentPadding,
keyboardPadding,
blankSpace,
scroll,
layout,
size,
contentOffsetY,
inverted,
keyboardLiftBehavior,
freeze,
} = options;
const scrollToTarget = useCallback(
(target: number) => {
"worklet";
if (contentOffsetY && IS_FABRIC) {
// eslint-disable-next-line react-compiler/react-compiler
contentOffsetY.value = target;
} else if (Platform.OS === "android") {
// Defer scrollTo so the animatedProps inset commit lands first;
// otherwise the native ScrollView clamps to the old range.
requestAnimationFrame(() => {
// check that view is still mounted and ref is actual
// otherwise it may lead to a crash
if (!scrollViewRef()) {
return;
}
scrollTo(scrollViewRef, 0, target, false);
});
} else {
scrollTo(scrollViewRef, 0, target, false);
}
},
[scrollViewRef, contentOffsetY],
);
useAnimatedReaction(
() => extraContentPadding.value,
(current, previous) => {
if (freeze.value || previous === null) {
return;
}
const rawDelta = current - previous;
if (rawDelta === 0) {
return;
}
// Compute effective delta considering blankSpace floor
const previousTotal = Math.max(
blankSpace.value,
keyboardPadding.value + previous,
);
const currentTotal = Math.max(
blankSpace.value,
keyboardPadding.value + current,
);
const effectiveDelta = currentTotal - previousTotal;
if (effectiveDelta === 0) {
// blankSpace absorbed the change
return;
}
const atEnd = isScrollAtEnd(
scroll.value,
layout.value.height,
size.value.height,
inverted,
);
// "persistent": scroll on grow, hold position on shrink (unless at end)
if (
keyboardLiftBehavior === "persistent" &&
effectiveDelta < 0 &&
!atEnd
) {
return;
}
if (!shouldShiftContent(keyboardLiftBehavior, atEnd)) {
return;
}
if (inverted) {
const target = Math.max(scroll.value - effectiveDelta, -currentTotal);
scrollToTarget(target);
} else {
const maxScroll = Math.max(
size.value.height - layout.value.height + currentTotal,
0,
);
const target = Math.min(scroll.value + effectiveDelta, maxScroll);
scrollToTarget(target);
}
},
[inverted, keyboardLiftBehavior],
);
}
export { useExtraContentPadding };
export type { UseExtraContentPaddingOptions };