Макеты для создания лучших анимаций

29 июня в выпуске Jetpack Compose 1.3.0-alpha01 был представлен новый макет LookaheadLayout. Чтобы понять концепцию LookaheadLayout, давайте представим, что у нас есть ExpandableFab, как показано ниже.

Вопреки тому, что мы себе представляли, это некрасиво, потому что нет анимации. Добавим анимацию.

Теперь ExpandableFab, которое мы себе представляли, завершено.

Анимации, включая ExpandableFab выше, продолжаются как начальный кадр, который является начальным состоянием, кадр анимации, в котором выполняется анимация, и конечный кадр, который является конечным состоянием.

Итак, чтобы реализовать такую ​​анимацию в Compose, похоже, нужно отслеживать кадр анимации, как принцип рисования анимации выше, и рисовать новый компонуемый, который подходит для каждого кадра.

Однако макет, который может отслеживать эти кадры анимации, отсутствует до версии 1.2.0. Макет, который появился, чтобы преодолеть это, называется LookaheadLayout.

LookaheadLayout предварительно просматривает кадры, которые должны быть нарисованы между кадрами начала и конца, и помогает нарисовать новый макет между кадрами начала и конца на основе информации (размеры, размещение), которая должна быть нарисована в этом кадре.

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

Информацию, рассчитанную на этапе просмотра вперед, можно получить с помощью Modifier.onPlaced, а промежуточную раскладку фактически можно разместить через Modifier.intermediateLayout.

Итак, мы узнали о концепции LookaheadLayout. Теперь давайте на самом деле использовать его.

/**
* A layout that pre-measures and executes a placement step later to determine the layout. => Pre-measure step: lookahead step
* After the lookahead phase is finished, another measure and placement phase is started
* via [LookaheadLayoutScope.intermediateLayout] which allows you to adjust the measurement and
* placement of the layout based on the lookahead result. => intermediate layout
*
* You can use this to gradually change the size and position of the layout
* towards a pre-measured target layout.
*
* @param content composable content to display
* @param modifier [Modifier] to apply to the layout
* @param measurePolicy Policies to use in layout measurement and positioning
*/
@ExperimentalComposeUiApi
@UiComposable
@Composable
fun LookaheadLayout(
content: @Composable @UiComposable LookaheadLayoutScope.() -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)

LookaheadLayout компонуемый, который находится на экспериментальной стадии, и он получает LookaheadLayoutScope в качестве объема содержимого.

/**
* Provides receiver scopes for all (direct and indirect) child layouts of [LookaheadLayout].
* Measurement and placement of the layout calculated in the lookahead stage
* can be observed through [Modifier.intermediateLayout] and [Modifier.onPlaced]
* in [LookaheadLayoutScope] respectively.
*/
@ExperimentalComposeUiApi
interface LookaheadLayoutScope {
fun Modifier.onPlaced(
onPlaced: (
lookaheadScopeCoordinates: LookaheadLayoutCoordinates,
layoutCoordinates: LookaheadLayoutCoordinates
) -> Unit
): Modifier
fun Modifier.intermediateLayout(
measure: MeasureScope.(
measurable: Measurable,
constraints: Constraints,
lookaheadSize: IntSize
) -> MeasureResult
): Modifier
}

LookaheadLayoutScope реализует Modifier.onPlaced и Modifier.intermediateLayout.

/**
* Called after the location where the intermediate layout will be placed is calculated.
*
* Given [LookaheadLayoutCoordinates], you can get the offset of the positioned intermediate layout and
* the offset of the currently positioned content using
* [LookaheadLayoutCoordinates.localLookaheadPositionOf] and [LookaheadLayoutCoordinates.localPositionOf].
* This allows you to adjust the placement of the content based on the calculated offset of the intermediate layout.
*
* [onPlaced lambda arguments]
*
* @param lookaheadScopeCoordinates [LookaheadLayoutCoordinates] used by [LookaheadLayout]
* @param layoutCoordinates [LookaheadLayoutCoordinates] used by composable of this modifier
*/
fun Modifier.onPlaced(
onPlaced: (
lookaheadScopeCoordinates: LookaheadLayoutCoordinates,
layoutCoordinates: LookaheadLayoutCoordinates
) -> Unit
): Modifier

Modifier.onPlaced вызывается с рассчитанными значениями в качестве аргумента, когда информация для размещения промежуточного макета вычисляется на этапе просмотра вперед. Параметры - это LookaheadLayoutCoordinates, которые используются LookaheadLayout, и этот модификатор компонуемый.

Процесс вызова Modifier.onPlaced таков:

LookaheadLayoutCoordinates, полученное в качестве аргумента, представляет собой интерфейс, который содержит LayoutCoordinates обоих макетов до и после этапа просмотра вперед. При этом можно получить рассчитанное смещение промежуточного макета и смещение текущего содержимого.

/**
* Holds the [LayoutCoordinates] of both the layout before and after the lookahead step.
*/
@ExperimentalComposeUiApi
sealed interface LookaheadLayoutCoordinates : LayoutCoordinates {
/**
* Converts [relativeToSource] in [sourceCoordinates] space to local coordinates.
* [sourceCoordinates] can be any [LookaheadLayoutCoordinates] belonging to the same layout hierarchy.
*
* Unlike [localPositionOf], [localLookaheadPositionOf] uses the lookahead position for coordinate calculation.
*
* @param sourceCoordinates [LookaheadLayoutCoordinates] with [Offset] to convert
* @param relativeToSource [Offset] that will convert
*
* @return [Offset] that converted into local coordinate
*/
fun localLookaheadPositionOf(
sourceCoordinates: LookaheadLayoutCoordinates,
relativeToSource: Offset = Offset.Zero
): Offset
}
/**
* A holder of the measured bounds for the layout.
*/
@JvmDefaultWithCompatibility
interface LayoutCoordinates {
// … skip
/**
* Converts [relativeToSource] in [sourceCoordinates] space to local coordinates.
* [sourceCoordinates] can be any [LookaheadLayoutCoordinates] belonging to the same layout hierarchy.
*
* @param sourceCoordinates [LookaheadLayoutCoordinates] with [Offset] to convert
* @param relativeToSource [Offset] that will convert
*
* @return [Offset] that converted into local coordinate
*/
fun localPositionOf(sourceCoordinates: LayoutCoordinates, relativeToSource: Offset): Offset
}

И LookaheadLayoutCoordinates.localLookaheadPositionOf, и LookaheadLayoutCoordinates.localPositionOf используются для получения преобразованного смещения относительно определенной координаты. Единственное отличие состоит в том, что в отличие от localPositionOf, localLookaheadPositionOf использует позицию просмотра вперед для вычисления координат.

Теперь давайте посмотрим на остальную часть Modifier.intermediateLayout.

/**
* Place an intermediate layout based on the information calculated in the lookahead step.
* The intermediate layout can be morphed through the [measure] argument,
* which is a lambda that provides the size of the intermediate layout.
*
* morph: changing the current shape to another shape
*
* [measure lambda arguments]
*
* @param measurable measurable of intermediate layout
* @param constraints constraints of intermediate layout
* @param lookaheadSize size of intermediate layout
*
* @return measure result
*/
fun Modifier.intermediateLayout(
measure: MeasureScope.(
measurable: Measurable,
constraints: Constraints,
lookaheadSize: IntSize
) -> MeasureResult
): Modifier

Можно использовать Modifier.intermediateLayout для размещения промежуточного макета на основе информации, рассчитанной на шаге просмотра вперед.

Modifier.onPlaced и Modifier.intermediateLayout это:

Теперь давайте вернемся к процессу кадра ExpandableFab, который мы видели в начале этой статьи, чтобы фактически использовать LookaheadLayout.

