Анимации в 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 — онлайн ресурс, который позволяет в реальном времени поиграть с кривой Безье, посмотреть, как она будет изменяться в зависимости от точек, и как при этом себя будет вести анимация.
Ну и по традиции приглашаю в ТГ-канал Кошелька про мобильную разработку — там мы пишем короткие заметки про то, как мы развиваем наше приложение ;) Всем плавных и красивых анимаций!