Анимации в Jetpack Compose довольно легко понять, применить и кастомизировать под требования дизайна. Но я ещё не видел ни одного туториала по анимациям в Compose на русском языке, поэтому подготовил на эту тему доклад для майского Mobius. А для тех, кто больше любит читать, чем слушать, написал статью. В материале мы обсудим виды анимаций, а также пройдём все шаги по способам их создания и кастомизации.

Зачем вашим приложениям анимации?
Человеку в принципе трудно воспринимать статичную картинку. Взаимодействия с предметами в жизни всегда происходят постепенно, а не мгновенно. Например, если мы хотим попить кофе, сначала нужно его сварить. А для этого надо подойти к кофемашине, установить стакан, нажать на кнопку, послушать шуршание кофейных зёрен, понаблюдать за тем, как образуется пенка … ну вы поняли.
Это поведение, свойственное объектам реального мира, привычно для пользователя. Поэтому его можно сохранять и передавать объектам виртуального мира: экранам, компонентам и элементам на экранах приложений. Кроме того, анимации приносят профит пользователю:
-
Они улучшают взаимодействие пользователя с интерфейсом;
-
Повышают плавность работы приложения;
-
Обеспечивают прогнозируемость работы приложения.
Приносят ли анимации пользу для бизнеса? Ответ — конечно же да, и вот почему:
-
С помощью анимаций можно увеличить конверсию за счет удержания текущих пользователей и привлечения новых. Например, если есть два почти одинаковых по сути приложения, но одно из них с анимациями, то оно, с большой вероятностью, станет фаворитом пользователей.
-
Анимации маскируют «медленную» работу приложения. Под словом «медленную» имеется в виду не троттлинг или фризинг приложения, а неоптимальный контракт между клиентом и сервером (долгие и частые сетевые запросы на многих экранах).
-
Наконец, анимации делают время ожидания более комфортным. Ведь когда экран не просто завис, а показывает анимированный прогресс загрузки, пользователи спокойно ожидают отклика, и не начинают нервно тапать по всему экрану.
Теперь у вас есть целый арсенал аргументов, зачем бизнесу необходимо тратить деньги, а разработчику — своё время на создание анимаций в приложении. Давайте перейдём к сути и обсудим, как их реализовать. В Jetpack Compose есть два типа анимаций: высокоуровневые и низкоуровневые.
Создание высокоуровневых анимаций
Начнём экскурс с высокоуровневых анимаций, так как они проще в использовании, требуют минимум действий для запуска, и, к тому же, разработаны с последними практиками Material Design Motion.
На данный момент в Jetpack Compose доступно 4 способа создания высокоуровневой анимации:
-
AnimatedVisibility
-
AnimatedContent
-
Crossfade
-
Modifier.animateContentSize
AnimatedVisibility
Этот способ подходит для анимирования появления и исчезновения контента. AnimatedVisibility — это composable-функция, которая имеет 2 конструктора:
@ExperimentalAnimationApi @Composable fun AnimatedVisibility( visible: Boolean, modifier: Modifier = Modifier, enter: EnterTransition = fadeIn() + expandIn(), exit: ExitTransition = shrinkOut() + fadeOut(), content: @Composable() AnimatedVisibilityScope.() -> Unit ) {...}
и
@ExperimentalAnimationApi @Composable fun AnimatedVisibility( visibleState: MutableTransitionState<Boolean>, modifier: Modifier = Modifier, enter: EnterTransition = fadeIn() + expandIn(), exit: ExitTransition = fadeOut() + shrinkOut(), content: @Composable() AnimatedVisibilityScope.() -> Unit ) {...}
Основная разница заключается в первом аргументе функций. Для первого конструктора необходимо передавать параметр visible с типом Boolean, а для второго — параметр visibleState с типом MutableTransitionState. Иными словами, MutableTransitionState — это стейт, при изменении которого и будет производиться анимирование контента.
Следующим важным аргументом функций является параметр enter c типом EnterTransition. При помощи EnterTransition мы можем указывать, как именно должен появляться контент на экране. В Jetpack Compose по дефолту доступно 8 разных типов транзишенов:

Дальше посмотрим на параметр exit c типом ExitTransition. При помощи ExitTransition мы указываем, как именно должен исчезать контент с экрана. По аналогии с EnterTransition в Jetpack Compose по дефолту доступно 8 разных типов ExitTransition:

