Привет, Хабр! Я Витя Строеску, последние пять лет в свободное от отдыха время занимаюсь разработкой под Android, три из которых — в команде мобильного оператора Т-Мобайл.

Поделюсь с вами опытом попытки конфигурации анимаций для айтемов у Compose LazyColumn. Мы переписывали наш главный экран с XML+View на Jetpack Compose, который состоит из списка с различного рода сложности айтемами.

Все шло гладко, пока не пришлось добавить айтем, который должен был увеличиваться по высоте. Мы заметили, что нижние карточки смещались не в такт.
Небольшой, но заметный рассинхрон, который сразу бросается в глаза.

Решение казалось очевидным: настроить animateItem или написать свою реализацию. Мы перепробовали несколько вариантов через стандартный API, проваливались в исходники compose.foundation, писали кастомный модификатор. На каждом шаге казалось, что решение рядом, но каждый раз упирались в новый тупик.

В статье разберем, почему возникает рассинхрон, как устроен механизм анимаций внутри LazyColumn, почему кастомный animateItem обречен с самого начала и к какому решению мы пришли в итоге.

Задача: список с расширяющимися элементами

Шел февраль 2026, мы использовали Jetpack Compose уже третий год: compose.runtime версии 1.10.2 и Compose Compiler: 2.2.21, Compose находится в stable уже около 4,5 лет.

Весь проект, за исключением главного и самого сложного экрана, переписан на compose. Экран — список из айтемов разного типа или разного contentType. Мы постепенно переписывали все айтемы на compose. Все шло довольно гладко — за три года использования руку мы уже неплохо набили.

Для понимания общей картины привожу весь код демоэкрана:

@Composable 
fun DemoAnimationScreen( 
    state: DemoAnimationState, 
) { 
    val onEvent = LocalCallbacks.getOn<DemoAnimationEvent>() 
    Scaffold( 
        topBar = { 
            TopAppBar( 
                backgroundColor = color.background.base, 
                contentColor = color.text.action, 
            ) { 
                Icon( 
                    modifier = Modifier 
                        .padding(start = 8.dp) 
                        .clickable { 
                            onEvent(DemoAnimationEvent.Close) 
                        }, 
                    painter = painterResource(TokenDrawable.tui_ic_vec_service_back_24dp), 
                    contentDescription = null, 
                ) 
            } 
        }, 
        floatingActionButton = { 
            Button( 
                onClick = { 
                    onEvent(DemoAnimationEvent.AddItem) 
                }, 
                content = { 
                    Icon( 
                        imageVector = Icons.Default.Add, 
                        contentDescription = "Add Item", 
                    ) 
                }, 
            ) 
        }, 
        content = { paddingValues -> 
            LazyColumn( 
                modifier = Modifier 
                    .padding(paddingValues) 
                    .fillMaxSize(), 
                verticalArrangement = Arrangement.spacedBy(16.dp), 
                contentPadding = PaddingValues(16.dp), 
            ) { 
                items( 
                    items = state.items, 
                    key = { item -> item.id }, 
                ) { item -> 
                    DemoAnimationCardItem( 
                        state = item, 
                        modifier = Modifier.animateItem(), 
                    ) 
                } 
            }   
        }, 
    ) 
}

Код DemoAnimationCardItem:

@Composable 
fun DemoAnimationCardItem( 
    state: DemoAnimationCard, 
    modifier: Modifier = Modifier, 
) { 
    var menuExpanded by remember { mutableStateOf(false) } 
    val onEvent = LocalCallbacks.getOn<DemoAnimationEvent>() 
    Box(modifier = modifier) { 
        Card( 
            shape = RoundedCornerShape(16.dp), 
            elevation = 4.dp, 
            modifier = Modifier.combinedClickable( 
                onClick = {}, 
                onLongClick = { menuExpanded = true }, 
                indication = null, 
                interactionSource = remember { MutableInteractionSource() }, 
            ), 
            content = { 
                val description = state.description 
                val banner = state.banner 
                Column( 
                    verticalArrangement = Arrangement.spacedBy(8.dp), 
                    modifier = Modifier 
                        .fillMaxWidth() 
                        .padding( 
                            start = 16.dp, 
                            end = 16.dp, 
                        ) 
                        .animateContentSize(), 
                ) { 
                    Text( 
                        modifier = Modifier.padding(top = 8.dp), 
                        text = state.title, 
                        style = font.heading.small.asTextStyle(), 
                    ) 
                    if (description != null) { 
                        Text( 
                            text = description, 
                            style = font.body.medium.asTextStyle(), 
                            color = color.text.secondary, 
                        ) 
                    } 
                    if (banner != null) { 
                        Box( 
                            modifier = Modifier 
                                .fillMaxWidth() 
                                .background( 
                                    color = color.status.positive, 
                                    shape = RoundedCornerShape(16.dp), 
                                ), 
                        ) { 
                            Text( 
                                text = banner.text, 
                                modifier = Modifier.padding(8.dp), 
                                color = color.text.primaryOnDark, 
                            ) 
                        } 
                    } 
                    Spacer(modifier = Modifier.height(8.dp)) 
                } 
            }, 
        ) 
   
        DemoAnimationItemActionsMenu( 
            expanded = menuExpanded, 
            onDismiss = { menuExpanded = false }, 
            onChangeClick = { onEvent(DemoAnimationEvent.ItemClick(state.id)) }, 
            onMoveUpClick = { onEvent(DemoAnimationEvent.MoveItemUp(state.id)) }, 
            onMoveDownClick = { onEvent(DemoAnimationEvent.MoveItemDown(state.id)) }, 
            onRemoveClick = { onEvent(DemoAnimationEvent.DeleteItem(state.id)) }, 
            offset = DpOffset(0.dp, 16.dp), 
        ) 
    } 
}

Проблема: рассинхронизация анимаций

У каждого айтема есть DemoAnimationItemActionsMenu, которая нужна для отображения floating-менюшки для удаления, перемещения или модификации айтема, чтобы тестить разные анимации. На нашу целевую тему DemoAnimationItemActionsMenu никак не влияет, поэтому пусть он вас не смущает.

На нашем экране в списке лежат айтемы, на айтем вешаем modifier.animateItem. Сам айтем имеет свойство ресайзиться в зависимости от контента. Посмотрим, как это выглядит.

Видим, что нижняя карточка не успевает смещаться по Y с той же скоростью, с которой расширяется в��рхняя. Поэтому верхняя карточка во время расширения перекрывает нижнюю, а во время схлопывания нижняя начинает смещаться слишком поздно, что создает странный gap. Это некрасиво, такой рассинхрон нас не устраивает, еще больше он не устраивает наших дизайнеров.

Попытки решения: стандартный API

Попытка 1. Заменяем spring на linear tween. Напрашивается элементарное решение: провалимся в animateItem и смотрим, какие дефолтные значения у аргументов:

fun Modifier.animateItem(
    fadeInSpec: FiniteAnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
    placementSpec: FiniteAnimationSpec<IntOffset>? = spring(
        stiffness = Spring.StiffnessMediumLow,
        visibilityThreshold = IntOffset.VisibilityThreshold
    ),
    fadeOutSpec: FiniteAnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow)
): Modifier = this then AnimateItemElement(
    fadeInSpec = fadeInSpec,
    placementSpec = placementSpec,
    fadeOutSpec = fadeOutSpec
)

Ну все понятно, в дефолтных значениях spring анимации, поэтому у нас все так и скачет. Заменим их на линейный tween:

LazyColumn( 
    modifier = Modifier 
        .padding(paddingValues) 
        .fillMaxSize(), 
    verticalArrangement = Arrangement.spacedBy(16.dp), 
    contentPadding = PaddingValues(16.dp), 
) { 
    items( 
        items = state.items, 
        key = { item -> item.id }, 
    ) { item -> 
  val fadeSpec = tween<Float>(durationMillis = 300, easing = LinearEasing) 
        val placementSpec = 
            tween<IntOffset>(durationMillis = 300, easing = LinearEasing) 
        val animateModifier = Modifier.animateItem( 
            fadeInSpec = fadeSpec, 
            fadeOutSpec = fadeSpec, 
            placementSpec = placementSpec, 
        ) 
        DemoAnimationCardItem( 
            state = item, 
            modifier = animateModifier, 
        ) 
    } 
}

Важно не забыть засетить аналогичную анимацию для трансформации айтема, иначе ничего не получится, так как там у нас тоже spring:

public fun Modifier.animateContentSize(
    animationSpec: FiniteAnimationSpec<IntSize> =
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntSize.VisibilityThreshold,
        ),
    finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null,
): Modifier =
    this.clipToBounds() then
        SizeAnimationModifierElement(animationSpec, Alignment.TopStart, finishedListener)

Заменим spring на линейный tween:

