| 1 | import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 4 | import type { StyleProp, ViewInstance, ViewStyle } from "react-native"; |
| 5 | import { BackHandler, Pressable, ScrollView, View } from "react-native"; |
| 6 | import { useKeyboardState } from "react-native-keyboard-controller"; |
| 7 | import Animated, { FadeIn } from "react-native-reanimated"; |
| 8 | |
| 9 | import { OverlayPortal } from "./OverlayPortal"; |
| 10 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 11 | import { MaterialMenuPopup } from "./MaterialMenuPopup"; |
| 12 | |
| 13 | const SCREEN_MARGIN = 12; |
| 14 | const 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. |
| 21 | type AnchorSnapshot = { |
| 22 | readonly x: number; |
| 23 | readonly y: number; |
| 24 | readonly width: number; |
| 25 | readonly height: number; |
| 26 | readonly keyboardWasVisible: boolean; |
| 27 | }; |
| 28 | |
| 29 | type OverlayFrame = { |
| 30 | readonly x: number; |
| 31 | readonly y: number; |
| 32 | readonly width: number; |
| 33 | readonly height: number; |
| 34 | }; |
| 35 | |
| 36 | export 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 | */ |
| 57 | export 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 | } |