Последним, и самым важным параметром в функцию AnimatedVisibility необходимо передать content, который нужно проанимировать. Таким образом, composable-функция AnimatedVisibility является своего рода composable-контейнером. Внутрь данного контейнера необходимо передавать UI-элементы экрана в виде composable-функций.
Рассмотрим AnimatedVisibility на примере.
Чтобы получить первую анимацию, нужно написать следующий код:
AnimatedVisibility( visible = visible, enter = slideInHorizontally() + expandHorizontally(expandFrom = Alignment.End) + fadeIn(), exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }) + shrinkHorizontally() + fadeOut(), ) { Image( modifier = Modifier.fillMaxWidth(), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", ) }
А для второй анимации соответственно:
AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = LinearEasing)), exit = fadeOut(animationSpec = tween(durationMillis = 300)), ) { Image( modifier = Modifier.fillMaxWidth(), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", ) }
Как можете заметить, эти два способа создания анимации идентичны за одним исключением — разные EnterTransition и ExitTransition. Для первого случая мы используем:
enter = slideInHorizontally() + expandHorizontally(expandFrom = Alignment.End) + fadeIn(), exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }) + shrinkHorizontally() + fadeOut(),
А для второго:
enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = LinearEasing)), exit = fadeOut(animationSpec = tween(durationMillis = 300)),
Соответственно, используя разные Transition-ы, можно создавать разное поведение для появления и исчезновения элементов на экране. Кстати, в Jetpack Compose доступен функционал объединения нескольких транзишенов одновременно. Делается это при помощи «волшебного» символа «+» между Transition. В результате получаем такую анимацию:

AnimatedContent
Этот способ подходит для анимирования контента внутри себя относительно стейта. AnimatedContent — это composable-функция, которая имеет следующий конструктор:
fun <S> AnimatedContent( targetState: S, modifier: Modifier = Modifier, transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = { fadeIn(animationSpec = tween(220, delayMillis = 90)) with fadeOut(animationSpec = tween(90)) }, contentAlignment: Alignment = Alignment.TopStart, content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit ) {...}
Первым и самым важным аргументом, который необходимо передать внутрь composable-функции AnimatedContent, является параметр targetState, то есть стейт, относительно которого будет анимироваться наш контент.
Далее указываем параметр transitionSpec. TransitionSpec — это характеристика транзишенов с привязкой к состоянию стейта, которые будут применяться для анимирования контента. Для указания характеристик необходимо использовать те же типы тразишенов, которые применяются для способа создания анимации AnimatedVisibility.
Затем, по аналогии с предыдущим способом, в функцию AnimatedContent передаём сам content, который необходимо проанимировать. Как и AnimatedVisibility, AnimatedContent является composable-контейнером.
Для создания анимаций с помощью AnimatedContent, вам понадобится следующий код:
AnimatedContent( targetState = state, transitionSpec = { fadeIn(animationSpec = tween(durationMillis = 150)) with fadeOut(animationSpec = tween(durationMillis = 150)) using SizeTransform { initialSize, targetSize -> if (targetState == State.EXPAND) { keyframes { IntSize(initialSize.width, initialSize.height) at 150 durationMillis = 300 } } else { keyframes { IntSize(targetSize.width, targetSize.height) at 150 durationMillis = 300 } } } } ) { targetExpanded -> if (targetExpanded == State.EXPAND) { Collapsed() } else { Expanded() } }
Первым параметром передаём state внутрь к composable-функции AnimatedContent. У данного стейта может быть два состояния: Expanded или Collapsed.
Далее указываем спецификацию для наших transition-ов:
transitionSpec = { fadeIn(animationSpec = tween(durationMillis = 150)) with fadeOut(animationSpec = tween(durationMillis = 150)) using SizeTransform { initialSize, targetSize -> if (targetState == State.EXPAND) { keyframes { IntSize(initialSize.width, initialSize.height) at 150 durationMillis = 300 } } else { keyframes { IntSize(targetSize.width, targetSize.height) at 150 durationMillis = 300 } } } }
В данном случае EnterTransition и ExitTransition-ы связаны между собой ключевым словом with.
fadeIn(animationSpec = tween(durationMillis = 150)) with fadeOut(animationSpec = tween(durationMillis = 150))
Затем при помощи ключевого слова using указывается, как именно будет изменяться размер контента. Для изменения размера в данном случае применяется специальный интерфейс SizeTransform, который определяет, как размер должен анимироваться между начальным и целевым содержимым. У интерфейса SizeTransform есть доступ как к начальному размеру, так и к конечному (целевому) размеру при создании анимации. Также SizeTransform контролирует, следует ли обрезать содержимое до размера компонента во время анимации.
SizeTransform { initialSize, targetSize -> if (targetState == State.EXPAND) { keyframes { IntSize(initialSize.width, initialSize.height) at 150 durationMillis = 300 } } else { keyframes { IntSize(targetSize.width, targetSize.height) at 150 durationMillis = 300 } } }
К сожалению, пока данный способ создания является Experimental.