modifier = Modifier 
    .fillMaxWidth() 
    .padding( 
        start = 16.dp, 
        end = 16.dp, 
    ) 
    .animateContentSize( 
        animationSpec = tween( 
            durationMillis = 300, 
            easing = LinearEasing, 
        ), 
    )

Посмотрим, что получилось.

Смогли увидеть разницу? Я вот с трудом. Транзишн стал линейным, но ожидаемой синхронизации мы не добились.

Попытка 2. Ускоряем placementSpec. Можно попробовать сделать placementSpec у modifier.animateItem быстрее. Может, так удастся добиться того, чтобы нижний айтем смещался без запоздания:

//...
val placementSpec = tween<IntOffset>(durationMillis = 100, easing = LinearEasing)
//...

Поменяв значение с 300 ms на 100 ms, я не увидел разницы, как и поменяв на 10 и на 1000. Можно сделать вывод, что длительность placementSpec никак не влияет на скорость смещения айтемов вниз. Resize айтема влияет только на перемещение элементов на другие позиции.

Попытка 3. Отключаем placementSpec. Может, дело в том, что animateContentSize слишком простой и предоставляет очень ограниченный API для конфигурирования поведения? Решили попробовать что-то более низкоуровневое.

Мы сделали несколько безуспешных попыток добиться иного поведения resize с использованием других анимаций. По мере того, как я перепробовал 1000 и 1 вариант, наткнулся на решение placementSpec установить в null:

val fadeSpec = tween<Float>(durationMillis = 300, easing = LinearEasing) 
val animateModifier = Modifier.animateItem( 
    fadeInSpec = fadeSpec, 
    fadeOutSpec = fadeSpec, 
    placementSpec = null, 
) 
DemoAnimationCardItem( 
    state = item, 
    modifier = animateModifier, 
)

В итоге получилась именно та синхронизация, которая нам нужна.

Все завелось, и я пошел за кофе! Кстати, такое же поведение актуально и для обычного Column, но он нам не подходит, так как элементов может быть много. Вернувшись, я осознал банальную и очевидную вещь: решение вовсе отключает анимацию перемещения айтемов (reordering/removing) и дизайнеры такое мне не простят. 

Вывод: решить проблему синхронизации смещения нижних айтемов при resize верхнего стандартными инструментами невозможно. Надо делать свой модификатор.

Погружение в исходники: как работает animateItem

Я решил провалиться в существующий Modifier.animateItem и скопипастить то, что там уже реализовано. А затем пробовать переопределять поведение в зависимости от того, что мне нужно. 

Копировать пришлось не так уж много. Я запустил код и увидел, что никаких анимаций больше нет вовсе. Разберемся по шагам, как работает оригинальный Modifier.animateItem.

Шаг 1. Провалимся в исходники. Открываю LazyItemScopeImpl.kt и вижу реализацию animateItem:

fun Modifier.animateItem(
    fadeInSpec: FiniteAnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow),
    placementSpec: FiniteAnimationSpec<IntOffset>? = spring(
        stiffness = Spring.StiffnessMediumLow,
        visibilityThreshold = IntOffset.VisibilityThreshold
    ),
    fadeOutSpec: FiniteAnimationSpec<Float>? = spring(stiffness = Spring.StiffnessMediumLow)
): Modifier = this then AnimateItemElement(
    fadeInSpec = fadeInSpec,
    placementSpec = placementSpec,
    fadeOutSpec = fadeOutSpec
)

В коде ничего сложного. Функция просто создает AnimateItemElement и передает в него спеки анимаций. Идем смотреть, что внутри AnimateItemElement:

private data class AnimateItemElement(
    val fadeInSpec: FiniteAnimationSpec<Float>?,
    val placementSpec: FiniteAnimationSpec<IntOffset>?,
    val fadeOutSpec: FiniteAnimationSpec<Float>?
) : ModifierNodeElement<LazyLayoutAnimationSpecsNode>() {
    override fun create() = LazyLayoutAnimationSpecsNode(
        fadeInSpec,
        placementSpec,
        fadeOutSpec
    )
    // ...
}

AnimateItemElement создает какой-то LazyLayoutAnimationSpecsNode. Провалимся и в него:

internal class LazyLayoutAnimationSpecsNode(
    var fadeInSpec: FiniteAnimationSpec<Float>?,
    var placementSpec: FiniteAnimationSpec<IntOffset>?,
    var fadeOutSpec: FiniteAnimationSpec<Float>?,
) : Modifier.Node(), ParentDataModifierNode {
    override fun Density.modifyParentData(parentData: Any?): Any {
        return this@LazyLayoutAnimationSpecsNode
    }
}

