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:
+11
-8
@@ -60,12 +60,11 @@ quietly relying on:
|
|||||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
|
||||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
|
||||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
translate/padding styles. The composer and chat content must both read that
|
||||||
together because they share the SharedValue. A portal'd popover is outside
|
provider-owned value. A portal'd popover is outside the composer tree — it
|
||||||
the composer tree — it does not get that transform unless you apply it
|
does not get that transform unless you apply it yourself.
|
||||||
yourself.
|
|
||||||
- **Layering.** The default root host renders after app content, so it sits
|
- **Layering.** The default root host renders after app content, so it sits
|
||||||
above compact sidebars. Content overlays that must sit below sidebars should
|
above compact sidebars. Content overlays that must sit below sidebars should
|
||||||
use the current `FloatingPanelPortalHost`.
|
use the current `FloatingPanelPortalHost`.
|
||||||
@@ -86,7 +85,8 @@ with Reanimated worklets the result is not always stable.
|
|||||||
|
|
||||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||||
the keyboard by re-measuring on every frame. Instead,
|
the keyboard by re-measuring on every frame. Instead,
|
||||||
**slave the popover's transform to the same SharedValue the composer uses**:
|
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
|
||||||
|
the composer uses**:
|
||||||
|
|
||||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||||
@@ -95,7 +95,10 @@ the keyboard by re-measuring on every frame. Instead,
|
|||||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||||
the measured position. When the keyboard moves afterward, the delta translates
|
the measured position. When the keyboard moves afterward, the delta translates
|
||||||
the popover by exactly the amount the composer translates. They move in
|
the popover by exactly the amount the composer translates. They move in
|
||||||
lockstep, no re-measurement needed.
|
lockstep, no re-measurement needed. Do not call
|
||||||
|
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
|
||||||
|
can briefly report a stale nonzero height with closed progress, and the shared
|
||||||
|
provider is where that is normalized.
|
||||||
|
|
||||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
|
|||||||
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
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 { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -945,9 +946,11 @@ function RootProviders({ children }: { children: ReactNode }) {
|
|||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<PortalProvider>
|
<KeyboardShiftProvider>
|
||||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
<PortalProvider>
|
||||||
</PortalProvider>
|
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||||
|
</PortalProvider>
|
||||||
|
</KeyboardShiftProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -337,14 +337,14 @@ export function TerminalPane({
|
|||||||
}, [clearKeyboardRefitTimeouts]);
|
}, [clearKeyboardRefitTimeouts]);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => keyboardShift.value > 0,
|
() => isMobile && keyboardShift.value > 0,
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (next === prev) {
|
if (next === prev) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runOnJS(pulseKeyboardRefits)();
|
runOnJS(pulseKeyboardRefits)();
|
||||||
},
|
},
|
||||||
[pulseKeyboardRefits],
|
[isMobile, pulseKeyboardRefits],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
|
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
|
||||||
import { Keyboard, View, useWindowDimensions } from "react-native";
|
import { Keyboard, View, useWindowDimensions } from "react-native";
|
||||||
import { Portal } from "@gorhom/portal";
|
import { Portal } from "@gorhom/portal";
|
||||||
import Animated, {
|
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
useAnimatedStyle,
|
|
||||||
useDerivedValue,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||||
import {
|
import {
|
||||||
measureFloatingPanelPortalHost,
|
measureFloatingPanelPortalHost,
|
||||||
useFloatingPanelPortalHostName,
|
useFloatingPanelPortalHostName,
|
||||||
} from "@/components/ui/floating-panel-portal";
|
} from "@/components/ui/floating-panel-portal";
|
||||||
|
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { SPACING } from "@/styles/theme";
|
import { SPACING } from "@/styles/theme";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
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.
|
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||||
const windowDimensions = useWindowDimensions();
|
const windowDimensions = useWindowDimensions();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const portalHostName = useFloatingPanelPortalHostName();
|
const portalHostName = useFloatingPanelPortalHostName();
|
||||||
|
const { shift } = useKeyboardShift();
|
||||||
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 openShift = useSharedValue(0);
|
const openShift = useSharedValue(0);
|
||||||
|
|
||||||
useEffect(() => {
|
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 { Platform } from "react-native";
|
||||||
import type { ViewStyle } from "react-native";
|
import type { ViewStyle } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -9,47 +16,25 @@ import {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import {
|
||||||
const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
|
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||||
|
resolveKeyboardShift,
|
||||||
function resolveKeyboardShift(input: {
|
} from "@/hooks/keyboard-shift-policy";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
type KeyboardShiftMode = "translate" | "padding";
|
type KeyboardShiftMode = "translate" | "padding";
|
||||||
|
|
||||||
export function useKeyboardShiftStyle(input: {
|
interface KeyboardShiftContextValue {
|
||||||
mode: KeyboardShiftMode;
|
|
||||||
enabled?: boolean;
|
|
||||||
iosMinHeight?: number;
|
|
||||||
}): {
|
|
||||||
shift: SharedValue<number>;
|
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 insets = useSafeAreaInsets();
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
|
||||||
const bottomInset = useSharedValue(insets.bottom);
|
const bottomInset = useSharedValue(insets.bottom);
|
||||||
const enabled = input.enabled ?? true;
|
|
||||||
const isIos = Platform.OS === "ios";
|
const isIos = Platform.OS === "ios";
|
||||||
const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomInset.value = insets.bottom;
|
bottomInset.value = insets.bottom;
|
||||||
@@ -57,19 +42,45 @@ export function useKeyboardShiftStyle(input: {
|
|||||||
|
|
||||||
const shift = useDerivedValue(() => {
|
const shift = useDerivedValue(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
const rawKeyboardHeight = Math.abs(keyboardHeight.value);
|
|
||||||
return resolveKeyboardShift({
|
return resolveKeyboardShift({
|
||||||
rawKeyboardHeight,
|
rawKeyboardHeight: Math.abs(keyboardHeight.value),
|
||||||
|
keyboardProgress: keyboardProgress.value,
|
||||||
bottomInset: bottomInset.value,
|
bottomInset: bottomInset.value,
|
||||||
isIos,
|
isIos,
|
||||||
iosMinHeight,
|
iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
|
||||||
enabled,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>(() => {
|
const style = useAnimatedStyle<ViewStyle>(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
if (input.mode === "padding") {
|
if (mode === "padding") {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return { paddingBottom: 0 };
|
return { paddingBottom: 0 };
|
||||||
}
|
}
|
||||||
@@ -77,8 +88,8 @@ export function useKeyboardShiftStyle(input: {
|
|||||||
return { paddingBottom: bottomInset.value + shift.value };
|
return { paddingBottom: bottomInset.value + shift.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { transform: [{ translateY: -shift.value }] };
|
return { transform: [{ translateY: enabled ? -shift.value : 0 }] };
|
||||||
}, [input.mode]);
|
}, [enabled, mode]);
|
||||||
|
|
||||||
return { shift, style };
|
return { shift, style };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user