Crossfade
Crossfade применяется для создания анимаций между состояниями с помощью анимации перекрёстного затухания (fade-анимаций). При изменении значения состояния (стейта), переданное в качестве параметра содержимое переключается с помощью анимации перекрестного затухания. Crossfade — это тоже composable-функция, которая имеет следующий конструктор:
@Composable fun <T> Crossfade( targetState: T, modifier: Modifier = Modifier, animationSpec: FiniteAnimationSpec<Float> = tween(), content: @Composable (T) -> Unit ) {...}
В первую очередь внутрь composable-функции Crossfade необходимо передать параметр targetState (стейт, относительно которого анимируем контент).
Далее указываем параметр animationSpec. AnimationSpec — это спецификация анимации, то есть такие параметры, как длительность анимации, задержка перед запуском анимации и т.п. Более подробно про спецификацию анимации поговорим чуть дальше.
Последним важным параметром в функцию Crossfade необходимо передать сам content. Как и в двух предыдущих случаях, Crossfade является composable-контейнером.
Давайте снова обратимся к примеру. Для анимации вам потребуется вот такой код:
Crossfade(targetState = state) { screen -> when (screen) { State.IMAGE -> SomeImage() State.TEXT -> SomeText() } }
В данном случае всё достаточно просто:
-
Внутрь composable-функции Crossfade передаём state.
-
В зависимости от стейта вызываем ту или иную composable-функцию, которая является контентом (изображение или текст соответственно). Пример:

Modifier.animateContentSize
Этот способ создания анимаций применяется для анимирования размера контента. AnimateContentSize — это extension-функция для Modifier-а. Получается, что данным способом можно проанимировать размер любой composable-функции, у которой имеется Modifier. AnimateContentSize имеет следующий конструктор:
fun Modifier.animateContentSize( animationSpec: FiniteAnimationSpec<IntSize> = spring(), finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null ): Modifier = composed( inspectorInfo = debugInspectorInfo { name = "animateContentSize" properties["animationSpec"] = animationSpec properties["finishedListener"] = finishedListener } ) {...}
Первым аргументом в конструкторе является параметр animationSpec.
Следующим аргументом, который, впрочем, не обязательно указывать, является finishedListener. Данный листенер предназначен для прослушивания состояния анимации.
Пишем код:
Column( modifier = Modifier .fillMaxWidth() .background(ColorPalette.contentStaticSecondary) .animateContentSize(), ) { HeaderItem(fullText) { fullText = !fullText } if (fullText) { Text( text = text, modifier = Modifier.padding(all = 16.dp) ) } }
Для контента в виде столбца (Column), содержащего другие composable-функции, у Modifier вызывается extension-функция animateContentSize(). А внутри самого столбца (Column) в зависимости от стейта вызывается соответствующая функция Text. Пример:

