diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index 91bb98e7a8..718d49a245 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings"; import { KeyboardTranslateView } from "@/keyboard/shift"; -import { createContext, useCallback, useContext, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native"; import Animated, { useAnimatedStyle, @@ -15,7 +15,11 @@ import Animated, { type SharedValue, } from "react-native-reanimated"; import { useKeyboardShift } from "@/keyboard/shift"; -import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity"; +import { + resolveCenteredClearance, + updateComposerCapacity, + type ComposerCapacity, +} from "./internal/capacity"; const ViewportCapacity = createContext | null>(null); @@ -34,11 +38,12 @@ function ComposerViewport({ }: ComposerViewportProps) { const measuredHeight = useSharedValue(0); const sizing = useSharedValue(undefined); - const { layoutShift } = useKeyboardShift(); + const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift(); useAnimatedReaction( () => ({ height: measuredHeight.value, bottomInset, + safeAreaBottom: safeAreaBottom.value, keyboardShift: layoutShift.value, centered, }), @@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) { ); } +/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */ +function useCenteredClearance(safeAreaBottom: number) { + const [viewportHeight, setViewportHeight] = useState(0); + const [formBottom, setFormBottom] = useState(0); + const measureViewport = useCallback((event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + }, []); + const measureForm = useCallback((event: LayoutChangeEvent) => { + const { y, height } = event.nativeEvent.layout; + setFormBottom(y + height); + }, []); + return { + value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }), + measureViewport, + measureForm, + }; +} + interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; @@ -91,6 +114,7 @@ export function ComposerDock({ }: ComposerDockProps) { const insets = useSafeAreaInsets(); const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); + const centeredClearance = useCenteredClearance(insets.bottom); // Preserve the existing centered form's visual balance on tablets. const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0; if (centered) { @@ -99,8 +123,13 @@ export function ComposerDock({ style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]} bottomInset={bottomInset} centered + onLayout={centeredClearance.measureViewport} > - + {content} diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index d6fa3f9606..87796f23d4 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; -import { resolveComposerCapacity, updateComposerCapacity } from "./capacity"; +import { + resolveCenteredClearance, + resolveComposerCapacity, + updateComposerCapacity, +} from "./capacity"; describe("composer viewport", () => { it("preserves the editing capacity when the keyboard closes", () => { - const viewport = { height: 582, bottomInset: 24, centered: false }; + const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false }; const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }); expect(open.capacity).toBe(245); @@ -15,6 +19,7 @@ describe("composer viewport", () => { const open = updateComposerCapacity(undefined, { height: 582, bottomInset: 24, + safeAreaBottom: 0, centered: false, keyboardShift: 308, }); @@ -22,6 +27,7 @@ describe("composer viewport", () => { updateComposerCapacity(open, { height: 650, bottomInset: 24, + safeAreaBottom: 0, centered: false, keyboardShift: 0, }).capacity, @@ -30,6 +36,7 @@ describe("composer viewport", () => { updateComposerCapacity(open, { height: 0, bottomInset: 24, + safeAreaBottom: 0, centered: false, keyboardShift: 0, }), @@ -39,6 +46,7 @@ describe("composer viewport", () => { const height = resolveComposerCapacity({ height: 582, bottomInset: 24, + safeAreaBottom: 0, keyboardShift: 308, centered: false, }); @@ -46,23 +54,80 @@ describe("composer viewport", () => { expect(582 - 24 - 308 - height).toBe(5); }); - it("keeps a centered tablet form below the header after translation", () => { + it("keeps a centered tablet form below the header once the keyboard reaches it", () => { + const viewport = 1000; + const bottomInset = 80; + const keyboardShift = 300; const height = resolveComposerCapacity({ - height: 1000, - bottomInset: 80, - keyboardShift: 300, + height: viewport, + bottomInset, + safeAreaBottom: 0, + keyboardShift, centered: true, }); - expect(height).toBe(310); - expect((1000 - 80 - height) / 2 - 300).toBe(5); + expect(height).toBe(695); + const restingTop = (viewport - bottomInset - height) / 2; + const bottomClearance = resolveCenteredClearance({ + viewportHeight: viewport, + safeAreaBottom: 0, + formBottom: restingTop + height, + }); + expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5); + }); + + it("leaves a centered form room to edit on a short foldable viewport", () => { + expect( + resolveComposerCapacity({ + height: 725, + bottomInset: 72, + safeAreaBottom: 0, + keyboardShift: 313, + centered: true, + }), + ).toBe(407); + }); + + it("rests a centered form on the keyboard above the bottom safe area", () => { + // The keyboard shift excludes the safe area that the keyboard also covers. + const geometry = { + height: 660, + bottomInset: 60, + safeAreaBottom: 32, + keyboardShift: 288, + centered: true, + }; + const height = resolveComposerCapacity(geometry); + const restingTop = (geometry.height - geometry.bottomInset - height) / 2; + const formBottom = restingTop + height; + const clearance = resolveCenteredClearance({ + viewportHeight: geometry.height, + safeAreaBottom: geometry.safeAreaBottom, + formBottom, + }); + const translation = Math.max(0, geometry.keyboardShift - clearance); + const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift; + expect(formBottom - translation).toBe(keyboardTop); + expect(restingTop - translation).toBe(5); }); it("uses the measured viewport before the first keyboard opening", () => { expect( - resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), + resolveComposerCapacity({ + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + keyboardShift: 0, + centered: false, + }), ).toBe(553); expect( - resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }), + resolveComposerCapacity({ + height: 300, + bottomInset: 0, + safeAreaBottom: 0, + keyboardShift: 200, + centered: false, + }), ).toBe(95); }); }); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts index d42537579e..eb5e4a66bd 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -1,6 +1,8 @@ interface ComposerGeometry { height: number; bottomInset: number; + /** The keyboard also covers this space below its shift. */ + safeAreaBottom: number; keyboardShift: number; centered: boolean; } @@ -12,11 +14,25 @@ export interface ComposerCapacity { export function resolveComposerCapacity(input: ComposerGeometry): number { "worklet"; - // A centered form grows upward by half its height. Reserve both halves so - // translating it still leaves five layout points below the header. - const clearance = input.keyboardShift + 5; - const reservedSpace = input.centered ? clearance * 2 : clearance; - return Math.max(0, input.height - input.bottomInset - reservedSpace); + // A centered form only translates by the keyboard overlap, so once the keyboard + // reaches it the form sits on the keyboard and fits above it, not twice over. + // A bottom-anchored composer pads the safe area inside its own capacity. + const available = input.centered + ? Math.min( + input.height - input.bottomInset, + input.height - input.safeAreaBottom - input.keyboardShift, + ) + : input.height - input.bottomInset - input.keyboardShift; + return Math.max(0, available - 5); +} + +/** Space below a centered form that the keyboard fills before the form moves. */ +export function resolveCenteredClearance(input: { + viewportHeight: number; + safeAreaBottom: number; + formBottom: number; +}): number { + return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom); } export function updateComposerCapacity( diff --git a/packages/app/src/keyboard/shift/internal/translate.android.tsx b/packages/app/src/keyboard/shift/internal/translate.android.tsx index 496e7b36ac..2a9e9d581d 100644 --- a/packages/app/src/keyboard/shift/internal/translate.android.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.android.tsx @@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; + /** Space below the view's resting position that the keyboard may fill before the view moves. */ + bottomClearance?: number; } export function KeyboardTranslateView({ children, enabled = true, + bottomClearance = 0, style, ...props }: KeyboardTranslateViewProps) { const { shift } = useKeyboardShift(); const keyboardStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: enabled ? -shift.value : 0 }], + transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }], })); return ( diff --git a/packages/app/src/keyboard/shift/internal/translate.ios.tsx b/packages/app/src/keyboard/shift/internal/translate.ios.tsx index 7a3ba7e992..348fe828d0 100644 --- a/packages/app/src/keyboard/shift/internal/translate.ios.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.ios.tsx @@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; + /** Space below the view's resting position that the keyboard may fill before the view moves. */ + bottomClearance?: number; } export function KeyboardTranslateView({ children, enabled = true, + bottomClearance = 0, style, ...props }: KeyboardTranslateViewProps) { const insets = useSafeAreaInsets(); const { height, progress } = useKeyboardAnimation(); const translateY = useMemo( - () => Animated.add(height, Animated.multiply(progress, insets.bottom)), - [height, insets.bottom, progress], + () => + Animated.add( + Animated.add(height, Animated.multiply(progress, insets.bottom)), + bottomClearance, + ).interpolate({ + inputRange: [-1, 0], + outputRange: [-1, 0], + extrapolateLeft: "extend", + extrapolateRight: "clamp", + }), + [bottomClearance, height, insets.bottom, progress], ); const keyboardStyle = useMemo( () => ({ transform: [{ translateY: enabled ? translateY : 0 }] }), diff --git a/packages/app/src/keyboard/shift/internal/translate.tsx b/packages/app/src/keyboard/shift/internal/translate.tsx index d866fc943c..6a0506c301 100644 --- a/packages/app/src/keyboard/shift/internal/translate.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.tsx @@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; + bottomClearance?: number; } export function KeyboardTranslateView({ children, enabled: _enabled, + bottomClearance: _bottomClearance, ...props }: KeyboardTranslateViewProps) { return {children};