В 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)
        )
      }
    }
  }
}

В эмуляторе должно получиться что-то на вроде:

Комментарии (6)


  1. Blacksavior
    14.07.2025 12:06

    Я понимаю каскад на десктопе, но в смартфонах... Это же будет неудобно. зачем?


  1. Shef1954 Автор
    14.07.2025 12:06

    Мне понадобилось настройки приложения "спрятать", чтобы случайно пользователь не сбил. Но и к десктопу это все тоже применимо.


    1. impalex
      14.07.2025 12:06

      Возможно стоит еще немного подумать над интерфейсом? Потому что каскадное меню на смартфоне - это перебор.

      PS: Эмулятор умеет записывать видео (ну или через adb shell screenrecord тоже вариант).


      1. Shef1954 Автор
        14.07.2025 12:06

        Спасибо за советы! Но у меня стоит задача повторить приложение написанное на Android Xamarin (Фактически на Jave) на Котлине. А на Андроиде есть класс android.view.Menu, который позволяет сделать сложные вложенные меню, что и используется в приложении. Вот что-то подобное мне нужно реализовать на Compose. Я погуглил "Каскадные меню в Compose" и ничего приличного не обнаружил. Поэтому пришлось подумать самому. Важный момент, который удалось найти - использование параметра key у функции remember.

        Запись видео на эмуляторе обязательно попробую.


  1. impalex
    14.07.2025 12:06

    Ну оно так-то достаточно просто делается... Пример, набросано за несколько минут:

    @Composable
    fun NestedMenuTest(name: String, modifier: Modifier = Modifier) {
        var isExpanded by rememberSaveable { mutableStateOf(false) }
        var isNestedExpanded by rememberSaveable { mutableStateOf(false) }
        Box(modifier = modifier) {
            IconButton(onClick = { isExpanded = true }) {
                Icon(Icons.Default.MoreVert, contentDescription = null)
            }
            DropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) {
                DropdownMenuItem(text = { Text(text = "Item 1") }, onClick = { })
                DropdownMenuItem(
                    text = { Text(text = "Item 2") }, onClick = { isNestedExpanded = true },
                    trailingIcon = {
                        Icon(Icons.Default.MoreVert, contentDescription = null)
                        DropdownMenu(expanded = isNestedExpanded, onDismissRequest = { isNestedExpanded = false }) {
                            DropdownMenuItem(text = { Text(text = "Nested Item 1") }, onClick = { })
                            DropdownMenuItem(text = { Text(text = "Nested Item 2") }, onClick = { })
                        }
                    })
                DropdownMenuItem(text = { Text(text = "Item 3") }, onClick = { })
            }
        }
    }
    Результат
    Результат


  1. Shef1954 Автор
    14.07.2025 12:06

    Но Вы не скрываете меню предыдущего уровня. А именно в сокрытии основная проблема.