mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
fix(app): rest the iPhone composer at the screen's bottom clearance (#8823)
* fix(app): rest the iPhone composer at the screen's bottom clearance The composer sat 50pt above the bottom edge on every iPhone: the dock padded the rectangular home-indicator safe area (34pt) and the composer stacked another 16pt under it. On iPhone Duo that safe area is also taller than needed; iOS 27.1's bottom-bar layout guide, which follows the screen's corners, rests 26pt from the edge. The native window-layout probe now publishes that clearance, and WindowLayoutProvider overrides the safe-area context's bottom with it, so every useSafeAreaInsets() consumer reads one bottom value and null falls back to the system inset. The docked composer applies max(bottom, 16) once and keeps 16pt above the keyboard through the translate view's transform; the gap is not animated as padding because layout updates through the keyboard transition are expensive on Android Fabric. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the Import session gap under the wide new workspace composer The composer's own bottom spacing used to separate it from the Import session pill on the wide form. With that spacing moved to the dock, the centered slot padded below the pill instead, so the pill touched the composer. The pill now owns its gap above. Co-authored-by: Elsa Granger <zeyugao@outlook.com> --------- Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
co-authored by
Elsa Granger
parent
b0a8a5db6d
commit
e9f897a66a
@@ -169,6 +169,8 @@ Electron window controls are top-corner obstructions, not a compact-layout condi
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
Each window edge has one safe-area owner. `WindowLayoutProvider` publishes the bottom inset that every surface reads through `useSafeAreaInsets()`: on iOS 27.1 and later it is UIKit's bottom-bar clearance, which refines the rectangular home-indicator inset to the screen's corners, and elsewhere the system inset. Surfaces apply that value once and never stack their own spacing under it; `WindowLayoutSurface` owns the horizontal insets the same way.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,6 +172,11 @@ slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
band or a paused transition when the stream list updates its viewport.
|
||||
|
||||
The docked composer rests at the larger of the safe-area bottom and 16pt. With
|
||||
the keyboard open it keeps 16pt above the keyboard through the translate view's
|
||||
`keyboardGap`, which rises further than the keyboard's safe-area overlap. Do not
|
||||
grow the composer's bottom padding with the keyboard instead.
|
||||
|
||||
Do not animate a layout prop through the keyboard transition. On Android
|
||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function box(locator: Locator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
const rect = await locator.boundingBox();
|
||||
expect(rect).not.toBeNull();
|
||||
return { top: rect!.y, bottom: rect!.y + rect!.height };
|
||||
}
|
||||
|
||||
test.describe("Composer bottom spacing", () => {
|
||||
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-bottom-spacing-",
|
||||
title: "Bottom spacing",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
const surface = await box(
|
||||
page.getByTestId("message-input-root").filter({ visible: true }).first(),
|
||||
);
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
expect(viewportHeight - surface.bottom).toBe(16);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the wide new workspace form keeps one content spacing above Import session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
|
||||
const importButton = await box(page.getByTestId("new-workspace-import-session"));
|
||||
expect(importButton.top - composer.bottom).toBe(16);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ final class WindowLayoutView: ExpoView {
|
||||
private var divisions: [CGRect] = []
|
||||
private var barGuide: UILayoutGuide?
|
||||
private var barEdge = "none"
|
||||
private var bottomBarGuide: UILayoutGuide?
|
||||
private var measuredBounds = CGRect.zero
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -36,6 +37,7 @@ final class WindowLayoutView: ExpoView {
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
previousPayload = nil
|
||||
bottomBarGuide = nil
|
||||
setNeedsUpdateConstraints()
|
||||
scheduleUpdate()
|
||||
}
|
||||
@@ -66,6 +68,11 @@ final class WindowLayoutView: ExpoView {
|
||||
case .trailing: edge = "trailing"
|
||||
default: edge = "none"
|
||||
}
|
||||
if bottomBarGuide == nil {
|
||||
// Where a bottom bar rests: above the home indicator, refined to the
|
||||
// screen's corners rather than the rectangular safe area.
|
||||
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
|
||||
}
|
||||
if edge != barEdge {
|
||||
barEdge = edge
|
||||
barGuide = edge == "none" ? nil : layoutGuide(
|
||||
@@ -93,10 +100,17 @@ final class WindowLayoutView: ExpoView {
|
||||
let bar = barGuide?.layoutFrame
|
||||
var barPayload: Any = NSNull()
|
||||
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
|
||||
// Before UIKit resolves the guide its frame has no height: publish null (the
|
||||
// system inset applies) rather than a false zero clearance.
|
||||
var bottomClearance: Any = NSNull()
|
||||
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
|
||||
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
|
||||
}
|
||||
let payload: [String: Any] = [
|
||||
"bounds": rect(bounds),
|
||||
"verticalBarEdge": barEdge,
|
||||
"verticalBar": barPayload,
|
||||
"bottomClearance": bottomClearance,
|
||||
"divisions": divisions.map(rect)
|
||||
]
|
||||
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
|
||||
|
||||
@@ -436,7 +436,7 @@ export function WorkspaceSetupDialog() {
|
||||
desktopMaxWidth={640}
|
||||
>
|
||||
<FileDropZone style={styles.section}>
|
||||
<KeyboardTranslateView>
|
||||
<KeyboardTranslateView style={styles.composerSlot}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
@@ -487,6 +487,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
composerSlot: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
composerInputWrapper: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -19,15 +19,18 @@ import Animated, {
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerBottomPlacement,
|
||||
resolveComposerSlotCapacity,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
keyboardGap?: number;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +39,7 @@ function ComposerViewport({
|
||||
children,
|
||||
onLayout,
|
||||
bottomInset = 0,
|
||||
keyboardGap = 0,
|
||||
centered = false,
|
||||
...props
|
||||
}: ComposerViewportProps) {
|
||||
@@ -48,6 +52,7 @@ function ComposerViewport({
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
keyboardGap,
|
||||
centered,
|
||||
}),
|
||||
(geometry) => {
|
||||
@@ -129,6 +134,10 @@ export function ComposerDock({
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
const placement = resolveComposerBottomPlacement({
|
||||
safeAreaBottom: insets.bottom,
|
||||
contentSpacing: SPACING[4],
|
||||
});
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
const fullscreenSurface = [
|
||||
@@ -176,7 +185,9 @@ export function ComposerDock({
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.centeredComposerSurface}>
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
@@ -185,8 +196,12 @@ export function ComposerDock({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
|
||||
<ComposerViewport style={dockStyles.viewport} keyboardGap={placement.keyboardGap}>
|
||||
<KeyboardTranslateView
|
||||
style={dockStyles.surface}
|
||||
enabled={!fullscreen}
|
||||
keyboardGap={placement.keyboardGap}
|
||||
>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
@@ -208,7 +223,7 @@ export function ComposerDock({
|
||||
style={
|
||||
fullscreen
|
||||
? fullscreenSurface
|
||||
: [dockStyles.composer, { paddingBottom: insets.bottom }]
|
||||
: [dockStyles.composer, { paddingBottom: placement.restingPadding }]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
@@ -232,6 +247,7 @@ const dockStyles = StyleSheet.create({
|
||||
centered: { flexShrink: 1, width: "100%" },
|
||||
// Reserve the composer's own capped height before the setup scroll view shrinks.
|
||||
centeredComposer: { width: "100%", flexShrink: 0 },
|
||||
centeredComposerSurface: { width: "100%", flexShrink: 1 },
|
||||
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
hiddenSetup: { display: "none" },
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ComposerDock({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
surface: { flex: 1, overflow: "hidden" },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
composer: { width: "100%", flexShrink: 1, paddingBottom: theme.spacing[4] },
|
||||
// Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor
|
||||
// never reaches the zoomed visual viewport, while a fixed box chains to the viewport
|
||||
// directly. React Native Web renders `fixed`; React Native's style type omits it.
|
||||
|
||||
@@ -1,18 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerBottomPlacement,
|
||||
resolveComposerSlotCapacity,
|
||||
resolveInlineComposerCapacity,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer bottom placement", () => {
|
||||
const keyboardHeight = 264;
|
||||
/** Keyboard top minus composer bottom, both measured up from the window bottom. */
|
||||
function keyboardGapAbove(safeAreaBottom: number): number {
|
||||
const placement = resolveComposerBottomPlacement({ safeAreaBottom, contentSpacing: 16 });
|
||||
const shift = keyboardHeight - safeAreaBottom;
|
||||
const lift = shift + Math.min(placement.keyboardGap, shift);
|
||||
return placement.restingPadding + lift - keyboardHeight;
|
||||
}
|
||||
|
||||
it("rests at the bottom clearance without stacking a second gutter", () => {
|
||||
expect(resolveComposerBottomPlacement({ safeAreaBottom: 34, contentSpacing: 16 })).toEqual({
|
||||
restingPadding: 34,
|
||||
keyboardGap: 16,
|
||||
});
|
||||
expect(
|
||||
resolveComposerBottomPlacement({ safeAreaBottom: 24, contentSpacing: 16 }).restingPadding,
|
||||
).toBe(24);
|
||||
});
|
||||
|
||||
it.each([0, 20, 34])("never rests closer to the edge than the content spacing (%s)", (inset) => {
|
||||
expect(
|
||||
resolveComposerBottomPlacement({ safeAreaBottom: inset, contentSpacing: 16 }).restingPadding,
|
||||
).toBe(Math.max(inset, 16));
|
||||
});
|
||||
|
||||
it("keeps a measured zero clearance at the content spacing", () => {
|
||||
expect(resolveComposerBottomPlacement({ safeAreaBottom: 0, contentSpacing: 16 })).toEqual({
|
||||
restingPadding: 16,
|
||||
keyboardGap: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 20, 24, 34])("keeps the content spacing above the keyboard (%s)", (inset) => {
|
||||
expect(keyboardGapAbove(inset)).toBe(16);
|
||||
});
|
||||
|
||||
it("reserves the gap's extra rise out of the docked composer's capacity", () => {
|
||||
const viewport = { height: 582, bottomInset: 0, safeAreaBottom: 34, centered: false };
|
||||
const closed = resolveComposerCapacity({ ...viewport, keyboardShift: 0, keyboardGap: 16 });
|
||||
const open = resolveComposerCapacity({ ...viewport, keyboardShift: 230, keyboardGap: 16 });
|
||||
expect(closed).toBe(577);
|
||||
expect(open).toBe(331);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -25,6 +73,7 @@ describe("composer viewport", () => {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -35,7 +84,13 @@ describe("composer viewport", () => {
|
||||
);
|
||||
});
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const viewport = {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
};
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -48,6 +103,7 @@ describe("composer viewport", () => {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -56,6 +112,7 @@ describe("composer viewport", () => {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -65,6 +122,7 @@ describe("composer viewport", () => {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -76,6 +134,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
});
|
||||
expect(height).toBe(245);
|
||||
@@ -91,6 +150,7 @@ describe("composer viewport", () => {
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(695);
|
||||
@@ -110,6 +170,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
@@ -122,6 +183,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
keyboardGap: 0,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
@@ -145,6 +207,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
@@ -154,6 +217,7 @@ describe("composer viewport", () => {
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
keyboardGap: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
|
||||
@@ -4,6 +4,8 @@ interface ComposerGeometry {
|
||||
/** The keyboard also covers this space below its shift. */
|
||||
safeAreaBottom: number;
|
||||
keyboardShift: number;
|
||||
/** A docked composer rises this much beyond the keyboard shift; see the placement. */
|
||||
keyboardGap: number;
|
||||
centered: boolean;
|
||||
}
|
||||
|
||||
@@ -24,10 +26,36 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height - input.bottomInset - input.keyboardShift;
|
||||
: input.height -
|
||||
input.bottomInset -
|
||||
input.keyboardShift -
|
||||
Math.min(input.keyboardGap, input.keyboardShift);
|
||||
return Math.max(0, available - 5);
|
||||
}
|
||||
|
||||
export interface ComposerBottomPlacement {
|
||||
/** The composer's distance from the window bottom with the keyboard closed. */
|
||||
restingPadding: number;
|
||||
/** How much further than the keyboard's safe-area overlap the composer rises. */
|
||||
keyboardGap: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bottom distance for the docked composer: the safe-area bottom, never closer to
|
||||
* the edge than the content spacing. With the keyboard open it keeps the content
|
||||
* spacing above the keyboard.
|
||||
*/
|
||||
export function resolveComposerBottomPlacement(input: {
|
||||
safeAreaBottom: number;
|
||||
contentSpacing: number;
|
||||
}): ComposerBottomPlacement {
|
||||
const restingPadding = Math.max(input.safeAreaBottom, input.contentSpacing);
|
||||
return {
|
||||
restingPadding,
|
||||
keyboardGap: input.safeAreaBottom + input.contentSpacing - restingPadding,
|
||||
};
|
||||
}
|
||||
|
||||
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||
export function resolveCenteredClearance(input: {
|
||||
viewportHeight: number;
|
||||
|
||||
@@ -2568,7 +2568,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
width: "100%",
|
||||
overflow: "visible",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
@@ -2578,7 +2577,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
minHeight: 0,
|
||||
alignItems: "stretch",
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
fullscreenFill: {
|
||||
flex: 1,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | nu
|
||||
export interface KeyboardTranslation {
|
||||
enabled: boolean;
|
||||
bottomClearance: number;
|
||||
keyboardGap: number;
|
||||
}
|
||||
|
||||
export interface WindowRect {
|
||||
@@ -38,7 +39,7 @@ export interface KeyboardTranslationScope {
|
||||
|
||||
/** Views outside a KeyboardTranslateView stay put. */
|
||||
export const KeyboardTranslationContext = createContext<KeyboardTranslationScope>({
|
||||
translation: { enabled: false, bottomClearance: 0 },
|
||||
translation: { enabled: false, bottomClearance: 0, keyboardGap: 0 },
|
||||
measureAtRest: measureInWindow,
|
||||
});
|
||||
|
||||
|
||||
@@ -83,16 +83,46 @@ describe("resolveKeyboardShift", () => {
|
||||
|
||||
describe("resolveKeyboardLift", () => {
|
||||
it("lifts a docked view by the whole shift", () => {
|
||||
expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0, keyboardGap: 0 }),
|
||||
).toBe(302);
|
||||
});
|
||||
|
||||
it("lets the keyboard fill a centered form's clearance before lifting it", () => {
|
||||
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136);
|
||||
expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267, keyboardGap: 0 }),
|
||||
).toBe(136);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455, keyboardGap: 0 }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("rises a docked composer's gap beyond the keyboard overlap, never at rest", () => {
|
||||
const docked = { enabled: true, bottomClearance: 0, keyboardGap: 16 };
|
||||
expect(resolveKeyboardLift({ ...docked, shift: 0 })).toBe(0);
|
||||
expect(resolveKeyboardLift({ ...docked, shift: 8 })).toBe(16);
|
||||
expect(resolveKeyboardLift({ ...docked, shift: 276 })).toBe(292);
|
||||
});
|
||||
|
||||
it("measures the shift from the window's bottom clearance, not the rectangular inset", () => {
|
||||
// iPhone Duo outer screen: the bar guide rests 24pt up where safeAreaInsets reports 34.
|
||||
const shift = resolveKeyboardShift({
|
||||
rawKeyboardHeight: 300,
|
||||
keyboardProgress: 1,
|
||||
bottomInset: 24,
|
||||
isIos: true,
|
||||
iosMinHeight: 120,
|
||||
});
|
||||
expect(shift).toBe(276);
|
||||
const lift = resolveKeyboardLift({ shift, enabled: true, bottomClearance: 0, keyboardGap: 16 });
|
||||
const restingComposerBottom = 24;
|
||||
expect(restingComposerBottom + lift - 300).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps a disabled view in place", () => {
|
||||
expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0);
|
||||
expect(
|
||||
resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0, keyboardGap: 0 }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,9 +37,14 @@ export function resolveKeyboardLift(input: {
|
||||
shift: number;
|
||||
enabled: boolean;
|
||||
bottomClearance: number;
|
||||
keyboardGap: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0;
|
||||
if (!input.enabled) return 0;
|
||||
// The gap opens over the shift's first points, so the view never rises at rest.
|
||||
return (
|
||||
Math.max(0, input.shift - input.bottomClearance) + Math.min(input.keyboardGap, input.shift)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldReconcileHiddenKeyboardEnd(input: {
|
||||
|
||||
@@ -10,18 +10,27 @@ interface KeyboardTranslateViewProps extends ViewProps {
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
/**
|
||||
* Distance the view rises beyond the keyboard's overlap of the safe area, so content
|
||||
* resting on the safe-area boundary keeps this gap above the open keyboard.
|
||||
*/
|
||||
keyboardGap?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
keyboardGap = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const frameRef = useRef<View>(null);
|
||||
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
|
||||
const translation = useMemo(
|
||||
() => ({ enabled, bottomClearance, keyboardGap }),
|
||||
[bottomClearance, enabled, keyboardGap],
|
||||
);
|
||||
const scope = useKeyboardTranslationScope(frameRef, translation);
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }],
|
||||
|
||||
@@ -10,30 +10,40 @@ interface KeyboardTranslateViewProps extends ViewProps {
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
/**
|
||||
* Distance the view rises beyond the keyboard's overlap of the safe area, so content
|
||||
* resting on the safe-area boundary keeps this gap above the open keyboard.
|
||||
*/
|
||||
keyboardGap?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
keyboardGap = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() =>
|
||||
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],
|
||||
);
|
||||
// Mirrors resolveKeyboardLift on the native driver: `overlap` is the negated shift.
|
||||
const translateY = useMemo(() => {
|
||||
const overlap = Animated.add(height, Animated.multiply(progress, insets.bottom));
|
||||
const lift = Animated.add(overlap, bottomClearance).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
if (!(keyboardGap > 0)) return lift;
|
||||
const gap = overlap.interpolate({
|
||||
inputRange: [-keyboardGap, 0],
|
||||
outputRange: [-keyboardGap, 0],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
return Animated.add(lift, gap);
|
||||
}, [bottomClearance, height, insets.bottom, keyboardGap, progress]);
|
||||
// Fullscreen resizes this view while disabling translation. iOS Fabric can
|
||||
// reapply its previous transform during that layout commit. Keep the graph
|
||||
// attached and apply the mode on the native animation frame; a static zero or
|
||||
@@ -57,7 +67,10 @@ export function KeyboardTranslateView({
|
||||
);
|
||||
|
||||
const frameRef = useRef<View>(null);
|
||||
const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]);
|
||||
const translation = useMemo(
|
||||
() => ({ enabled, bottomClearance, keyboardGap }),
|
||||
[bottomClearance, enabled, keyboardGap],
|
||||
);
|
||||
const scope = useKeyboardTranslationScope(frameRef, translation);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,12 +5,14 @@ interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
bottomClearance?: number;
|
||||
keyboardGap?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
bottomClearance: _bottomClearance,
|
||||
keyboardGap: _keyboardGap,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
|
||||
@@ -33,11 +33,12 @@ export function ImportSessionButton({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
|
||||
// border lines up with the composer's border.
|
||||
// The composer sits spacing[4] in from the dock; the pill's border lines up with the
|
||||
// composer's border, spacing[4] below it.
|
||||
wide: {
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
pill: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import { WindowLayoutContext } from "./context";
|
||||
import type { WindowLayout } from "./model";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { WindowLayoutContext, useWindowLayout } from "./context";
|
||||
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
|
||||
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
@@ -34,10 +35,18 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
|
||||
style={StyleSheet.absoluteFill}
|
||||
onGeometryChange={onGeometryChange}
|
||||
/>
|
||||
{children}
|
||||
<WindowSafeAreaInsets>{children}</WindowSafeAreaInsets>
|
||||
</View>
|
||||
</WindowLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
|
||||
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
|
||||
const systemInsets = useSafeAreaInsets();
|
||||
const layout = useWindowLayout();
|
||||
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
|
||||
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({ root: { flex: 1 } });
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
|
||||
|
||||
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
function layout(bottomClearance: number | null | undefined): WindowLayout {
|
||||
return {
|
||||
bounds: { x: 0, y: 0, width: 402, height: 874 },
|
||||
verticalBarEdge: "none",
|
||||
verticalBar: null,
|
||||
bottomClearance,
|
||||
divisions: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveSafeAreaInsets", () => {
|
||||
it("replaces only the bottom with the measured bar clearance", () => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
|
||||
...systemInsets,
|
||||
bottom: 24,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a measured zero instead of falling back to the system inset", () => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
|
||||
});
|
||||
|
||||
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
|
||||
});
|
||||
|
||||
it("leaves system insets alone before the native layout arrives", () => {
|
||||
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,25 @@ export interface WindowLayout {
|
||||
bounds: WindowLayoutRect;
|
||||
verticalBarEdge: "none" | "leading" | "trailing";
|
||||
verticalBar: WindowLayoutRect | null;
|
||||
/**
|
||||
* Points from the window's bottom edge to where a bottom bar rests, or null where
|
||||
* the system has no bottom bar guide. Zero is a measured value.
|
||||
*/
|
||||
bottomClearance?: number | null;
|
||||
divisions: WindowLayoutRect[];
|
||||
}
|
||||
|
||||
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
|
||||
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
|
||||
systemInsets: Insets,
|
||||
layout: WindowLayout | null,
|
||||
): Insets {
|
||||
const clearance = layout?.bottomClearance;
|
||||
return typeof clearance === "number" && clearance !== systemInsets.bottom
|
||||
? { ...systemInsets, bottom: clearance }
|
||||
: systemInsets;
|
||||
}
|
||||
|
||||
export interface DesktopDivisionLayout {
|
||||
sidebarWidth: number;
|
||||
gap: number;
|
||||
|
||||
Reference in New Issue
Block a user