Jetpack Compose представляет собой декларативный UI-тулкит Android: вы пишете Kotlin-функции с @Composable, которые описывают UI для текущего состояния, а Compose вызывает их заново, когда это состояние меняется.
Декларативно, а не императивно: с View вы вручную меняете виджеты (textView.text = ..., view.visibility = GONE) и сами держите их в синхроне с данными; в Compose вы меняете состояние, а UI подстраивается.
Только Kotlin: нет XML-разметки, findViewById и view binding. Разметка, списки и стили описываются обычными функциями.
Композиция вместо наследования: вы комбинируете маленькие функции, а не наследуетесь от View.
Интероп в обе стороны: AndroidView встраивает View в Compose, ComposeView встраивает Compose в XML, поэтому приложение можно переводить экран за экраном.
Подключение: Gradle-плагин org.jetbrains.kotlin.plugin.compose (компилятор Compose поставляется вместе с Kotlin начиная с 2.0) плюс платформа androidx.compose:compose-bom, которая держит версии всех библиотек Compose совместимыми.
AndroidJunior
Основы Jetpack Compose
Composable-функции, модификаторы, layout, рекомпозиция, Material 3
Закрыто вопросов
0 / 23
- 0Повторить
- 10Часто спрашивают
Вопросы
Composable называют Kotlin-функцию с аннотацией @Composable, которая не возвращает UI, а эмитит его в композицию. Плагин компилятора Compose добавляет ей скрытый параметр Composer, и передать его дальше может только другая composable-функция.
**Возвращает Unit, эмитит UI: и называется в PascalCase, как тип.
Почему только из другой composable**: скрытый параметр $composer и есть правило вызова, та же идея, что у suspend со скрытым Continuation; обычному коду передавать нечего.
Выполняется часто: в любом порядке и может быть отменена, поэтому тело должно быть быстрым и без побочных эффектов; эффекты выносят в LaunchedEffect и его аналоги.
Вход в Compose: из обычного кода через точку входа, владеющую композицией: setContent в Activity или ComposeView.setContent.
Рекомпозицией называют повторный вызов composable-функций, когда прочитанное ими состояние изменилось, чтобы UI соответствовал новому состоянию. Перезапускаются только скоупы, которые читали изменившееся состояние, а дочерние composable-функции с неизменными аргументами пропускаются.
Триггер: запись в State Compose (mutableStateOf, collectAsStateWithLifecycle, mutableStateListOf), который читался во время композиции. Обычный var или поле класса рекомпозицию не вызывают никогда.
Отслеживание: snapshot-система запоминает, какой restart-скоуп какое состояние читал. Запись инвалидирует ровно эти скоупы, и они перезапускаются до следующего кадра.
Пропуск: composable-функция, вызванная с теми же аргументами, что и в прошлый раз, пропускается.
У inline-раскладок нет своего скоупа: Column, Row и Box объявлены как inline-функции, поэтому чтение состояния в их содержимом относится к внешней composable-функции.
Следствия: composable-функция может выполняться много раз, а рекомпозиция может быть отменена на середине, поэтому тело должно быть быстрым и без побочных эффектов.
Чтение состояния внутри лямбды layout или draw (Modifier.offset { }, drawBehind { }) рекомпозицию не вызывает вовсе: перезапускается только эта более поздняя фаза.
Modifier представляет собой упорядоченную неизменяемую цепочку декораций (размер, отступы, фон, обработка нажатий, семантика), применяемую к composable-функции. Каждый модификатор оборачивает всё, что идёт после него, поэтому смена порядка меняет результат.
Читайте снаружи внутрь: padding(16.dp).background(Red) оставляет отступ прозрачным и красит только внутреннюю область; background(Red).padding(16.dp) красит и отступ.
Область нажатия: clickable { }.padding(16.dp) делает отступ кликабельным, и ripple его покрывает; padding(16.dp).clickable { } так не делает.
Ограничения размера идут вниз по цепочке: последующий size() не может переопределить заданный раньше фиксированный размер.
Соглашение: переиспользуемая composable-функция принимает modifier: Modifier = Modifier первым необязательным параметром и применяет его первым на корневом элементе, чтобы декорации вызывающего оказались снаружи.
Модификаторы со скоупом: weight существует только внутри RowScope/ColumnScope, а align живёт внутри BoxScope, поэтому IDE не предлагает их в других местах.
Column складывает элементы вертикально, Row складывает горизонтально, а Box кладёт их друг на друга. Arrangement управляет расстановкой по главной оси, alignment отвечает за поперечную.
Arrangement: verticalArrangement у Column, horizontalArrangement у Row: Arrangement.spacedBy(8.dp), SpaceBetween, Center.
Alignment: horizontalAlignment у Column, verticalAlignment у Row; Modifier.align(...) переопределяет его для одного элемента.
Weight: Modifier.weight(1f) отдаёт элементу место, оставшееся после измерения элементов без веса, например заголовку, который занимает всё, кроме иконки и цены.
Box: contentAlignment задаёт позицию по умолчанию, а Modifier.align(Alignment.TopEnd) ставит один элемент; так делают бейджи и оверлеи.
Промежутки и перенос: Spacer(Modifier.height(8.dp)) добавляет фиксированный промежуток; FlowRow переносит элементы на следующую строку, как в типичной группе чипов.
Вложенность дешёвая: Compose измеряет каждый элемент один раз, поэтому Row внутри Column не несёт той цены, которую имели глубокие иерархии View.
Column компонует и измеряет все дочерние элементы сразу, а LazyColumn обрабатывает только видимые элементы плюс несколько следующих. Column подходит для короткого фиксированного набора, LazyColumn годится для любого длинного или неограниченного списка.
В сравнении с RecyclerView
Нет Adapter, ViewHolder и LayoutManager; элементы описываются DSL: items(list, key = { it.id }) { ... }.
Нет DiffUtil: при изменении списка Compose рекомпозирует только элементы с изменившимися данными, а key играет роль стабильной идентичности элемента.
RecyclerView переиспользует объекты View; LazyColumn переиспользует композицию ушедших за экран элементов, сопоставляя их по contentType (аналог view type).
Позиция прокрутки хранится в rememberLazyListState(); LazyRow, LazyVerticalGrid и LazyVerticalStaggeredGrid заменяют остальные LayoutManager.
Частые ошибки
Без key элементы идентифицируются по позиции, поэтому состояние строк и Modifier.animateItem() ломаются после вставки или перестановки.
item { list.forEach { Row(it) } } превращает весь список в один элемент и убивает ленивость.
LazyColumn внутри Column с verticalScroll падает, потому что получает бесконечную высоту.
Используйте слот: параметр типа @Composable () -> Unit, который компонент вызывает там, где должно быть содержимое вызывающего. Компонент отвечает за раскладку и стили, а вызывающий отвечает за содержимое; так устроены Scaffold, Button и TopAppBar.
Ставьте content: @Composable () -> Unit последним, чтобы вызывающий мог использовать trailing lambda.
Давайте слоту ресивер, когда вызывающему нужен скоуп родителя: content: @Composable RowScope.() -> Unit открывает внутри блока Modifier.weight и align.
Необязательную область делайте nullable-слотом (leading: (@Composable () -> Unit)? = null) и не резервируйте под неё место, когда он null.
Соблюдайте порядок параметров, принятый во фреймворке: обязательные параметры, затем modifier: Modifier = Modifier, затем необязательные, затем content-лямбда.
Слоты заменяют растущий список флагов (showIcon, iconRes, subtitleColor), который компонент иначе собирает по одному баг-репорту за раз.
Тема Material 3 задаётся одним composable, MaterialTheme, который хранит цветовую схему, типографику и формы; каждый Material-компонент под ним читает их через MaterialTheme.colorScheme, .typography и .shapes.
lightColorScheme(...) и darkColorScheme(...) строят схемы. У каждой роли есть парная onX (primary/onPrimary) для содержимого поверх неё, что сохраняет правильный контраст.
dynamicLightColorScheme(context) / dynamicDarkColorScheme(context) берут цвета из обоев на Android 12+; проверяйте версию SDK и держите статический fallback.
isSystemInDarkTheme() выбирает схему; настройка в приложении может её переопределить.
Surface задаёт фон и подходящий цвет содержимого, поэтому Text и Icon внутри автоматически получают правильный цвет.
Свои токены (отступы, фирменные цвета) кладут в собственный CompositionLocal рядом с MaterialTheme, а не впихивают в его цветовые роли.
Scaffold раскладывает экран Material 3 по слотам (topBar, bottomBar, floatingActionButton, snackbarHost) и передаёт оставшуюся область контента как PaddingValues. SnackbarHostState.showSnackbar объявлена как suspend-функция: она возвращается, только когда snackbar скрыт.
Применяйте отступы: content-лямбда получает innerPadding, учитывающий бары и системные insets; если его игнорировать, контент уходит под верхний бар.
FAB в слоте: а не в Box поверх контента, тогда он не перекрывается нижним баром и insets.
Snackbar: val hostState = remember { SnackbarHostState() }, snackbarHost = { SnackbarHost(hostState) }, затем showSnackbar(...) возвращает SnackbarResult.ActionPerformed или Dismissed.
Откуда вызывать: из rememberCoroutineScope().launch { } в обработчике клика, но не из тела composable.
Один Scaffold на экран; вложенные Scaffold удваивают insets и показывают snackbar не там.
Каждое поле представляет собой OutlinedTextField, текст которого хранится в вашем состоянии. KeyboardOptions выбирает клавиатуру и её кнопку действия, а KeyboardActions решает, что эта кнопка делает: переводит фокус на следующее поле или отправляет форму.
Состояние: текст каждого поля лежит в rememberSaveable { mutableStateOf("") }, а onValueChange записывает его сразу и без асинхронных шагов, иначе быстрый ввод теряет символы.
Клавиатура: KeyboardType.Email показывает клавишу @, KeyboardType.Password отключает подсказки, PasswordVisualTransformation() скрывает текст, а singleLine = true не даёт Enter добавить строку.
Кнопка действия: ImeAction.Next на поле email, ImeAction.Done на поле пароля. По умолчанию Next переводит фокус вперёд, а Done прячет клавиатуру; если onDone отправляет форму, вызовите там clearFocus(), чтобы клавиатура всё равно закрылась.
Фокус: focusManager.moveFocus(FocusDirection.Down) переводит его явно; FocusRequester на Modifier.focusRequester() фокусирует нужное поле при открытии экрана.
В новых версиях Material 3 есть TextField(state = rememberTextFieldState()) на основе состояния с единым обработчиком onKeyboardAction и SecureTextField для паролей.
@Preview помечает composable, который Android Studio отрисовывает без запуска приложения: без Activity, без ViewModel, без DI-графа. Поэтому экран отображается в превью, только если всё нужное приходит в него параметрами.
Разделите экран на два
Stateful-обёртка (route), HomeRoute(viewModel: HomeViewModel = hiltViewModel()), собирает состояние и передаёт простые значения и лямбды в stateless HomeScreen(state, onRefresh).
В превью показываете только stateless-часть с тестовыми данными.
Полезные дополнения
@Preview(showBackground = true, fontScale = 1.5f, uiMode = UI_MODE_NIGHT_YES) проверяет конфигурации, которые ломают вёрстку; @PreviewLightDark, @PreviewFontScales и @PreviewScreenSizes генерируют несколько превью сразу.
@PreviewParameter с PreviewParameterProvider рисует по превью на каждое значение, например для состояний загрузки, пустого списка и ошибки.
LocalInspectionMode.current равен true внутри тулинга; по нему рисуют заглушку вместо картинки из сети.
Зависимости: ui-tooling-preview как implementation и ui-tooling как debugImplementation.
Для новой фичи в 2026 году Compose является выбором по умолчанию: меньше кода, UI управляется состоянием, а новые UI-API (Material 3, адаптивные раскладки) Google выпускает сначала для Compose. XML ещё оправдан внутри большого модуля на View, который команда в ближайшее время трогать не будет.
Почему Compose
Нет адаптеров, ViewHolder и связки XML с кодом, поэтому экран со списком занимает в разы меньше кода.
@Preview и live edit сокращают цикл обратной связи; UI-тесты обращаются к дереву семантики, а не к id вьюх.
Новые экраны живут и в старом приложении: через ComposeView или как destination в навигации на Compose.
На что обратить внимание
Производительность требует понимания: небрежное чтение состояния вызывает лишние рекомпозиции; нужно знать, как работают рекомпозиция и пропуск (skipping).
Debug-сборки медленные: debuggable-сборка не проходит через R8 и теряет многие оптимизации рантайма, поэтому производительность оценивают только в release-сборке с Baseline Profile.
Старт и размер: библиотека увеличивает APK, а первый кадр без Baseline Profile рисуется медленнее.
Интероп не бесплатен: две системы раскладки в одном дереве, а часть библиотек, существующих только для View, приходится оборачивать в AndroidView.
Переписывать стабильные XML-экраны ради миграции редко окупается: новый код пишут на Compose, старые экраны переносят, когда их всё равно меняют.
Кадр проходит до трёх фаз по порядку: composition решает, что показать, layout измеряет и размещает каждый узел, drawing его рисует. Compose запоминает, в какой фазе прочитано состояние, поэтому значение, прочитанное только в лямбде layout или draw, перезапускает эту фазу и следующие, но не composition.
Что делает каждая фаза
Composition: выполняет @Composable-функции и обновляет дерево layout-узлов и их модификаторов. Рекомпозиция повторяет только эту фазу и только для скоупов, которые читали изменившееся состояние.
Layout: два шага на каждый узел. Measure: узел измеряет каждый дочерний узел один раз с выбранными им ограничениями, а затем выбирает свой размер. Place: размещает дочерние узлы.
Drawing: каждый узел рисует на канве себя, а затем дочерние узлы.
Почему фазу можно пропустить
Чтение состояния подписывает ту фазу, в которой оно произошло. Modifier.background(color) читает анимированный цвет во время composition, поэтому каждый кадр рекомпозирует скоуп; drawBehind { drawRect(color) } читает его при отрисовке, и на каждом кадре выполняется только перерисовка.
В фазе layout состояние читают блоки измерения и размещения у Layout и Modifier.layout { }, а также лямбда Modifier.offset { }; в фазе drawing его читают drawBehind { }, drawWithContent { }, Canvas { } и блок graphicsLayer { }: это самое дешёвое место, чтобы анимировать прозрачность, поворот или сдвиг. Значение, которое меняется каждый кадр, передают лямбдой вроде () -> Float и читают внутри одного из этих блоков.
A будет 100×100 dp, весь красный; B будет 50×50 с зелёным центром 30×30; C будет 70×70 с синим квадратом 50×50 и отступом 10 dp; D занимает слот шириной 200 dp, а серый блок 80×40 стоит в нём по центру.
Ограничения размера идут по цепочке слева направо, и обычный модификатор размера может только сузить то, что получил:
A: первый size(100.dp) фиксирует ограничения ровно на 100. Второй size(50.dp) обязан в них уложиться, поэтому приводится к 100.
B: size(50.dp) задаёт внешний блок; padding(10.dp) оставляет 30 dp всему, что идёт дальше, поэтому фон закрашивает только внутренние 30×30.
C: padding идёт первым, поэтому блок 50 dp получает поле 10 dp с каждой стороны: всего 70×70, синий только в середине.
D: width(200.dp) фиксирует ширину; requiredWidth(80.dp) игнорирует входящие ограничения и измеряется в 80. Родитель всё равно видит 200, а содержимое центрируется в этом пространстве.
Правило: побеждает первый фиксированный размер; required* нужен только тогда, когда элемент обязан нарушить ограничения родителя.
У items(messages) нет key, поэтому remember-состояние каждой строки привязано к её позиции, а не к сообщению. Когда сообщение вставляется сверху, все сообщения сдвигаются на одну позицию вниз, а expanded = true остаётся на своём месте и раскрывает то сообщение, которое теперь там.
Почему: без key LazyColumn идентифицирует элемент по индексу. Всё в композиции элемента (remember, rememberSaveable, запущенный LaunchedEffect, текст TextField) принадлежит этому индексу, поэтому вставка, удаление или перестановка передаёт состояние другому сообщению.
Исправление: key = { it.id } даёт каждому элементу стабильную идентичность: состояние следует за сообщением, а LazyListState держит скролл на том же сообщении. Ключ должен быть уникальным (дубликат падает) и сохраняемым в Bundle для rememberSaveable: Long, String, Int.
Когда состояние должно пережить строку: с ключом состояние всё равно теряется, когда строка уходит из композиции при скролле. Используйте rememberSaveable или храните expandedIds: Set<Long> во ViewModel и передавайте expanded вниз.
Как поймать: UI-тест на Compose: раскрыть одну строку, вставить элемент сверху и проверить, что раскрыто то же сообщение.
Composable входит в композицию, рекомпозируется ноль или больше раз и покидает её. Этот жизненный цикл отделён от жизненного цикла Activity: уход в фон ничего не удаляет из композиции, поэтому события уровня экрана вроде ON_START нужно отслеживать явно.
Жизненный цикл в композиции
Вход: первый вызов; remember создаёт значения, LaunchedEffect запускает корутину, DisposableEffect выполняет настройку.
Рекомпозиция: функция выполняется снова; запомненные значения сохраняются, эффекты перезапускаются только при смене ключей.
Выход: if перестал её вызывать, destination навигации снят со стека или элемент списка ушёл за экран. Запомненные значения отбрасываются, выполняется onDispose, корутины эффектов отменяются.
Реакция на жизненный цикл Activity
collectAsStateWithLifecycle() перестаёт собирать Flow ниже STARTED, в отличие от collectAsState().
LifecycleStartEffect(key) { ...; onStopOrDispose { } } и LifecycleResumeEffect связывают работу start/stop с жизненным циклом; LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { } обрабатывает одно событие. Они лежат в lifecycle-runtime-compose.
Внутри NavHost LocalLifecycleOwner указывает на back stack entry destination, а не на Activity.
LaunchedEffect продолжает работать, пока приложение в фоне; всё, что должно вставать на паузу (превью камеры, обновления геолокации), должно жить в lifecycle-aware эффекте.
LaunchedEffect привязан к композиции, а не к жизненному циклу Activity. Нажатие Home только останавливает Activity; композиция остаётся жива, поэтому цикл while (true) продолжает вызывать refresh() каждые 10 секунд, пока Activity не уничтожена или процесс не убит.
Почему: эффект отменяется, только когда покидает композицию или меняется его ключ. Compose ставит тактирование кадров на паузу, когда Activity остановлена, но цикл, использующий только delay, кадров не ждёт, поэтому его ничто не останавливает. ОС может со временем заморозить или убить кэшированный процесс, но код не должен на это рассчитывать.
Ложный след: collectAsStateWithLifecycle() прекращает сбор scores ниже STARTED, но на другие эффекты экрана не влияет.
Исправление: repeatOnLifecycle(Lifecycle.State.STARTED) внутри LaunchedEffect отменяет блок на ON_STOP и перезапускает на ON_START (LifecycleStartEffect подходит для не-suspend работы). Внутри NavHost владельцем служит запись бэкстека, поэтому опрос встаёт на паузу и под другим экраном.
Лучше место: слой данных: flow { while (true) { emit(api.scores()); delay(10.seconds) } } с stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ...) останавливается через 5 секунд после отписки UI.
Приложения с таргетом Android 15 (API 35) рисуются edge-to-edge, поэтому контент оказывается под статус-баром и навигационной панелью, пока вы не примените window insets, через innerPadding у Scaffold или inset-модификаторы вроде safeDrawingPadding().
Что изменилось: targetSdk 35 принудительно включает edge-to-edge; targetSdk 36 (Android 16) убирает возможность отказа через windowOptOutEdgeToEdgeEnforcement. Всё равно вызывайте enableEdgeToEdge() в onCreate, чтобы старые версии вели себя так же, а иконки баров получали правильный контраст.
Scaffold: передаёт insets в контент как PaddingValues; применяйте их, иначе контент прячется под барами.
Модификаторы: safeDrawingPadding() для общего случая (он уже включает клавиатуру и вырез); statusBarsPadding(), navigationBarsPadding(), displayCutoutPadding() для одной стороны.
Списки: передавайте insets как contentPadding, а не как отступ самого списка. Тогда строки прокручиваются под барами, а первая и последняя строки всё равно их не перекрывают.
Клавиатура: imePadding() после systemBarsPadding(), когда отступ нужен только под бары, плюс android:windowSoftInputMode="adjustResize".
Без двойных отступов: применённые insets поглощаются, поэтому дочерний элемент, снова запросивший safeDrawing, получит ноль; Modifier.consumeWindowInsets делает это явно.
Выбирайте раскладку по классу размера окна, а не по устройству: читайте currentWindowAdaptiveInfoV2().windowSizeClass и переключайте раскладку на ширинах 600 dp и 840 dp. С targetSdk 36 Android 16 игнорирует фиксацию ориентации и запрет ресайза на дисплеях шириной от 600 dp.
Адаптация раскладки
Окно, а не устройство: в режиме разделённого экрана приложение на планшете может занимать 400 dp, поэтому флаг isTablet ошибается; класс размера следует за текущим окном и обновляется при каждом ресайзе.
Брейкпоинты: compact до 600 dp, medium до 840 dp, expanded выше, проверка через isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND); функция V2 сообщает ещё large (1200 dp) и extra-large (1600 dp).
Состояние при ресайзе: складывание или изменение размера окна является изменением конфигурации и пересоздаёт Activity. Храните состояние во ViewModel или в rememberSaveable и поднимайте его выше ветвления раскладки, чтобы LazyListState сохранял скролл, когда одна панель превращается в две.
Что меняет Android 16
На sw600dp и шире система игнорирует screenOrientation, resizeableActivity="false", minAspectRatio, maxAspectRatio и setRequestedOrientation(), и приложение заполняет окно: раскладка только под портрет растягивается на планшете в альбомной ориентации, а поворот, который раньше был заблокирован, теперь пересоздаёт Activity.
Исключения: игры (android:appCategory="game"), экраны уже sw600dp и пользователи, выбравшие поведение приложения по умолчанию в настройках соотношения сторон. Отказ через свойство PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY временный: с targetSdk 37 он перестаёт работать.
Navigation Compose хранит back stack composable-destination внутри NavHost, которым управляет NavController. Начиная с Navigation 2.8 маршрутами служат @Serializable-классы, поэтому аргументы становятся типизированными свойствами, а не строковыми шаблонами.
rememberNavController() создаёт контроллер; NavHost(navController, startDestination = Home) объявляет граф; composable<Profile> { } регистрирует destination.
navController.navigate(Profile(id = 42)) добавляет экран, popBackStack() снимает его. В destination backStackEntry.toRoute<Profile>() возвращает типизированный маршрут; во ViewModel то же делает savedStateHandle.toRoute<Profile>().
Держите контроллер наверху: передавайте экранам лямбды вроде onOpenProfile, а не NavController, тогда экраны можно превьюить и тестировать.
Старый код использует строковые маршруты вида "profile/{id}" с navArgument; они по-прежнему работают. Navigation 3, где приложение само владеет back stack как обычным списком, служит более новой альтернативой.
AndroidView встраивает View в composable, а ComposeView встраивает composable в иерархию View. Вместе они позволяют переводить приложение по одному экрану или компоненту.
View внутри Compose
В AndroidView(factory = { context -> WebView(context) }, update = { it.loadUrl(url) }) параметр factory выполняется один раз, а update выполняется заново каждый раз, когда меняется прочитанное в нём состояние.
onRelease освобождает ресурсы, когда View покидает композицию, например вызывает WebView.destroy().
View со своим жизненным циклом, например MapView, требует наблюдать LocalLifecycleOwner в DisposableEffect и пробрасывать события в её onStart/onStop.
AndroidViewBinding(MyLayoutBinding::inflate) { } переиспользует целую XML-разметку.
Compose внутри View
Добавьте ComposeView в разметку или создайте его в коде и вызовите setContent { }.
Во Fragment задайте ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, чтобы композиция уничтожалась вместе с view фрагмента, а не при отсоединении view от окна.
Тема и стили текста сами через границу не переходят, а две системы раскладки в одном дереве чего-то стоят, поэтому держите интероп на листьях дерева или на границах экранов.
Рядом с деревом UI Compose строит дерево семантики, которое описывает смысл каждого элемента: текст, роль, состояние, доступные действия. TalkBack и тестовые API Compose читают только это дерево, а не вашу разметку.
Откуда оно берётся: Text добавляет текст, clickable добавляет действие нажатия и роль, а Modifier.semantics { } отвечает за всё остальное.
Слияние: clickable и Modifier.semantics(mergeDescendants = true) сворачивают поддерево в один узел: один элемент для TalkBack и один узел для теста.
Тесты: onNodeWithText и onNodeWithTag ищут в том же дереве; Modifier.testTag добавляет тег.
Область нажатия: Material-компоненты сохраняют область 48 dp; голая Icon с clickable её не сохраняет, поэтому используйте IconButton.
Composable Layout даёт вам дочерние элементы как measurable плюс входящие ограничения; вы измеряете каждый элемент, выбираете свой размер и расставляете элементы. Правило: каждый дочерний элемент можно измерить только один раз за проход layout.
Как это работает
Измеряйте каждый элемент с выбранными вами ограничениями: передайте их без изменений или copy(minWidth = 0, maxWidth = half), чтобы дать элементу меньше места.
Выберите свой размер в пределах входящих ограничений, затем расставьте полученные placeable внутри layout(width, height) { } через placeRelative, который зеркалит раскладку для языков с письмом справа налево.
Повторное измерение элемента бросает исключение. Именно правило одного прохода делает глубокую вложенность в Compose дешёвой, тогда как View-система могла измерять экспоненциально.
Инструменты полегче и потяжелее
Один дочерний элемент: хватит Modifier.layout { measurable, constraints -> } для своего смещения или размера, зависящего от родителя.
Intrinsics: Modifier.height(IntrinsicSize.Min) спрашивает у элементов предпочтительный размер до измерения, например чтобы разделитель был высотой с более высокий из двух текстов. Это стоит дополнительного прохода.
SubcomposeLayout: когда само содержимое зависит от измерения, как в BoxWithConstraints и ленивых списках. Он компонует во время layout и работает медленнее, поэтому это последний вариант.