В Jetpack Compose легко реализуется меню первого уровня. Но реализация вложенного (каскадного) меню не очевидна, поскольку в лямбде onClick пункта меню DropdownMenuItem() невозможно вызвать @Composable функцию.
Мне пришлось потратить значительное время на поиски решения. Для того, чтобы помочь ищущим предлагаю разработанный мной вариант.
Тест меню реализован на основе проекта Empty Activity Android Studio. Для тестирования предлагается создать этот пустой проект и добавить в него последовательно кусочки кода из статьи.
Для отображения иконок вложенных меню необходимо импортировать
import androidx.compose.material.icons.automirrored.filled.ArrowRight
для чего следует включить в зависимости файла build.gradle.kts
implementation(libs.androidx.material.icons.extended)
и файла libs.versions.toml
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
Строка меню представлена объектами:
data class Menu(var title: String = "",
var onClick: String = "",
var menu: ListMenu = ListMenu()
)
Меню представляет собой объект-список:
data class ListMenu ( private var menu: MutableList<Menu> = mutableListOf<Menu>(),
var Count: Int = 0 )
{ // Добавить элемент ListMenu
fun add(menu: Menu) {
menu.add(menu)
Count++
}
// Получить элемент Menu по индексу i
fun getItem(i: Int): Menu {return _menu.get(i)}
}
На базе данных объектов реализуем простое трехуровневое меню:
fun testList(): ListMenu {
var mListMenu1: ListMenu = ListMenu()
// Меню первого уровня
mListMenu1.add(Menu("Пункт 1"))
var mListMenu2 = ListMenu() // Меню второго уровня
var sListMenu3 = ListMenu() // Меню третьего уровня
sListMenu3.add(Menu("Пункт 6"))
sListMenu3.add(Menu("Пункт 7"))
mListMenu2.add(Menu("Пункт 3", "", sListMenu3))
mListMenu2.add(Menu("Пункт 4"))
mListMenu1.add(Menu("Пункт 2", "", mListMenu2))
mListMenu1.add(Menu("Пункт 5"))
return mListMenu1
}
Для реализации потребуется простейший стек для сохранения списков меню:
class RStack {
companion object {
private var globlist: MutableList<ListMenu> = mutableListOf<ListMenu>()
fun push(listMenu: ListMenu) {
globlist.add(0,listMenu)
}
fun pop(i:Int = 0): ListMenu {
if (i<Count())
return globlist[i]
else
return ListMenu()
}
fun clear() { globlist.clear() }
// Глубина стека
fun Count(): Int { return _globlist.count() }
}
}
Меню состоит из двух @Compasable функций:
меню первого уровня:
@Composable
fun GlobalMenu( listMenu: ListMenu,) {
var expanded by remember { mutableStateOf(false) }
var showSubMenu by remember { mutableStateOf(false) }
Box( modifier = Modifier.fillMaxWidth().padding(16.dp) ) {
IconButton(onClick = { expanded = !expanded }) {}
DropdownMenu( expanded = expanded,
onDismissRequest = { expanded = false } ) {
RStack.clear()
for (i in 0..listMenu.Count-1) {
DropdownMenuItem(
onClick = { expanded = false
if (listMenu.getItem(i).menu.Count>0) {
RStack.push(listMenu.getItem(i).menu)
showSubMenu = true
}
},
text = { Text(listMenu.getItem(i).title) },
trailingIcon = {
if (listMenu.getItem(i).menu.Count>0)
Icon(Icons.AutoMirrored.Filled.ArrowRight,
contentDescription = "Показать подменю")
}
)
}
}
}
if(showSubMenu) SubMenu(RStack.pop(),expanded)
}
в котором определен контейнер Box для размещения всех меню и меню следующих уровней:
@Composable
fun SubMenu(listMenu: ListMenu, key: Boolean) {
var expanded by remember(key) {mutableStateOf(true)}
var showSubMenu by remember {mutableStateOf(false)}
DropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
RStack.clear()
for (i in 0..listMenu.Count-1) {
DropdownMenuItem(
onClick = {
expanded = !expanded
if (listMenu.getItem(i).menu.Count>0) {
RStack.push(listMenu.getItem(i).menu)
showSubMenu = true
}
},
text = { Text(listMenu.getItem(i).title) },
trailingIcon = {
if (listMenu.getItem(i).menu.Count>0)
Icon(Icons.AutoMirrored.Filled.ArrowRight,
contentDescription = "Показать подменю"
}
)
}
}
if(showSubMenu) SubMenu(RStack.pop(),expanded)
}
Как можно видеть в этом меню используется рекурсивный вызов.
При формировании соответствующего уровня меню производится проверка наличия подменю у пунктов меню и сохранение в стек подменю, если оно имеется.
Для проверки работы меню достаточно в проекте Empty Activiti заменить onCreate на следующий код:
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TestCascadeMenuTheme {
Scaffold(
topBar = { // Верхняя панель
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text("Test menu")
},
actions = {
IconButton(onClick = { }) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Localized description"
)
val listMenu = testList()
GlobalMenu(listMenu)
}
}
)
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
В эмуляторе должно получиться что-то на вроде:
