Pull to refresh
257.08
JUG Ru Group
Конференции для Senior-разработчиков

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

Reading time25 min
Views26K

Анимации в 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 — онлайн ресурс, который позволяет в реальном времени поиграть с кривой Безье, посмотреть, как она будет изменяться в зависимости от точек, и как при этом себя будет вести анимация.

Ну и по традиции приглашаю в ТГ-канал Кошелька про мобильную разработку — там мы пишем короткие заметки про то, как мы развиваем наше приложение ;) Всем плавных и красивых анимаций!

Tags:
Hubs:
Total votes 17: ↑17 and ↓0+17
Comments1

Articles

Information

Website
jugru.org
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
Алексей Федоров