Итак, с высокоуровневыми анимациями закончили, идём дальше.
Низкоуровневые анимации
Все высокоуровневые API анимаций построены на основе низкоуровневых анимационных API. Далее мы разберём все способы создания низкоуровневых анимаций, а именно:
-
Animatable
-
animate*AsState
-
Animation: TargetBasedAnimation и DecayAnimation
-
updateTransition
-
rememberInfiniteTransition
Animatable
Класс Animatable содержит все необходимые данные о запущенной анимации: начальное значение, конечное значение, прогресс. Кроме того, Animatable поддерживает анимирование двух типов значений float и color. Ниже приведены конструкторы данного класса:
fun Animatable( initialValue: Float, visibilityThreshold: Float = Spring.DefaultDisplacementThreshold ) = Animatable( initialValue, Float.VectorConverter, visibilityThreshold )
fun Animatable(initialValue: Color): Animatable<Color, AnimationVector4D> = Animatable(initialValue, (Color.VectorConverter)(initialValue.colorSpace))
Первым аргументом необходимо передать параметр initialValue. Данный параметр задаёт начальное значение, с которого должна стартовать анимация.
Вторым аргументом является необязательный параметр visibilityThreshold — это порог видимости или то, с какой точностью анимация будет считаться выполненной, то есть достигшей своего целевого значения.
Разберём вот такой код:
var animated by remember { mutableStateOf(false) } val rotation = remember { Animatable(initialValue = 360f) } LaunchedEffect(animated) { rotation.animateTo( targetValue = if (animated) 0f else 360f, animationSpec = tween(durationMillis = 1000), ) } Image( modifier = Modifier.graphicsLayer { rotationY = rotation.value }, painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
Сначала мы объявляем переменную rotation и присваиваем ей значение Animatable через функцию remember. Причем при объявлении Animatable передаём начальное значение initialValue = 360f.
Далее вызываем LaunchedEffect. Это сделано для того, чтобы получить coroutine scope, в рамках которого мы будем вызывать suspend-функцию. (Это сделано только для данного примера и не является аксиомой).
У класса Animatable есть suspend-функция animateTo, которая позволяет проанимировать значение по мере его изменения. При этом изменение значения является непрерывным, и любая текущая анимация будет отменена. Внутрь функции animateTo в качестве параметров необходимо передать targetValue (целевое/конечное значение) и animationSpec (спецификацию анимации). Финальным шагом необходимо применить полученное анимированное значение к самому контенту. В данном примере контентом является Image, у которого через modifier изменяется вращение по оси Y. Результат выглядит следующим образом:

animate*AsState
Функции animate*AsState являются простейшими API анимации в Compose для анимации одного значения. Вам нужно предоставить только конечное (целевое) значение, и API запускает анимацию от текущего значения до конечного.
В Jetpack Compose по умолчанию доступно несколько поддерживаемых типов анимации из группы animate*AsState:

Для примера рассмотрим, что необходимо передавать для работы данной анимации на двух функциях:
@Composable fun animateFloatAsState( targetValue: Float, animationSpec: AnimationSpec<Float> = defaultAnimation, visibilityThreshold: Float = 0.01f, finishedListener: ((Float) -> Unit)? = null ): State<Float> {...}
и
@Composable fun animateDpAsState( targetValue: Dp, animationSpec: AnimationSpec<Dp> = dpDefaultSpring, finishedListener: ((Dp) -> Unit)? = null ): State<Dp> {...}
Первым и обязательным параметром является targetValue — это необходимое конечное (целевое) значение, к которому будет стремиться анимация, начиная с текущего значения. Вторым необязательным параметром является animationSpec. Третьим необязательным параметром является visibilityThreshold. И последним необязательным параметром можно указать finishedListener.
Пишем следующий код:
val rotation by animateFloatAsState( targetValue = if (state == State.IMAGE_FORWARD) 180f else 0f, animationSpec = tween(durationMillis = 1000, easing = LinearEasing), ) Box( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .graphicsLayer { rotationY = rotation }, contentAlignment = Alignment.Center, ) {...}
Для получения анимированного значения создаётся переменная rotation и вызывается функция animateFloatAsState.
В конструктор данной функции передаётся целевое значение targetValue, к которому будет стремиться анимация. В данном примере значение targetValue зависит от состояния стейта и может принимать значение либо 180f, либо 0f.
Также в конструктор функции animateFloatAsState передаётся параметр animationSpec. Здесь это длительность анимации в 1000 мс и тип кривой смягчения.
В финале необходимо применить полученное анимированное значение rotation к необходимому контенту. В данном примере контентом является Box, у которого через modifier изменяется вращение по оси Y.
По сути, animate*AsState использует Animatable под капотом, и сама анимация выглядит вот так:

Animation
Animation — это интерфейс анимаций с контролем состояния анимаций. В Jetpack Compose доступно две реализации данного интерфейса:
-
TargetBasedAnimation
-
DecayAnimation
Предлагаю более подробно разобраться с данными реализациями. Начнём с TargetBasedAnimation — это API анимации самого низкого уровня. Другие API охватывают большинство сценариев использования, но использование TargetBasedAnimation напрямую позволяет вам самостоятельно контролировать время воспроизведения анимации.
Ниже приведён конструктор класса:
constructor( animationSpec: AnimationSpec<T>, typeConverter: TwoWayConverter<T, V>, initialValue: T, targetValue: T, initialVelocityVector: V? = null ) : this( animationSpec.vectorize(typeConverter), typeConverter, initialValue, targetValue, initialVelocityVector )
Как видно из конструктора, для реализации анимации с использованием TargetBasedAnimation нам необходимо указать:
-
animationSpec — спецификацию анимации;
-
typeConverter — конвертор типа, который позволяет анимировать определенный тип данных. Для базовых типов в Jetpack Compose доступны дефолтные конверторы;
-
initialValue и targetValue — начальное и конечное значение соответственно;
-
initialVelocityVector — начальное значение вектора скорости анимации.
Пишем код:
var state by remember { mutableStateOf(false) } val anim = remember { TargetBasedAnimation( animationSpec = tween(durationMillis = 2000), typeConverter = Float.VectorConverter, initialValue = 100f, targetValue = 300f, ) } var playTime by remember { mutableStateOf(0L) } var animationValue by remember { mutableStateOf(0) } LaunchedEffect(state) { val startTime = withFrameNanos { it } do { playTime = withFrameNanos { it } - startTime animationValue = anim.getValueFromNanos(playTime).toInt() } while (!anim.isFinishedFromNanos(playTime)) } Image( modifier = Modifier.size(animationValue.dp), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
Здесь достаточно много строк, и нужно по очереди разбираться, что к чему.
val anim = remember { TargetBasedAnimation( animationSpec = tween(durationMillis = 2000), typeConverter = Float.VectorConverter, initialValue = 100f, targetValue = 300f, ) }
Во-первых, необходимо объявить переменную anim и объявить класс TargetBasedAnimation. Далее в конструкторе данного класса указываем необходимые параметры: спецификацию анимации (в данном случае это длительность 2 сек.), конвертор типа, начальное и конечное значения.
var animationValue by remember { mutableStateOf(0) }
Затем объявляется отдельная переменная для того, чтобы записывать в неё полученное анимированное значение.
LaunchedEffect(state) { val startTime = withFrameNanos { it } do { playTime = withFrameNanos { it } - startTime animationValue = anim.getValueFromNanos(playTime).toInt() } while (!anim.isFinishedFromNanos(playTime))
А дальше происходит самое интересное! Для реализации анимации при помощи TargetBasedAnimation необходимы coroutines. Именно поэтому в качестве примера используется LaunchedEffect, внутри которого доступен coroutine scope. В рамках этого coroutine scope мы будем запускать необходимые suspend-функции. (Так сделано только для конкретно этого примера)
Следующим и важным шагом необходимо получить время фрейма в наносекундах при помощи функции withFrameNanos. Далее мы получаем анимированное значение с помощью функции getValueFromNanos на основании разницы во времени между фреймами начального и конечного значения.
Image( modifier = Modifier.size(animationValue.dp), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
В завершение полученное анимированное значение применяется к контенту при помощи соответствующей функции у Modifier. Пример:

Второй реализацией интерфейса Animation является класс DecayAnimation, который также является API анимации самого низкого уровня. Данный способ создания анимации позволяет реализовать анимацию «затухания». Другими словами, к концу своего выполнения анимация будет плавно завершаться. Реализация DecayAnimation похожа на TargetBasedAnimation, но есть и важные отличия:
constructor( animationSpec: DecayAnimationSpec<T>, typeConverter: TwoWayConverter<T, V>, initialValue: T, initialVelocityVector: V ) : this( animationSpec.vectorize(typeConverter), typeConverter, initialValue, initialVelocityVector )
Чтобы создать анимацию с использованием DecayAnimation, нам необходимо указать:
-
animationSpec — спецификацию анимации;
-
typeConverter — конвертор типа, который позволяет анимировать опредёленный тип данных. Для базовых типов в Jetpack Compose доступны дефолтные конверторы;
-
initialValue — начальное значение (в отличие от TargetBasedAnimation, где мы ещё указывали и целевое значение targetValue);
-
initialVelocityVector — начальное значение вектора скорости, с которым будет затухать анимация. Важно, что в данном способе это обязательный параметр.
Теорию разобрали, теперь к практике. Пишем код:
var state by remember { mutableStateOf(false) } val anim = remember { DecayAnimation( animationSpec = FloatExponentialDecaySpec(frictionMultiplier = 0.7f), initialValue = 0f, initialVelocity = 500f ) } var playTime by remember { mutableStateOf(0L) } var animationValue by remember { mutableStateOf(0) } LaunchedEffect(state) { val startTime = withFrameNanos { it } do { playTime = withFrameNanos { it } - startTime animationValue = anim.getValueFromNanos(playTime).toInt() } while (!anim.isFinishedFromNanos(playTime)) } Image( modifier = Modifier.size(animationValue.dp), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
Давайте внимательнее посмотрим на этот блок:
val anim = remember { DecayAnimation( animationSpec = FloatExponentialDecaySpec(frictionMultiplier = 0.7f), initialValue = 0f, initialVelocity = 500f, ) }
Во-первых, необходимо объявить переменную anim и объявить класс DecayAnimation. Далее в конструктор данного класса нужно передать необходимые параметры: спецификацию анимации, начальное значение и начальное значение вектора скорости.
var animationValue by remember { mutableStateOf(0) }
Затем объявляется отдельная переменная для того, чтобы записывать в неё полученное анимированное значение.
LaunchedEffect(state) { val startTime = withFrameNanos { it } do { playTime = withFrameNanos { it } - startTime animationValue = anim.getValueFromNanos(playTime).toInt() } while (!anim.isFinishedFromNanos(playTime)) }
А дальше, как и для способа TargetBasedAnimation, здесь необходимы coroutines. Следующий шаг — получить время фрейма в наносекундах при помощи функции withFrameNanos. Далее, по аналогии с предыдущим методом, получаем анимированное значение с помощью функции getValueFromNanos.
Image( modifier = Modifier.size(animationValue.dp), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
И, наконец, применяем полученное анимированное значение к контенту при помощи соответствующей функции у Modifier. В результате получаем анимацию:

updateTransition
updateTransition — это способ создания низкоуровневой анимации, который позволяет запускать одну или несколько анимаций одновременно.
Если сравнивать с анимациями во view, то updateTransition является аналогом anomatorSet.
Ниже приведён конструктор функции:
@Composable fun <T> updateTransition( targetState: T, label: String? = null ): Transition<T> { val transition = remember { Transition(targetState, label = label) } transition.animateTo(targetState) DisposableEffect(transition) { onDispose { transition.onTransitionEnd() } } return transition }
Данная функция возвращает Transition, который как раз позволяет управлять одной или несколькими анимациями в качестве дочерних элементов и запускать анимации одновременно между несколькими состояниями. Функция updateTransition создает и запоминает экземпляр Transition и обновляет его состояние. Для того, чтобы получить Transition, в функцию updateTransition нужно передать:
-
targetState — стейт, при изменении которого необходимо запускать анимацию;
-
label — лейбл в виде текста, который служит для того, чтобы можно было различать различные Transition-ы на этапе отладки.
Для объекта Transition можно применить функции расширения animate* для создания дочерней анимации. В Jetpack Compose доступно 10 таких extension-функций в зависимости от необходимого типа:

Эти функции animate* возвращают анимированное значение, которое обновляется за каждый кадр во время анимации, когда состояние Transition обновляется с помощью updateTransition.
Чтобы реализовать такую анимацию, пишем код:
val transition = updateTransition(state, label = "") val sizeValue by transition.animateDp( transitionSpec = { tween(durationMillis = 1000) }, label = "", ) { screenState -> if (screenState == State.Up) { 136.dp } else { 56.dp } } val rotateValue by transition.animateFloat( transitionSpec = { tween(durationMillis = 1000) }, label = "", ) { screenState -> if (screenState == State.Up) { 0f } else { 360f } } Image( modifier = Modifier .fillMaxWidth() .rotate(rotateValue) .size(sizeValue), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
Теперь чуть подробнее разберём, что необходимо сделать для создания и запуска двух анимаций одновременно.
Для начала необходимо получить Transition, используя функцию updateTransition, при этом передавая в неё state. В данном примере state может иметь 2 состояния: State.Up и State.Down.
val sizeValue by transition.animateDp( transitionSpec = { tween(durationMillis = 1000) }, label = "", ) { screenState -> if (screenState == State.UP) { 136.dp } else { 56.dp } }
Далее объявляем переменную sizeValue, в которую будем записывать проанимированное значение размера изображения. У полученного Transition, который будет реагировать на изменение стейта, вызываем extension-функцию animateDp. В конструктор данной функции передаём необходимые параметры:
-
transitionSpec — спецификация транзишена, в данном примере указано, что длительность анимации составляет 1 секунду;
-
label — лейбл для данной анимации.
А в лямбде функции animateDp, в зависимости от стейта, указываем целевое значение переменной sizeValue, к которому оно будет стремиться, начиная от текущего значения.
val rotateValue by transition.animateFloat( transitionSpec = { tween(durationMillis = 1000) }, label = "", ) { screenState -> if (screenState == State.UP) { 0f } else { 360f } }
Аналогичным образом создается анимация для поворота изображения:
-
Создаётся отдельная переменная rotateValue;
-
У Transition вызывается extension-функция animateFloat;
-
Указываются необходимые параметры в конструктор функции animateFloat:
-
transitionSpec — спецификация анимации (здесь длительность анимации составляет 1 секунду)
-
label — лейбл для данной анимации.
Image( modifier = Modifier .fillMaxWidth() .rotate(rotateValue) .size(sizeValue), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", ) -
-
Полученные анимированные значения размера (sizeValue) и поворота (rotateValue) применяются к контенту. В данном примере контент представляет собой изображение.
-
Применяем анимированные значения при помощи extension-функций modifier-а.
Вот так выглядит готовая анимация с использованием updateTransition:

rememberInfiniteTransition
Следующий способ создания низкоуровневой анимации — это composable-функция rememberInfiniteTransition. Данная функция похожа на updateTransition за одним исключением: rememberInfiniteTransition возвращает InfiniteTransition. InfiniteTransition — это специальный транзишен, который позволяет запускать и контролировать одну или несколько бесконечных анимаций.
Ниже показан конструктор данной функции:
@Composable fun rememberInfiniteTransition(): InfiniteTransition { val infiniteTransition = remember { InfiniteTransition() } infiniteTransition.run() return infiniteTransition }
Здесь, в отличие от updateTransition, не нужно привязываться к стейту, а можно сразу получить InfiniteTransition и работать с ним. Также существует и отличие по количеству extension-функций, которые доступны и могут применяться для InfiniteTransition. По умолчанию в Jetpack Compose для InfiniteTransition доступно три функции, которые могут анимировать следующие типы данных: color, float и value.
Конструкторы данных функций показаны ниже:
Color:
@Composable fun InfiniteTransition.animateColor( initialValue: Color, targetValue: Color, animationSpec: InfiniteRepeatableSpec<Color> ): State<Color> {...}
Float:
@Composable fun InfiniteTransition.animateFloat( initialValue: Float, targetValue: Float, animationSpec: InfiniteRepeatableSpec<Float> ): State<Float> {...}
Value:
@Composable fun <T, V : AnimationVector> InfiniteTransition.animateValue( initialValue: T, targetValue: T, typeConverter: TwoWayConverter<T, V>, animationSpec: InfiniteRepeatableSpec<T> ): State<T> {...}
Для использования этих функций необходимо указать:
-
initialValue — начальное значение, с которого будет начинаться анимация параметра;
-
targetValue — конечное значение, куда будет стремиться и где будет заканчиваться анимация;
-
animationSpec — спецификация анимации;
-
typeConverter (только для animateValue) — конвертор типа, который позволяет нам анимировать определенный тип данных.
Чтобы реализовать анимацию, пишем код:
val infiniteTransition = rememberInfiniteTransition() val sizeValue by infiniteTransition.animateFloat( initialValue = 40.dp.value, targetValue = 136.dp.value, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ) ) val rotationValue by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000, easing = LinearEasing), repeatMode = RepeatMode.Restart, ) ) Image( modifier = Modifier .fillMaxWidth() .rotate(rotationValue) .size(sizeValue.dp), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", )
Данный код очень похож на код из предыдущего примера создания анимации при помощи функции updateTransition.
val infiniteTransition = rememberInfiniteTransition()
-
Получаем InfiniteTransition, используя функцию rememberInfiniteTransition.
val sizeValue by infiniteTransition.animateFloat( initialValue = 40.dp.value, targetValue = 136.dp.value, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ) ) -
Объявляем переменную sizeValue, в которую будет записываться проанимированное значение размера изображения. Для этого у полученного InfiniteTransition, вызываем extension-функцию animateFloat. В конструктор данной функции передаём необходимые параметры:
-
initialValue (начальное значение) — 40.dp
-
targetValue (конечное значение) — 136.dp
-
animationSpec (спецификация анимации) — длительность анимации 1 секунда, а также поведение анимации при достижении конечного значения (repeatMode)
val rotationValue by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1000, easing = LinearEasing), repeatMode = RepeatMode.Restart, ) ) -
-
Аналогично поступаем для анимации поворота. Объявляем переменную rotationValue, в которую будем записывать проанимированное значение поворота изображения. У полученного InfiniteTransition вызываем extension-функцию animateFloat. В конструктор данной функции передаём необходимые параметры:
-
initialValue (начальное значение) — 0f
-
targetValue (конечное значение) — 360f
-
animationSpec (спецификация анимации) — длительность 1 секунда, а также поведение анимации при достижении конечного значения (repeatMode)
Image( modifier = Modifier .fillMaxWidth() .rotate(rotation) .size(size.dp), painter = painterResource(id = R.drawable.ic_logo), contentDescription = "", ) -
-
Применяем полученные анимированные значения размера (sizeValue) и поворота (rotateValue) к контенту (изображению). Анимированные значения применяем при помощи функций extension-функций modifier-а.
В итоге получается вот такая анимация:

Мы рассмотрели все способы создания высокоуровневых и низкоуровневых анимаций (спасибо, что дочитали до этого момента). Осталось разобрать, как кастомизировать эти анимации, и на этом наш туториал можно смело считать завершённым!
Способы кастомизации анимации
В зависимости от способа создания анимации, всегда доступен один из способов кастомизации анимации при помощи параметров:
-
animationSpec
-
transitionSpec (в случае с updateTransition)
Каждый параметр можно кастомизировать одним из способов, которые доступны в Jetpack Compose:
|
spring |
tween |
keyframes |
repeatable |
infiniteRepeatable |
snap |
Рассмотрим их по порядку.
spring
spring — это способ кастомизации, который создает основанную на физике пружины анимацию между начальным и конечным значениями. Так выглядит конструктор данной функции:
@Stable fun <T> spring( dampingRatio: Float = Spring.DampingRatioNoBouncy, stiffness: Float = Spring.StiffnessMedium, visibilityThreshold: T? = null ): SpringSpec<T> {...}
В конструктор необходимо передать два обязательных параметра, по которым будет строиться спецификация анимации:
-
dampingRatio — демпфирование. Определяет, насколько быстро будут затухать колебания пружины;
-
stiffness — жёсткость. Определяет, как быстро пружина должна двигаться к конечному значению.
В Jetpack Compose доступно пять различных характеристик демпфирования. Визуальное представление доступных характеристик и их значение показаны на рисунке ниже:

Аналогично с характеристикой жёсткости stiffness доступны дефолтные реализации. Их значение показано на рисунке ниже:

tween
tween — это способ кастомизации, который позволяет создать характеристику анимации между начальными и конечными значениями за указанную продолжительность с использованием кривой смягчения. Конструктор функции выглядит так:
@Stable fun <T> tween( durationMillis: Int = DefaultDurationMillis, delayMillis: Int = 0, easing: Easing = FastOutSlowInEasing ): TweenSpec<T> = {...}
В него необходимо передать три обязательных параметра, по которым будет строиться спецификация анимации:
-
durationMillis — продолжительность анимации в миллисекундах;
-
delayMillis — задержка в миллисекундах, которая будет выполняться до запуска анимации;
-
easing — кривая смягчения, по которой будет выполняться анимация. (Является аналогом интерполятора, как в анимациях во view).
Если с продолжительностью и задержкой вроде всё и так понятно, то параметр кривой смягчения предлагаю рассмотреть поподробнее.
В физическом мире объекты не запускаются и не останавливаются мгновенно. Им требуется время, чтобы ускориться и замедлиться. Easing — это характеристика, которая заставляет элементы двигаться так, будто естественные силы, такие как трение, гравитация и масса, работают. Другими словами, easing позволяет анимированным элементам ускоряться и замедляться с разной скоростью.
Всего в Jetpack Compose доступно 5 дефолтных easing:
-
FastOutSlowInEasing
-
LinearOutSlowInEasing
-
FastOutLinearInEasing
-
LinearEasing
-
CubicBezierEasing
Первые 4 способа — это реализация конкретной кривой, которая показана ниже на графиках:




А вот CubicBezierEasing — это easing, который позволяет реализовать свою собственную кривую смягчения. Данный easing основан на кривой Безье, которая строится по четырём точкам. Ниже показан конструктор данной кривой:
@Immutable class CubicBezierEasing( private val a: Float, private val b: Float, private val c: Float, private val d: Float ) : Easing {...}

На графике показано, как будет изменяться кривая при изменении одной из точек.
keyframes
keyframes — это спецификация анимации по ключевым кадрам. Кадры анимируются на основе значений моментальных снимков, указанных при разных временных метках во время анимации. В любой момент времени значение анимации будет интерполировано между двумя значениями ключевых кадров.
Конструктор этого способа кастомизации выглядит вот так:
@Stable fun <T> keyframes( init: KeyframesSpec.KeyframesSpecConfig<T>.() -> Unit ): KeyframesSpec<T> {...}
Он не слишком информативный, поэтому предлагаю рассмотреть на конкретном примере:
keyframes { durationMillis = 300 delayMillis = 100 val firstValue = IntSize(width = 200, height = 100) val firstFrame = 150 val secondValue = IntSize(width = 300, height = 200) val secondFrame = 250 firstValue at firstFrame secondValue at secondFrame with FastOutLinearInEasing }
Для каждого из этих ключевых кадров можно указать:
-
durationMillis — общую длительность анимации в миллисекундах;
-
delayMillis — задержку перед анимацией в миллисекундах;
-
firstValue и firstFrame — анимируемый тип со временной отметкой, то есть в какой момент времени должно быть достигнуто необходимое значение.
Стоит отметить, что помимо анимированного типа с временем кадра можно также указать кривую смягчения, которая может быть применена к данному фрейму.
repeatable
repeatable — это спецификация анимации, которая позволяет создавать повторяющиеся анимации на основе длительности, пока не достигнет указанного количества итераций. Создать эту спецификацию можно так:
@Stable fun <T> repeatable( iterations: Int, animation: DurationBasedAnimationSpec<T>, repeatMode: RepeatMode = RepeatMode.Restart ): RepeatableSpec<T> {...}
Спецификация будет строиться по трём обязательным параметрам:
-
iterations — количество итераций повторений анимации;
-
animation — спецификация анимации, основанная на длительности анимации;
-
repeatMode — режим повторения анимации.
В Jetpack Compose доступно два варианта repeatMode:
-
RepeatMode.Reverse — режим, при котором по достижении конечного значения анимация начинает воспроизводиться в обратном порядке, то есть от конечного значения к начальному.
-
RepeatMode.Restart — режим, при котором по достижении конечного значения анимация начинает воспроизводиться с самого начала, то есть перезапускается.
infiniteRepeatable
infiniteRepeatable — это спецификация анимации, которая позволяет создавать бесконечные анимации на основе длительности. Рассмотрим, как её создать:
@Stable fun <T> infiniteRepeatable( animation: DurationBasedAnimationSpec<T>, repeatMode: RepeatMode = RepeatMode.Restart ): InfiniteRepeatableSpec<T> {...}
Эта спецификация строится всего по двум обязательным параметрам:
-
animation — спецификация анимации, основанная на длительности анимации;
-
repeatMode — режим повторения анимации. Если уже забыли, как он работает, листайте выше.
snap
И, наконец, snap — это спецификация анимации, которая немедленно переключает текущее значение на конечное значение.
Конструктор snap выглядит вот так:
@Stable fun <T> snap(delayMillis: Int = 0) = SnapSpec<T>(delayMillis)
Из конструктора функции видно, что доступен только параметр delayMillis, который позволяет указать задержку в миллисекундах перед запуском анимации.
На этом экскурс по анимациям закончен, и напоследок я хотел бы поделиться литературой, которая позволит закрепить и более детально разобраться в основах анимации в Jetpack Compose:
-
Material Design animation — гайдлайны по созданию анимаций и их характеристик.
-
Cubic Bezier — онлайн ресурс, который позволяет в реальном времени поиграть с кривой Безье, посмотреть, как она будет изменяться в зависимости от точек, и как при этом себя будет вести анимация.
Ну и по традиции приглашаю в ТГ-канал Кошелька про мобильную разработку — там мы пишем короткие заметки про то, как мы развиваем наше приложение 😉 Всем плавных и красивых анимаций!
ссылка на оригинал статьи https://habr.com/ru/company/jugru/blog/683656/
Добавить комментарий