Skip’s open-source SkipUI library implements the SwiftUI API for Android. To do so, SkipUI leverages Compose, Android’s own modern, declarative UI framework.

While implementing SwiftUI’s Menu, we discovered that Compose doesn’t build in support for nested dropdown menus. Googling revealed that we weren’t the only devs wondering how to present a sub-menu from a Compose menu item, but the answers we found didn’t meet our needs. The code below represents our own simple, general solution to nested dropdown menus in Compose.

Note: SkipUI’s implementation is tied to SwiftUI internals, so this is an untested and simplified port of the actual code.

// Simple menu model. You could expand this for icons, section
// headings, dividers, etc
class MenuModel(title: String, val items: List<MenuItem>): MenuItem(title, {})
open class MenuItem(val title: String, val action: () -> Unit)

// Display a menu model, whose items can include nested menu models
@Composable fun DropdownMenu(menu: MenuModel) {
    val isMenuExpanded = remember { mutableStateOf(false) }
    val nestedMenu = remember { mutableStateOf<MenuModel?>(null) }
    val coroutineScope = rememberCoroutineScope()

    // Action to replace the currently-displayed menu with the
    // given one on item selection. The hardcoded delays are
    // unfortunate but improve the user experience
    val replaceMenu: (MenuModel?) -> Unit = { menu ->
        coroutineScope.launch {
            // Allow selection animation before dismiss
            delay(200)
            isMenuExpanded.value = false
            // Prevent flash of primary menu on nested dismiss
            delay(100)
            nestedMenu.value = null
            if (menu != null) {
                nestedMenu.value = menu
                isMenuExpanded.value = true
            }
        }
    }

    DropdownMenu(expanded = isMenuExpanded.value, onDismissRequest = {
        isMenuExpanded.value = false
        coroutineScope.launch {
            // Prevent flash of primary menu on nested dismiss
            delay(100)
            nestedMenu.value = null
        }
    }) {
        for (item in menu.items) {
            DropdownMenuItem(text = { Text(item.title) }, onClick = {
                item.action()
                replaceMenu(item as? MenuModel)
            })
        }
    }
}

You can see this in action in the Skip Showcase app’s menu playground:

Nested dropdown menus in the Skip Showcase app

We hope that you find this useful! If you have questions or suggestions for improvements, please reach out to us on Mastodon @skiptools@mas.to, via chat #skip:gitter.im, or in our discussion forums.