Skip to content

Instantly share code, notes, and snippets.

@MachFour
Last active January 22, 2024 14:39
Show Gist options
  • Save MachFour/369ebb56a66e2f583ebfb988dda2decf to your computer and use it in GitHub Desktop.
Save MachFour/369ebb56a66e2f583ebfb988dda2decf to your computer and use it in GitHub Desktop.
Jetpack Compose Material3 ActionMenu
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)
}
@MachFour
Copy link
Author

MachFour commented Oct 6, 2021

Hi @volo-droid, thanks for pointing that out. I hadn't updated the gist in a while, but I've just updated it with the version I'm currently using :)

@romsahel
Copy link

romsahel commented Nov 24, 2023

Hi and thank you for the snippet!
I just found this script and wanted to pitch in as I had to make a few changes to make it work.

  1. With material3, line 73 should be:
DropdownMenuItem(onClick = item.onClick, text = { Text(item.name) })
  1. The DropdownMenuItem is not displayed at the correct position. A Box component should be added as a parent of the IconButton and the DropdownMenu (line 63):
Box {
    IconButton(onClick = { menuVisible.value = true }) {
        Icon(Icons.Default.MoreVert, "More actions")
    }
    DropdownMenu(
        expanded = menuVisible.value,
        onDismissRequest = { menuVisible.value = false },
    ) {
        // ...
    }
}
  1. Here is how it can be used:
@Composable
fun DrawActionMenu() {
    val items = listOf(
        ActionItem(
            R.string.refresh_action,
            Icons.Default.Refresh,
            OverflowMode.ALWAYS_OVERFLOW
        ) {
            // trigger refresh
        },
        ActionItem(
            R.string.settings,
            Icons.Default.Settings,
            OverflowMode.ALWAYS_OVERFLOW
        ) {
            // show settings
        },
    )
    ActionMenu(items)
}

@Preview
@Composable
fun PreviewActions() {
    Surface {
        Row(
            modifier = Modifier.padding(all = 8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("This is a title", modifier = Modifier.weight(1f))
            DrawActionMenu()
        }
    }
}

@MachFour
Copy link
Author

Hi @romsahel, thanks for the suggestions!

  1. The Material3 updates are perfect, thanks. I've just updated my app to Compose Material3 and I'll publish my revisions.
  2. I tested this out and couldn't notice a difference adding the 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment