Skip to content
Source
Sign inSign up

AndroidAnchoredMenu.android.tsx

223 lines7.6 KB

1import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu";
2import type { ReactNode } from "react";
3import { use, useRef, useState } from "react";
4import type { StyleProp, ViewInstance, ViewStyle } from "react-native";
5import { Pressable, ScrollView, View } from "react-native";
6import { useKeyboardState } from "react-native-keyboard-controller";
7​
8import { useHardwareBack } from "../../lib/backHandler";
9import {
10 deriveMenuHorizontalPlacement,
11 deriveMenuVerticalPlacement,
12} from "../../lib/menu-placement";
13import { WindowHingeContext } from "../../native/window-hinge-context";
14import { OverlayPortal } from "./OverlayPortal";
15import { useAndroidControlSizing } from "./useAndroidControlSizing";
16import { MenuPopup } from "./MenuPopup";
17​
18const SCREEN_MARGIN = 12;
19const ANCHOR_GAP = 6;
20​
21// Freeze both snapshots at opening so keyboard and screen reflows cannot flip menu direction.
22type AnchorSnapshot = {
23 readonly x: number;
24 readonly y: number;
25 readonly width: number;
26 readonly height: number;
27 readonly keyboardWasVisible: boolean;
28};
29​
30type OverlayFrame = {
31 readonly x: number;
32 readonly y: number;
33 readonly width: number;
34 readonly height: number;
35};
36​
37export type AndroidAnchoredMenuProps = {
38 readonly actions: readonly MenuAction[];
39 readonly title?: string;
40 readonly onPressAction?: MenuComponentProps["onPressAction"];
41 readonly className?: string;
42 readonly style?: StyleProp<ViewStyle>;
43 readonly children: ReactNode | ((open: () => void) => ReactNode);
44};
45​
46export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
47 const { scale, menuWidth: desiredMenuWidth, menuItemHeight } = useAndroidControlSizing();
48 const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
49 const [path, setPath] = useState<readonly MenuAction[]>([]);
50 const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
51 const windowHinge = use(WindowHingeContext);
52 // State, not a ref: `open` reaches render-prop children, and the compiler
53 // bails out of the whole component when a ref might be read during render.
54 const [anchorNode, setAnchorNode] = useState<ViewInstance | null>(null);
55 const overlayRef = useRef<ViewInstance>(null);
56​
57 const keyboardVisible = useKeyboardState((state) => state.isVisible);
58 const keyboardHeight = useKeyboardState((state) => state.height);
59 const close = () => {
60 setAnchor(null);
61 setPath([]);
62 setOverlay(null);
63 };
64​
65 const open = () => {
66 anchorNode?.measureInWindow((x, y, width, height) => {
67 setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible });
68 });
69 };
70​
71 const measureOverlay = () => {
72 overlayRef.current?.measureInWindow((x, y, width, height) => {
73 setOverlay({ x, y, width, height });
74 });
75 };
76​
77 // Only the in-window (keyboard) presentation needs this; the native popup
78 // handles back itself.
79 useHardwareBack(anchor?.keyboardWasVisible === true, () => {
80 if (path.length > 0) {
81 setPath((current) => current.slice(0, -1));
82 } else {
83 close();
84 }
85 return true;
86 });
87​
88 const parent = path[path.length - 1] ?? null;
89 const levelActions = (parent?.subactions ?? props.actions).filter(
90 (action) => !(action.attributes?.hidden ?? false),
91 );
92​
93 const local =
94 anchor === null || overlay === null
95 ? null
96 : {
97 x: anchor.x - overlay.x,
98 y: anchor.y - overlay.y,
99 width: anchor.width,
100 height: anchor.height,
101 };
102 const { left, width: menuWidth } =
103 local === null || overlay === null
104 ? { left: 0, width: desiredMenuWidth }
105 : deriveMenuHorizontalPlacement({
106 anchorX: local.x,
107 anchorWidth: local.width,
108 overlayWidth: overlay.width,
109 desiredWidth: desiredMenuWidth,
110 margin: SCREEN_MARGIN,
111 hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width },
112 });
113 const verticalPlacement =
114 local === null || overlay === null
115 ? null
116 : deriveMenuVerticalPlacement({
117 anchorY: local.y,
118 anchorHeight: local.height,
119 overlayHeight: overlay.height,
120 // This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable.
121 keyboardInset: keyboardVisible ? keyboardHeight : 0,
122 gap: ANCHOR_GAP,
123 margin: SCREEN_MARGIN,
124 minHeight: 2 * menuItemHeight + 14 * scale,
125 });
126​
127 const onPressItem = (action: MenuAction) => {
128 if ((action.subactions?.length ?? 0) > 0) {
129 setPath((current) => [...current, action]);
130 return;
131 }
132 close();
133 if (action.id !== undefined) {
134 props.onPressAction?.({
135 nativeEvent: { event: action.id },
136 } as Parameters<NonNullable<MenuComponentProps["onPressAction"]>>[0]);
137 }
138 };
139​
140 return (
141 <>
142 {typeof props.children === "function" ? (
143 <View
144 ref={setAnchorNode}
145 collapsable={false}
146 className={props.className}
147 style={props.style}
148 >
149 {props.children(open)}
150 </View>
151 ) : (
152 <Pressable
153 ref={setAnchorNode}
154 accessibilityRole="button"
155 className={props.className}
156 collapsable={false}
157 style={props.style}
158 onPress={open}
159 >
160 <View pointerEvents="none">{props.children}</View>
161 </Pressable>
162 )}
163 {anchor === null ? null : (
164 <OverlayPortal>
165 <View
166 ref={overlayRef}
167 collapsable={false}
168 className="absolute inset-0"
169 onLayout={measureOverlay}
170 >
171 <Pressable accessible={false} className="absolute inset-0" onPress={close} />
172 {local === null ? null : !anchor.keyboardWasVisible ? (
173 <MenuPopup
174 // Compose aligns to the trigger's start if it fits; hinge-safe triggers keep menus on their side.
175 anchor={windowHinge ? { ...local, x: left, width: menuWidth } : local}
176 menuWidth={menuWidth}
177 actions={levelActions}
178 title={props.title}
179 parent={parent}
180 onPress={onPressItem}
181 onBack={() => setPath((current) => current.slice(0, -1))}
182 onClose={close}
183 />
184 ) : (
185 <View
186 // No entering animation: it freezes this frame before the Compose host reports its size.
187 className="absolute overflow-hidden bg-card-alt shadow-md"
188 style={{
189 left,
190 width: menuWidth,
191 borderRadius: 4 * scale,
192 ...verticalPlacement,
193 }}
194 >
195 {/* Compose DropdownMenu takes focus; in-window editor menus preserve the keyboard. */}
196​
197 {/* The first item tap must act while the keyboard is open. */}
198 <ScrollView
199 contentContainerStyle={{ paddingVertical: 7 * scale }}
200 bounces={false}
201 keyboardShouldPersistTaps="always"
202 showsVerticalScrollIndicator={false}
203 >
204 <MenuPopup
205 inline
206 anchor={local}
207 menuWidth={menuWidth}
208 actions={levelActions}
209 title={props.title}
210 parent={parent}
211 onPress={onPressItem}
212 onBack={() => setPath((current) => current.slice(0, -1))}
213 onClose={close}
214 />
215 </ScrollView>
216 </View>
217 )}
218 </View>
219 </OverlayPortal>
220 )}
221 </>
222 );