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

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

9e9ca9b37c92dd95a5be601be12c3d12.jpg

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

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

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

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

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

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

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

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

AnimatedVisibilityAnimatedVisibility

AnimatedContent

Этот способ подходит для анимирования контента внутри себя относительно стейта. AnimatedContent — это composable-функция, которая имеет следующий конструктор:

fun  AnimatedContent(
    targetState: S,
    modifier: Modifier = Modifier,
    transitionSpec: AnimatedContentScope.() -> 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.

AnimatedContentAnimatedContent

Crossfade

Crossfade применяется для создания анимаций между состояниями с помощью анимации перекрёстного затухания (fade-анимаций). При изменении значения состояния (стейта), переданное в качестве параметра содержимое переключается с помощью анимации перекрестного затухания. Crossfade — это тоже composable-функция, которая имеет следующий конструктор:

@Composable
fun  Crossfade(
    targetState: T,
    modifier: Modifier = Modifier,
    animationSpec: FiniteAnimationSpec = 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-функцию, которая является контентом (изображение или текст соответственно). Пример:

CrossfadeCrossfade

Modifier.animateContentSize

Этот способ создания анимаций применяется для анимирования размера контента. AnimateContentSize — это extension-функция для Modifier-а. Получается, что данным способом можно проанимировать размер любой composable-функции, у которой имеется Modifier. AnimateContentSize имеет следующий конструктор:

fun Modifier.animateContentSize(
    animationSpec: FiniteAnimationSpec = 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.animateContentSizeModifier.animateContentSize

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

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

Все высокоуровневые API анимаций построены на основе низкоуровневых анимационных API. Далее мы разберём все способы создания низкоуровневых анимаций, а именно:

Animatable

Класс Animatable содержит все необходимые данные о запущенной анимации: начальное значение, конечное значение, прогресс. Кроме того, Animatable поддерживает анимирование двух типов значений float и color. Ниже приведены конструкторы данного класса:

fun Animatable(
    initialValue: Float,
    visibilityThreshold: Float = Spring.DefaultDisplacementThreshold
) = Animatable(
    initialValue,
    Float.VectorConverter,
    visibilityThreshold
)
fun Animatable(initialValue: Color): Animatable =
    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. Результат выглядит следующим образом:

AnimatableAnimatable

animate*AsState

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

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

Типы переменных из группы  animate*AsStateТипы переменных из группы  animate*AsState

Для примера рассмотрим, что необходимо передавать для работы данной анимации на двух функциях:

@Composable
fun animateFloatAsState(
    targetValue: Float,
    animationSpec: AnimationSpec = defaultAnimation,
    visibilityThreshold: Float = 0.01f,
    finishedListener: ((Float) -> Unit)? = null
): State {...}

и

@Composable
fun animateDpAsState(
    targetValue: Dp,
    animationSpec: AnimationSpec = dpDefaultSpring,
    finishedListener: ((Dp) -> Unit)? = null
): State {...}

Первым и обязательным параметром является 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*AsStateanimate*AsState

Animation

Animation — это интерфейс анимаций с контролем состояния анимаций. В Jetpack Compose доступно две реализации данного интерфейса:

  • TargetBasedAnimation

  • DecayAnimation

Предлагаю более подробно разобраться с данными реализациями. Начнём с TargetBasedAnimation — это API анимации самого низкого уровня. Другие API охватывают большинство сценариев использования, но использование TargetBasedAnimation напрямую позволяет вам самостоятельно контролировать время воспроизведения анимации.

Ниже приведён конструктор класса:

constructor(
    animationSpec: AnimationSpec,
    typeConverter: TwoWayConverter,
    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. Пример:

TargetBasedAnimationTargetBasedAnimation

Второй реализацией интерфейса Animation является класс DecayAnimation, который также является API анимации самого низкого уровня. Данный способ создания анимации позволяет реализовать анимацию «затухания». Другими словами, к концу своего выполнения анимация будет плавно завершаться. Реализация DecayAnimation похожа на TargetBasedAnimation, , но есть и важные отличия:

  constructor(
    animationSpec: DecayAnimationSpec,
    typeConverter: TwoWayConverter,
    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. В результате получаем анимацию:

DecayAnimationDecayAnimation

updateTransition

updateTransition — это способ создания низкоуровневой анимации, который позволяет запускать одну или несколько анимаций одновременно. 

Если сравнивать с анимациями во view, то updateTransition является аналогом anomatorSet.

Ниже приведён конструктор функции:

@Composable
fun  updateTransition(
    targetState: T,
    label: String? = null
): Transition {
    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:

updateTransitionupdateTransition

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
): State {...}

Float:

@Composable
fun InfiniteTransition.animateFloat(
    initialValue: Float,
    targetValue: Float,
    animationSpec: InfiniteRepeatableSpec
): State {...}

Value:

@Composable
fun  InfiniteTransition.animateValue(
    initialValue: T,
    targetValue: T,
    typeConverter: TwoWayConverter,
    animationSpec: InfiniteRepeatableSpec
): State {...}

Для использования этих функций необходимо указать:

  • 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-а.

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

rememberInfiniteTransitionrememberInfiniteTransition

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

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

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

Каждый  параметр можно кастомизировать одним из способов, которые доступны в Jetpack Compose:

spring

tween

keyframes

repeatable

infiniteRepeatable

snap

Рассмотрим их по порядку.

spring

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

@Stable
fun  spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy,
    stiffness: Float = Spring.StiffnessMedium,
    visibilityThreshold: T? = null
): SpringSpec {...}

В конструктор необходимо передать два обязательных параметра, по которым будет строиться спецификация анимации:

  • dampingRatio — демпфирование. Определяет, насколько быстро будут затухать колебания пружины;

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

В Jetpack Compose доступно пять различных характеристик демпфирования. Визуальное представление доступных характеристик и их значение показаны на рисунке ниже:

© Habrahabr.ru