mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 05:42:58 +02:00
Open menu row menus flush under the bar (#143)
Material3's DropdownMenu clamps every popup to 48dp below the window's top edge (MenuVerticalMargin in its position provider); the margin is applied after the offset parameter and the provider is not replaceable, so the in-window menu bar's menus all opened with a visible gap hanging below the bar. Replace them with a plain focusable Popup in MenuDefaults styling and an own position provider that puts the menu directly under its anchor, flipping above only when there is no room below. The anchor box takes the full bar height so the menu starts at the bar's bottom edge, not at the centred title text. Claude-Session: https://claude.ai/code/session_01BegdsGSNNxKR6VPNPNvfoc Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
a28abe1591
commit
d38b385166
4 files changed
+195
-17
No files matched your search
@@ -6,14 +6,20 @@ import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -21,8 +27,16 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import androidx.compose.ui.window.MenuBar
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.utils.PlatformUtils
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
@@ -163,7 +177,14 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
||||
val hovered by interactions.collectIsHoveredAsState()
|
||||
val open = rowState.openMenu == menu.title
|
||||
|
||||
Box {
|
||||
/*
|
||||
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
|
||||
* rather than at the centred title text a few pixels above it.
|
||||
*/
|
||||
Box(
|
||||
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = menu.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -195,29 +216,90 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
||||
),
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = open, onDismissRequest = rowState::close) {
|
||||
menu.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is MenuEntry.Item -> DropdownMenuItem(
|
||||
text = {
|
||||
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
enabled = entry.enabled,
|
||||
onClick = { rowState.pick(entry.onClick) },
|
||||
)
|
||||
/*
|
||||
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
|
||||
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
|
||||
* click and Escape come with a focusable popup), same MenuDefaults look; only the
|
||||
* expand animation is gone, which a menu bar does not have anyway.
|
||||
*/
|
||||
if (open) {
|
||||
Popup(
|
||||
popupPositionProvider = MenuRowPopupPositionProvider,
|
||||
onDismissRequest = rowState::close,
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
Surface(
|
||||
shape = MenuDefaults.shape,
|
||||
color = MenuDefaults.containerColor,
|
||||
tonalElevation = MenuDefaults.TonalElevation,
|
||||
shadowElevation = MenuDefaults.ShadowElevation,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
|
||||
.width(IntrinsicSize.Max)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
menu.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is MenuEntry.Item -> DropdownMenuItem(
|
||||
text = {
|
||||
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
enabled = entry.enabled,
|
||||
onClick = { rowState.pick(entry.onClick) },
|
||||
)
|
||||
|
||||
MenuEntry.Separator ->
|
||||
HorizontalDivider(
|
||||
thickness = Density.HAIRLINE,
|
||||
color = palette.separatorLine,
|
||||
)
|
||||
MenuEntry.Separator ->
|
||||
HorizontalDivider(
|
||||
thickness = Density.HAIRLINE,
|
||||
color = palette.separatorLine,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
|
||||
*
|
||||
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
|
||||
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
|
||||
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
|
||||
* below the bar. The margin is applied after the `offset` parameter and the provider is
|
||||
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
|
||||
*/
|
||||
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
): IntOffset {
|
||||
val preferredX = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> anchorBounds.left
|
||||
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
|
||||
}
|
||||
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
|
||||
val below = anchorBounds.bottom
|
||||
val above = anchorBounds.top - popupContentSize.height
|
||||
val y = when {
|
||||
below + popupContentSize.height <= windowSize.height -> below
|
||||
above >= 0 -> above
|
||||
else -> maxOf(0, windowSize.height - popupContentSize.height)
|
||||
}
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
|
||||
private const val HOVER_ALPHA = 0.35f
|
||||
|
||||
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
|
||||
|
||||
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
|
||||
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
|
||||
@@ -219,6 +219,29 @@ class MainMenuRowScreenTest {
|
||||
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu starts at the bar's bottom edge, like any menu bar's menu. Material3's
|
||||
* DropdownMenu cannot do this: its position provider refuses any y closer than 48 dp
|
||||
* (MenuVerticalMargin) to the window's top edge, and the bar sits exactly there, so
|
||||
* every menu opened with a visible gap hanging below the bar.
|
||||
*
|
||||
* The popup-semantics node is a scene-filling layout that positions the menu inside
|
||||
* itself, so its own bounds say nothing; the first item is measured instead, which
|
||||
* sits [MENU_POPUP_VERTICAL_PADDING] below the menu's top edge.
|
||||
*/
|
||||
@Test
|
||||
fun theMenuOpensFlushUnderTheBar() = runComposeUiTest {
|
||||
showRow(onlineState(), RecordingActions())
|
||||
|
||||
onNodeWithText("File").performClick()
|
||||
val firstItem = awaitNode(inPopup("Connect to 144/432 MHz"))
|
||||
|
||||
val rowBottom = onNodeWithTag(rowTag).fetchSemanticsNode().boundsInWindow.bottom
|
||||
val itemTop = firstItem.fetchSemanticsNode().boundsInWindow.top
|
||||
val paddingPx = 8f
|
||||
assertEquals(rowBottom + paddingPx, itemTop, 0.5f, "the menu does not start at the bar's bottom edge")
|
||||
}
|
||||
|
||||
/** A second click on an open title closes it again, the way a menu bar behaves. */
|
||||
@Test
|
||||
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The menu row's own popup placement, as pure arithmetic.
|
||||
*
|
||||
* Material3's DropdownMenuPositionProvider refuses any y closer than 48 dp to the window's
|
||||
* top edge, which pushed every menu of the bar down to that margin. This provider must not:
|
||||
* a menu-bar menu starts flush under its anchor, whatever the distance to the window edge.
|
||||
*/
|
||||
class MenuRowPopupPositionProviderTest {
|
||||
|
||||
private val window = IntSize(800, 600)
|
||||
private val menu = IntSize(200, 150)
|
||||
|
||||
private fun positionOf(
|
||||
anchor: IntRect,
|
||||
window: IntSize = this.window,
|
||||
direction: LayoutDirection = LayoutDirection.Ltr,
|
||||
menu: IntSize = this.menu,
|
||||
) = MenuRowPopupPositionProvider.calculatePosition(
|
||||
anchorBounds = anchor,
|
||||
windowSize = window,
|
||||
layoutDirection = direction,
|
||||
popupContentSize = menu,
|
||||
)
|
||||
|
||||
/** The regression itself: an anchor at the very top gets its menu directly below it. */
|
||||
@Test
|
||||
fun opensFlushUnderAnAnchorAtTheTopOfTheWindow() {
|
||||
val position = positionOf(IntRect(0, 0, 40, 30))
|
||||
assertEquals(IntOffset(0, 30), position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staysInsideTheWindowWhenTheAnchorIsNearTheRightEdge() {
|
||||
val position = positionOf(IntRect(760, 0, 800, 30))
|
||||
assertEquals(IntOffset(600, 30), position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opensAboveTheAnchorWhenThereIsNoRoomBelow() {
|
||||
val position = positionOf(IntRect(0, 560, 40, 590))
|
||||
assertEquals(IntOffset(0, 410), position)
|
||||
}
|
||||
|
||||
/** No room on either side: pinned to the window's bottom edge rather than clipped. */
|
||||
@Test
|
||||
fun staysInsideAWindowTooSmallForTheMenu() {
|
||||
val position = positionOf(IntRect(0, 40, 40, 70), window = IntSize(800, 100), menu = IntSize(200, 90))
|
||||
assertEquals(IntOffset(0, 10), position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alignsTheRightEdgesInRightToLeftLayouts() {
|
||||
val position = positionOf(IntRect(600, 0, 640, 30), direction = LayoutDirection.Rtl)
|
||||
assertEquals(IntOffset(440, 30), position)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user