Макеты для создания лучших анимаций
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]