Добавьте контейнеру Modifier.animateContentSize(): когда его измеренный размер меняется, он плавно анимируется от старого к новому вместо скачка.
Порядок важен: модификаторы перед ним (background, clip) следуют анимированному размеру; ставьте его перед padding или size, которые должны войти в анимируемую область.
Анимируется только размер контейнера: новое содержимое внутри появляется сразу. Если оно тоже должно проявляться, оберните его в AnimatedVisibility.
Спецификация задаётся параметром: animateContentSize(animationSpec = spring(...)), плюс finishedListener, если нужно знать момент окончания.
AndroidMiddle
Анимации и жесты в Compose
animate*AsState, Transition, AnimatedContent, Animatable, pointerInput, drag, nestedScroll, Canvas
Закрыто вопросов
0 / 24
- 0Повторить
Вопросы
Оберните его в AnimatedVisibility(visible = ...): он компонует содержимое, когда visible равно true, проигрывает EnterTransition при появлении и ExitTransition при исчезновении, а по окончании выхода убирает содержимое из композиции.
**Эффекты комбинируются через +**: fadeIn() + slideInVertically() для входа, fadeOut() + shrinkVertically() для выхода; у каждого свой animationSpec.
Дочерние элементы могут отличаться: Modifier.animateEnterExit на потомке внутри содержимого задаёт ему собственные вход и выход.
Первая композиция: чтобы анимировать уже на первом кадре, передайте MutableTransitionState(false).apply { targetState = true } вместо boolean.
Соседние элементы в Column или Row анимируют свою позицию, пока элемент растёт или сжимается.
Замена одного содержимого другим: это другой инструмент: AnimatedContent или более простой Crossfade.
Это не то же самое, что alpha: при alpha 0 узел остаётся размещённым и принимает касания, а AnimatedVisibility его убирает.
Создайте rememberInfiniteTransition() и анимируйте на нём значения через animateFloat или animateColor со спецификацией infiniteRepeatable; анимация стартует, когда попадает в композицию, и останавливается, когда из неё уходит.
**RepeatMode.Reverse проигрывает туда и обратно (пульсация); RepeatMode.Restart прыгает к началу (спиннер, проход шиммера).
Метода stop() нет**: чтобы закончить цикл, перестаньте его компоновать, например покажите загруженный контент вместо плейсхолдера. Для фиксированного числа повторов есть repeatable(iterations = 3, ...).
**Читайте значение в graphicsLayer { } или в лямбде отрисовки: тогда каждый кадр только перерисовывается, без рекомпозиции.
Анимации отключены**: при «Удалить анимации» бесконечный переход останавливается на targetValue, а это может быть неудачный кадр; проверьте ValueAnimator.areAnimatorsEnabled() и покажите статичный плейсхолдер.
Лямбда контента игнорирует свой параметр и читает захваченный count, поэтому уходящая копия тоже рекомпозируется с новым значением. Отображайте состояние, которое получает лямбда: { target -> Text("Total: $target") }.
Две копии одновременно: пока идёт переход, AnimatedContent держит в композиции и уходящий, и появляющийся контент, и каждая копия получает параметром лямбды то состояние, для которого была создана.
**transitionSpec** возвращает ContentTransform: появление нового контента togetherWith исчезновение старого (togetherWith заменил устаревший with). Внутри доступны initialState и targetState, так что можно сдвигать вверх, когда число растёт, и вниз, когда уменьшается.
Для обычного нажатия подходит clickable, а если нужны ещё долгое нажатие или двойной тап, то combinedClickable; к detectTapGestures переходите, только когда тап не является «кликом», например когда важна позиция нажатия на графике.
**clickable**: это не просто слушатель: он рисует ripple через InteractionSource, публикует семантическое действие onClick, которое вызывает TalkBack, делает узел фокусируемым для клавиатуры и D-pad и обрабатывает enabled = false.
**combinedClickable(onClick, onLongClick, onDoubleClick)** сохраняет всё это. Если задан onDoubleClick, onClick срабатывает только после таймаута двойного тапа, потому что нужно дождаться, не будет ли второго нажатия.
**detectTapGestures** даёт сырые колбэки с Offset и больше ничего: ни ripple, ни семантики, ни фокуса. Если пользователь должен уметь это активировать, добавьте Modifier.semantics { onClick(...) } сами.
Делайте clip до clickable, иначе ripple нарисуется прямоугольником поверх скруглённой карточки.
Все три дают DrawScope с drawCircle, drawLine, drawArc, drawPath и прочими: Canvas служит самостоятельным пустым элементом, drawBehind рисует под существующим composable, а drawWithContent позволяет рисовать поверх его детей.
**Canvas(modifier) { }**: это просто Spacer с drawBehind. Собственного размера у него нет, поэтому задайте size или fillMaxWidth, иначе он ничего не нарисует.
**drawWithContent { drawContent(); ... } решает, где в порядке отрисовки окажутся дети; так рисуют бейдж, затемнение или подсветку сверху.
Единицы измеряются в пикселях**: DrawScope является Density, поэтому внутри работает 4.dp.toPx(), а size и center описывают область.
Отрисовка идёт последней фазой: чтение стейта внутри лямбды отрисовки вызывает только перерисовку, без рекомпозиции и лейаута, поэтому анимированный прогресс так рисовать дёшево.
animate*AsState анимирует одно значение к цели; updateTransition ведёт несколько значений от одного состояния, чтобы они оставались синхронными; Animatable даёт императивное значение на корутинах, когда анимацией должен управлять жест или событие.
**animate*AsState** работает по принципу «запустил и забыл»: меняете цель, и анимация перенацеливается на лету. Нет ни snapTo, ни stop, ни передачи скорости, поэтому прогресс-бар, который при повторе должен мгновенно сброситься в 0, вместо этого плавно уезжает назад.
**updateTransition(targetState)**: animateDp, animateColor, animateFloat идут от одних часов: стартуют, перенацеливаются и заканчиваются вместе, а Animation Preview в Android Studio умеет их прокручивать. На нём построены AnimatedVisibility и AnimatedContent.
**Animatable(0f)**: хранится в remember и управляется из LaunchedEffect или корутины жеста: animateTo, snapTo (без анимации), animateDecay (fling), stop, updateBounds. Все вызовы идут через MutatorMutex, поэтому новый отменяет текущий, а не борется с ним.
Простое правило: одно состояние в одно значение: animate*AsState; одно состояние во много значений: updateTransition; жест, последовательность или мгновенный сброс: Animatable.
Со spring по умолчанию все три переносят текущую скорость в новую цель, поэтому частые переключения выглядят непрерывно.
A выполняет их друг за другом (сначала alpha, затем offsetY) и длится 600 мс, потому что animateTo является suspend-функцией, которая возвращается только по окончании анимации. B запускает их одновременно и укладывается в 300 мс, потому что каждый launch стартует свою дочернюю корутину.
Это и есть вся модель цепочек: последовательность задаётся suspend-вызовами подряд, а параллельность задаёт launch. Чтобы продолжить только после завершения обеих параллельных частей, оберните их в coroutineScope { launch { ... }; launch { ... } } и поставьте следующий animateTo после него.
Задержка между элементами: delay(80) между запусками или tween(durationMillis = 300, delayMillis = 80) у второй анимации.
Отмена: если ключ эффекта меняется или composable уходит из композиции, корутина отменяется, animateTo бросает CancellationException, и значение остаётся там, где остановилось. Второй animateTo на том же Animatable отменяет первый.
Декларативные альтернативы: updateTransition ведёт несколько значений параллельно от одного состояния, а keyframes или delayMillis в его спецификациях дают последовательность с задержками без корутин.
Animatable(0f) создаётся в теле composable без remember, поэтому каждая рекомпозиция строит новый экземпляр со значением 0; перезапущенный LaunchedEffect анимирует уже его с нуля, а прежнее значение теряется.
Почему так происходит: тело composable выполняется заново при каждой рекомпозиции, и обычный val каждый раз создаётся заново. Только remember сохраняет объект в slot table между рекомпозициями.
Менее заметный симптом: если функция рекомпонуется по другой причине (например, родитель передал новый modifier), а progress не изменился, эффект не перезапускается: он продолжает двигать старый экземпляр, а drawBehind читает новый, и полоска выглядит пустой.
Как работает исправление: remember { Animatable(0f) } хранит один экземпляр; новый progress перезапускает эффект, тот отменяет текущий animateTo, и следующий animateTo продолжает с текущего значения (а со spring ещё и со скоростью).
Вариант проще: для одного целевого значения animateFloatAsState(progress) сам делает и remember, и эффект.
Как поймать в следующий раз: lint-проверка UnrememberedAnimatable, которая поставляется вместе с animation-core, подсвечивает эту строку; родственная UnrememberedMutableState ловит mutableStateOf без remember.
spring подходит для движения, которое могут прервать или которое следует за жестом: он переносит текущую скорость в новую цель; tween берут, когда дизайнер задал точную длительность и кривую; keyframes нужен для многошагового движения, например с перелётом.
**spring(dampingRatio, stiffness)**: физика без длительности. Это значение по умолчанию для animate*AsState и Animatable. Spring.DampingRatioMediumBouncy добавляет отскок, Spring.StiffnessLow замедляет.
**tween(durationMillis, delayMillis, easing)**: фиксированные время и кривая: FastOutSlowInEasing, LinearEasing или CubicBezierEasing, скопированный из макета. Если прервать его на середине, он стартует заново от текущего значения без скорости, и виден излом; для прозрачности и цвета подходит.
**keyframes { durationMillis = 375; 1.08f at 250 }: значения в заданные моменты времени, у каждого отрезка своё сглаживание.
visibilityThreshold**: spring приближается к цели асимптотически и никогда точно её не достигает, поэтому нужен порог «достаточно близко», на котором он прыгает к цели и завершается. Типизированные API передают разумный порог (Dp.VisibilityThreshold равен 0.1 dp), а голый Animatable(0f) использует 0.01f; если анимировать так пиксели, кадры тратятся на движение, которого никто не видит. Передавайте Animatable(0f, visibilityThreshold = 0.5f).
Добавьте Modifier.animateItem() на корень каждого элемента и задайте стабильные key; тогда новые элементы проявляются, удалённые исчезают, а переставленные плавно едут на новое место.
**animateItem(fadeInSpec, placementSpec, fadeOutSpec)** в Compose 1.7 заменил animateItemPlacement(), который умел только перемещения. Передайте null вместо спецификации, чтобы отключить эту часть. В LazyRow и ленивых гридах работает так же.
Ключи и есть механизм: без key элементы определяются по индексу, поэтому сортировка выглядит как смена содержимого на месте, и ничего не двигается. Ключ должен быть уникальным и стабильным: key = { it.hashCode() } у data-класса с меняющимися полями превращает каждое изменение в удаление плюс вставку.
Ставьте его на самый внешний лейаут элемента: на вложенном Text ничего не анимируется, потому что двигается корневой узел элемента.
Анимируются только позиция и появление: если растёт содержимое самого элемента, всё равно нужен animateContentSize().
Оберните оба экрана в один SharedTransitionLayout, пометьте совпадающие элементы Modifier.sharedElement с одинаковым ключом и передайте каждому AnimatedVisibilityScope перехода, который меняет экраны; в NavHost это receiver лямбды composable { }.
**SharedTransitionLayout** ставится снаружи NavHost и даёт SharedTransitionScope, в котором живут sharedElement и rememberSharedContentState.
**sharedElement: для одного и того же контента на обоих экранах (обложка). sharedBounds нужен для разного контента в общей области, например карточки, которая становится экраном; он добавляет появление и исчезновение для содержимого.
Без перехода нет и shared element**: скоуп может прийти из AnimatedContent, AnimatedVisibility или destination в NavHost. С Navigation Compose 2.8+ элемент ещё и следует за жестом predictive back.
Элемент летит в оверлее поверх остального контента, поэтому выходит за обрезающих родителей, но может нарисоваться поверх верхней панели; Modifier.renderInSharedTransitionScopeOverlay() на панели оставляет её сверху.
Типичные баги: ключи из индекса или дублирующиеся среди видимых элементов, а также экран, скомпонованный вне NavHost, где нет скоупа для передачи. API находится за @ExperimentalSharedTransitionApi, поэтому держите opt-in в одном месте.
Compose масштабирует анимации по системному коэффициенту длительности анимаций, поэтому при 0 анимация, запущенная из композиции, сразу перескакивает в конец; самим нужно обработать зацикленное движение и анимации, которые управляются извне композиции.
Где живёт настройка: «Удалить анимации» в специальных возможностях и «Длительность анимации» в параметрах разработчика пишут в Settings.Global.ANIMATOR_DURATION_SCALE.
Как она попадает в код: рекомпозер окна кладёт MotionDurationScale в свой корутинный контекст. animate*AsState, переходы, AnimatedVisibility и вызовы Animatable из LaunchedEffect или rememberCoroutineScope() его наследуют.
Циклы замирают на случайном кадре: при коэффициенте 0 InfiniteTransition прыгает к своему targetValue и останавливается, а это может быть полпути шиммера. Проверяйте ValueAnimator.areAnimatorsEnabled() и рисуйте осознанный статичный кадр. Ручной цикл while (true) { anim.animateTo(...) } при 0 возвращается мгновенно и крутится вхолостую, поэтому его нужно защитить.
Скоупы, созданные вручную: viewModelScope или свой CoroutineScope этого контекста не несут. Держите стейт анимаций в UI-слое.
Крупное движение: параллакс, сильный зум и автовоспроизведение представляют собой вестибулярную проблему, а не вопрос вкуса; привяжите их к тому же флагу, прочитанному один раз и переданному через CompositionLocal.
Тесты идут на виртуальных часах, которые настройку игнорируют: поставьте composeTestRule.mainClock.autoAdvance = false и шагайте через advanceTimeBy(), чтобы проверять середину анимации.
graphicsLayer рисует composable в отдельный слой рендеринга со свойствами трансформации (alpha, scaleX/Y, rotationZ, translationX/Y, shadowElevation, clip); их изменение перерисовывает слой без рекомпозиции и повторного измерения контента.
Используйте форму с лямбдой: graphicsLayer { alpha = a } читает стейт в фазе отрисовки, и анимация на 60 fps стоит одну перерисовку на кадр. Форма с параметром graphicsLayer(alpha = a), как и Modifier.alpha(a), читает его в композиции и рекомпозирует каждый кадр.
Лейаут не сдвигается: сдвинутый или отмасштабированный слой сохраняет своё место в лейауте, соседи остаются на месте. Это инструмент для визуального движения, а не для изменения раскладки.
Точка опоры и 3D: transformOrigin задаёт центр трансформации; rotationX/Y вместе с cameraDistance дают переворот карточки.
**compositingStrategy**: Auto затемняет перекрывающиеся дочерние элементы как одну группу, когда alpha меньше 1; ModulateAlpha или Offscreen выбирайте только после замеров.
**alpha = 0f не означает «нет элемента»**: узел всё ещё участвует в лейауте и получает касания. Чтобы убрать его, перестаньте его компоновать.
pointerInput(key) { } запускает suspend-блок, в котором вы ждёте события указателя (через хелперы вроде detectDragGestures или вручную через awaitEachGesture и awaitFirstDown); блок перезапускается только при смене ключа.
Сначала хелперы: detectTapGestures, detectDragGestures и его горизонтальный и вертикальный варианты, detectDragGesturesAfterLongPress, detectTransformGestures. Это тонкие обёртки над циклом ниже.
Низкоуровневый цикл: awaitEachGesture { } выполняется раз на жест и ждёт, пока поднимутся все пальцы, прежде чем начать следующий. Внутри события читают awaitFirstDown(), awaitPointerEvent(), хелперы touch slop и drag(pointerId) { }.
**change.consume() помечает изменение как обработанное; встроенные детекторы выше по дереву пропускают поглощённые изменения, и так дочерний элемент забирает жест себе.
Ключи**: Unit означает, что корутина никогда не перезапускается, и колбэк, захваченный при первом запуске, остаётся навсегда. Читайте колбэки через rememberUpdatedState; ключ по самой лямбде перезапускает блок, отменяя жест на середине, при каждой смене её идентичности.
Порядок модификаторов: padding перед pointerInput делает отступ некликабельным; если поставить его после, отступ входит в зону касания.
Если подходят draggable, scrollable и transformable, берите их: они уже обрабатывают touch slop, скорость и fling.
Смещения жеста приходят в пикселях, а offsetX.dp считает накопленную сумму пикселей в dp, поэтому чип сдвигается в density раз дальше пальца (в 2,75 раза на типичном телефоне с 440 dpi); правильно это выглядит только на экране с плотностью 1,0.
Почему так происходит: dragAmount из detectHorizontalDragGestures приходит в px, а Modifier.offset(x: Dp) при раскладке умножает аргумент на плотность экрана. До .dp оба значения остаются обычными Float, поэтому путаница компилируется без единого предупреждения.
Как работает исправление: перегрузка с лямбдой offset { IntOffset(...) } принимает пиксели, и стейт с раскладкой используют одну единицу. Храните состояние жеста в px и переводите через LocalDensity только там, где API требует Dp.
Бонус: лямбда читается в фазе раскладки, поэтому перетаскивание больше не рекомпонует DraggableChip на каждом событии указателя; перезапускается только размещение.
Как поймать в следующий раз: проверяйте перетаскивание на устройстве с высокой плотностью, а не на эмуляторе с плотностью 1,0, и называйте пиксельные значения с суффиксом Px; UI-тест, который свайпает на известное расстояние через performTouchInput и проверяет новую позицию, тоже это поймает.
Жест принадлежит первому узлу, который вызвал consume() для изменения указателя. detectDragGestures забирает перетаскивание в любом направлении, как только пройден touch slop, и поэтому крадёт у списка вертикальные свайпы; исправляется детектором с фиксированной ориентацией.
Три прохода: каждое событие проходит Initial (от родителя к ребёнку), Main (от ребёнка к родителю, здесь работают детекторы) и Final (снова от родителя к ребёнку). В Main первым право на жест получает самый внутренний узел.
Исправление: detectHorizontalDragGestures или Modifier.draggable(orientation = Orientation.Horizontal) реагируют только на горизонтальный slop, и вертикальное движение уходит списку. По той же причине HorizontalPager внутри вертикальной прокрутки работает без доработок.
Перехват родителем: слушайте PointerEventPass.Initial и поглощайте событие там, до того как его увидят дети; так работает заблокированный оверлей, который глотает касания.
Корректный проигрыш: детектор, увидевший поглощённое изменение, отменяется: onTap не сработает, если список уже забрал касание под прокрутку.
Одна ось: два вертикальных скроллируемых элемента не борются за указатель. Внутренний прокручивается первым и отдаёт неиспользованное наружу через nested scroll.
Используйте Modifier.transformable с rememberTransformableState или detectTransformGestures внутри pointerInput; оба на каждое событие отдают изменения масштаба, сдвига и поворота, которые вы накапливаете в стейте и применяете через graphicsLayer.
Копите масштаб умножением: scale = (scale * zoomChange).coerceIn(1f, 5f); при сложении значения уплывают.
**Применяйте в graphicsLayer { }: масштаб и сдвиг меняются каждый кадр, поэтому читайте их в фазе отрисовки.
Ограничьте сдвиг**: при масштабе s картинку можно сдвинуть максимум на (s - 1) * size / 2 в каждую сторону, пока её край не ушёл из области просмотра.
Внутри пейджера: transformable(state, canPan = { scale > 1f }) отдаёт пейджеру перетаскивания одним пальцем, пока картинка не увеличена.
Зум к пальцам: detectTransformGestures даёт ещё и centroid; скорректируйте по нему сдвиг, чтобы точка под пальцами оставалась на месте.
Зум двойным тапом: detectTapGestures(onDoubleTap = ...) плюс Animatable для масштаба, чтобы скачок был анимирован.
Для стандартного случая берите SwipeToDismissBox из Material 3; для своих точек фиксации используйте AnchoredDraggableState с DraggableAnchors и Modifier.anchoredDraggable, а сдвигать контент по его смещению нужно самим.
Якоря сопоставляют каждому состоянию позицию в пикселях и обычно зависят от измеренного размера, поэтому задавайте их через updateAnchors из onSizeChanged. До этого смещение равно NaN, а requireOffset() бросает исключение.
**anchoredDraggable обрабатывает только жест**: позицию применяйте через Modifier.offset { IntOffset(state.requireOffset().roundToInt(), 0) }; лямбда читает её на этапе размещения, поэтому перетаскивание не рекомпозирует строку.
Правила доводки: начиная с Foundation 1.8 порог по позиции и анимация защёлкивания задаются в AnchoredDraggableDefaults.flingBehavior(...), а не в конструкторе состояния; старый конструктор с порогами устарел.
**currentValue и settledValue**: currentValue следует за ближайшим пройденным якорем, даже посреди перетаскивания; settledValue меняется только когда перетаскивание или анимация осели на якоре. Удаляйте элемент по settledValue.
Из кода: state.animateTo(value) и state.snapTo(value) двигают элемент без жеста, например чтобы закрыть открытую строку.
drawWithCache делит отрисовку на две части: внешний блок выполняется только при смене размера или прочитанного в нём стейта, а возвращаемый им блок onDrawBehind или onDrawWithContent выполняется на каждом кадре, поэтому дорогие объекты создаются один раз и переиспользуются.
Почему это важно: drawBehind выполняет всю лямбду на каждом проходе отрисовки. Созданные там Path, Brush, Stroke или раскладка текста становятся мусором каждый кадр, и паузы GC видны как пропущенные кадры во время анимации.
**Читайте анимированные значения только внутри onDraw*: чтение стейта в блоке кеша инвалидирует кеш при каждом изменении, и вы снова аллоцируете на каждом кадре.
Объекты, зависящие от размера, кладите в кеш**: кеш пересоздаётся при смене размера, поэтому путь, построенный от size, остаётся корректным.
Текст: rememberTextMeasurer() создаётся в composable, textMeasurer.measure(...) вызывается в блоке кеша, а drawText(layoutResult) вызывается в блоке отрисовки.
Во время перетаскивания делайте snapTo в позицию пальца, чтобы ничего не анимировалось; при отпускании передайте измеренную скорость в animateDecay, чтобы элемент катился по инерции, или в animateTo(target, initialVelocity = v), чтобы пружиной вернуться в точку покоя, не потеряв бросок.
Во время перетаскивания
**stop() при касании: fling от предыдущего жеста может ещё идти и будет бороться с пальцем за значение.
snapTo(value + delta) на каждое изменение**, запущенный в корутине. Это suspend-функция, которая отменяет текущую анимацию; Animatable упорядочивает записи через свой MutatorMutex.
Скорость: Modifier.draggable передаёт её в onDragStopped. В сыром цикле pointerInput кормите VelocityTracker через addPosition(change.uptimeMillis, change.position) и вызывайте calculateVelocity() при отпускании.
После отпускания
**splineBasedDecay** (или rememberSplineBasedDecay()) совпадает с платформенным fling и учитывает плотность экрана. calculateTargetValue(current, velocity) заранее говорит, где остановится fling; по нему и решают, укатиться или вернуться пружиной.
**updateBounds(lower, upper)** ограничивает затухание; оно заканчивается с AnimationEndReason.BoundReached, и оттуда можно вернуться пружиной с оставшейся скоростью.
Ловушки
Если хранить смещение в mutableStateOf и двигать его вручную, теряются скорость, прерываемость и границы, а Animatable существует именно для этого.
Читайте значение в Modifier.offset { } или graphicsLayer { }, а не в композиции, иначе каждый кадр рекомпозирует поддерево.
Дельта прокрутки сначала идёт в onPreScroll родителей, начиная с внешнего, затем в прокручиваемый дочерний элемент, а остаток возвращается через onPostScroll; сворачивающийся заголовок сжимается в pre-scroll и разворачивается в post-scroll, когда список уже наверху.
Как идёт диспетчеризация
onPreScroll(available, source) и onPostScroll(consumed, available, source) возвращают поглощённый Offset. Для fling есть зеркальные suspend-методы onPreFling и onPostFling, которые принимают Velocity.
Возвращайте ровно то, что использовали: Offset.Zero пропускает всё дальше, а если заявить больше, чем использовано, прокрутка начнёт залипать.
source равен NestedScrollSource.UserInput для пальца и SideEffect для fling или анимации (в Compose UI 1.7 переименованы из Drag и Fling).
Сворачивающийся заголовок
Палец движется вверх (available.y < 0): поглощайте в onPreScroll, пока заголовок полностью не свернётся; он сжимается раньше, чем двинется список.
Палец движется вниз: в pre-scroll ничего не берите, а остаток забирайте в onPostScroll (он ненулевой, только когда список уже наверху). Если поглощать движение вниз в pre-scroll, получается баг: заголовок разворачивается до того, как список прокрутился обратно.
Храните смещение в mutableFloatStateOf и читайте его в блоке layout { }, чтобы кадр прокрутки заново размещал заголовок, а не рекомпозировал его.
Прежде чем писать своё
В Material 3 уже есть TopAppBarDefaults.exitUntilCollapsedScrollBehavior(), enterAlwaysScrollBehavior() и pinnedScrollBehavior(), которые подключаются через Modifier.nestedScroll(scrollBehavior.nestedScrollConnection). Внутри View-шного CoordinatorLayout прокрутку Compose с ним связывает rememberNestedScrollInteropConnection().
Оставьте один лейаут, у которого меняются параметры, оберните его в LookaheadScope и повесьте на каждый элемент Modifier.animateBounds(lookaheadScope): lookahead-проход вычисляет, куда едет каждый элемент, и тот анимирует размер и позицию к этой цели.
Почему обычные инструменты не справляются
**animateItem** анимирует перемещения внутри одного ленивого лейаута. Если заменить LazyColumn на LazyVerticalGrid, все элементы уничтожаются и компонуются заново, и двигать нечего.
**animateContentSize анимирует собственный размер узла, но не его позицию.
animate*AsState в offset требует знать конечную точку в пикселях, а её знает только родитель после измерения.
Механизм
LookaheadScope { } измеряет и размещает поддерево так, будто изменение уже завершилось, а затем даёт каждому ребёнку приблизиться к этому результату за несколько кадров.
Modifier.animateBounds** (Compose 1.8, за @ExperimentalSharedTransitionApi) даёт готовую анимацию размера и позиции; SharedTransitionLayout использует тот же механизм с совпадающими ключами.
**Modifier.approachLayout: низкоуровневая точка расширения: вы измеряете с собственными анимированными ограничениями и сообщаете, когда приближение закончено, чтобы узел вернулся к обычному одному проходу.
Переезд между родителями**: если ребёнок действительно меняет родителя (Column превращается в Row), movableContentOf сохраняет его стейт при переезде.
Ловушки
У узлов вне скоупа нет цели; граница скоупа совпадает с границей анимации.
Элементам нужны стабильные key, иначе Compose сопоставит их по позиции и анимирует не те.
Modifier.blur работает только на Android 12 (API 31) и новее, а AGSL RuntimeShader работает только на Android 13 (API 33); ниже размытие молча ничего не делает, поэтому сначала спроектируйте запасной вариант (градиент или полупрозрачную подложку), а эффект добавляйте поверх.
Что есть
**Modifier.blur(radius, edgeTreatment)** работает через RenderEffect; graphicsLayer { renderEffect = BlurEffect(...) } делает то же самое на уровне слоя.
Размывается собственный контент composable, а не то, что под ним. «Матовое стекло» поверх прокручиваемого контента означает размывать копию фона самостоятельно или взять библиотеку вроде Haze.
Размытие фона окна: отдельная функция: Window.setBackgroundBlurRadius на API 31+, которую система может отключить, поэтому проверяйте WindowManager.isCrossWindowBlurEnabled().
**RuntimeShader** компилирует программу на AGSL. Его используют как заливку через ShaderBrush или пропускают через него пиксели composable с помощью RenderEffect.createRuntimeShaderEffect(...).asComposeRenderEffect(). Uniform-переменные обновляйте в фазе отрисовки.
Как это выпускать
Проверяйте Build.VERSION.SDK_INT один раз (в composable, который владеет эффектом) и там же выбирайте весь внешний вид, а не разбрасывайте проверки версий по дереву.
Brush.linearGradient, radialGradient и sweepGradient работают на всех поддерживаемых версиях, и это честный запасной вариант.
Размытие стоит GPU-времени пропорционально радиусу и площади. Анимация радиуса перезапускает эффект каждый кадр, поэтому анимируйте прозрачность размытия с фиксированным радиусом и не используйте размытие в элементах списков.