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
+6 -3
View File
@@ -66,6 +66,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
@@ -945,9 +946,11 @@ function RootProviders({ children }: { children: ReactNode }) {
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
@@ -337,14 +337,14 @@ export function TerminalPane({
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => keyboardShift.value > 0,
() => isMobile && keyboardShift.value > 0,
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
},
[pulseKeyboardRefits],
[isMobile, pulseKeyboardRefits],
);
useEffect(() => {
@@ -1,19 +1,14 @@
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
import { Keyboard, View, useWindowDimensions } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -68,18 +63,8 @@ export function AutocompletePopover({
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const windowDimensions = useWindowDimensions();
const insets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName();
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInsetSV = useSharedValue(insets.bottom);
useEffect(() => {
bottomInsetSV.value = insets.bottom;
}, [bottomInsetSV, insets.bottom]);
const shift = useDerivedValue(() =>
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
);
const { shift } = useKeyboardShift();
const openShift = useSharedValue(0);
useEffect(() => {
@@ -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 };
}