From 95fd7c52f57c3b49f5dba1e8c064292d6590b516 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Fri, 30 Oct 2026 01:54:19 -0700 Subject: [PATCH] fix(mobile): Android menus follow their button when the hinge moves the split An open menu kept the anchor and hinge it measured at opening, so opening or closing the hinge left it placed for the old split until it was reopened. The workspace layout now counts pane settles (including a crease no pane slides to), and an open menu measures its anchor again after each one. The measuring effect's cleanup drops a measurement that a newer one or a close has superseded. Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/components/ui/AndroidAnchoredMenu.tsx | 35 ++++++++++++++----- .../layout/AdaptiveWorkspaceLayout.tsx | 32 ++++++++++++----- .../mobile/src/native/window-hinge-context.ts | 11 ++++-- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index a1f6a02cc32eb..a7bbd6f5027ea 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -1,12 +1,13 @@ import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; import type { ReactNode } from "react"; -import { use, useRef, useState } from "react"; +import { use, useEffect, useEffectEvent, useRef, useState } from "react"; import type { StyleProp, ViewInstance, ViewStyle } from "react-native"; import { Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { useHardwareBack } from "../../lib/backHandler"; +import type { VerticalHinge } from "../../lib/layout"; import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; @@ -17,11 +18,13 @@ const SCREEN_MARGIN = 12; const ANCHOR_GAP = 6; // Freeze both snapshots at opening so keyboard and screen reflows cannot flip menu direction. +// A hinge change moves the anchor with its pane, so both are re-measured once the panes settle. type AnchorSnapshot = { readonly x: number; readonly y: number; readonly width: number; readonly height: number; + readonly hinge: VerticalHinge | null; readonly keyboardWasVisible: boolean; }; @@ -47,7 +50,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [path, setPath] = useState([]); const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); - const windowHinge = use(WindowHingeContext); + const { hinge: windowHinge, panesSettled } = use(WindowHingeContext); // State, not a ref: `open` reaches render-prop children, and the compiler // bails out of the whole component when a ref might be read during render. const [anchorNode, setAnchorNode] = useState(null); @@ -55,18 +58,33 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); + const [opening, setOpening] = useState<{ readonly keyboardWasVisible: boolean } | null>(null); const close = () => { + setOpening(null); setAnchor(null); setPath([]); setOverlay(null); setRootHeight(null); }; - const open = () => { - anchorNode?.measureInWindow((x, y, width, height) => { - setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); + const open = () => setOpening({ keyboardWasVisible: keyboardVisible }); + + const placeAnchor = useEffectEvent( + (x: number, y: number, width: number, height: number, keyboardWasVisible: boolean) => + setAnchor({ x, y, width, height, hinge: windowHinge, keyboardWasVisible }), + ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the native anchor view; cleanup drops a superseded or closed measurement + useEffect(() => { + if (opening === null || anchorNode === null) return; + let current = true; + anchorNode.measureInWindow((x, y, width, height) => { + if (current) placeAnchor(x, y, width, height, opening.keyboardWasVisible); }); - }; + return () => { + current = false; + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A pane settle moves the anchor, so it is measured again. + }, [opening, anchorNode, panesSettled]); const measureOverlay = () => { overlayRef.current?.measureInWindow((x, y, width, height) => { @@ -91,6 +109,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { (action) => !(action.attributes?.hidden ?? false), ); + const hinge = anchor?.hinge ?? null; const local = anchor === null || overlay === null ? null @@ -109,7 +128,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { overlayWidth: overlay.width, desiredWidth: desiredMenuWidth, margin: SCREEN_MARGIN, - hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width }, + hinge: hinge && { x: hinge.x - overlay.x, width: hinge.width }, }); // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable. const usableBottom = @@ -171,7 +190,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( { @@ -679,12 +687,18 @@ function AdaptiveWorkspaceLayoutContent( ...current, listPaneFloor: current.listPaneTarget, listPaneCeiling: current.listPaneTarget, + settled: current.settled + 1, }, ); }; const listPaneFloor = listPaneSizing.listPaneFloor ?? 0; const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0; const listPaneRevision = listPaneSizing.revision; + const panesSettled = listPaneSizing.settled; + const windowHingeContext = useMemo( + () => ({ hinge: windowHinge, panesSettled }), + [windowHinge, panesSettled], + ); // Android lays the content out at its new width as soon as a pane toggles, then holds it // against the edge that stays put while the other one slides, so nothing reflows after the // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content @@ -1019,7 +1033,7 @@ function AdaptiveWorkspaceLayoutContent( savedFilters={props.savedThreadListFilters} > - + {usesPresentationHost ? ( {props.children} diff --git a/apps/mobile/src/native/window-hinge-context.ts b/apps/mobile/src/native/window-hinge-context.ts index f2e031b335d0a..21a3271c04f17 100644 --- a/apps/mobile/src/native/window-hinge-context.ts +++ b/apps/mobile/src/native/window-hinge-context.ts @@ -1,5 +1,12 @@ import { createContext } from "react"; import type { VerticalHinge } from "../lib/layout"; -/** A creased vertical hinge in window coordinates, for overlays placed in window space. */ -export const WindowHingeContext = createContext(null); +/** + * A creased vertical hinge in window coordinates, for overlays placed in window space. + * `panesSettled` counts finished workspace pane animations. Panes move what an overlay is + * anchored to, such as a sidebar button when the hinge resizes the list. + */ +export const WindowHingeContext = createContext<{ + readonly hinge: VerticalHinge | null; + readonly panesSettled: number; +}>({ hinge: null, panesSettled: 0 });