| 1 | const fs = require("node:fs"); |
| 2 | const path = require("node:path"); |
| 3 | const { |
| 4 | AndroidConfig, |
| 5 | withAndroidColors, |
| 6 | withAndroidColorsNight, |
| 7 | withAndroidStyles, |
| 8 | withDangerousMod, |
| 9 | } = require("expo/config-plugins"); |
| 10 | |
| 11 | // @react-native-menu/menu renders an AppCompat PopupMenu on Android, which |
| 12 | // inherits the dated default popup chrome from the app theme. These resources |
| 13 | // restyle it to match the app palette (global.css --color-card / --color-foreground) |
| 14 | // with rounded corners, and anchor it below the button instead of overlapping it. |
| 15 | |
| 16 | const POPUP_BACKGROUND_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?> |
| 17 | <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> |
| 18 | <solid android:color="@color/popup_menu_background" /> |
| 19 | <corners android:radius="12dp" /> |
| 20 | </shape> |
| 21 | `; |
| 22 | |
| 23 | // Checkable menu rows insert a CheckBox at the row's right edge; the theme |
| 24 | // swaps its square-box button for this check glyph so the selected option |
| 25 | // shows a plain right-aligned check. |
| 26 | const CHECK_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?> |
| 27 | <vector xmlns:android="http://schemas.android.com/apk/res/android" |
| 28 | android:width="18dp" |
| 29 | android:height="18dp" |
| 30 | android:viewportWidth="24" |
| 31 | android:viewportHeight="24" |
| 32 | android:tint="@color/popup_menu_item_text"> |
| 33 | <path |
| 34 | android:fillColor="#FFFFFFFF" |
| 35 | android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" /> |
| 36 | </vector> |
| 37 | `; |
| 38 | |
| 39 | // CheckBox button drawable: check glyph when selected, nothing otherwise. |
| 40 | const CHECKBOX_BUTTON_SELECTOR = `<?xml version="1.0" encoding="utf-8"?> |
| 41 | <selector xmlns:android="http://schemas.android.com/apk/res/android"> |
| 42 | <item android:state_checked="true" android:drawable="@drawable/ic_menu_check" /> |
| 43 | <item android:drawable="@android:color/transparent" /> |
| 44 | </selector> |
| 45 | `; |
| 46 | |
| 47 | // Replaces the default filled-triangle submenu indicator with a stroked ">" |
| 48 | // chevron. |
| 49 | const SUBMENU_ARROW_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?> |
| 50 | <vector xmlns:android="http://schemas.android.com/apk/res/android" |
| 51 | android:width="20dp" |
| 52 | android:height="20dp" |
| 53 | android:viewportWidth="24" |
| 54 | android:viewportHeight="24" |
| 55 | android:alpha="0.6" |
| 56 | android:tint="@color/popup_menu_item_text"> |
| 57 | <path |
| 58 | android:pathData="M9,6l6,6l-6,6" |
| 59 | android:strokeColor="#FFFFFFFF" |
| 60 | android:strokeWidth="2" |
| 61 | android:strokeLineCap="round" |
| 62 | android:strokeLineJoin="round" |
| 63 | android:fillColor="#00000000" /> |
| 64 | </vector> |
| 65 | `; |
| 66 | |
| 67 | const COLORS = { |
| 68 | light: { background: "#F7F7F7", itemText: "#262626" }, |
| 69 | night: { background: "#161616", itemText: "#F5F5F5" }, |
| 70 | }; |
| 71 | |
| 72 | function assignStyleItem(style, name, value) { |
| 73 | style.item = style.item ?? []; |
| 74 | const existing = style.item.find((item) => item.$?.name === name); |
| 75 | if (existing) { |
| 76 | existing._ = value; |
| 77 | } else { |
| 78 | style.item.push({ _: value, $: { name } }); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | function withPopupMenuStyles(config) { |
| 83 | return withAndroidStyles(config, (config) => { |
| 84 | const resources = config.modResults.resources; |
| 85 | resources.style = resources.style ?? []; |
| 86 | |
| 87 | const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); |
| 88 | if (appTheme) { |
| 89 | assignStyleItem(appTheme, "popupMenuStyle", "@style/AppPopupMenu"); |
| 90 | assignStyleItem(appTheme, "android:popupMenuStyle", "@style/AppPopupMenu"); |
| 91 | assignStyleItem( |
| 92 | appTheme, |
| 93 | "textAppearanceLargePopupMenu", |
| 94 | "@style/AppPopupMenu.TextAppearance", |
| 95 | ); |
| 96 | assignStyleItem( |
| 97 | appTheme, |
| 98 | "textAppearanceSmallPopupMenu", |
| 99 | "@style/AppPopupMenu.TextAppearance", |
| 100 | ); |
| 101 | // Submenu popups show their parent item as a header row that reads a |
| 102 | // separate theme attribute, so it needs the same themed text color. |
| 103 | assignStyleItem( |
| 104 | appTheme, |
| 105 | "textAppearancePopupMenuHeader", |
| 106 | "@style/AppPopupMenu.HeaderTextAppearance", |
| 107 | ); |
| 108 | // Menu item views resolve their submenu arrow from this style |
| 109 | // (android:listMenuViewStyle / android:subMenuArrow are public attrs). |
| 110 | assignStyleItem(appTheme, "android:listMenuViewStyle", "@style/AppPopupMenuListMenuView"); |
| 111 | // Checkable rows inflate a plain CheckBox at the row end; restyle its |
| 112 | // button so the selected option shows a right-aligned check glyph |
| 113 | // instead of a square box. Both framework and AppCompat attrs are set |
| 114 | // since the popup may inflate either CheckBox flavor. App-wide for |
| 115 | // native checkboxes, which the app otherwise doesn't use. |
| 116 | assignStyleItem(appTheme, "android:checkboxStyle", "@style/AppPopupMenuCheckBox"); |
| 117 | assignStyleItem(appTheme, "checkboxStyle", "@style/AppPopupMenuCheckBoxCompat"); |
| 118 | } |
| 119 | |
| 120 | resources.style = resources.style.filter( |
| 121 | (style) => |
| 122 | ![ |
| 123 | "AppPopupMenu", |
| 124 | "AppPopupMenu.TextAppearance", |
| 125 | "AppPopupMenu.HeaderTextAppearance", |
| 126 | "AppPopupMenuListMenuView", |
| 127 | "AppPopupMenuCheckBox", |
| 128 | "AppPopupMenuCheckBoxCompat", |
| 129 | ].includes(style.$?.name), |
| 130 | ); |
| 131 | resources.style.push( |
| 132 | { |
| 133 | $: { name: "AppPopupMenu", parent: "Widget.AppCompat.PopupMenu" }, |
| 134 | item: [ |
| 135 | { _: "@drawable/popup_menu_background", $: { name: "android:popupBackground" } }, |
| 136 | { _: "false", $: { name: "android:overlapAnchor" } }, |
| 137 | { _: "4dp", $: { name: "android:dropDownVerticalOffset" } }, |
| 138 | ], |
| 139 | }, |
| 140 | { |
| 141 | $: { name: "AppPopupMenu.TextAppearance", parent: "TextAppearance.AppCompat.Menu" }, |
| 142 | item: [ |
| 143 | { _: "15sp", $: { name: "android:textSize" } }, |
| 144 | { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } }, |
| 145 | // DM Sans (--font-sans); embedded by the expo-font plugin config in |
| 146 | // app.config.ts. |
| 147 | { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, |
| 148 | ], |
| 149 | }, |
| 150 | { |
| 151 | $: { |
| 152 | name: "AppPopupMenu.HeaderTextAppearance", |
| 153 | parent: "TextAppearance.AppCompat.Widget.PopupMenu.Header", |
| 154 | }, |
| 155 | item: [ |
| 156 | { _: "15sp", $: { name: "android:textSize" } }, |
| 157 | { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } }, |
| 158 | { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, |
| 159 | ], |
| 160 | }, |
| 161 | // The framework default (Widget.Material.ListMenuView) only carries |
| 162 | // subMenuArrow, so replacing the style wholesale is safe. |
| 163 | { |
| 164 | $: { name: "AppPopupMenuListMenuView" }, |
| 165 | item: [{ _: "@drawable/popup_menu_submenu_arrow", $: { name: "android:subMenuArrow" } }], |
| 166 | }, |
| 167 | { |
| 168 | $: { |
| 169 | name: "AppPopupMenuCheckBox", |
| 170 | parent: "android:Widget.Material.CompoundButton.CheckBox", |
| 171 | }, |
| 172 | item: [{ _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }], |
| 173 | }, |
| 174 | { |
| 175 | $: { |
| 176 | name: "AppPopupMenuCheckBoxCompat", |
| 177 | parent: "Widget.AppCompat.CompoundButton.CheckBox", |
| 178 | }, |
| 179 | item: [ |
| 180 | { _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }, |
| 181 | // buttonCompat wins over android:button in AppCompatCheckBox. |
| 182 | { _: "@drawable/popup_menu_check_button", $: { name: "buttonCompat" } }, |
| 183 | ], |
| 184 | }, |
| 185 | ); |
| 186 | |
| 187 | return config; |
| 188 | }); |
| 189 | } |
| 190 | |
| 191 | function withPopupMenuColors(config) { |
| 192 | config = withAndroidColors(config, (config) => { |
| 193 | config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { |
| 194 | name: "popup_menu_background", |
| 195 | value: COLORS.light.background, |
| 196 | }); |
| 197 | config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { |
| 198 | name: "popup_menu_item_text", |
| 199 | value: COLORS.light.itemText, |
| 200 | }); |
| 201 | return config; |
| 202 | }); |
| 203 | config = withAndroidColorsNight(config, (config) => { |
| 204 | config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { |
| 205 | name: "popup_menu_background", |
| 206 | value: COLORS.night.background, |
| 207 | }); |
| 208 | config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { |
| 209 | name: "popup_menu_item_text", |
| 210 | value: COLORS.night.itemText, |
| 211 | }); |
| 212 | return config; |
| 213 | }); |
| 214 | return config; |
| 215 | } |
| 216 | |
| 217 | function withPopupMenuBackgroundDrawable(config) { |
| 218 | return withDangerousMod(config, [ |
| 219 | "android", |
| 220 | async (config) => { |
| 221 | const drawableDir = path.join( |
| 222 | config.modRequest.platformProjectRoot, |
| 223 | "app", |
| 224 | "src", |
| 225 | "main", |
| 226 | "res", |
| 227 | "drawable", |
| 228 | ); |
| 229 | fs.mkdirSync(drawableDir, { recursive: true }); |
| 230 | fs.writeFileSync( |
| 231 | path.join(drawableDir, "popup_menu_background.xml"), |
| 232 | POPUP_BACKGROUND_DRAWABLE, |
| 233 | ); |
| 234 | fs.writeFileSync(path.join(drawableDir, "ic_menu_check.xml"), CHECK_DRAWABLE); |
| 235 | fs.writeFileSync( |
| 236 | path.join(drawableDir, "popup_menu_check_button.xml"), |
| 237 | CHECKBOX_BUTTON_SELECTOR, |
| 238 | ); |
| 239 | fs.writeFileSync( |
| 240 | path.join(drawableDir, "popup_menu_submenu_arrow.xml"), |
| 241 | SUBMENU_ARROW_DRAWABLE, |
| 242 | ); |
| 243 | return config; |
| 244 | }, |
| 245 | ]); |
| 246 | } |
| 247 | |
| 248 | module.exports = function withAndroidModernPopupMenu(config) { |
| 249 | return withPopupMenuBackgroundDrawable(withPopupMenuColors(withPopupMenuStyles(config))); |
| 250 | }; |