Видим ParentDataModifierNode, который в modifyParentData возвращает... самого себя? То есть этот модификатор просто помечает элемент, прикрепляя к нему информацию об анимациях через parentData. Сам модификатор ничего не анимирует.

Шаг 2. Найдем того, кто анимирует. Если модификатор только помечает элемент спеками анимаций, значит, кто-то другой должен эти спеки читать и применять. И этот кто-то должен быть внутри самого LazyColumn.

Копаю дальше и нахожу LazyLayoutItemAnimator.kt — внутренний механизм LazyColumn, который отвечает за все анимации. Смотрю, как он использует наши спеки.

internal class LazyLayoutItemAnimator {
    fun initializeAnimation(/* ... */) {
        // ...
        val specs = getParentData(index).specs
        if (specs != null) {
            // используем fadeInSpec, placementSpec, fadeOutSpec
        }
    }
}

Натыкаюсь на интересную строчку:

private val Any?.specs get() = this as? LazyLayoutAnimationSpecsNode

Аниматор пытается скастить parentData к типу LazyLayoutAnimationSpecsNode. И если каст успешный — использует спеки. А если нет — игнорирует.

Шаг 3. Разобраться, почему мой скопированный код не работает. Когда я скопировал код LazyLayoutAnimationSpecsNode себе в проект, у меня получился класс:

// Мой класс
package ru.my_application.demo_screen
 
internal class LazyLayoutAnimationSpecsNode(/* ... */) { /* ... */ }

А оригинальный находится в другом пакете:

// Оригинальный класс
package androidx.compose.foundation.lazy.layout
 
internal class LazyLayoutAnimationSpecsNode(/* ... */) { /* ... */ }

И когда LazyLayoutItemAnimator делает каст:

this as? androidx.compose.foundation.lazy.layout.LazyLayoutAnimationSpecsNode

Этот каст возвращает null, потому что мой класс — другой тип, даже если код внутри идентичный.

Оригинальный LazyLayoutAnimationSpecsNode помечен как internal, то есть он недоступен за пределами модуля compose.foundation. Я не могу ни унаследоваться от него, ни создать его экземпляр, ни даже сослаться на этот тип.

Шаг 4. Найти обходные пути. Как можно как-то обойти эту проблему? Например:

  • Использовать рефлексию? В это лезть я не захотел, хотя, может, и стоит разобраться. Но на текущем этапе я решил скипнуть этот пункт, потому что в��глядит как что-то не самое простое.

  • Создать свой LazyColumn? Это сработало бы, но означало форкнуть всю реализацию (а это 5000+ строк кода), что совершенно нецелесообразно.

Получается, что архитектура LazyColumn не предусматривает возможности кастомизации анимаций извне. Это не баг, это сознательное архитектурное решение. Команда Jetpack Compose просто не сделала этот API расширяемым (надеюсь, что только пока).

Значит, создать свой animateItem с другим поведением невозможно. Стандартный API не дает такой гибкости, а обойти это технически не получается без форка всего LazyColumn.

Альтернатива: кастомный модификатор на уровне Composable

Я решил попробовать не копипастить существующее решение, а написать свое. Публикую примерный черновик CustomModifier.kt:

fun Modifier.customAnimateItem(): Modifier = composed {
    val scope = rememberCoroutineScope()
    val fadeInSpec: FiniteAnimationSpec<Float> = tween(300, easing = LinearEasing)
    val fadeOutSpec: FiniteAnimationSpec<Float> = tween(300, easing = LinearEasing)
    val placementSpec: FiniteAnimationSpec<IntOffset> = tween(300, easing = LinearEasing)
    var isInitialComposition by remember { mutableStateOf(true) }
    val alphaAnimation = remember {
        Animatable(
            initialValue = if (isInitialComposition) 0f else 1f,
        )
    }
    val scaleAnimation = remember {
        Animatable(
            initialValue = if (isInitialComposition) 0.8f else 1f,
        )
    }
 
    LaunchedEffect(Unit) {
        if (isInitialComposition) {
            // Запускаем fade in и scale in параллельно
            launch {
                alphaAnimation.animateTo(
                    targetValue = 1f,
                    animationSpec = fadeInSpec,
                )
            }
            launch {
                scaleAnimation.animateTo(
                    targetValue = 1f,
                    animationSpec = fadeInSpec,
                )
            }
            isInitialComposition = false
        }
    }
 
    // Тут должна быть анимация исчезновения (Fade Out),
    DisposableEffect(Unit) {
        onDispose {
            scope.launch {
                // Запускаем fade out и scale out параллельно
                launch {
                    alphaAnimation.animateTo(
                        targetValue = 0f,
                        animationSpec = fadeOutSpec,
                    )
                }
                launch {
                    scaleAnimation.animateTo(
                        targetValue = 0.8f,
                        animationSpec = fadeOutSpec,
                    )
                }
            }
        }
    }
 
    // Применяем анимации
    this.graphicsLayer {
            alpha = alphaAnimation.value
            scaleX = scaleAnimation.value
            scaleY = scaleAnimation.value
        }
        .animatePlacement(
            scope = scope,
            animationSpec = placementSpec,
        )
}
 
