Instantly share code, notes, and snippets.
Last active
January 22, 2024 14:39
-
Star
(16)
16
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save MachFour/369ebb56a66e2f583ebfb988dda2decf to your computer and use it in GitHub Desktop.
Jetpack Compose Material3 ActionMenu
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.annotation.StringRes | |
import androidx.compose.material.icons.Icons | |
import androidx.compose.material.icons.filled.Call | |
import androidx.compose.material.icons.filled.Delete | |
import androidx.compose.material.icons.filled.Email | |
import androidx.compose.material.icons.filled.Menu | |
import androidx.compose.material.icons.filled.MoreVert | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.DropdownMenu | |
import androidx.compose.material3.DropdownMenuItem | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.IconButton | |
import androidx.compose.material3.LocalContentColor | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.material3.TopAppBar | |
import androidx.compose.material3.TopAppBarDefaults | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.key | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.res.stringResource | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.tooling.preview.PreviewLightDark | |
// Essentially a wrapper around a lambda function to give it a name and icon | |
// akin to Android menu XML entries. | |
// As an item on the action bar, the action will be displayed with an IconButton | |
// with the given icon, if not null. Otherwise, the string from the name resource is used. | |
// In overflow menu, item will always be displayed as text. | |
data class ActionItem( | |
@StringRes | |
val nameRes: Int, | |
val icon: ImageVector? = null, | |
val overflowMode: OverflowMode = OverflowMode.IF_NECESSARY, | |
val doAction: () -> Unit, | |
) { | |
// allow 'calling' the action like a function | |
operator fun invoke() = doAction() | |
} | |
// Whether action items are allowed to overflow into a dropdown menu - or NOT SHOWN to hide | |
enum class OverflowMode { | |
NEVER_OVERFLOW, IF_NECESSARY, ALWAYS_OVERFLOW, NOT_SHOWN | |
} | |
@OptIn(ExperimentalMaterial3Api::class) | |
@PreviewLightDark | |
@Composable | |
fun PreviewActionMenu() { | |
val items = listOf( | |
ActionItem(R.string.call, Icons.Default.Call, OverflowMode.NEVER_OVERFLOW) {}, | |
ActionItem(R.string.send, /* Icons.Default.Send */ null, OverflowMode.IF_NECESSARY) {}, | |
ActionItem(R.string.email, Icons.Default.Email, OverflowMode.IF_NECESSARY) {}, | |
ActionItem(R.string.delete, Icons.Default.Delete, OverflowMode.IF_NECESSARY) {}, | |
) | |
TopAppBar( | |
title = { Text("App bar") }, | |
navigationIcon = { | |
IconButton(onClick = {}) { | |
Icon(Icons.Default.Menu, "Menu") | |
} | |
}, | |
colors = TopAppBarDefaults.topAppBarColors( | |
containerColor = MaterialTheme.colorScheme.surface, | |
navigationIconContentColor = MaterialTheme.colorScheme.onSurface, | |
titleContentColor = MaterialTheme.colorScheme.onSurface, | |
actionIconContentColor = MaterialTheme.colorScheme.onSurface, | |
), | |
actions = { | |
ActionMenu(items, numIcons = 3) | |
} | |
) | |
} | |
// Note: should be used in a RowScope | |
@Composable | |
fun ActionMenu( | |
items: List<ActionItem>, | |
numIcons: Int = 3, // includes overflow menu icon; may be overridden by NEVER_OVERFLOW | |
menuVisible: MutableState<Boolean> = remember { mutableStateOf(false) } | |
) { | |
if (items.isEmpty()) { | |
return | |
} | |
// decide how many action items to show as icons | |
val (appbarActions, overflowActions) = remember(items, numIcons) { | |
separateIntoIconAndOverflow(items, numIcons) | |
} | |
for (i in appbarActions.indices) { | |
val item = appbarActions[i] | |
key(item.hashCode()) { | |
val name = stringResource(item.nameRes) | |
if (item.icon != null) { | |
IconButton(onClick = item.doAction) { | |
Icon(item.icon, name) | |
} | |
} else { | |
TextButton( | |
onClick = item.doAction, | |
colors = ButtonDefaults.textButtonColors(contentColor = LocalContentColor.current) | |
) { | |
Text(text = name) | |
} | |
} | |
} | |
} | |
if (overflowActions.isNotEmpty()) { | |
IconButton(onClick = { menuVisible.value = true }) { | |
Icon(Icons.Default.MoreVert, "More actions") | |
} | |
DropdownMenu( | |
expanded = menuVisible.value, | |
onDismissRequest = { menuVisible.value = false }, | |
) { | |
for (i in overflowActions.indices) { | |
val item = overflowActions[i] | |
key(item.hashCode()) { | |
DropdownMenuItem( | |
text = { Text(s(item.nameRes)) }, | |
onClick = { | |
menuVisible.value = false | |
item.doAction() | |
}, | |
leadingIcon = item.icon?.let { | |
{ Icon(it, null) } | |
}, | |
) | |
} | |
} | |
} | |
} | |
} | |
private fun separateIntoIconAndOverflow( | |
items: List<ActionItem>, | |
numIcons: Int | |
): Pair<List<ActionItem>, List<ActionItem>> { | |
var (iconCount, overflowCount, preferIconCount) = Triple(0, 0, 0) | |
for (i in items.indices) { | |
val item = items[i] | |
when (item.overflowMode) { | |
OverflowMode.NEVER_OVERFLOW -> iconCount++ | |
OverflowMode.IF_NECESSARY -> preferIconCount++ | |
OverflowMode.ALWAYS_OVERFLOW -> overflowCount++ | |
OverflowMode.NOT_SHOWN -> {} | |
} | |
} | |
val needsOverflow = iconCount + preferIconCount > numIcons || overflowCount > 0 | |
val actionIconSpace = numIcons - (if (needsOverflow) 1 else 0) | |
val iconActions = ArrayList<ActionItem>() | |
val overflowActions = ArrayList<ActionItem>() | |
var iconsAvailableBeforeOverflow = actionIconSpace - iconCount | |
for (i in items.indices) { | |
val item = items[i] | |
when (item.overflowMode) { | |
OverflowMode.NEVER_OVERFLOW -> { | |
iconActions.add(item) | |
} | |
OverflowMode.ALWAYS_OVERFLOW -> { | |
overflowActions.add(item) | |
} | |
OverflowMode.IF_NECESSARY -> { | |
if (iconsAvailableBeforeOverflow > 0) { | |
iconActions.add(item) | |
iconsAvailableBeforeOverflow-- | |
} else { | |
overflowActions.add(item) | |
} | |
} | |
OverflowMode.NOT_SHOWN -> { | |
// skip | |
} | |
} | |
} | |
return Pair(iconActions, overflowActions) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi @romsahel, thanks for the suggestions!
Box
vs not adding it. It looks fine on my app, where the overflow icon is positioned next to the right (end) edge of the screen. Maybe it's been fixed in the Material3 library since you posted this? I'm currently on version 1.2.0-beta02.