1import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu";
2import type { ReactNode } from "react";
3import { useCallback, useEffect, useRef, useState } from "react";
4import type { StyleProp, ViewInstance, ViewStyle } from "react-native";
5import { BackHandler, Pressable, ScrollView, View } from "react-native";
6import { useKeyboardState } from "react-native-keyboard-controller";
7import Animated, { FadeIn } from "react-native-reanimated";
8
9import { OverlayPortal } from "./OverlayPortal";
10import { useAndroidControlSizing } from "./useAndroidControlSizing";
11import { MaterialMenuPopup } from "./MaterialMenuPopup";
12
13const SCREEN_MARGIN = 12;
14const ANCHOR_GAP = 6;
15
16// Anchor position is snapshotted in window coordinates when the menu opens;
17// the overlay root measures itself the same way, and the menu is placed from
18// the delta. Both snapshots are taken at open time so later reflows (keyboard
19// show/hide, screen transitions) can't flip an opens-up menu to opens-down
20// mid-presentation.
21type AnchorSnapshot = {
22 readonly x: number;
23 readonly y: number;
24 readonly width: number;
25 readonly height: number;
26 readonly keyboardWasVisible: boolean;
27};
28
29type OverlayFrame = {
30 readonly x: number;
31 readonly y: number;
32 readonly width: number;
33 readonly height: number;
34};
35
36export type AndroidAnchoredMenuProps = {
37 readonly actions: readonly MenuAction[];
38 readonly title?: string;
39 readonly onPressAction?: MenuComponentProps["onPressAction"];
40 /** Applied to the anchor wrapper — call sites flex these to fill toolbars. */
41 readonly className?: string;
42 readonly style?: StyleProp<ViewStyle>;
43 /**
44 * Plain children open the menu on tap (the wrapper owns the press). A
45 * render function keeps the children interactive and hands them `open` to
46 * call from their own gesture — e.g. a row that selects on tap and opens
47 * this menu on long-press.
48 */
49 readonly children: ReactNode | ((open: () => void) => ReactNode);
50};
51
52/**
53 * Adapts the app's MenuView actions to Material dropdowns on Android. Editor
54 * menus render native Material rows in-window to retain keyboard focus; other
55 * menus use the native popup for placement, animation and dismissal.
56 */
57export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
58 const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing();
59 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
60 const [path, setPath] = useState<readonly MenuAction[]>([]);
61 // Height of the modal's root view, in the modal's own coordinate space.
62 // Menus that flip above their anchor are pinned by their BOTTOM edge
63 // (bottom = rootHeight - anchorTop), so drill-in height changes grow
64 // upward without any re-measurement — positioning them via `top` from the
65 // menu's measured height made every submenu transition settle over two
66 // frames and jitter.
67 const [rootHeight, setRootHeight] = useState<number | null>(null);
68 // Window frame of the overlay root, measured on layout. Anchor coordinates
69 // are converted into this frame, so the menu lands correctly no matter
70 // where the portal host sits (status bar, keyboard resize, etc.).
71 const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
72 const menuWidth =
73 overlay === null
74 ? desiredMenuWidth
75 : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN));
76 const anchorRef = useRef<ViewInstance>(null);
77 const overlayRef = useRef<ViewInstance>(null);
78
79 const keyboardVisible = useKeyboardState((state) => state.isVisible);
80 const keyboardHeight = useKeyboardState((state) => state.height);
81 const close = useCallback(() => {
82 setAnchor(null);
83 setPath([]);
84 setOverlay(null);
85 setRootHeight(null);
86 }, []);
87
88 const open = useCallback(() => {
89 anchorRef.current?.measureInWindow((x, y, width, height) => {
90 setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible });
91 });
92 }, [keyboardVisible]);
93
94 const measureOverlay = useCallback(() => {
95 overlayRef.current?.measureInWindow((x, y, width, height) => {
96 setOverlay({ x, y, width, height });
97 setRootHeight(height);
98 });
99 }, []);
100
101 // The native popup owns back dismissal. In-window menus need a handler;
102 // back returns to the parent submenu before closing the overlay.
103 const submenuDepth = path.length;
104 useEffect(() => {
105 if (anchor === null || !anchor.keyboardWasVisible) {
106 return;
107 }
108 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
109 if (submenuDepth > 0) {
110 setPath((current) => current.slice(0, -1));
111 } else {
112 close();
113 }
114 return true;
115 });
116 return () => subscription.remove();
117 }, [anchor, close, submenuDepth]);
118
119 const parent = path[path.length - 1] ?? null;
120 const levelActions = (parent?.subactions ?? props.actions).filter(
121 (action) => !(action.attributes?.hidden ?? false),
122 );
123
124 // Anchor in overlay-local coordinates (both measured in window space).
125 const local =
126 anchor === null || overlay === null
127 ? null
128 : {
129 x: anchor.x - overlay.x,
130 y: anchor.y - overlay.y,
131 width: anchor.width,
132 height: anchor.height,
133 };
134 const preferredLeft =
135 local === null || overlay === null
136 ? 0
137 : local.x + local.width / 2 <= overlay.width / 2
138 ? local.x
139 : local.x + local.width - menuWidth;
140 const left =
141 overlay === null
142 ? 0
143 : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN);
144 // The keyboard stays up while the menu is open (in-window overlay, no
145 // focus change), so the space it covers is not usable — without this the
146 // composer-pill menus "open down" into the IME and can't be tapped.
147 const usableBottom =
148 overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0);
149 const spaceBelow =
150 local === null || overlay === null
151 ? 0
152 : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN;
153 const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN;
154 const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove;
155 const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480);
156 // The menu needs the overlay frame before it can be placed; it stays
157 // unmounted for that first frame so the fade-in plays at the final position.
158 const placeable = local !== null && rootHeight !== null;
159
160 const onPressItem = useCallback(
161 (action: MenuAction) => {
162 if ((action.subactions?.length ?? 0) > 0) {
163 setPath((current) => [...current, action]);
164 return;
165 }
166 close();
167 if (action.id !== undefined) {
168 props.onPressAction?.({
169 nativeEvent: { event: action.id },
170 } as Parameters<NonNullable<MenuComponentProps["onPressAction"]>>[0]);
171 }
172 },
173 [close, props.onPressAction],
174 );
175
176 return (
177 <>
178 {typeof props.children === "function" ? (
179 <View ref={anchorRef} collapsable={false} className={props.className} style={props.style}>
180 {props.children(open)}
181 </View>
182 ) : (
183 <Pressable
184 ref={anchorRef}
185 accessibilityRole="button"
186 className={props.className}
187 collapsable={false}
188 style={props.style}
189 onPress={open}
190 >
191 <View pointerEvents="none">{props.children}</View>
192 </Pressable>
193 )}
194 {anchor === null ? null : (
195 <OverlayPortal>
196 <View
197 ref={overlayRef}
198 collapsable={false}
199 className="absolute inset-0"
200 onLayout={measureOverlay}
201 >
202 <Pressable accessible={false} className="absolute inset-0" onPress={close} />
203 {!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
204 <MaterialMenuPopup
205 anchor={local}
206 menuWidth={menuWidth}
207 actions={levelActions}
208 title={props.title}
209 parent={parent}
210 onPress={onPressItem}
211 onBack={() => setPath((current) => current.slice(0, -1))}
212 onClose={close}
213 />
214 ) : (
215 <Animated.View
216 entering={FadeIn.duration(120)}
217 className="absolute overflow-hidden bg-card-alt shadow-md"
218 style={{
219 left,
220 maxHeight,
221 width: menuWidth,
222 borderRadius: 4 * scale,
223 ...(opensDown
224 ? { top: local.y + local.height + ANCHOR_GAP }
225 : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
226 }}
227 >
228 {/* Compose DropdownMenu takes popup focus in the pinned Expo UI version.
229 Keep editor menus in-window so opening one preserves the keyboard. */}
230
231 {/* keyboardShouldPersistTaps: the menu often opens over an
232 active editor; the first item tap must act, not just
233 dismiss the keyboard. */}
234 <ScrollView
235 contentContainerStyle={{ paddingVertical: 7 * scale }}
236 bounces={false}
237 keyboardShouldPersistTaps="always"
238 showsVerticalScrollIndicator={false}
239 >
240 <MaterialMenuPopup
241 inline
242 anchor={local}
243 menuWidth={menuWidth}
244 actions={levelActions}
245 title={props.title}
246 parent={parent}
247 onPress={onPressItem}
248 onBack={() => setPath((current) => current.slice(0, -1))}
249 onClose={close}
250 />
251 </ScrollView>
252 </Animated.View>
253 )}
254 </View>
255 </OverlayPortal>
256 )}
257 </>
258 );
259}