Keep keyboard offsets tied to visibility

Derive app keyboard offsets from one provider-owned shared value so chat content, composer surfaces, and floating panels share the same policy.

Treat progress-zero keyboard samples as closed, even if Android reports a stale nonzero height.
This commit is contained in:
Mohamed Boudra
2026-06-18 12:03:44 +07:00
parent e4da3693e9
commit 4e899c644a
7 changed files with 138 additions and 73 deletions
@@ -0,0 +1,23 @@
export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
export function resolveKeyboardShift(input: {
rawKeyboardHeight: number;
keyboardProgress: number;
bottomInset: number;
isIos: boolean;
iosMinHeight: number;
}): number {
"worklet";
if (!(input.keyboardProgress > 0) || !(input.rawKeyboardHeight > 0)) {
return 0;
}
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
return 0;
}
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveKeyboardShift } from "./keyboard-shift-policy";
describe("resolveKeyboardShift", () => {
it("keeps the existing open-keyboard offset behavior", () => {
expect(
resolveKeyboardShift({
rawKeyboardHeight: 320,
keyboardProgress: 1,
bottomInset: 24,
isIos: false,
iosMinHeight: 120,
}),
).toBe(296);
});
it("treats progress zero as closed even when Android reports a stale height", () => {
expect(
resolveKeyboardShift({
rawKeyboardHeight: 320,
keyboardProgress: 0,
bottomInset: 24,
isIos: false,
iosMinHeight: 120,
}),
).toBe(0);
});
it("still ignores small iOS accessory bar reports", () => {
expect(
resolveKeyboardShift({
rawKeyboardHeight: 80,
keyboardProgress: 1,
bottomInset: 0,
isIos: true,
iosMinHeight: 120,
}),
).toBe(0);
});
});
@@ -1,4 +1,11 @@
import { useEffect } from "react";
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -9,47 +16,25 @@ import {
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
function resolveKeyboardShift(input: {
rawKeyboardHeight: number;
bottomInset: number;
isIos: boolean;
iosMinHeight: number;
enabled: boolean;
}): number {
"worklet";
if (!input.enabled) {
return 0;
}
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
return 0;
}
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
} from "@/hooks/keyboard-shift-policy";
type KeyboardShiftMode = "translate" | "padding";
export function useKeyboardShiftStyle(input: {
mode: KeyboardShiftMode;
enabled?: boolean;
iosMinHeight?: number;
}): {
interface KeyboardShiftContextValue {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
} {
bottomInset: SharedValue<number>;
}
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const enabled = input.enabled ?? true;
const isIos = Platform.OS === "ios";
const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT;
useEffect(() => {
bottomInset.value = insets.bottom;
@@ -57,19 +42,45 @@ export function useKeyboardShiftStyle(input: {
const shift = useDerivedValue(() => {
"worklet";
const rawKeyboardHeight = Math.abs(keyboardHeight.value);
return resolveKeyboardShift({
rawKeyboardHeight,
rawKeyboardHeight: Math.abs(keyboardHeight.value),
keyboardProgress: keyboardProgress.value,
bottomInset: bottomInset.value,
isIos,
iosMinHeight,
enabled,
iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
});
});
const value = useMemo(
() => ({
shift,
bottomInset,
}),
[bottomInset, shift],
);
return createElement(KeyboardShiftContext.Provider, { value }, children);
}
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
} {
const { shift, bottomInset } = useKeyboardShift();
const mode = input.mode;
const enabled = input.enabled ?? true;
const style = useAnimatedStyle<ViewStyle>(() => {
"worklet";
if (input.mode === "padding") {
if (mode === "padding") {
if (!enabled) {
return { paddingBottom: 0 };
}
@@ -77,8 +88,8 @@ export function useKeyboardShiftStyle(input: {
return { paddingBottom: bottomInset.value + shift.value };
}
return { transform: [{ translateY: -shift.value }] };
}, [input.mode]);
return { transform: [{ translateY: enabled ? -shift.value : 0 }] };
}, [enabled, mode]);
return { shift, style };
}