Процесс кадра анимации теперь можно отслеживать через LookaheadLayout, а размер и смещение внутри этого кадра анимации изменяются. Мы можем реализовать это, используя Modifier.onPlaced и Modifier.intermediateLayout.

/**
* Given [LookaheadLayoutCoordinates], you can get the offset of the positioned intermediate layout and
* the offset of the currently positioned content using
* [LookaheadLayoutCoordinates.localLookaheadPositionOf] and [LookaheadLayoutCoordinates.localPositionOf].
* This allows you to adjust the placement of the content based on the calculated offset of the intermediate layout.
*/
fun Modifier.onPlaced(
onPlaced: (
lookaheadScopeCoordinates: LookaheadLayoutCoordinates,
layoutCoordinates: LookaheadLayoutCoordinates
) -> Unit
): Modifier
/**
* Place an intermediate layout based on the information calculated in the lookahead step.
* The intermediate layout can be morphed through the [measure] argument,
* which is a lambda that provides the size of the intermediate layout.
*/
fun Modifier.intermediateLayout(
measure: MeasureScope.(
measurable: Measurable,
constraints: Constraints,
lookaheadSize: IntSize
) -> MeasureResult
): Modifier

Чтобы настроить размер, трансформируйте промежуточный макет с помощью аргумента измерения, который представляет собой лямбду, предоставляющую размер промежуточного макета с помощью Modifier.imtermediateLayout. Чтобы настроить смещение, отрегулируйте размещение контента на основе смещения промежуточного макета, рассчитанного с помощью Modifier.onPlaced.

Давайте сначала создадим Modifier.movement для настройки смещения.

fun Modifier.movement(lookaheadScope: LookaheadLayoutScope) = composed {
var targetOffset: IntOffset? by remember { mutableStateOf(null) } // offset to place
var placementOffset by remember { mutableStateOf(IntOffset.Zero) } // current offset
with(lookaheadScope) {
this@composed
.onPlaced { lookaheadScopeCoordinates, layoutCoordinates ->
// Returns the lookahead position of this modifier in the local coordinates of the LookaheadLayout
targetOffset = lookaheadScopeCoordinates
.localLookaheadPositionOf(sourceCoordinates = layoutCoordinates)
.round() // Rounding offsets to the nearest IntOffset value
// Returns the current position of this modifier in the local coordinates of the LookaheadLayout
placementOffset = lookaheadScopeCoordinates
.localPositionOf(
sourceCoordinates = layoutCoordinates,
relativeToSource = Offset.Zero
)
.round()
}
.intermediateLayout { measurable, constraints, _ ->
val placeable = measurable.measure(constraints)
layout(width = placeable.width, height = placeable.height) {
// Place at moved offset
val (x, y) = targetOffset!! - placementOffset
placeable.place(x = x, y = y)
}
}
}
}

Затем создал Modifier.transformation для изменения размера.

fun Modifier.transformation(lookaheadScope: LookaheadLayoutScope) = with(lookaheadScope) {
intermediateLayout { measurable, _, lookaheadSize ->
val (width, height) = lookaheadSize // Determine width and height by lookahead size
val animatedConstraints = Constraints.fixed(
width = width.coerceAtLeast(0), // Minimum set to 0
height = height.coerceAtLeast(0)
)
val placeable = measurable.measure(animatedConstraints)
layout(width = placeable.width, height = placeable.height) { // Layout to fit lookahead size
placeable.place(x = 0, y = 0)
}
}
}

Теперь, чтобы применить сделанные таким образом модификаторы, существующий Fab оборачиваем LookaheadLayout и подключаем модификатор.

@Composable
fun ExpandableFabBasic(modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(false) }
val screenMaxWidth = LocalConfiguration.current.screenWidthDp
LookaheadLayout(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.padding(16.dp),
content = {
Fab(
modifier = Modifier
.size(
size = FabDefaults.size(
isExpanded = isExpanded,
maxWidthDp = screenMaxWidth.dp
)
)
.movement(lookaheadScope = this)
.transformation(lookaheadScope = this)
.noRippleClickable {
isExpanded = !isExpanded
},
isExpanded = isExpanded
)
},
measurePolicy = DefaultMeasurePolicy
)
}

Результат:

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

Animatable можно использовать для обработки анимации. Давайте анимируем изменения размера и смещения.

fun Modifier.animateMovement(
lookaheadScope: LookaheadLayoutScope,
animationSpec: AnimationSpec<IntOffset> = defaultSpring()
) = composed {
var placementOffset by remember { mutableStateOf(IntOffset.Zero) }
var targetOffset: IntOffset? by remember { mutableStateOf(null) }
var targetOffsetAnimation: Animatable<IntOffset, AnimationVector2D>? by remember {
mutableStateOf(null)
}
LaunchedEffect(Unit) {
snapshotFlow { targetOffset }.collect { target ->
if (target != null && target != targetOffsetAnimation?.targetValue) {
targetOffsetAnimation?.run {
launch {
animateTo(
targetValue = target,
animationSpec = animationSpec
)
}
} ?: Animatable(
initialValue = target,
typeConverter = IntOffset.VectorConverter
).let { offsetAnimatable ->
targetOffsetAnimation = offsetAnimatable
}
}
}
}
with(lookaheadScope) {
this@composed
.onPlaced { lookaheadScopeCoordinates, layoutCoordinates ->
targetOffset = lookaheadScopeCoordinates
.localLookaheadPositionOf(sourceCoordinates = layoutCoordinates)
.round()
placementOffset = lookaheadScopeCoordinates
.localPositionOf(
sourceCoordinates = layoutCoordinates,
relativeToSource = Offset.Zero
)
.round()
}
.intermediateLayout { measurable, constraints, _ ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
val (x, y) = (
targetOffsetAnimation?.value ?: targetOffset!!
) - placementOffset
placeable.place(x, y)
}
}
}
}
fun Modifier.animateTransformation(
lookaheadScope: LookaheadLayoutScope,
animationSpec: AnimationSpec<IntSize> = defaultSpring(),
) = composed {
var targetSize: IntSize? by remember { mutableStateOf(null) }
var targetSizeAnimation: Animatable<IntSize, AnimationVector2D>? by remember {
mutableStateOf(null)
}
LaunchedEffect(Unit) {
snapshotFlow { targetSize }.collect { target ->
if (target != null && target != targetSizeAnimation?.targetValue) {
targetSizeAnimation?.run {
launch {
animateTo(
targetValue = target,
animationSpec = animationSpec
)
}
} ?: Animatable(
initialValue = target,
typeConverter = IntSize.VectorConverter
).let { sizeAnimatable ->
targetSizeAnimation = sizeAnimatable
}
}
}
}
with(lookaheadScope) {
this@composed.intermediateLayout { measurable, _, lookaheadSize ->
targetSize = lookaheadSize
val (width, height) = targetSizeAnimation?.value ?: lookaheadSize
val animatedConstraints = Constraints.fixed(
width = width.coerceAtLeast(0),
height = height.coerceAtLeast(0)
)
val placeable = measurable.measure(animatedConstraints)
layout(width = placeable.width, height = placeable.height) {
placeable.place(x = 0, y = 0)
}
}
}
}

Наконец, он выглядит так, как мы хотим.

Конец!

Эта статья представила LookaheadLayout. Используя LookaheadLayout, вы можете легко реализовать множество анимаций, таких как переход общего элемента.

Полный код ExpandableFab, использованный в примере в этой статье, можно найти по ссылке ниже.

Кроме того, после выпуска версии 1.3.0-alpha01 Compose начал переходить на независимую систему управления версиями. На данный момент отделен только компилятор, и если установить версию компилятора Compose на 1.3.0-alpha01, то можно использовать версию Kotlin 1.7.0.

Спасибо за прочтение.

[View in Korean]