private fun Modifier.animatePlacement(
    scope: CoroutineScope,
    animationSpec: FiniteAnimationSpec<IntOffset>,
): Modifier = composed {
    // Хранение предыдущей позиции элемента (в координатах родителя)
    var previousPosition by remember { mutableStateOf<IntOffset?>(null) }
 
    // Анимация для смещения
    val offsetAnimation = remember {
        Animatable(
            initialValue = IntOffset.Zero,
            typeConverter = IntOffset.VectorConverter,
        )
    }
 
    this.layout { measurable, constraints ->
        // Измеряем элемент
        val placeable = measurable.measure(constraints)
 
        layout(placeable.width, placeable.height) {
            // Получаем текущую позицию элемента в координатах родителя
            val currentPosition = coordinates?.positionInParent()?.round() ?: IntOffset.Zero
 
            // Детекция изменения позиции
            val previous = previousPosition
            if (previous != null && previous != currentPosition && currentPosition != IntOffset.Zero) {
                // Вычисляем дельту перемещения (от старой позиции к новой)
                val delta = previous - currentPosition
 
                // Запускаем анимацию смещения
                scope.launch {
                    // Прерываем текущую анимацию если она выполняется
                    offsetAnimation.stop()
 
                    // Устанавливаем начальное смещение (визуально элемент остается в старой позиции)
                    offsetAnimation.snapTo(delta)
 
                    // Анимируем к нулевому смещению (элемент плавно движется к новой позиции)
                    offsetAnimation.animateTo(
                        targetValue = IntOffset.Zero,
                        animationSpec = animationSpec,
                    )
                }
            }
 
            // Обновляем сохраненную позицию
            if (currentPosition != IntOffset.Zero) {
                previousPosition = currentPosition
            }
 
            // Размещаем элемент с учетом анимированного смещения
            placeable.place(
                x = offsetAnimation.value.x,
                y = offsetAnimation.value.y,
            )
        }
    }
}

Применим модификатор к айтемам и посмотрим, как это будет выглядеть.

Механика expand/collapse работает почти так, как мне нужно. А что с остальными анимациями списка — посмотрим дальше.

Добавление айтема происходит анимированно, перемещение айтемов между позициями тоже работает корректно, но вот удаление подвело... 

Удаление элемента происходит без анимации, хотя я вроде это предусмотрел и вызываю запуск анимации в onDispose. 

Почему кастомная реализация обречена на провал

Я продолжил копать и понял, что проблема в фундаментальном различии между тем, на каком уровне работают два подхода.

Мой кастомный модификатор:

Стандартный animateItem:

Работает на уровне Composable (DisposableEffect, LaunchedEffect)

Узнает об удалении через DisposableEffect.onDispose

Работает на уровне Layout (measure/layout фазы)

LazyList сам контролирует, когда создавать и удалять композиции

Разберем, что происходит при удалении элемента в моем подходе:

Frame 1: items = [A, B, C]
         UI Tree: [CompA, CompB, CompC]

         ↓ Пользователь удаляет B

Frame 2: items = [A, C]  State изменился
         ↓
         Compose перекомпозирует LazyColumn
         ↓
         LazyColumn НЕ создает composable для B (его нет в items)
         ↓
         Композиция B удаляется из дерева
         ↓
         DisposableEffect.onDispose вызывается
         ↓
         Анимация запускается, НО элемента уже нет на экране

К моменту, когда DisposableEffect.onDispose срабатывает, элемент уже удален из UI. Анимировать нечего.

А теперь посмотрим, как это работает у стандартного animateItem. Я снова провалился в исходники и нашел LazyLayoutItemAnimator — внутренний класс, который отслеживает все элементы списка и управляет их анимациями. Вот упрощенная версия того, что он делает:

