Android: replace the two overflow menus with anchored panels

The library's and the BIOS manager's menus were DropdownMenus, so
every row in both was pad-dead. The library one is the worse loss: it
holds sort order, cover style, custom and English titles, show-hidden,
the background picker and Exit -- most of the library's settings, none
of them reachable without a touchscreen.

Both keep their position. A menu that belongs to one button has to look
like that button's menu, not a prompt about the whole screen, so the
primitive gains an anchor: a root-space point the panel pins its
top-left to, clamped so one opened near an edge stays on screen. The
trigger reports its own bottom-left through onGloballyPositioned, the
same mechanism the focusable modifier already uses to track rows. Their
scrim is lighter than a prompt's for the same reason.

Rows derive their nav id from their label, which is unique within each
menu -- that registers all twelve library rows without threading an id
argument through twelve call sites.

That is the last DropdownMenu and the last ModalBottomSheet in the app.
Three AlertDialogs remain, all of them text entry, and they are the next
commit.
This commit is contained in:
Brian Degenhardt
2026-08-02 20:49:36 -07:00
parent 48b4298cf6
commit 0ce5d36218
3 changed files with 140 additions and 35 deletions
@@ -1,5 +1,7 @@
package com.armsx2.ui.bios
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
@@ -20,7 +22,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -83,7 +84,19 @@ fun BiosManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Bio
title = str("setup.page.bios.title"),
leading = { RoundAction("", str("action.back"), onBack) },
actions = {
Box {
var actionsAnchor by remember {
mutableStateOf(androidx.compose.ui.geometry.Offset.Zero)
}
Box(
Modifier.onGloballyPositioned {
// Bottom-left of the button, so the panel hangs below it as the
// dropdown it replaces did.
val p = it.positionInRoot()
actionsAnchor = androidx.compose.ui.geometry.Offset(
p.x, p.y + it.size.height,
)
},
) {
RoundAction(
glyph = "",
description = str("games.toolbar.more"),
@@ -96,6 +109,7 @@ fun BiosManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Bio
onImportFile = { picker.launch(arrayOf("application/octet-stream", "*/*")) },
onImportFolder = { folderPicker.launch(null) },
onRefresh = viewModel::refresh,
anchor = actionsAnchor,
)
}
},
@@ -160,38 +174,53 @@ private fun BiosActionsMenu(
onImportFile: () -> Unit,
onImportFolder: () -> Unit,
onRefresh: () -> Unit,
anchor: androidx.compose.ui.geometry.Offset,
) {
fun closeThen(action: () -> Unit) {
onDismiss()
action()
}
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
modifier = Modifier.widthIn(min = 280.dp, max = 340.dp),
shape = RoundedCornerShape(22.dp),
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 14.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)),
if (!expanded) return
// Anchored under its own ⋮ button — see the library's overflow menu for the reasoning.
com.armsx2.ui.common.PadModal(
key = "bios-actions",
onDismiss = onDismiss,
anchor = anchor,
scrimAlpha = 0.32f,
) {
Text(
text = str("setup.page.bios.title"),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
BiosActionMenuItem("", str("action.import")) { closeThen(onImportFile) }
BiosActionMenuItem("", str("action.importFolder")) { closeThen(onImportFolder) }
BiosActionMenuItem("", str("games.card.refresh")) { closeThen(onRefresh) }
Surface(
modifier = Modifier.widthIn(min = 280.dp, max = 340.dp),
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 14.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)),
) {
Column {
Text(
text = str("setup.page.bios.title"),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
BiosActionMenuItem("", str("action.import")) { closeThen(onImportFile) }
BiosActionMenuItem("", str("action.importFolder")) { closeThen(onImportFolder) }
BiosActionMenuItem("", str("games.card.refresh")) { closeThen(onRefresh) }
}
}
}
}
@Composable
private fun BiosActionMenuItem(glyph: String, label: String, onClick: () -> Unit) {
DropdownMenuItem(
modifier = Modifier.controllerFocusable(
controllerId = "bios-actions:$label",
shape = RoundedCornerShape(12.dp),
onConfirm = onClick,
),
text = {
Text(
text = label,
@@ -5,19 +5,29 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
import com.armsx2.ui.settings.LocalNavLayer
import com.armsx2.ui.settings.SettingsControllerNav
@@ -66,6 +76,7 @@ object PadModals {
internal val onDismiss: State<(() -> Unit)?>,
internal val initialFocusId: State<String?>,
internal val scrollState: State<ScrollState?>,
internal val anchor: State<Offset?>,
) {
// Deliberately a plain var and not state: it must survive every recomposition of the
// content without causing one. Focus is claimed once per open, then the pad owns it.
@@ -131,6 +142,8 @@ object PadModals {
* @param scrollState the body's scroll state, when the content can outgrow the panel. Up/Down
* scroll it once the selection has nowhere left to move, which is the only way a pad can read
* a panel that has just one focusable in it.
* @param anchor root-space position to pin the panel's top-left to, clamped to stay on screen.
* For a menu belonging to a specific button; [alignment] is ignored when it is set.
*/
@Composable
fun PadModal(
@@ -140,6 +153,7 @@ fun PadModal(
scrimAlpha: Float = 0.62f,
initialFocusId: String? = null,
scrollState: ScrollState? = null,
anchor: Offset? = null,
content: @Composable () -> Unit,
) {
// Re-published on EVERY recomposition, so a closure can never go stale. The nav registry
@@ -151,10 +165,11 @@ fun PadModal(
val dismissState = rememberUpdatedState(onDismiss)
val focusState = rememberUpdatedState(initialFocusId)
val scrollStateHolder = rememberUpdatedState(scrollState)
val anchorState = rememberUpdatedState(anchor)
val entry = remember(key) {
PadModals.Entry(
key, contentState, alignmentState, scrimState, dismissState, focusState,
scrollStateHolder,
scrollStateHolder, anchorState,
)
}
DisposableEffect(entry) {
@@ -202,18 +217,43 @@ fun PadModalHost() {
)
for (entry in entries) {
key(entry.key) {
Box(Modifier.fillMaxSize(), contentAlignment = entry.alignment.value) {
val anchor = entry.anchor.value
// Absorb taps on the panel itself, or the scrim's dismiss fires through it and
// the modal closes as you press its own buttons.
val absorbTaps = @Composable { inner: @Composable () -> Unit ->
Box(
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
// Absorb taps on the panel itself, or the scrim's dismiss fires
// through it and the modal closes as you press its own buttons.
onClick = {},
),
) {
CompositionLocalProvider(LocalNavLayer provides entry.key) {
entry.content.value()
CompositionLocalProvider(LocalNavLayer provides entry.key) { inner() }
}
}
if (anchor == null) {
Box(Modifier.fillMaxSize(), contentAlignment = entry.alignment.value) {
absorbTaps { entry.content.value() }
}
} else {
// Anchored to its trigger — the ⋮ menus, which have to keep reading as
// "this button's menu" rather than a prompt about the whole screen.
BoxWithConstraints(Modifier.fillMaxSize()) {
var size by remember { mutableStateOf(IntSize.Zero) }
// Clamp so a panel opened from a trigger near the right or bottom edge
// stays fully on screen instead of running off it. Before it has been
// measured the clamp is a no-op, which is harmless: the anchor is a
// point on screen by construction.
val x = anchor.x.roundToInt()
.coerceIn(0, (constraints.maxWidth - size.width).coerceAtLeast(0))
val y = anchor.y.roundToInt()
.coerceIn(0, (constraints.maxHeight - size.height).coerceAtLeast(0))
Box(
Modifier
.offset { IntOffset(x, y) }
.onSizeChanged { size = it },
) {
absorbTaps { entry.content.value() }
}
}
}
@@ -1,5 +1,7 @@
package com.armsx2.ui.home
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.foundation.layout.heightIn
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -50,7 +52,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -397,7 +398,19 @@ fun HomeScreen(
selected = tb && tbi == 2,
framed = false,
)
Box {
var overflowAnchor by remember {
mutableStateOf(androidx.compose.ui.geometry.Offset.Zero)
}
Box(
Modifier.onGloballyPositioned {
// Bottom-left of the button: the panel hangs below it, the way
// the dropdown it replaces did.
val p = it.positionInRoot()
overflowAnchor = androidx.compose.ui.geometry.Offset(
p.x, p.y + it.size.height,
)
},
) {
RoundAction(
"",
str("games.toolbar.more"),
@@ -425,6 +438,7 @@ fun HomeScreen(
onChooseBackground = { backgroundPicker.launch(arrayOf("image/*")) },
onClearBackground = LibraryBackground::clear,
onExitApp = { showExitConfirm = true },
anchor = overflowAnchor,
)
if (showExitConfirm) {
com.armsx2.ui.common.ConfirmOverlay(
@@ -832,22 +846,34 @@ private fun LibraryOverflowMenu(
onChooseBackground: () -> Unit,
onClearBackground: () -> Unit,
onExitApp: () -> Unit,
anchor: androidx.compose.ui.geometry.Offset,
) {
fun closeThen(action: () -> Unit) {
onDismiss()
action()
}
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
if (!expanded) return
// Anchored under its own ⋮ button, so it still reads as that button's menu rather than a
// prompt about the whole screen. Was a DropdownMenu, which is its own focused Android
// window and therefore had no controller route to any of these rows.
com.armsx2.ui.common.PadModal(
key = "library-overflow",
onDismiss = onDismiss,
anchor = anchor,
// A menu belonging to one button should not black out the library behind it.
scrimAlpha = 0.32f,
) {
Surface(
modifier = Modifier.widthIn(min = 320.dp, max = 380.dp),
shape = RoundedCornerShape(22.dp),
containerColor = MaterialTheme.colorScheme.surface,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 14.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)),
) {
) {
// Plain Column, never Lazy — the registry only sees composed rows.
Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState())) {
Text(
text = str("games.section.library"),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
@@ -926,6 +952,8 @@ private fun LibraryOverflowMenu(
) {
closeThen(onExitApp)
}
}
}
}
}
@@ -991,7 +1019,15 @@ private fun LibraryOverflowItem(
trailing != null -> Text(trailing, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold)
}
},
modifier = Modifier.padding(horizontal = 6.dp),
modifier = Modifier
.padding(horizontal = 6.dp)
// Labels are unique within this menu, so they make stable ids without threading an
// extra argument through all twelve call sites.
.controllerFocusable(
controllerId = "library-overflow:$label",
shape = RoundedCornerShape(12.dp),
onConfirm = onClick,
),
)
}