Как создавать анимации в Jetpack Compose

от автора

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

Зачем вашим приложениям анимации?

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

Это поведение, свойственное объектам реального мира, привычно для пользователя. Поэтому его можно сохранять и передавать объектам виртуального мира: экранам, компонентам и элементам на экранах приложений. Кроме того, анимации приносят профит пользователю:

  • Они улучшают взаимодействие пользователя с интерфейсом;

  • Повышают плавность работы приложения;

  • Обеспечивают прогнозируемость работы приложения.

Приносят ли анимации пользу для бизнеса? Ответ — конечно же да, и вот почему:

  1. С помощью анимаций можно увеличить конверсию за счет удержания текущих пользователей и привлечения новых. Например, если есть два почти одинаковых по сути приложения, но одно из них с анимациями, то оно, с большой вероятностью, станет фаворитом пользователей.

  2. Анимации маскируют «медленную» работу приложения. Под словом «медленную» имеется в виду не троттлинг или фризинг приложения, а неоптимальный контракт между клиентом и сервером (долгие и частые сетевые запросы на многих экранах).

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

Теперь у вас есть целый арсенал аргументов, зачем бизнесу необходимо тратить деньги, а разработчику — своё время на создание анимаций в приложении. Давайте перейдём к сути и обсудим, как их реализовать. В Jetpack Compose есть два типа анимаций: высокоуровневые и низкоуровневые.

Создание высокоуровневых анимаций

Начнём экскурс с высокоуровневых анимаций, так как они проще в использовании, требуют минимум действий для запуска, и, к тому же, разработаны с последними практиками Material Design Motion.

На данный момент в Jetpack Compose доступно 4 способа создания высокоуровневой анимации:

  1. AnimatedVisibility

  2. AnimatedContent

  3. Crossfade

  4. 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 разных типов транзишенов:

EnterTransition. Взято из официальной документации.
EnterTransition. Взято из официальной документации.

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

ExitTransition.  Взято из официальной документации.
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. В результате получаем такую анимацию:

AnimatedVisibility
AnimatedVisibility

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.

AnimatedContent
AnimatedContent

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()     } }

В данном случае всё достаточно просто:

  1. Внутрь  composable-функции Crossfade передаём state.

  2. В зависимости от стейта вызываем ту или иную composable-функцию, которая является контентом (изображение или текст соответственно). Пример:

Crossfade
Crossfade

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. Пример:

Modifier.animateContentSize
Modifier.animateContentSize

Итак, с высокоуровневыми анимациями закончили, идём дальше.

Низкоуровневые анимации

Все высокоуровневые 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. Результат выглядит следующим образом:

Animatable
Animatable

animate*AsState

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

В Jetpack Compose по умолчанию доступно несколько поддерживаемых типов анимации из группы animate*AsState:

Типы переменных из группы  animate*AsState
Типы переменных из группы  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 под капотом, и сама анимация выглядит вот так:

animate*AsState
animate*AsState

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. Пример:

TargetBasedAnimation
TargetBasedAnimation

Второй реализацией интерфейса 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. В результате получаем анимацию:

DecayAnimation
DecayAnimation

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-функций в зависимости от необходимого типа:

Набор функции расширения для Transition
Набор функции расширения для Transition

Эти функции 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     } }

Аналогичным образом создается  анимация для поворота изображения:

  1. Создаётся отдельная переменная rotateValue;

  2. У Transition вызывается extension-функция animateFloat;

  3. Указываются необходимые параметры в конструктор функции animateFloat:

    • transitionSpec — спецификация анимации (здесь длительность анимации составляет 1 секунду)

    • label — лейбл для данной анимации.

    Image(     modifier = Modifier         .fillMaxWidth()         .rotate(rotateValue)         .size(sizeValue),     painter = painterResource(id = R.drawable.ic_logo),     contentDescription = "", )
  4. Полученные анимированные значения размера (sizeValue) и поворота (rotateValue) применяются к контенту. В данном примере контент представляет собой изображение. 

  5. Применяем анимированные значения при помощи extension-функций modifier-а.

Вот так выглядит готовая анимация с использованием updateTransition:

updateTransition
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()
  1. Получаем 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,     ) )

  2. Объявляем переменную 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,     ) )

  3. Аналогично поступаем для анимации поворота. Объявляем переменную 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 = "", )

  4. Применяем полученные анимированные значения размера (sizeValue) и поворота (rotateValue) к контенту (изображению). Анимированные значения применяем при помощи функций extension-функций modifier-а.

В итоге получается вот такая анимация:

rememberInfiniteTransition
rememberInfiniteTransition

Мы рассмотрели все способы создания высокоуровневых и низкоуровневых анимаций (спасибо, что дочитали до этого момента). Осталось разобрать, как кастомизировать эти анимации, и на этом наш туториал можно смело считать завершённым!

Способы кастомизации анимации

В зависимости от способа создания анимации, всегда доступен один из способов кастомизации анимации при помощи параметров:

  • 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:

  1. FastOutSlowInEasing

  2. LinearOutSlowInEasing

  3. FastOutLinearInEasing

  4. LinearEasing

  5. CubicBezierEasing

Первые 4 способа — это реализация конкретной кривой, которая показана ниже на графиках:

FastOutSlowInEasing
FastOutSlowInEasing
LinearOutSlowInEasing
LinearOutSlowInEasing
FastOutLinearInEasing
FastOutLinearInEasing
LinearEasing
LinearEasing

А вот CubicBezierEasing — это easing, который позволяет реализовать свою собственную кривую смягчения. Данный easing основан на кривой Безье, которая строится по четырём точкам.  Ниже показан конструктор данной кривой:

@Immutable class CubicBezierEasing(     private val a: Float,     private val b: Float,     private val c: Float,     private val d: Float ) : Easing {...}
CubicBezierEasing
CubicBezierEasing

На графике показано, как будет изменяться кривая при изменении одной из точек.

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:

  1. RepeatMode.Reverse — режим, при котором по достижении конечного значения анимация начинает воспроизводиться в обратном порядке, то есть  от конечного значения к начальному.

  2. 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *