mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
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:
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user