react-native-keyboard-controller
Version:
Keyboard manager which works in identical way on both iOS and Android
240 lines (225 loc) • 10.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.useChatKeyboard = useChatKeyboard;
var _reactNativeReanimated = require("react-native-reanimated");
var _hooks = require("../../../hooks");
var _useScrollState = _interopRequireDefault(require("../../hooks/useScrollState"));
var _helpers = require("./helpers");
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
/**
* 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, options) {
const {
inverted,
keyboardLiftBehavior,
freeze,
offset,
blankSpace,
extraContentPadding
} = options;
const padding = (0, _reactNativeReanimated.useSharedValue)(0);
const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0);
const offsetBeforeScroll = (0, _reactNativeReanimated.useSharedValue)(0);
const targetKeyboardHeight = (0, _reactNativeReanimated.useSharedValue)(0);
const closing = (0, _reactNativeReanimated.useSharedValue)(false);
const minimumPaddingFractionOnOpen = (0, _reactNativeReanimated.useSharedValue)(0);
const actualOpenShift = (0, _reactNativeReanimated.useSharedValue)(0);
const {
layout,
size,
offset: scroll,
onLayout,
onContentSizeChange
} = (0, _useScrollState.default)(scrollViewRef);
const clampScrollIfNeeded = (effective, totalPaddingForMaxScroll) => {
"worklet";
const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : effective;
const maxScroll = Math.max(size.value.height - layout.value.height + paddingForMax, 0);
if (scroll.value > maxScroll) {
(0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, maxScroll, false);
}
};
(0, _hooks.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 = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
const atEnd = (0, _helpers.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 = (0, _helpers.getVisibleMinimumPaddingFraction)(scroll.value, layout.value.height, size.value.height, blankSpace.value, inverted);
const minimumPaddingAbsorbed = visibleFraction >= 1 ? (0, _helpers.getMinimumPaddingAbsorbed)(blankSpace.value, extraContentPadding.value) : 0;
const scrollEffective = (0, _helpers.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 = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
const minimumPaddingAbsorbed = (0, _helpers.getMinimumPaddingAbsorbed)(blankSpace.value, extraContentPadding.value) * minimumPaddingFractionOnOpen.value;
const scrollEffective = (0, _helpers.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 = (0, _helpers.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;
}
(0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, 0, false);
return;
}
if (!(0, _helpers.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;
(0, _reactNativeReanimated.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;
(0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false);
} else {
const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
const minimumPaddingAbsorbed = (0, _helpers.getMinimumPaddingAbsorbed)(blankSpace.value, extraContentPadding.value) * minimumPaddingFractionOnOpen.value;
const scrollEffective = (0, _helpers.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 (!(0, _helpers.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);
(0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, Math.min(keepAt, maxScroll), false);
return;
}
const target = (0, _helpers.clampedScrollTarget)(offsetBeforeScroll.value, scrollEffective, size.value.height, layout.value.height, actualTotalPadding);
(0, _reactNativeReanimated.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 = (0, _helpers.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
};
}
//# sourceMappingURL=index.js.map