internal class LazyLayoutItemAnimator {
    // Отслеживание элементов по ключам
    private val keyToItemInfoMap = mutableMapOf<Any, ItemInfo>()
 
    // Элементы, которые исчезают (fade out)
    private val disappearingItems = mutableMapOf<Any, DisappearingItemInfo>()
 
    fun onMeasured(
        currentItems: List<ItemInfo>,
        // ...
    ) {
        // Определяем, какие элементы исчезли
        val currentKeys = currentItems.map { it.key }.toSet()
        val previousKeys = keyToItemInfoMap.keys
 
        val disappearedKeys = previousKeys - currentKeys
 
        disappearedKeys.forEach { key ->
            val itemInfo = keyToItemInfoMap[key]!!
 
            // КЛЮЧЕВОЙ МОМЕНТ: Если у элемента есть fadeOutSpec,
            // Он не удаляется сразу
            if (itemInfo.fadeOutSpec != null) {
                disappearingItems[key] = DisappearingItemInfo(
                    itemInfo = itemInfo,
                    fadeOutSpec = itemInfo.fadeOutSpec,
                )
                // Элемент остается в дереве
            }
        }
    }
}

LazyLayoutItemAnimator сам решает, когда удалять элемент из композиции. Если у элемента есть fadeOutSpec, аниматор говорит: «Погоди, не удаляй его, дай я сначала анимацию проиграю».

Дальше можно увидеть упрощенную логику того, как LazyColumn создает композиции:

fun measureLazyList(
    itemProvider: LazyListItemProvider,
    itemAnimator: LazyLayoutItemAnimator,
    // ...
) {
    // 1. Получаем список элементов из State
    val items = itemProvider.getItems()  // [A, C] (B удален)
 
    // 2. Получаем исчезающие элементы из аниматора
    val disappearingItems = itemAnimator.getDisappearingItems()  // [B]
 
    // 3. Создаем композиции для:
    //	- Видимых элементов: A, C
    //	- Исчезающих элементов: B (вот почему B остается)
    val allMeasurables = mutableListOf<Measurable>()
 
    items.forEach { item ->
        allMeasurables.add(createMeasurable(item))
    }
 
    disappearingItems.forEach { (key, disappearingInfo) ->
        // Создаем measurable для исчезающего элемента
    allMeasurables.add(createDisappearingMeasurable(disappearingInfo))
    }
 
    // ...
}

Значит, даже после того, как элемент удален из items = [A, C], LazyColumn продолжает создавать для него композицию, потому что он находится в списке disappearingItems. И только когда анимация fade out завершается, аниматор удаляет элемент из disappearingItems и LazyColumn перестает создавать для него композицию.

Теперь все встало на свои места. Чтобы повторить такое поведение, мне нужен доступ к:

  • LazyLayoutItemAnimator, который отслеживает элементы и решает, когда их удалять;

  • LazyListMeasureResult — результату measure-фазы;

  • createMeasurable() — методу для создания Measurable для элемента.

Но все эти API internal в модуле compose.foundation. Из моего кода я не могу:

  • удерживать композицию после удаления из State;

  • создавать Measurable для удаленного элемента;

  • размещать элемент с fade out анимацией в layout фазе.

Я создал issue и очень надеюсь, что это скоро поправят. Если для кого-то проблема тоже актуальна — присоединяйтесь, будем наблюдать вместе.

Не могу НИ ЧЕ ГО
Не могу НИ ЧЕ ГО

Итог

Помимо проблемы с resize, о которой мы говорили на протяжении большей части статьи, есть и другая проблема. 

Modifier.animateItem() поддерживает только fadeIn/fadeOut анимации появления, а нам в нашей дизайн-системе этого мало. Нам нужно добавлять scale, в том числе при перемещении айтемов, а кому-то, наверное, нужны и другие анимации. По этой причине мы наблюдаем еще за этим issue. В нем речь идет о том, что будут добавлять поддержку полного набора transitions аналогично AnimatedVisibility, такие как slide, expand, shrink, scale и другие EnterTransition/ExitTransition. Думаю, кому-то тоже может быть полезным.

В конечном итоге мы решили не отказываться от идеи делать этот экран на Jetpack Compose, просто чуть сменили вектор и использовали для списка RecyclerView, а сами айтемы оставили на Compose в надежде на то, что в скором будущем проблему поправят и мы сможем избавиться от этого костыля. Нам этого очень хотелось бы, так как бенчмарк-тесты показали, что с точки зрения перфоманса такой подход ощутимо хуже сказывается на FPS.

Делитесь своими мыслями и опытом в комментариях!