Merge changes from topic "scene-transition-layout" into udc-qpr-dev
* changes: Introduce SceneTransitionLayout (1/2) Fix child constraints in (Horizontal|Vertical)Grid
This commit is contained in:
committed by
Android (Google) Code Review
commit
497b75bb1d
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.DisposableEffectResult
|
||||
import androidx.compose.runtime.DisposableEffectScope
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import com.android.compose.ui.util.lerp
|
||||
|
||||
/**
|
||||
* Animate a shared Int value.
|
||||
*
|
||||
* @see SceneScope.animateSharedValueAsState
|
||||
*/
|
||||
@Composable
|
||||
fun SceneScope.animateSharedIntAsState(
|
||||
value: Int,
|
||||
key: ValueKey,
|
||||
element: ElementKey,
|
||||
canOverflow: Boolean = true,
|
||||
): State<Int> {
|
||||
return animateSharedValueAsState(value, key, element, ::lerp, canOverflow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a shared Float value.
|
||||
*
|
||||
* @see SceneScope.animateSharedValueAsState
|
||||
*/
|
||||
@Composable
|
||||
fun SceneScope.animateSharedFloatAsState(
|
||||
value: Float,
|
||||
key: ValueKey,
|
||||
element: ElementKey,
|
||||
canOverflow: Boolean = true,
|
||||
): State<Float> {
|
||||
return animateSharedValueAsState(value, key, element, ::lerp, canOverflow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a shared Dp value.
|
||||
*
|
||||
* @see SceneScope.animateSharedValueAsState
|
||||
*/
|
||||
@Composable
|
||||
fun SceneScope.animateSharedDpAsState(
|
||||
value: Dp,
|
||||
key: ValueKey,
|
||||
element: ElementKey,
|
||||
canOverflow: Boolean = true,
|
||||
): State<Dp> {
|
||||
return animateSharedValueAsState(value, key, element, ::lerp, canOverflow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a shared Color value.
|
||||
*
|
||||
* @see SceneScope.animateSharedValueAsState
|
||||
*/
|
||||
@Composable
|
||||
fun SceneScope.animateSharedColorAsState(
|
||||
value: Color,
|
||||
key: ValueKey,
|
||||
element: ElementKey,
|
||||
): State<Color> {
|
||||
return animateSharedValueAsState(value, key, element, ::lerp, canOverflow = false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> animateSharedValueAsState(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
key: ValueKey,
|
||||
value: T,
|
||||
lerp: (T, T, Float) -> T,
|
||||
canOverflow: Boolean,
|
||||
): State<T> {
|
||||
val sharedValue = remember(key) { Element.SharedValue(key, value) }
|
||||
if (value != sharedValue.value) {
|
||||
sharedValue.value = value
|
||||
}
|
||||
|
||||
DisposableEffect(element, scene, sharedValue) {
|
||||
addSharedValueToElement(element, scene, sharedValue)
|
||||
}
|
||||
|
||||
return remember(layoutImpl, element, sharedValue, lerp, canOverflow) {
|
||||
derivedStateOf { computeValue(layoutImpl, element, sharedValue, lerp, canOverflow) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> DisposableEffectScope.addSharedValueToElement(
|
||||
element: Element,
|
||||
scene: Scene,
|
||||
sharedValue: Element.SharedValue<T>,
|
||||
): DisposableEffectResult {
|
||||
val sceneValues =
|
||||
element.sceneValues[scene.key] ?: error("Element $element is not present in $scene")
|
||||
val sharedValues = sceneValues.sharedValues
|
||||
|
||||
sharedValues[sharedValue.key] = sharedValue
|
||||
return onDispose { sharedValues.remove(sharedValue.key) }
|
||||
}
|
||||
|
||||
private fun <T> computeValue(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
element: Element,
|
||||
sharedValue: Element.SharedValue<T>,
|
||||
lerp: (T, T, Float) -> T,
|
||||
canOverflow: Boolean,
|
||||
): T {
|
||||
val state = layoutImpl.state.transitionState
|
||||
if (
|
||||
state !is TransitionState.Transition ||
|
||||
state.fromScene == state.toScene ||
|
||||
!layoutImpl.isTransitionReady(state)
|
||||
) {
|
||||
return sharedValue.value
|
||||
}
|
||||
|
||||
fun sceneValue(scene: SceneKey): Element.SharedValue<T>? {
|
||||
val sceneValues = element.sceneValues[scene] ?: return null
|
||||
val value = sceneValues.sharedValues[sharedValue.key] ?: return null
|
||||
return value as Element.SharedValue<T>
|
||||
}
|
||||
|
||||
val fromValue = sceneValue(state.fromScene)
|
||||
val toValue = sceneValue(state.toScene)
|
||||
return if (fromValue != null && toValue != null) {
|
||||
val progress = if (canOverflow) state.progress else state.progress.coerceIn(0f, 1f)
|
||||
lerp(fromValue.value, toValue.value, progress)
|
||||
} else if (fromValue != null) {
|
||||
fromValue.value
|
||||
} else if (toValue != null) {
|
||||
toValue.value
|
||||
} else {
|
||||
sharedValue.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Transition to [target] using a canned animation. This function will try to be smart and take over
|
||||
* the currently running transition, if there is one.
|
||||
*/
|
||||
internal fun CoroutineScope.animateToScene(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
target: SceneKey,
|
||||
) {
|
||||
val state = layoutImpl.state.transitionState
|
||||
if (state.currentScene == target) {
|
||||
// This can happen in 3 different situations, for which there isn't anything else to do:
|
||||
// 1. There is no ongoing transition and [target] is already the current scene.
|
||||
// 2. The user is swiping to [target] from another scene and released their pointer such
|
||||
// that the gesture was committed and the transition is animating to [scene] already.
|
||||
// 3. The user is swiping from [target] to another scene and either:
|
||||
// a. didn't release their pointer yet.
|
||||
// b. released their pointer such that the swipe gesture was cancelled and the
|
||||
// transition is currently animating back to [target].
|
||||
return
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is TransitionState.Idle -> animate(layoutImpl, target)
|
||||
is TransitionState.Transition -> {
|
||||
if (state.toScene == state.fromScene) {
|
||||
// Same as idle.
|
||||
animate(layoutImpl, target)
|
||||
return
|
||||
}
|
||||
|
||||
// A transition is currently running: first check whether `transition.toScene` or
|
||||
// `transition.fromScene` is the same as our target scene, in which case the transition
|
||||
// can be accelerated or reversed to end up in the target state.
|
||||
|
||||
if (state.toScene == target) {
|
||||
// The user is currently swiping to [target] but didn't release their pointer yet:
|
||||
// animate the progress to `1`.
|
||||
|
||||
check(state.fromScene == state.currentScene)
|
||||
val progress = state.progress
|
||||
if ((1f - progress).absoluteValue < ProgressVisibilityThreshold) {
|
||||
// The transition is already finished (progress ~= 1): no need to animate.
|
||||
layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
|
||||
} else {
|
||||
// The transition is in progress: start the canned animation at the same
|
||||
// progress as it was in.
|
||||
// TODO(b/290184746): Also take the current velocity into account.
|
||||
animate(layoutImpl, target, startProgress = progress)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (state.fromScene == target) {
|
||||
// There is a transition from [target] to another scene: simply animate the same
|
||||
// transition progress to `0`.
|
||||
|
||||
check(state.toScene == state.currentScene)
|
||||
val progress = state.progress
|
||||
if (progress.absoluteValue < ProgressVisibilityThreshold) {
|
||||
// The transition is at progress ~= 0: no need to animate.
|
||||
layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
|
||||
} else {
|
||||
// TODO(b/290184746): Also take the current velocity into account.
|
||||
animate(layoutImpl, target, startProgress = progress, reversed = true)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Generic interruption; the current transition is neither from or to [target].
|
||||
// TODO(b/290930950): Better handle interruptions here.
|
||||
animate(layoutImpl, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.animate(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
target: SceneKey,
|
||||
startProgress: Float = 0f,
|
||||
reversed: Boolean = false,
|
||||
) {
|
||||
val fromScene = layoutImpl.state.transitionState.currentScene
|
||||
|
||||
val animationSpec = layoutImpl.transitions.transitionSpec(fromScene, target).spec
|
||||
val visibilityThreshold =
|
||||
(animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
|
||||
val animatable = Animatable(startProgress, visibilityThreshold = visibilityThreshold)
|
||||
|
||||
val targetProgress = if (reversed) 0f else 1f
|
||||
val transition =
|
||||
if (reversed) {
|
||||
OneOffTransition(target, fromScene, currentScene = target, animatable)
|
||||
} else {
|
||||
OneOffTransition(fromScene, target, currentScene = target, animatable)
|
||||
}
|
||||
|
||||
// Change the current layout state to use this new transition.
|
||||
layoutImpl.state.transitionState = transition
|
||||
|
||||
// Animate the progress to its target value.
|
||||
launch {
|
||||
animatable.animateTo(targetProgress, animationSpec)
|
||||
|
||||
// Unless some other external state change happened, the state should now be idle.
|
||||
if (layoutImpl.state.transitionState == transition) {
|
||||
layoutImpl.state.transitionState = TransitionState.Idle(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class OneOffTransition(
|
||||
override val fromScene: SceneKey,
|
||||
override val toScene: SceneKey,
|
||||
override val currentScene: SceneKey,
|
||||
private val animatable: Animatable<Float, AnimationVector1D>,
|
||||
) : TransitionState.Transition {
|
||||
override val progress: Float
|
||||
get() = animatable.value
|
||||
}
|
||||
|
||||
// TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size
|
||||
// and screen density.
|
||||
private const val ProgressVisibilityThreshold = 1e-3f
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import androidx.compose.ui.geometry.lerp
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.IntermediateMeasureScope
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.layout.intermediateLayout
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.round
|
||||
import com.android.compose.animation.scene.transformation.PropertyTransformation
|
||||
import com.android.compose.modifiers.thenIf
|
||||
import com.android.compose.ui.util.lerp
|
||||
|
||||
/** An element on screen, that can be composed in one or more scenes. */
|
||||
internal class Element(val key: ElementKey) {
|
||||
/**
|
||||
* The last offset assigned to this element, relative to the SceneTransitionLayout containing
|
||||
* it.
|
||||
*/
|
||||
var lastOffset = Offset.Unspecified
|
||||
|
||||
/** The last size assigned to this element. */
|
||||
var lastSize = SizeUnspecified
|
||||
|
||||
/** The last alpha assigned to this element. */
|
||||
var lastAlpha = 1f
|
||||
|
||||
/** The mapping between a scene and the values/state this element has in that scene, if any. */
|
||||
val sceneValues = SnapshotStateMap<SceneKey, SceneValues>()
|
||||
|
||||
override fun toString(): String {
|
||||
return "Element(key=$key)"
|
||||
}
|
||||
|
||||
/** The target values of this element in a given scene. */
|
||||
class SceneValues {
|
||||
var size by mutableStateOf(SizeUnspecified)
|
||||
var offset by mutableStateOf(Offset.Unspecified)
|
||||
val sharedValues = SnapshotStateMap<ValueKey, SharedValue<*>>()
|
||||
}
|
||||
|
||||
/** A shared value of this element. */
|
||||
class SharedValue<T>(val key: ValueKey, initialValue: T) {
|
||||
var value by mutableStateOf(initialValue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SizeUnspecified = IntSize(Int.MAX_VALUE, Int.MAX_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
/** The implementation of [SceneScope.element]. */
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
internal fun Modifier.element(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
key: ElementKey,
|
||||
): Modifier {
|
||||
val sceneValues = remember(scene, key) { Element.SceneValues() }
|
||||
val element =
|
||||
// Get the element associated to [key] if it was already composed in another scene,
|
||||
// otherwise create it and add it to our Map<ElementKey, Element>. This is done inside a
|
||||
// withoutReadObservation() because there is no need to recompose when that map is mutated.
|
||||
Snapshot.withoutReadObservation {
|
||||
val element =
|
||||
layoutImpl.elements[key] ?: Element(key).also { layoutImpl.elements[key] = it }
|
||||
val previousValues = element.sceneValues[scene.key]
|
||||
if (previousValues == null) {
|
||||
element.sceneValues[scene.key] = sceneValues
|
||||
} else if (previousValues != sceneValues) {
|
||||
error("$key was composed multiple times in $scene")
|
||||
}
|
||||
|
||||
element
|
||||
}
|
||||
|
||||
DisposableEffect(scene, sceneValues, element) {
|
||||
onDispose {
|
||||
element.sceneValues.remove(scene.key)
|
||||
|
||||
// This was the last scene this element was in, so remove it from the map.
|
||||
if (element.sceneValues.isEmpty()) {
|
||||
layoutImpl.elements.remove(element.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val alpha =
|
||||
remember(layoutImpl, element, scene) {
|
||||
derivedStateOf { elementAlpha(layoutImpl, element, scene) }
|
||||
}
|
||||
val isOpaque by remember(alpha) { derivedStateOf { alpha.value == 1f } }
|
||||
SideEffect {
|
||||
if (isOpaque && element.lastAlpha != 1f) {
|
||||
element.lastAlpha = 1f
|
||||
}
|
||||
}
|
||||
|
||||
return drawWithContent {
|
||||
if (shouldDrawElement(layoutImpl, scene, element)) {
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
.modifierTransformations(layoutImpl, scene, element, sceneValues)
|
||||
.intermediateLayout { measurable, constraints ->
|
||||
val placeable =
|
||||
measure(layoutImpl, scene, element, sceneValues, measurable, constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
place(layoutImpl, scene, element, sceneValues, placeable, placementScope = this)
|
||||
}
|
||||
}
|
||||
.thenIf(!isOpaque) {
|
||||
Modifier.graphicsLayer {
|
||||
val alpha = alpha.value
|
||||
this.alpha = alpha
|
||||
element.lastAlpha = alpha
|
||||
}
|
||||
}
|
||||
.testTag(key.name)
|
||||
}
|
||||
|
||||
private fun shouldDrawElement(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
): Boolean {
|
||||
val state = layoutImpl.state.transitionState
|
||||
|
||||
// Always draw the element if there is no ongoing transition or if the element is not shared.
|
||||
if (
|
||||
state !is TransitionState.Transition ||
|
||||
state.fromScene == state.toScene ||
|
||||
!layoutImpl.isTransitionReady(state) ||
|
||||
state.fromScene !in element.sceneValues ||
|
||||
state.toScene !in element.sceneValues
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
val otherScene =
|
||||
layoutImpl.scenes.getValue(
|
||||
if (scene.key == state.fromScene) {
|
||||
state.toScene
|
||||
} else {
|
||||
state.fromScene
|
||||
}
|
||||
)
|
||||
|
||||
// When the element is shared, draw the one in the highest scene unless it is a background, i.e.
|
||||
// it is usually drawn below everything else.
|
||||
val isHighestScene = scene.zIndex > otherScene.zIndex
|
||||
return if (element.key.isBackground) {
|
||||
!isHighestScene
|
||||
} else {
|
||||
isHighestScene
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain the [com.android.compose.animation.scene.transformation.ModifierTransformation] applied
|
||||
* throughout the current transition, if any.
|
||||
*/
|
||||
private fun Modifier.modifierTransformations(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
): Modifier {
|
||||
when (val state = layoutImpl.state.transitionState) {
|
||||
is TransitionState.Idle -> return this
|
||||
is TransitionState.Transition -> {
|
||||
val fromScene = state.fromScene
|
||||
val toScene = state.toScene
|
||||
if (fromScene == toScene) {
|
||||
// Same as idle.
|
||||
return this
|
||||
}
|
||||
|
||||
return layoutImpl.transitions
|
||||
.transitionSpec(fromScene, state.toScene)
|
||||
.transformations(element.key)
|
||||
.modifier
|
||||
.fold(this) { modifier, transformation ->
|
||||
with(transformation) {
|
||||
modifier.transform(layoutImpl, scene, element, sceneValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun elementAlpha(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
element: Element,
|
||||
scene: Scene
|
||||
): Float {
|
||||
return computeValue(
|
||||
layoutImpl,
|
||||
scene,
|
||||
element,
|
||||
sceneValue = { 1f },
|
||||
transformation = { it.alpha },
|
||||
idleValue = 1f,
|
||||
currentValue = { 1f },
|
||||
lastValue = { element.lastAlpha },
|
||||
::lerp,
|
||||
)
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun IntermediateMeasureScope.measure(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): Placeable {
|
||||
// Update the size this element has in this scene when idle.
|
||||
val targetSizeInScene = lookaheadSize
|
||||
if (targetSizeInScene != sceneValues.size) {
|
||||
// TODO(b/290930950): Better handle when this changes to avoid instant size jumps.
|
||||
sceneValues.size = targetSizeInScene
|
||||
}
|
||||
|
||||
// Some lambdas called (max once) by computeValue() will need to measure [measurable], in which
|
||||
// case we store the resulting placeable here to make sure the element is not measured more than
|
||||
// once.
|
||||
var maybePlaceable: Placeable? = null
|
||||
|
||||
fun Placeable.size() = IntSize(width, height)
|
||||
|
||||
val targetSize =
|
||||
computeValue(
|
||||
layoutImpl,
|
||||
scene,
|
||||
element,
|
||||
sceneValue = { it.size },
|
||||
transformation = { it.size },
|
||||
idleValue = lookaheadSize,
|
||||
currentValue = { measurable.measure(constraints).also { maybePlaceable = it }.size() },
|
||||
lastValue = {
|
||||
val lastSize = element.lastSize
|
||||
if (lastSize != Element.SizeUnspecified) {
|
||||
lastSize
|
||||
} else {
|
||||
measurable.measure(constraints).also { maybePlaceable = it }.size()
|
||||
}
|
||||
},
|
||||
::lerp,
|
||||
)
|
||||
|
||||
val placeable =
|
||||
maybePlaceable
|
||||
?: measurable.measure(
|
||||
Constraints.fixed(
|
||||
targetSize.width.coerceAtLeast(0),
|
||||
targetSize.height.coerceAtLeast(0),
|
||||
)
|
||||
)
|
||||
|
||||
element.lastSize = placeable.size()
|
||||
return placeable
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun IntermediateMeasureScope.place(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
placeable: Placeable,
|
||||
placementScope: Placeable.PlacementScope,
|
||||
) {
|
||||
with(placementScope) {
|
||||
// Update the offset (relative to the SceneTransitionLayout) this element has in this scene
|
||||
// when idle.
|
||||
val coords = coordinates!!
|
||||
val targetOffsetInScene = lookaheadScopeCoordinates.localLookaheadPositionOf(coords)
|
||||
if (targetOffsetInScene != sceneValues.offset) {
|
||||
// TODO(b/290930950): Better handle when this changes to avoid instant offset jumps.
|
||||
sceneValues.offset = targetOffsetInScene
|
||||
}
|
||||
|
||||
val currentOffset = lookaheadScopeCoordinates.localPositionOf(coords, Offset.Zero)
|
||||
val targetOffset =
|
||||
computeValue(
|
||||
layoutImpl,
|
||||
scene,
|
||||
element,
|
||||
sceneValue = { it.offset },
|
||||
transformation = { it.offset },
|
||||
idleValue = targetOffsetInScene,
|
||||
currentValue = { currentOffset },
|
||||
lastValue = {
|
||||
val lastValue = element.lastOffset
|
||||
if (lastValue.isSpecified) {
|
||||
lastValue
|
||||
} else {
|
||||
currentOffset
|
||||
}
|
||||
},
|
||||
::lerp,
|
||||
)
|
||||
|
||||
element.lastOffset = targetOffset
|
||||
placeable.place((targetOffset - currentOffset).round())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value that should be used depending on the current layout state and transition.
|
||||
*
|
||||
* Important: This function must remain inline because of all the lambda parameters. These lambdas
|
||||
* are necessary because getting some of them might require some computation, like measuring a
|
||||
* Measurable.
|
||||
*
|
||||
* @param layoutImpl the [SceneTransitionLayoutImpl] associated to [element].
|
||||
* @param scene the scene containing [element].
|
||||
* @param element the element being animated.
|
||||
* @param sceneValue the value being animated.
|
||||
* @param transformation the transformation associated to the value being animated.
|
||||
* @param idleValue the value when idle, i.e. when there is no transition happening.
|
||||
* @param currentValue the value that would be used if it is not transformed. Note that this is
|
||||
* different than [idleValue] even if the value is not transformed directly because it could be
|
||||
* impacted by the transformations on other elements, like a parent that is being translated or
|
||||
* resized.
|
||||
* @param lastValue the last value that was used. This should be equal to [currentValue] if this is
|
||||
* the first time the value is set.
|
||||
* @param lerp the linear interpolation function used to interpolate between two values of this
|
||||
* value type.
|
||||
*/
|
||||
private inline fun <T> computeValue(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValue: (Element.SceneValues) -> T,
|
||||
transformation: (ElementTransformations) -> PropertyTransformation<T>?,
|
||||
idleValue: T,
|
||||
currentValue: () -> T,
|
||||
lastValue: () -> T,
|
||||
lerp: (T, T, Float) -> T,
|
||||
): T {
|
||||
val state = layoutImpl.state.transitionState
|
||||
|
||||
// There is no ongoing transition.
|
||||
if (state !is TransitionState.Transition || state.fromScene == state.toScene) {
|
||||
return idleValue
|
||||
}
|
||||
|
||||
// A transition was started but it's not ready yet (not all elements have been composed/laid
|
||||
// out yet). Use the last value that was set, to make sure elements don't unexpectedly jump.
|
||||
if (!layoutImpl.isTransitionReady(state)) {
|
||||
return lastValue()
|
||||
}
|
||||
|
||||
val fromScene = state.fromScene
|
||||
val toScene = state.toScene
|
||||
val fromValues = element.sceneValues[fromScene]
|
||||
val toValues = element.sceneValues[toScene]
|
||||
|
||||
if (fromValues == null && toValues == null) {
|
||||
error("This should not happen, element $element is neither in $fromScene or $toScene")
|
||||
}
|
||||
|
||||
// TODO(b/291053278): Handle overscroll correctly. We should probably coerce between [0f, 1f]
|
||||
// here and consume overflows at drawing time, somehow reusing Compose OverflowEffect or some
|
||||
// similar mechanism.
|
||||
val transitionProgress = state.progress
|
||||
|
||||
// The element is shared: interpolate between the value in fromScene and the value in toScene.
|
||||
// TODO(b/290184746): Support non linear shared paths as well as a way to make sure that shared
|
||||
// elements follow the finger direction.
|
||||
if (fromValues != null && toValues != null) {
|
||||
return lerp(
|
||||
sceneValue(fromValues),
|
||||
sceneValue(toValues),
|
||||
transitionProgress,
|
||||
)
|
||||
}
|
||||
|
||||
val transformation =
|
||||
transformation(
|
||||
layoutImpl.transitions.transitionSpec(fromScene, toScene).transformations(element.key)
|
||||
)
|
||||
// If there is no transformation explicitly associated to this element value, let's use
|
||||
// the value given by the system (like the current position and size given by the layout
|
||||
// pass).
|
||||
?: return currentValue()
|
||||
|
||||
// Get the transformed value, i.e. the target value at the beginning (for entering elements) or
|
||||
// end (for leaving elements) of the transition.
|
||||
val targetValue =
|
||||
transformation.transform(
|
||||
layoutImpl,
|
||||
scene,
|
||||
element,
|
||||
fromValues ?: toValues!!,
|
||||
state,
|
||||
idleValue,
|
||||
)
|
||||
|
||||
// TODO(b/290184746): Make sure that we don't overflow transformations associated to a range.
|
||||
val rangeProgress = transformation.range?.progress(transitionProgress) ?: transitionProgress
|
||||
|
||||
// Interpolate between the value at rest and the value before entering/after leaving.
|
||||
val isEntering = fromValues == null
|
||||
return if (isEntering) {
|
||||
lerp(targetValue, idleValue, rangeProgress)
|
||||
} else {
|
||||
lerp(idleValue, targetValue, rangeProgress)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
/**
|
||||
* A base class to create unique keys, associated to an [identity] that is used to check the
|
||||
* equality of two key instances.
|
||||
*/
|
||||
sealed class Key(val name: String, val identity: Any) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (this.javaClass != other?.javaClass) return false
|
||||
return identity == (other as? Key)?.identity
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return identity.hashCode()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "Key(name=$name)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Key for a scene. */
|
||||
class SceneKey(name: String, identity: Any = Object()) : Key(name, identity) {
|
||||
override fun toString(): String {
|
||||
return "SceneKey(name=$name)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Key for an element. */
|
||||
class ElementKey(
|
||||
name: String,
|
||||
identity: Any = Object(),
|
||||
|
||||
/**
|
||||
* Whether this element is a background and usually drawn below other elements. This should be
|
||||
* set to true to make sure that shared backgrounds are drawn below elements of other scenes.
|
||||
*/
|
||||
val isBackground: Boolean = false,
|
||||
) : Key(name, identity), ElementMatcher {
|
||||
override fun matches(key: ElementKey): Boolean {
|
||||
return key == this
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ElementKey(name=$name)"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Matches any element whose [key identity][ElementKey.identity] matches [predicate]. */
|
||||
fun withIdentity(predicate: (Any) -> Boolean): ElementMatcher {
|
||||
return object : ElementMatcher {
|
||||
override fun matches(key: ElementKey): Boolean = predicate(key.identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Key for a shared value of an element. */
|
||||
class ValueKey(name: String, identity: Any = Object()) : Key(name, identity) {
|
||||
override fun toString(): String {
|
||||
return "ValueKey(name=$name)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
/**
|
||||
* A scene transition state.
|
||||
*
|
||||
* This models the same thing as [TransitionState], with the following distinctions:
|
||||
* 1. [TransitionState] values are backed by the Snapshot system (Compose State objects) and can be
|
||||
* used by callers tracking State reads, for instance in Compose code during the composition,
|
||||
* layout or Compose drawing phases.
|
||||
* 2. [ObservableTransitionState] values are backed by Kotlin [Flow]s and can be collected by
|
||||
* non-Compose code to observe value changes.
|
||||
* 3. [ObservableTransitionState.Transition.fromScene] and
|
||||
* [ObservableTransitionState.Transition.toScene] will never be equal, while
|
||||
* [TransitionState.Transition.fromScene] and [TransitionState.Transition.toScene] can be equal.
|
||||
*/
|
||||
sealed class ObservableTransitionState {
|
||||
/** No transition/animation is currently running. */
|
||||
data class Idle(val scene: SceneKey) : ObservableTransitionState()
|
||||
|
||||
/** There is a transition animating between two scenes. */
|
||||
data class Transition(
|
||||
val fromScene: SceneKey,
|
||||
val toScene: SceneKey,
|
||||
val progress: Flow<Float>,
|
||||
) : ObservableTransitionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* The current [ObservableTransitionState]. This models the same thing as
|
||||
* [SceneTransitionLayoutState.transitionState], except that it is backed by Flows and can be used
|
||||
* by non-Compose code to observe state changes.
|
||||
*/
|
||||
fun SceneTransitionLayoutState.observableTransitionState(): Flow<ObservableTransitionState> {
|
||||
return snapshotFlow {
|
||||
when (val state = transitionState) {
|
||||
is TransitionState.Idle -> ObservableTransitionState.Idle(state.currentScene)
|
||||
is TransitionState.Transition -> {
|
||||
if (state.fromScene == state.toScene) {
|
||||
ObservableTransitionState.Idle(state.currentScene)
|
||||
} else {
|
||||
ObservableTransitionState.Transition(
|
||||
fromScene = state.fromScene,
|
||||
toScene = state.toScene,
|
||||
progress = snapshotFlow { state.progress },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/** A scene in a [SceneTransitionLayout]. */
|
||||
internal class Scene(
|
||||
val key: SceneKey,
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
content: @Composable SceneScope.() -> Unit,
|
||||
actions: Map<UserAction, SceneKey>,
|
||||
zIndex: Float,
|
||||
) {
|
||||
private val scope = SceneScopeImpl(layoutImpl, this)
|
||||
|
||||
var content by mutableStateOf(content)
|
||||
var userActions by mutableStateOf(actions)
|
||||
var zIndex by mutableFloatStateOf(zIndex)
|
||||
var size by mutableStateOf(IntSize.Zero)
|
||||
|
||||
@Composable
|
||||
fun Content(modifier: Modifier = Modifier) {
|
||||
Box(modifier.zIndex(zIndex).onPlaced { size = it.size }) { scope.content() }
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "Scene(key=$key)"
|
||||
}
|
||||
}
|
||||
|
||||
private class SceneScopeImpl(
|
||||
private val layoutImpl: SceneTransitionLayoutImpl,
|
||||
private val scene: Scene,
|
||||
) : SceneScope {
|
||||
@Composable
|
||||
override fun Modifier.element(key: ElementKey): Modifier {
|
||||
return element(layoutImpl, scene, key)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun <T> animateSharedValueAsState(
|
||||
value: T,
|
||||
key: ValueKey,
|
||||
element: ElementKey,
|
||||
lerp: (T, T, Float) -> T,
|
||||
canOverflow: Boolean
|
||||
): State<T> {
|
||||
val element =
|
||||
layoutImpl.elements[element]
|
||||
?: error(
|
||||
"Element $element is not composed. Make sure to call animateSharedXAsState " +
|
||||
"*after* Modifier.element(key)."
|
||||
)
|
||||
|
||||
return animateSharedValueAsState(
|
||||
layoutImpl,
|
||||
scene,
|
||||
element,
|
||||
key,
|
||||
value,
|
||||
lerp,
|
||||
canOverflow,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
/**
|
||||
* [SceneTransitionLayout] is a container that automatically animates its content whenever
|
||||
* [currentScene] changes, using the transitions defined in [transitions].
|
||||
*
|
||||
* Note: You should use [androidx.compose.animation.AnimatedContent] instead of
|
||||
* [SceneTransitionLayout] if it fits your need. Use [SceneTransitionLayout] over AnimatedContent if
|
||||
* you need support for swipe gestures, shared elements or transitions defined declaratively outside
|
||||
* UI code.
|
||||
*
|
||||
* @param currentScene the current scene
|
||||
* @param onChangeScene a mutator that should set [currentScene] to the given scene when called.
|
||||
* This is called when the user commits a transition to a new scene because of a [UserAction], for
|
||||
* instance by triggering back navigation or by swiping to a new scene.
|
||||
* @param transitions the definition of the transitions used to animate a change of scene.
|
||||
* @param state the observable state of this layout.
|
||||
* @param scenes the configuration of the different scenes of this layout.
|
||||
*/
|
||||
@Composable
|
||||
fun SceneTransitionLayout(
|
||||
currentScene: SceneKey,
|
||||
onChangeScene: (SceneKey) -> Unit,
|
||||
transitions: SceneTransitions,
|
||||
modifier: Modifier = Modifier,
|
||||
state: SceneTransitionLayoutState = remember { SceneTransitionLayoutState(currentScene) },
|
||||
scenes: SceneTransitionLayoutScope.() -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val layoutImpl = remember {
|
||||
SceneTransitionLayoutImpl(
|
||||
onChangeScene,
|
||||
scenes,
|
||||
transitions,
|
||||
state,
|
||||
density,
|
||||
)
|
||||
}
|
||||
|
||||
layoutImpl.onChangeScene = onChangeScene
|
||||
layoutImpl.transitions = transitions
|
||||
layoutImpl.density = density
|
||||
layoutImpl.setScenes(scenes)
|
||||
layoutImpl.setCurrentScene(currentScene)
|
||||
|
||||
layoutImpl.Content(modifier)
|
||||
}
|
||||
|
||||
interface SceneTransitionLayoutScope {
|
||||
/**
|
||||
* Add a scene to this layout, identified by [key].
|
||||
*
|
||||
* You can configure [userActions] so that swiping on this layout or navigating back will
|
||||
* transition to a different scene.
|
||||
*
|
||||
* Important: scene order along the z-axis follows call order. Calling scene(A) followed by
|
||||
* scene(B) will mean that scene B renders after/above scene A.
|
||||
*/
|
||||
fun scene(
|
||||
key: SceneKey,
|
||||
userActions: Map<UserAction, SceneKey> = emptyMap(),
|
||||
content: @Composable SceneScope.() -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
interface SceneScope {
|
||||
/**
|
||||
* Tag an element identified by [key].
|
||||
*
|
||||
* Tagging an element will allow you to reference that element when defining transitions, so
|
||||
* that the element can be transformed and animated when the scene transitions in or out.
|
||||
*
|
||||
* Additionally, this [key] will be used to detect elements that are shared between scenes to
|
||||
* automatically interpolate their size, offset and [shared values][animateSharedValueAsState].
|
||||
*
|
||||
* TODO(b/291566282): Migrate this to the new Modifier Node API and remove the @Composable
|
||||
* constraint.
|
||||
*/
|
||||
@Composable fun Modifier.element(key: ElementKey): Modifier
|
||||
|
||||
/**
|
||||
* Animate some value of a shared element.
|
||||
*
|
||||
* @param value the value of this shared value in the current scene.
|
||||
* @param key the key of this shared value.
|
||||
* @param element the element associated with this value.
|
||||
* @param lerp the *linear* interpolation function that should be used to interpolate between
|
||||
* two different values. Note that it has to be linear because the [fraction] passed to this
|
||||
* interpolator is already interpolated.
|
||||
* @param canOverflow whether this value can overflow past the values it is interpolated
|
||||
* between, for instance because the transition is animated using a bouncy spring.
|
||||
* @see animateSharedIntAsState
|
||||
* @see animateSharedFloatAsState
|
||||
* @see animateSharedDpAsState
|
||||
* @see animateSharedColorAsState
|
||||
*/
|
||||
@Composable
|
||||
fun <T> animateSharedValueAsState(
|
||||
value: T,
|
||||
key: ValueKey,
|
||||
element: ElementKey,
|
||||
lerp: (start: T, stop: T, fraction: Float) -> T,
|
||||
canOverflow: Boolean,
|
||||
): State<T>
|
||||
}
|
||||
|
||||
/** An action performed by the user. */
|
||||
sealed interface UserAction
|
||||
|
||||
/** The user navigated back, either using a gesture or by triggering a KEYCODE_BACK event. */
|
||||
object Back : UserAction
|
||||
|
||||
/** The user swiped on the container. */
|
||||
enum class Swipe : UserAction {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.layout.LookaheadScope
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.android.compose.ui.util.fastForEach
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
internal class SceneTransitionLayoutImpl(
|
||||
onChangeScene: (SceneKey) -> Unit,
|
||||
builder: SceneTransitionLayoutScope.() -> Unit,
|
||||
transitions: SceneTransitions,
|
||||
internal val state: SceneTransitionLayoutState,
|
||||
density: Density,
|
||||
) {
|
||||
internal val scenes = SnapshotStateMap<SceneKey, Scene>()
|
||||
internal val elements = SnapshotStateMap<ElementKey, Element>()
|
||||
|
||||
/** The scenes that are "ready", i.e. they were composed and fully laid-out at least once. */
|
||||
private val readyScenes = SnapshotStateMap<SceneKey, Boolean>()
|
||||
|
||||
internal var onChangeScene by mutableStateOf(onChangeScene)
|
||||
internal var transitions by mutableStateOf(transitions)
|
||||
internal var density: Density by mutableStateOf(density)
|
||||
|
||||
/**
|
||||
* The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have
|
||||
* any scene configured or right before the first measure pass of the layout.
|
||||
*/
|
||||
internal var size by mutableStateOf(IntSize.Zero)
|
||||
|
||||
init {
|
||||
setScenes(builder)
|
||||
}
|
||||
|
||||
internal fun scene(key: SceneKey): Scene {
|
||||
return scenes[key] ?: error("Scene $key is not configured")
|
||||
}
|
||||
|
||||
internal fun setScenes(builder: SceneTransitionLayoutScope.() -> Unit) {
|
||||
// Keep a reference of the current scenes. After processing [builder], the scenes that were
|
||||
// not configured will be removed.
|
||||
val scenesToRemove = scenes.keys.toMutableSet()
|
||||
|
||||
// The incrementing zIndex of each scene.
|
||||
var zIndex = 0f
|
||||
|
||||
object : SceneTransitionLayoutScope {
|
||||
override fun scene(
|
||||
key: SceneKey,
|
||||
userActions: Map<UserAction, SceneKey>,
|
||||
content: @Composable SceneScope.() -> Unit,
|
||||
) {
|
||||
scenesToRemove.remove(key)
|
||||
|
||||
val scene = scenes[key]
|
||||
if (scene != null) {
|
||||
// Update an existing scene.
|
||||
scene.content = content
|
||||
scene.userActions = userActions
|
||||
scene.zIndex = zIndex
|
||||
} else {
|
||||
// New scene.
|
||||
scenes[key] =
|
||||
Scene(
|
||||
key,
|
||||
this@SceneTransitionLayoutImpl,
|
||||
content,
|
||||
userActions,
|
||||
zIndex,
|
||||
)
|
||||
}
|
||||
|
||||
zIndex++
|
||||
}
|
||||
}
|
||||
.builder()
|
||||
|
||||
scenesToRemove.forEach { scenes.remove(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun setCurrentScene(key: SceneKey) {
|
||||
val channel = remember { Channel<SceneKey>(Channel.CONFLATED) }
|
||||
SideEffect { channel.trySend(key) }
|
||||
LaunchedEffect(channel) {
|
||||
for (newKey in channel) {
|
||||
// Inspired by AnimateAsState.kt: let's poll the last value to avoid being one frame
|
||||
// late.
|
||||
val newKey = channel.tryReceive().getOrNull() ?: newKey
|
||||
animateToScene(this@SceneTransitionLayoutImpl, newKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
internal fun Content(modifier: Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
// Handle horizontal and vertical swipes on this layout.
|
||||
// Note: order here is important and will give a slight priority to the vertical
|
||||
// swipes.
|
||||
.swipeToScene(layoutImpl = this, Orientation.Horizontal)
|
||||
.swipeToScene(layoutImpl = this, Orientation.Vertical)
|
||||
.onSizeChanged { size = it }
|
||||
) {
|
||||
LookaheadScope {
|
||||
val scenesToCompose =
|
||||
when (val state = state.transitionState) {
|
||||
is TransitionState.Idle -> listOf(scene(state.currentScene))
|
||||
is TransitionState.Transition -> {
|
||||
if (state.toScene != state.fromScene) {
|
||||
listOf(scene(state.toScene), scene(state.fromScene))
|
||||
} else {
|
||||
listOf(scene(state.fromScene))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back events.
|
||||
// TODO(b/290184746): Make sure that this works with SystemUI once we use
|
||||
// SceneTransitionLayout in Flexiglass.
|
||||
scene(state.transitionState.currentScene).userActions[Back]?.let { backScene ->
|
||||
BackHandler { onChangeScene(backScene) }
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
// At this point, all scenes in scenesToCompose are fully laid out so they
|
||||
// are marked as ready. This is necessary because the animation code needs
|
||||
// to know the position and size of the elements in each scenes they are in,
|
||||
// so [readyScenes] will be used to decide whether the transition is ready
|
||||
// (see isTransitionReady() below).
|
||||
//
|
||||
// We can't do that in a DisposableEffect or SideEffect because those are
|
||||
// run between composition and layout. LaunchedEffect could work and might
|
||||
// be better, but it looks like launched effects run a frame later than this
|
||||
// code so doing this here seems better for performance.
|
||||
scenesToCompose.fastForEach { readyScenes[it.key] = true }
|
||||
}
|
||||
) {
|
||||
scenesToCompose.fastForEach { scene ->
|
||||
val key = scene.key
|
||||
key(key) {
|
||||
DisposableEffect(key) { onDispose { readyScenes.remove(key) } }
|
||||
|
||||
scene.Content(
|
||||
Modifier.drawWithContent {
|
||||
when (val state = state.transitionState) {
|
||||
is TransitionState.Idle -> drawContent()
|
||||
is TransitionState.Transition -> {
|
||||
// Don't draw scenes that are not ready yet.
|
||||
if (
|
||||
readyScenes.containsKey(key) ||
|
||||
state.fromScene == state.toScene
|
||||
) {
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether [transition] is ready, i.e. the elements of both scenes of the transition were
|
||||
* laid out at least once.
|
||||
*/
|
||||
internal fun isTransitionReady(transition: TransitionState.Transition): Boolean {
|
||||
return readyScenes.containsKey(transition.fromScene) &&
|
||||
readyScenes.containsKey(transition.toScene)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/** The state of a [SceneTransitionLayout]. */
|
||||
class SceneTransitionLayoutState(initialScene: SceneKey) {
|
||||
/**
|
||||
* The current [TransitionState]. All values read here are backed by the Snapshot system.
|
||||
*
|
||||
* To observe those values outside of Compose/the Snapshot system, use
|
||||
* [SceneTransitionLayoutState.observableTransitionState] instead.
|
||||
*/
|
||||
var transitionState: TransitionState by mutableStateOf(TransitionState.Idle(initialScene))
|
||||
internal set
|
||||
}
|
||||
|
||||
sealed interface TransitionState {
|
||||
/**
|
||||
* The current effective scene. If a new transition was triggered, it would start from this
|
||||
* scene.
|
||||
*
|
||||
* For instance, when swiping from scene A to scene B, the [currentScene] is A when the swipe
|
||||
* gesture starts, but then if the user flings their finger and commits the transition to scene
|
||||
* B, then [currentScene] becomes scene B even if the transition is not finished yet and is
|
||||
* still animating to settle to scene B.
|
||||
*/
|
||||
val currentScene: SceneKey
|
||||
|
||||
/** No transition/animation is currently running. */
|
||||
data class Idle(override val currentScene: SceneKey) : TransitionState
|
||||
|
||||
/**
|
||||
* There is a transition animating between two scenes.
|
||||
*
|
||||
* Important note: [fromScene] and [toScene] might be the same, in which case this [Transition]
|
||||
* should be treated the same as [Idle]. This is designed on purpose so that a [Transition] can
|
||||
* be started without knowing in advance where it is transitioning to, making the logic of
|
||||
* [swipeToScene] easier to reason about.
|
||||
*/
|
||||
interface Transition : TransitionState {
|
||||
/** The scene this transition is starting from. */
|
||||
val fromScene: SceneKey
|
||||
|
||||
/** The scene this transition is going to. */
|
||||
val toScene: SceneKey
|
||||
|
||||
/**
|
||||
* The progress of the transition. This is usually in the `[0; 1]` range, but it can also be
|
||||
* less than `0` or greater than `1` when using transitions with a spring AnimationSpec or
|
||||
* when flinging quickly during a swipe gesture.
|
||||
*/
|
||||
val progress: Float
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.android.compose.animation.scene.transformation.AnchoredSize
|
||||
import com.android.compose.animation.scene.transformation.AnchoredTranslate
|
||||
import com.android.compose.animation.scene.transformation.EdgeTranslate
|
||||
import com.android.compose.animation.scene.transformation.Fade
|
||||
import com.android.compose.animation.scene.transformation.ModifierTransformation
|
||||
import com.android.compose.animation.scene.transformation.PropertyTransformation
|
||||
import com.android.compose.animation.scene.transformation.RangedPropertyTransformation
|
||||
import com.android.compose.animation.scene.transformation.ScaleSize
|
||||
import com.android.compose.animation.scene.transformation.Transformation
|
||||
import com.android.compose.animation.scene.transformation.Translate
|
||||
import com.android.compose.ui.util.fastForEach
|
||||
import com.android.compose.ui.util.fastMap
|
||||
|
||||
/** The transitions configuration of a [SceneTransitionLayout]. */
|
||||
class SceneTransitions(
|
||||
val transitionSpecs: List<TransitionSpec>,
|
||||
) {
|
||||
private val cache = mutableMapOf<SceneKey, MutableMap<SceneKey, TransitionSpec>>()
|
||||
|
||||
internal fun transitionSpec(from: SceneKey, to: SceneKey): TransitionSpec {
|
||||
return cache.getOrPut(from) { mutableMapOf() }.getOrPut(to) { findSpec(from, to) }
|
||||
}
|
||||
|
||||
private fun findSpec(from: SceneKey, to: SceneKey): TransitionSpec {
|
||||
val spec = transition(from, to) { it.from == from && it.to == to }
|
||||
if (spec != null) {
|
||||
return spec
|
||||
}
|
||||
|
||||
val reversed = transition(from, to) { it.from == to && it.to == from }
|
||||
if (reversed != null) {
|
||||
return reversed.reverse()
|
||||
}
|
||||
|
||||
val relaxedSpec =
|
||||
transition(from, to) {
|
||||
(it.from == from && it.to == null) || (it.to == to && it.from == null)
|
||||
}
|
||||
if (relaxedSpec != null) {
|
||||
return relaxedSpec
|
||||
}
|
||||
|
||||
return transition(from, to) {
|
||||
(it.from == to && it.to == null) || (it.to == from && it.from == null)
|
||||
}
|
||||
?.reverse()
|
||||
?: defaultTransition(from, to)
|
||||
}
|
||||
|
||||
private fun transition(
|
||||
from: SceneKey,
|
||||
to: SceneKey,
|
||||
filter: (TransitionSpec) -> Boolean,
|
||||
): TransitionSpec? {
|
||||
var match: TransitionSpec? = null
|
||||
transitionSpecs.fastForEach { spec ->
|
||||
if (filter(spec)) {
|
||||
if (match != null) {
|
||||
error("Found multiple transition specs for transition $from => $to")
|
||||
}
|
||||
match = spec
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
private fun defaultTransition(from: SceneKey, to: SceneKey) =
|
||||
TransitionSpec(from, to, emptyList(), snap())
|
||||
}
|
||||
|
||||
/** The definition of a transition between [from] and [to]. */
|
||||
data class TransitionSpec(
|
||||
val from: SceneKey?,
|
||||
val to: SceneKey?,
|
||||
val transformations: List<Transformation>,
|
||||
val spec: AnimationSpec<Float>,
|
||||
) {
|
||||
private val cache = mutableMapOf<ElementKey, ElementTransformations>()
|
||||
|
||||
internal fun reverse(): TransitionSpec {
|
||||
return copy(
|
||||
from = to,
|
||||
to = from,
|
||||
transformations = transformations.fastMap { it.reverse() },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun transformations(element: ElementKey): ElementTransformations {
|
||||
return cache.getOrPut(element) { computeTransformations(element) }
|
||||
}
|
||||
|
||||
/** Filter [transformations] to compute the [ElementTransformations] of [element]. */
|
||||
private fun computeTransformations(element: ElementKey): ElementTransformations {
|
||||
val modifier = mutableListOf<ModifierTransformation>()
|
||||
var offset: PropertyTransformation<Offset>? = null
|
||||
var size: PropertyTransformation<IntSize>? = null
|
||||
var alpha: PropertyTransformation<Float>? = null
|
||||
|
||||
fun <T> onPropertyTransformation(
|
||||
root: PropertyTransformation<T>,
|
||||
current: PropertyTransformation<T> = root,
|
||||
) {
|
||||
when (current) {
|
||||
is Translate,
|
||||
is EdgeTranslate,
|
||||
is AnchoredTranslate -> {
|
||||
throwIfNotNull(offset, element, property = "offset")
|
||||
offset = root as PropertyTransformation<Offset>
|
||||
}
|
||||
is ScaleSize,
|
||||
is AnchoredSize -> {
|
||||
throwIfNotNull(size, element, property = "size")
|
||||
size = root as PropertyTransformation<IntSize>
|
||||
}
|
||||
is Fade -> {
|
||||
throwIfNotNull(alpha, element, property = "alpha")
|
||||
alpha = root as PropertyTransformation<Float>
|
||||
}
|
||||
is RangedPropertyTransformation -> onPropertyTransformation(root, current.delegate)
|
||||
}
|
||||
}
|
||||
|
||||
transformations.fastForEach { transformation ->
|
||||
if (!transformation.matcher.matches(element)) {
|
||||
return@fastForEach
|
||||
}
|
||||
|
||||
when (transformation) {
|
||||
is ModifierTransformation -> modifier.add(transformation)
|
||||
is PropertyTransformation<*> -> onPropertyTransformation(transformation)
|
||||
}
|
||||
}
|
||||
|
||||
return ElementTransformations(modifier, offset, size, alpha)
|
||||
}
|
||||
|
||||
private fun throwIfNotNull(
|
||||
previous: PropertyTransformation<*>?,
|
||||
element: ElementKey,
|
||||
property: String,
|
||||
) {
|
||||
if (previous != null) {
|
||||
error("$element has multiple transformations for its $property property")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The transformations of an element during a transition. */
|
||||
internal class ElementTransformations(
|
||||
val modifier: List<ModifierTransformation>,
|
||||
val offset: PropertyTransformation<Offset>?,
|
||||
val size: PropertyTransformation<IntSize>?,
|
||||
val alpha: PropertyTransformation<Float>?,
|
||||
)
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Modifier.swipeToScene(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
orientation: Orientation,
|
||||
): Modifier {
|
||||
val state = layoutImpl.state.transitionState
|
||||
val currentScene = layoutImpl.scene(state.currentScene)
|
||||
val transition = remember {
|
||||
// Note that the currentScene here does not matter, it's only used for initializing the
|
||||
// transition and will be replaced when a drag event starts.
|
||||
SwipeTransition(initialScene = currentScene)
|
||||
}
|
||||
|
||||
val enabled = state == transition || currentScene.shouldEnableSwipes(orientation)
|
||||
|
||||
// Immediately start the drag if this our [transition] is currently animating to a scene (i.e.
|
||||
// the user released their input pointer after swiping in this orientation) and the user can't
|
||||
// swipe in the other direction.
|
||||
val startDragImmediately =
|
||||
state == transition &&
|
||||
transition.isAnimatingOffset &&
|
||||
!currentScene.shouldEnableSwipes(orientation.opposite())
|
||||
|
||||
// The velocity threshold at which the intent of the user is to swipe up or down. It is the same
|
||||
// as SwipeableV2Defaults.VelocityThreshold.
|
||||
val velocityThreshold = with(LocalDensity.current) { 125.dp.toPx() }
|
||||
|
||||
// The positional threshold at which the intent of the user is to swipe to the next scene. It is
|
||||
// the same as SwipeableV2Defaults.PositionalThreshold.
|
||||
val positionalThreshold = with(LocalDensity.current) { 56.dp.toPx() }
|
||||
|
||||
return draggable(
|
||||
orientation = orientation,
|
||||
enabled = enabled,
|
||||
startDragImmediately = startDragImmediately,
|
||||
onDragStarted = { onDragStarted(layoutImpl, transition, orientation) },
|
||||
state =
|
||||
rememberDraggableState { delta -> onDrag(layoutImpl, transition, orientation, delta) },
|
||||
onDragStopped = { velocity ->
|
||||
onDragStopped(
|
||||
layoutImpl,
|
||||
transition,
|
||||
velocity,
|
||||
velocityThreshold,
|
||||
positionalThreshold,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
|
||||
var _currentScene by mutableStateOf(initialScene)
|
||||
override val currentScene: SceneKey
|
||||
get() = _currentScene.key
|
||||
|
||||
var _fromScene by mutableStateOf(initialScene)
|
||||
override val fromScene: SceneKey
|
||||
get() = _fromScene.key
|
||||
|
||||
var _toScene by mutableStateOf(initialScene)
|
||||
override val toScene: SceneKey
|
||||
get() = _toScene.key
|
||||
|
||||
override val progress: Float
|
||||
get() {
|
||||
val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset
|
||||
if (distance == 0f) {
|
||||
// This can happen only if fromScene == toScene.
|
||||
error(
|
||||
"Transition.progress should be called only when Transition.fromScene != " +
|
||||
"Transition.toScene"
|
||||
)
|
||||
}
|
||||
return offset / distance
|
||||
}
|
||||
|
||||
/** The current offset caused by the drag gesture. */
|
||||
var dragOffset by mutableFloatStateOf(0f)
|
||||
|
||||
/**
|
||||
* Whether the offset is animated (the user lifted their finger) or if it is driven by gesture.
|
||||
*/
|
||||
var isAnimatingOffset by mutableStateOf(false)
|
||||
|
||||
/** The animatable used to animate the offset once the user lifted its finger. */
|
||||
val offsetAnimatable = Animatable(0f, visibilityThreshold = OffsetVisibilityThreshold)
|
||||
|
||||
/**
|
||||
* The job currently animating [offsetAnimatable], if it is animating. Note that setting this to
|
||||
* a new job will automatically cancel the previous one.
|
||||
*/
|
||||
var offsetAnimationJob: Job? = null
|
||||
set(value) {
|
||||
field?.cancel()
|
||||
field = value
|
||||
}
|
||||
|
||||
/** The absolute distance between [fromScene] and [toScene]. */
|
||||
var absoluteDistance = 0f
|
||||
|
||||
/**
|
||||
* The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
|
||||
* or to the left of [toScene].
|
||||
*/
|
||||
var _distance by mutableFloatStateOf(0f)
|
||||
val distance: Float
|
||||
get() = _distance
|
||||
}
|
||||
|
||||
/** The destination scene when swiping up or left from [this@upOrLeft]. */
|
||||
private fun Scene.upOrLeft(orientation: Orientation): SceneKey? {
|
||||
return when (orientation) {
|
||||
Orientation.Vertical -> userActions[Swipe.Up]
|
||||
Orientation.Horizontal -> userActions[Swipe.Left]
|
||||
}
|
||||
}
|
||||
|
||||
/** The destination scene when swiping down or right from [this@downOrRight]. */
|
||||
private fun Scene.downOrRight(orientation: Orientation): SceneKey? {
|
||||
return when (orientation) {
|
||||
Orientation.Vertical -> userActions[Swipe.Down]
|
||||
Orientation.Horizontal -> userActions[Swipe.Right]
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether swipe should be enabled in the given [orientation]. */
|
||||
private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean {
|
||||
return upOrLeft(orientation) != null || downOrRight(orientation) != null
|
||||
}
|
||||
|
||||
private fun Orientation.opposite(): Orientation {
|
||||
return when (this) {
|
||||
Orientation.Vertical -> Orientation.Horizontal
|
||||
Orientation.Horizontal -> Orientation.Vertical
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDragStarted(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
transition: SwipeTransition,
|
||||
orientation: Orientation,
|
||||
) {
|
||||
if (layoutImpl.state.transitionState == transition) {
|
||||
// This [transition] was already driving the animation: simply take over it.
|
||||
if (transition.isAnimatingOffset) {
|
||||
// Stop animating and start from where the current offset. Setting the animation job to
|
||||
// `null` will effectively cancel the animation.
|
||||
transition.isAnimatingOffset = false
|
||||
transition.offsetAnimationJob = null
|
||||
transition.dragOffset = transition.offsetAnimatable.value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(b/290184746): Better handle interruptions here if state != idle.
|
||||
|
||||
val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
|
||||
|
||||
transition._currentScene = fromScene
|
||||
transition._fromScene = fromScene
|
||||
|
||||
// We don't know where we are transitioning to yet given that the drag just started, so set it
|
||||
// to fromScene, which will effectively be treated the same as Idle(fromScene).
|
||||
transition._toScene = fromScene
|
||||
|
||||
transition.dragOffset = 0f
|
||||
transition.isAnimatingOffset = false
|
||||
transition.offsetAnimationJob = null
|
||||
|
||||
// Use the layout size in the swipe orientation for swipe distance.
|
||||
// TODO(b/290184746): Also handle custom distances for transitions. With smaller distances, we
|
||||
// will also have to make sure that we correctly handle overscroll.
|
||||
transition.absoluteDistance =
|
||||
when (orientation) {
|
||||
Orientation.Horizontal -> layoutImpl.size.width
|
||||
Orientation.Vertical -> layoutImpl.size.height
|
||||
}.toFloat()
|
||||
|
||||
if (transition.absoluteDistance > 0f) {
|
||||
layoutImpl.state.transitionState = transition
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDrag(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
transition: SwipeTransition,
|
||||
orientation: Orientation,
|
||||
delta: Float,
|
||||
) {
|
||||
transition.dragOffset += delta
|
||||
|
||||
// First check transition.fromScene should be changed for the case where the user quickly swiped
|
||||
// twice in a row to accelerate the transition and go from A => B then B => C really fast.
|
||||
maybeHandleAcceleratedSwipe(transition, orientation)
|
||||
|
||||
val fromScene = transition._fromScene
|
||||
val upOrLeft = fromScene.upOrLeft(orientation)
|
||||
val downOrRight = fromScene.downOrRight(orientation)
|
||||
val offset = transition.dragOffset
|
||||
|
||||
// Compute the target scene depending on the current offset.
|
||||
val targetSceneKey: SceneKey
|
||||
val signedDistance: Float
|
||||
when {
|
||||
offset < 0f && upOrLeft != null -> {
|
||||
targetSceneKey = upOrLeft
|
||||
signedDistance = -transition.absoluteDistance
|
||||
}
|
||||
offset > 0f && downOrRight != null -> {
|
||||
targetSceneKey = downOrRight
|
||||
signedDistance = transition.absoluteDistance
|
||||
}
|
||||
else -> {
|
||||
targetSceneKey = fromScene.key
|
||||
signedDistance = 0f
|
||||
}
|
||||
}
|
||||
|
||||
if (transition._toScene.key != targetSceneKey) {
|
||||
transition._toScene = layoutImpl.scenes.getValue(targetSceneKey)
|
||||
}
|
||||
|
||||
if (transition._distance != signedDistance) {
|
||||
transition._distance = signedDistance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change fromScene in the case where the user quickly swiped multiple times in the same direction
|
||||
* to accelerate the transition from A => B then B => C.
|
||||
*/
|
||||
private fun maybeHandleAcceleratedSwipe(
|
||||
transition: SwipeTransition,
|
||||
orientation: Orientation,
|
||||
) {
|
||||
val toScene = transition._toScene
|
||||
val fromScene = transition._fromScene
|
||||
|
||||
// If the swipe was not committed, don't do anything.
|
||||
if (fromScene == toScene || transition._currentScene != toScene) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the offset is past the distance then let's change fromScene so that the user can swipe to
|
||||
// the next screen or go back to the previous one.
|
||||
val offset = transition.dragOffset
|
||||
val absoluteDistance = transition.absoluteDistance
|
||||
if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) {
|
||||
transition.dragOffset += absoluteDistance
|
||||
transition._fromScene = toScene
|
||||
} else if (offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key) {
|
||||
transition.dragOffset -= absoluteDistance
|
||||
transition._fromScene = toScene
|
||||
}
|
||||
|
||||
// Important note: toScene and distance will be updated right after this function is called,
|
||||
// using fromScene and dragOffset.
|
||||
}
|
||||
|
||||
private fun CoroutineScope.onDragStopped(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
transition: SwipeTransition,
|
||||
velocity: Float,
|
||||
velocityThreshold: Float,
|
||||
positionalThreshold: Float,
|
||||
) {
|
||||
// The state was changed since the drag started; don't do anything.
|
||||
if (layoutImpl.state.transitionState != transition) {
|
||||
return
|
||||
}
|
||||
|
||||
// We were not animating.
|
||||
if (transition._fromScene == transition._toScene) {
|
||||
layoutImpl.state.transitionState = TransitionState.Idle(transition._fromScene.key)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute the destination scene (and therefore offset) to settle in.
|
||||
val targetScene: Scene
|
||||
val targetOffset: Float
|
||||
val offset = transition.dragOffset
|
||||
val distance = transition.distance
|
||||
if (
|
||||
shouldCommitSwipe(
|
||||
offset,
|
||||
distance,
|
||||
velocity,
|
||||
velocityThreshold,
|
||||
positionalThreshold,
|
||||
wasCommitted = transition._currentScene == transition._toScene,
|
||||
)
|
||||
) {
|
||||
targetOffset = distance
|
||||
targetScene = transition._toScene
|
||||
} else {
|
||||
targetOffset = 0f
|
||||
targetScene = transition._fromScene
|
||||
}
|
||||
|
||||
// If the effective current scene changed, it should be reflected right now in the current scene
|
||||
// state, even before the settle animation is ongoing. That way all the swipeables and back
|
||||
// handlers will be refreshed and the user can for instance quickly swipe vertically from A => B
|
||||
// then horizontally from B => C, or swipe from A => B then immediately go back B => A.
|
||||
if (targetScene != transition._currentScene) {
|
||||
transition._currentScene = targetScene
|
||||
layoutImpl.onChangeScene(targetScene.key)
|
||||
}
|
||||
|
||||
// Animate the offset.
|
||||
transition.offsetAnimationJob = launch {
|
||||
transition.offsetAnimatable.snapTo(offset)
|
||||
transition.isAnimatingOffset = true
|
||||
|
||||
transition.offsetAnimatable.animateTo(
|
||||
targetOffset,
|
||||
// TODO(b/290184746): Make this spring spec configurable.
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = OffsetVisibilityThreshold
|
||||
),
|
||||
initialVelocity = velocity,
|
||||
)
|
||||
|
||||
// Now that the animation is done, the state should be idle. Note that if the state was
|
||||
// changed since this animation started, some external code changed it and we shouldn't do
|
||||
// anything here. Note also that this job will be cancelled in the case where the user
|
||||
// intercepts this swipe.
|
||||
if (layoutImpl.state.transitionState == transition) {
|
||||
layoutImpl.state.transitionState = TransitionState.Idle(targetScene.key)
|
||||
}
|
||||
|
||||
transition.offsetAnimationJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the swipe to the target scene should be committed or not. This is inspired by
|
||||
* SwipeableV2.computeTarget().
|
||||
*/
|
||||
private fun shouldCommitSwipe(
|
||||
offset: Float,
|
||||
distance: Float,
|
||||
velocity: Float,
|
||||
velocityThreshold: Float,
|
||||
positionalThreshold: Float,
|
||||
wasCommitted: Boolean,
|
||||
): Boolean {
|
||||
fun isCloserToTarget(): Boolean {
|
||||
return (offset - distance).absoluteValue < offset.absoluteValue
|
||||
}
|
||||
|
||||
// Swiping up or left.
|
||||
if (distance < 0f) {
|
||||
return if (offset > 0f || velocity >= velocityThreshold) {
|
||||
false
|
||||
} else {
|
||||
velocity <= -velocityThreshold ||
|
||||
(offset <= -positionalThreshold && !wasCommitted) ||
|
||||
isCloserToTarget()
|
||||
}
|
||||
}
|
||||
|
||||
// Swiping down or right.
|
||||
return if (offset < 0f || velocity <= -velocityThreshold) {
|
||||
false
|
||||
} else {
|
||||
velocity >= velocityThreshold ||
|
||||
(offset >= positionalThreshold && !wasCommitted) ||
|
||||
isCloserToTarget()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of pixels below which there won't be a visible difference in the transition and from
|
||||
* which the animation can stop.
|
||||
*/
|
||||
private const val OffsetVisibilityThreshold = 0.5f
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Define the [transitions][SceneTransitions] to be used with a [SceneTransitionLayout]. */
|
||||
fun transitions(builder: SceneTransitionsBuilder.() -> Unit): SceneTransitions {
|
||||
return transitionsImpl(builder)
|
||||
}
|
||||
|
||||
@DslMarker annotation class TransitionDsl
|
||||
|
||||
@TransitionDsl
|
||||
interface SceneTransitionsBuilder {
|
||||
/**
|
||||
* Define the default animation to be played when transitioning [to] the specified scene, from
|
||||
* any scene. For the animation specification to apply only when transitioning between two
|
||||
* specific scenes, use [from] instead.
|
||||
*
|
||||
* @see from
|
||||
*/
|
||||
fun to(
|
||||
to: SceneKey,
|
||||
builder: TransitionBuilder.() -> Unit = {},
|
||||
): TransitionSpec
|
||||
|
||||
/**
|
||||
* Define the animation to be played when transitioning [from] the specified scene. For the
|
||||
* animation specification to apply only when transitioning between two specific scenes, pass
|
||||
* the destination scene via the [to] argument.
|
||||
*
|
||||
* When looking up which transition should be used when animating from scene A to scene B, we
|
||||
* pick the single transition matching one of these predicates (in order of importance):
|
||||
* 1. from == A && to == B
|
||||
* 2. to == A && from == B, which is then treated in reverse.
|
||||
* 3. (from == A && to == null) || (from == null && to == B)
|
||||
* 4. (from == B && to == null) || (from == null && to == A), which is then treated in reverse.
|
||||
*/
|
||||
fun from(
|
||||
from: SceneKey,
|
||||
to: SceneKey? = null,
|
||||
builder: TransitionBuilder.() -> Unit = {},
|
||||
): TransitionSpec
|
||||
}
|
||||
|
||||
@TransitionDsl
|
||||
interface TransitionBuilder : PropertyTransformationBuilder {
|
||||
/**
|
||||
* The [AnimationSpec] used to animate the progress of this transition from `0` to `1` when
|
||||
* performing programmatic (not input pointer tracking) animations.
|
||||
*/
|
||||
var spec: AnimationSpec<Float>
|
||||
|
||||
/**
|
||||
* Define a progress-based range for the transformations inside [builder].
|
||||
*
|
||||
* For instance, the following will fade `Foo` during the first half of the transition then it
|
||||
* will translate it by 100.dp during the second half.
|
||||
*
|
||||
* ```
|
||||
* fractionRange(end = 0.5f) { fade(Foo) }
|
||||
* fractionRange(start = 0.5f) { translate(Foo, x = 100.dp) }
|
||||
* ```
|
||||
*
|
||||
* @param start the start of the range, in the [0; 1] range.
|
||||
* @param end the end of the range, in the [0; 1] range.
|
||||
*/
|
||||
fun fractionRange(
|
||||
start: Float? = null,
|
||||
end: Float? = null,
|
||||
builder: PropertyTransformationBuilder.() -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Define a timestamp-based range for the transformations inside [builder].
|
||||
*
|
||||
* For instance, the following will fade `Foo` during the first half of the transition then it
|
||||
* will translate it by 100.dp during the second half.
|
||||
*
|
||||
* ```
|
||||
* spec = tween(500)
|
||||
* timestampRange(end = 250) { fade(Foo) }
|
||||
* timestampRange(start = 250) { translate(Foo, x = 100.dp) }
|
||||
* ```
|
||||
*
|
||||
* Important: [spec] must be a [androidx.compose.animation.core.DurationBasedAnimationSpec] if
|
||||
* you call [timestampRange], otherwise this will throw. The spec duration will be used to
|
||||
* transform this range into a [fractionRange].
|
||||
*
|
||||
* @param startMillis the start of the range, in the [0; spec.duration] range.
|
||||
* @param endMillis the end of the range, in the [0; spec.duration] range.
|
||||
*/
|
||||
fun timestampRange(
|
||||
startMillis: Int? = null,
|
||||
endMillis: Int? = null,
|
||||
builder: PropertyTransformationBuilder.() -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Punch a hole in the element(s) matching [matcher] that has the same bounds as [bounds] and
|
||||
* using the given [shape].
|
||||
*
|
||||
* Punching a hole in an element will "remove" any pixel drawn by that element in the hole area.
|
||||
* This can be used to make content drawn below an opaque element visible. For example, if we
|
||||
* have [this lockscreen scene](http://shortn/_VYySFnJDhN) drawn below
|
||||
* [this shade scene](http://shortn/_fpxGUk0Rg7) and punch a hole in the latter using the big
|
||||
* clock time bounds and a RoundedCornerShape(10dp), [this](http://shortn/_qt80IvORFj) would be
|
||||
* the result.
|
||||
*/
|
||||
fun punchHole(matcher: ElementMatcher, bounds: ElementKey, shape: Shape = RectangleShape)
|
||||
}
|
||||
|
||||
@TransitionDsl
|
||||
interface PropertyTransformationBuilder {
|
||||
/**
|
||||
* Fade the element(s) matching [matcher]. This will automatically fade in or fade out if the
|
||||
* element is entering or leaving the scene, respectively.
|
||||
*/
|
||||
fun fade(matcher: ElementMatcher)
|
||||
|
||||
/** Translate the element(s) matching [matcher] by ([x], [y]) dp. */
|
||||
fun translate(matcher: ElementMatcher, x: Dp = 0.dp, y: Dp = 0.dp)
|
||||
|
||||
/**
|
||||
* Translate the element(s) matching [matcher] from/to the [edge] of the [SceneTransitionLayout]
|
||||
* animating it.
|
||||
*
|
||||
* If [startsOutsideLayoutBounds] is `true`, then the element will start completely outside of
|
||||
* the layout bounds (i.e. none of it will be visible at progress = 0f if the layout clips its
|
||||
* content). If it is `false`, then the element will start aligned with the edge of the layout
|
||||
* (i.e. it will be completely visible at progress = 0f).
|
||||
*/
|
||||
fun translate(matcher: ElementMatcher, edge: Edge, startsOutsideLayoutBounds: Boolean = true)
|
||||
|
||||
/**
|
||||
* Translate the element(s) matching [matcher] by the same amount that [anchor] is translated
|
||||
* during this transition.
|
||||
*
|
||||
* Note: This currently only works if [anchor] is a shared element of this transition.
|
||||
*
|
||||
* TODO(b/290184746): Also support anchors that are not shared but translated because of other
|
||||
* transformations, like an edge translation.
|
||||
*/
|
||||
fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey)
|
||||
|
||||
/**
|
||||
* Scale the [width] and [height] of the element(s) matching [matcher]. Note that this scaling
|
||||
* is done during layout, so it will potentially impact the size and position of other elements.
|
||||
*
|
||||
* TODO(b/290184746): Also provide a scaleDrawing() to scale an element at drawing time.
|
||||
*/
|
||||
fun scaleSize(matcher: ElementMatcher, width: Float = 1f, height: Float = 1f)
|
||||
|
||||
/**
|
||||
* Scale the element(s) matching [matcher] so that it grows/shrinks to the same size as [anchor]
|
||||
* .
|
||||
*
|
||||
* Note: This currently only works if [anchor] is a shared element of this transition.
|
||||
*/
|
||||
fun anchoredSize(matcher: ElementMatcher, anchor: ElementKey)
|
||||
}
|
||||
|
||||
/** An interface to match one or more elements. */
|
||||
interface ElementMatcher {
|
||||
/** Whether the element with key [key] matches this matcher. */
|
||||
fun matches(key: ElementKey): Boolean
|
||||
}
|
||||
|
||||
/** The edge of a [SceneTransitionLayout]. */
|
||||
enum class Edge {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.DurationBasedAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.android.compose.animation.scene.transformation.AnchoredSize
|
||||
import com.android.compose.animation.scene.transformation.AnchoredTranslate
|
||||
import com.android.compose.animation.scene.transformation.EdgeTranslate
|
||||
import com.android.compose.animation.scene.transformation.Fade
|
||||
import com.android.compose.animation.scene.transformation.PropertyTransformation
|
||||
import com.android.compose.animation.scene.transformation.PunchHole
|
||||
import com.android.compose.animation.scene.transformation.RangedPropertyTransformation
|
||||
import com.android.compose.animation.scene.transformation.ScaleSize
|
||||
import com.android.compose.animation.scene.transformation.Transformation
|
||||
import com.android.compose.animation.scene.transformation.TransformationRange
|
||||
import com.android.compose.animation.scene.transformation.Translate
|
||||
|
||||
internal fun transitionsImpl(
|
||||
builder: SceneTransitionsBuilder.() -> Unit,
|
||||
): SceneTransitions {
|
||||
val impl = SceneTransitionsBuilderImpl().apply(builder)
|
||||
return SceneTransitions(impl.transitionSpecs)
|
||||
}
|
||||
|
||||
private class SceneTransitionsBuilderImpl : SceneTransitionsBuilder {
|
||||
val transitionSpecs = mutableListOf<TransitionSpec>()
|
||||
|
||||
override fun to(to: SceneKey, builder: TransitionBuilder.() -> Unit): TransitionSpec {
|
||||
return transition(from = null, to = to, builder)
|
||||
}
|
||||
|
||||
override fun from(
|
||||
from: SceneKey,
|
||||
to: SceneKey?,
|
||||
builder: TransitionBuilder.() -> Unit
|
||||
): TransitionSpec {
|
||||
return transition(from = from, to = to, builder)
|
||||
}
|
||||
|
||||
private fun transition(
|
||||
from: SceneKey?,
|
||||
to: SceneKey?,
|
||||
builder: TransitionBuilder.() -> Unit,
|
||||
): TransitionSpec {
|
||||
val impl = TransitionBuilderImpl().apply(builder)
|
||||
val spec =
|
||||
TransitionSpec(
|
||||
from,
|
||||
to,
|
||||
impl.transformations,
|
||||
impl.spec,
|
||||
)
|
||||
transitionSpecs.add(spec)
|
||||
return spec
|
||||
}
|
||||
}
|
||||
|
||||
private class TransitionBuilderImpl : TransitionBuilder {
|
||||
val transformations = mutableListOf<Transformation>()
|
||||
override var spec: AnimationSpec<Float> = spring(stiffness = Spring.StiffnessLow)
|
||||
|
||||
private var range: TransformationRange? = null
|
||||
private val durationMillis: Int by lazy {
|
||||
val spec = spec
|
||||
if (spec !is DurationBasedAnimationSpec) {
|
||||
error("timestampRange {} can only be used with a DurationBasedAnimationSpec")
|
||||
}
|
||||
|
||||
spec.vectorize(Float.VectorConverter).durationMillis
|
||||
}
|
||||
|
||||
override fun punchHole(matcher: ElementMatcher, bounds: ElementKey, shape: Shape) {
|
||||
transformations.add(PunchHole(matcher, bounds, shape))
|
||||
}
|
||||
|
||||
override fun fractionRange(
|
||||
start: Float?,
|
||||
end: Float?,
|
||||
builder: PropertyTransformationBuilder.() -> Unit
|
||||
) {
|
||||
range = TransformationRange(start, end)
|
||||
builder()
|
||||
range = null
|
||||
}
|
||||
|
||||
override fun timestampRange(
|
||||
startMillis: Int?,
|
||||
endMillis: Int?,
|
||||
builder: PropertyTransformationBuilder.() -> Unit
|
||||
) {
|
||||
if (startMillis != null && (startMillis < 0 || startMillis > durationMillis)) {
|
||||
error("invalid start value: startMillis=$startMillis durationMillis=$durationMillis")
|
||||
}
|
||||
|
||||
if (endMillis != null && (endMillis < 0 || endMillis > durationMillis)) {
|
||||
error("invalid end value: endMillis=$startMillis durationMillis=$durationMillis")
|
||||
}
|
||||
|
||||
val start = startMillis?.let { it.toFloat() / durationMillis }
|
||||
val end = endMillis?.let { it.toFloat() / durationMillis }
|
||||
fractionRange(start, end, builder)
|
||||
}
|
||||
|
||||
private fun transformation(transformation: PropertyTransformation<*>) {
|
||||
if (range != null) {
|
||||
transformations.add(RangedPropertyTransformation(transformation, range!!))
|
||||
} else {
|
||||
transformations.add(transformation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun fade(matcher: ElementMatcher) {
|
||||
transformation(Fade(matcher))
|
||||
}
|
||||
|
||||
override fun translate(matcher: ElementMatcher, x: Dp, y: Dp) {
|
||||
transformation(Translate(matcher, x, y))
|
||||
}
|
||||
|
||||
override fun translate(
|
||||
matcher: ElementMatcher,
|
||||
edge: Edge,
|
||||
startsOutsideLayoutBounds: Boolean
|
||||
) {
|
||||
transformation(EdgeTranslate(matcher, edge, startsOutsideLayoutBounds))
|
||||
}
|
||||
|
||||
override fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey) {
|
||||
transformation(AnchoredTranslate(matcher, anchor))
|
||||
}
|
||||
|
||||
override fun scaleSize(matcher: ElementMatcher, width: Float, height: Float) {
|
||||
transformation(ScaleSize(matcher, width, height))
|
||||
}
|
||||
|
||||
override fun anchoredSize(matcher: ElementMatcher, anchor: ElementKey) {
|
||||
transformation(AnchoredSize(matcher, anchor))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementKey
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneKey
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
|
||||
/** Anchor the size of an element to the size of another element. */
|
||||
internal class AnchoredSize(
|
||||
override val matcher: ElementMatcher,
|
||||
private val anchor: ElementKey,
|
||||
) : PropertyTransformation<IntSize> {
|
||||
override fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: IntSize,
|
||||
): IntSize {
|
||||
fun anchorSizeIn(scene: SceneKey): IntSize {
|
||||
val size = layoutImpl.elements[anchor]?.sceneValues?.get(scene)?.size
|
||||
return if (size != null && size != Element.SizeUnspecified) {
|
||||
size
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
// This simple implementation assumes that the size of [element] is the same as the size of
|
||||
// the [anchor] in [scene], so simply transform to the size of the anchor in the other
|
||||
// scene.
|
||||
return if (scene.key == transition.fromScene) {
|
||||
anchorSizeIn(transition.toScene)
|
||||
} else {
|
||||
anchorSizeIn(transition.fromScene)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementKey
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneKey
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
|
||||
/** Anchor the translation of an element to another element. */
|
||||
internal class AnchoredTranslate(
|
||||
override val matcher: ElementMatcher,
|
||||
private val anchor: ElementKey,
|
||||
) : PropertyTransformation<Offset> {
|
||||
override fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: Offset,
|
||||
): Offset {
|
||||
val anchor = layoutImpl.elements[anchor] ?: return value
|
||||
fun anchorOffsetIn(scene: SceneKey): Offset? {
|
||||
return anchor.sceneValues[scene]?.offset?.takeIf { it.isSpecified }
|
||||
}
|
||||
|
||||
// [element] will move the same amount as [anchor] does.
|
||||
// TODO(b/290184746): Also support anchors that are not shared but translated because of
|
||||
// other transformations, like an edge translation.
|
||||
val anchorFromOffset = anchorOffsetIn(transition.fromScene) ?: return value
|
||||
val anchorToOffset = anchorOffsetIn(transition.toScene) ?: return value
|
||||
val offset = anchorToOffset - anchorFromOffset
|
||||
|
||||
return if (scene.key == transition.toScene) {
|
||||
Offset(
|
||||
value.x - offset.x,
|
||||
value.y - offset.y,
|
||||
)
|
||||
} else {
|
||||
Offset(
|
||||
value.x + offset.x,
|
||||
value.y + offset.y,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.android.compose.animation.scene.Edge
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
|
||||
/** Translate an element from an edge of the layout. */
|
||||
internal class EdgeTranslate(
|
||||
override val matcher: ElementMatcher,
|
||||
private val edge: Edge,
|
||||
private val startsOutsideLayoutBounds: Boolean = true,
|
||||
) : PropertyTransformation<Offset> {
|
||||
override fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: Offset
|
||||
): Offset {
|
||||
val sceneSize = scene.size
|
||||
val elementSize = sceneValues.size
|
||||
if (elementSize == Element.SizeUnspecified) {
|
||||
return value
|
||||
}
|
||||
|
||||
return when (edge) {
|
||||
Edge.Top ->
|
||||
if (startsOutsideLayoutBounds) {
|
||||
Offset(value.x, -elementSize.height.toFloat())
|
||||
} else {
|
||||
Offset(value.x, 0f)
|
||||
}
|
||||
Edge.Left ->
|
||||
if (startsOutsideLayoutBounds) {
|
||||
Offset(-elementSize.width.toFloat(), value.y)
|
||||
} else {
|
||||
Offset(0f, value.y)
|
||||
}
|
||||
Edge.Bottom ->
|
||||
if (startsOutsideLayoutBounds) {
|
||||
Offset(value.x, sceneSize.height.toFloat())
|
||||
} else {
|
||||
Offset(value.x, (sceneSize.height - elementSize.height).toFloat())
|
||||
}
|
||||
Edge.Right ->
|
||||
if (startsOutsideLayoutBounds) {
|
||||
Offset(sceneSize.width.toFloat(), value.y)
|
||||
} else {
|
||||
Offset((sceneSize.width - elementSize.width).toFloat(), value.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
|
||||
/** Fade an element in or out. */
|
||||
internal class Fade(
|
||||
override val matcher: ElementMatcher,
|
||||
) : PropertyTransformation<Float> {
|
||||
override fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: Float
|
||||
): Float {
|
||||
// Return the alpha value of [element] either when it starts fading in or when it finished
|
||||
// fading out, which is `0` in both cases.
|
||||
return 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.withSaveLayer
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementKey
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
|
||||
/** Punch a hole in an element using the bounds of another element and a given [shape]. */
|
||||
internal class PunchHole(
|
||||
override val matcher: ElementMatcher,
|
||||
private val bounds: ElementKey,
|
||||
private val shape: Shape,
|
||||
) : ModifierTransformation {
|
||||
override fun Modifier.transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
): Modifier {
|
||||
return drawWithContent {
|
||||
val bounds = layoutImpl.elements[bounds]
|
||||
if (
|
||||
bounds == null ||
|
||||
bounds.lastSize == Element.SizeUnspecified ||
|
||||
bounds.lastOffset == Offset.Unspecified
|
||||
) {
|
||||
drawContent()
|
||||
return@drawWithContent
|
||||
}
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
canvas.withSaveLayer(size.toRect(), Paint()) {
|
||||
drawContent()
|
||||
|
||||
val offset = bounds.lastOffset - element.lastOffset
|
||||
translate(offset.x, offset.y) { drawHole(bounds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawHole(bounds: Element) {
|
||||
if (shape == RectangleShape) {
|
||||
drawRect(Color.Black, blendMode = BlendMode.DstOut)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(b/290184746): Cache outline if the size of bounds does not change.
|
||||
drawOutline(
|
||||
shape.createOutline(
|
||||
bounds.lastSize.toSize(),
|
||||
layoutDirection,
|
||||
this,
|
||||
),
|
||||
Color.Black,
|
||||
blendMode = BlendMode.DstOut,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Scales the size of an element. Note that this makes the element resize every frame and will
|
||||
* therefore impact the layout of other elements.
|
||||
*/
|
||||
internal class ScaleSize(
|
||||
override val matcher: ElementMatcher,
|
||||
private val width: Float = 1f,
|
||||
private val height: Float = 1f,
|
||||
) : PropertyTransformation<IntSize> {
|
||||
override fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: IntSize,
|
||||
): IntSize {
|
||||
return IntSize(
|
||||
width = (value.width * width).roundToInt(),
|
||||
height = (value.height * height).roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
|
||||
/** A transformation applied to one or more elements during a transition. */
|
||||
sealed interface Transformation {
|
||||
/**
|
||||
* The matcher that should match the element(s) to which this transformation should be applied.
|
||||
*/
|
||||
val matcher: ElementMatcher
|
||||
|
||||
/*
|
||||
* Reverse this transformation. This is called when we use Transition(from = A, to = B) when
|
||||
* animating from B to A and there is no Transition(from = B, to = A) defined.
|
||||
*/
|
||||
fun reverse(): Transformation = this
|
||||
}
|
||||
|
||||
/** A transformation that is applied on the element during the whole transition. */
|
||||
internal interface ModifierTransformation : Transformation {
|
||||
/** Apply the transformation to [element]. */
|
||||
// TODO(b/290184746): Figure out a public API for custom transformations that don't have access
|
||||
// to these internal classes.
|
||||
fun Modifier.transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
): Modifier
|
||||
}
|
||||
|
||||
/** A transformation that changes the value of an element property, like its size or offset. */
|
||||
internal sealed interface PropertyTransformation<T> : Transformation {
|
||||
/**
|
||||
* The range during which the transformation is applied. If it is `null`, then the
|
||||
* transformation will be applied throughout the whole scene transition.
|
||||
*/
|
||||
val range: TransformationRange?
|
||||
get() = null
|
||||
|
||||
/**
|
||||
* Transform [value], i.e. the value of the transformed property without this transformation.
|
||||
*/
|
||||
// TODO(b/290184746): Figure out a public API for custom transformations that don't have access
|
||||
// to these internal classes.
|
||||
fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: T,
|
||||
): T
|
||||
}
|
||||
|
||||
/**
|
||||
* A [PropertyTransformation] associated to a range. This is a helper class so that normal
|
||||
* implementations of [PropertyTransformation] don't have to take care of reversing their range when
|
||||
* they are reversed.
|
||||
*/
|
||||
internal class RangedPropertyTransformation<T>(
|
||||
val delegate: PropertyTransformation<T>,
|
||||
override val range: TransformationRange,
|
||||
) : PropertyTransformation<T> by delegate {
|
||||
override fun reverse(): Transformation {
|
||||
return RangedPropertyTransformation(
|
||||
delegate.reverse() as PropertyTransformation<T>,
|
||||
range.reverse()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The progress-based range of a [PropertyTransformation]. */
|
||||
data class TransformationRange
|
||||
private constructor(
|
||||
val start: Float,
|
||||
val end: Float,
|
||||
) {
|
||||
constructor(
|
||||
start: Float? = null,
|
||||
end: Float? = null
|
||||
) : this(start ?: BoundUnspecified, end ?: BoundUnspecified)
|
||||
|
||||
init {
|
||||
require(!start.isSpecified() || (start in 0f..1f))
|
||||
require(!end.isSpecified() || (end in 0f..1f))
|
||||
require(!start.isSpecified() || !end.isSpecified() || start <= end)
|
||||
}
|
||||
|
||||
/** Reverse this range. */
|
||||
fun reverse() = TransformationRange(start = reverseBound(end), end = reverseBound(start))
|
||||
|
||||
/** Get the progress of this range given the global [transitionProgress]. */
|
||||
fun progress(transitionProgress: Float): Float {
|
||||
return when {
|
||||
start.isSpecified() && end.isSpecified() ->
|
||||
((transitionProgress - start) / (end - start)).coerceIn(0f, 1f)
|
||||
!start.isSpecified() && !end.isSpecified() -> transitionProgress
|
||||
end.isSpecified() -> (transitionProgress / end).coerceAtMost(1f)
|
||||
else -> ((transitionProgress - start) / (1f - start)).coerceAtLeast(0f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.isSpecified() = this != BoundUnspecified
|
||||
|
||||
private fun reverseBound(bound: Float): Float {
|
||||
return if (bound.isSpecified()) {
|
||||
1f - bound
|
||||
} else {
|
||||
BoundUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BoundUnspecified = Float.MIN_VALUE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.compose.animation.scene.Element
|
||||
import com.android.compose.animation.scene.ElementMatcher
|
||||
import com.android.compose.animation.scene.Scene
|
||||
import com.android.compose.animation.scene.SceneTransitionLayoutImpl
|
||||
import com.android.compose.animation.scene.TransitionState
|
||||
|
||||
/** Translate an element by a fixed amount of density-independent pixels. */
|
||||
internal class Translate(
|
||||
override val matcher: ElementMatcher,
|
||||
private val x: Dp = 0.dp,
|
||||
private val y: Dp = 0.dp,
|
||||
) : PropertyTransformation<Offset> {
|
||||
override fun transform(
|
||||
layoutImpl: SceneTransitionLayoutImpl,
|
||||
scene: Scene,
|
||||
element: Element,
|
||||
sceneValues: Element.SceneValues,
|
||||
transition: TransitionState.Transition,
|
||||
value: Offset,
|
||||
): Offset {
|
||||
return with(layoutImpl.density) {
|
||||
Offset(
|
||||
value.x + x.toPx(),
|
||||
value.y + y.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.constrainWidth
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
@@ -126,18 +125,20 @@ private fun Grid(
|
||||
((columns - 1) * horizontalSpacing.toPx()).roundToInt()
|
||||
val totalVerticalSpacingBetweenChildren = ((rows - 1) * verticalSpacing.toPx()).roundToInt()
|
||||
val childConstraints =
|
||||
Constraints().apply {
|
||||
if (constraints.maxWidth != Constraints.Infinity) {
|
||||
constrainWidth(
|
||||
Constraints(
|
||||
maxWidth =
|
||||
if (constraints.maxWidth != Constraints.Infinity) {
|
||||
(constraints.maxWidth - totalHorizontalSpacingBetweenChildren) / columns
|
||||
)
|
||||
}
|
||||
if (constraints.maxHeight != Constraints.Infinity) {
|
||||
constrainWidth(
|
||||
} else {
|
||||
Constraints.Infinity
|
||||
},
|
||||
maxHeight =
|
||||
if (constraints.maxHeight != Constraints.Infinity) {
|
||||
(constraints.maxHeight - totalVerticalSpacingBetweenChildren) / rows
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Constraints.Infinity
|
||||
}
|
||||
)
|
||||
|
||||
val placeables = buildList {
|
||||
for (cellIndex in measurables.indices) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.systemui.compose.modifiers
|
||||
package com.android.compose.modifiers
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.ui.util
|
||||
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
/**
|
||||
* Iterates through a [List] using the index and calls [action] for each item. This does not
|
||||
* allocate an iterator like [Iterable.forEach].
|
||||
*
|
||||
* **Do not use for collections that come from public APIs**, since they may not support random
|
||||
* access in an efficient way, and this method may actually be a lot slower. Only use for
|
||||
* collections that are created by code we control and are known to support random access.
|
||||
*/
|
||||
@Suppress("BanInlineOptIn")
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
internal inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
|
||||
contract { callsInPlace(action) }
|
||||
for (index in indices) {
|
||||
val item = get(index)
|
||||
action(item)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list containing the results of applying the given [transform] function to each element
|
||||
* in the original collection.
|
||||
*
|
||||
* **Do not use for collections that come from public APIs**, since they may not support random
|
||||
* access in an efficient way, and this method may actually be a lot slower. Only use for
|
||||
* collections that are created by code we control and are known to support random access.
|
||||
*/
|
||||
@Suppress("BanInlineOptIn")
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
internal inline fun <T, R> List<T>.fastMap(transform: (T) -> R): List<R> {
|
||||
contract { callsInPlace(transform) }
|
||||
val target = ArrayList<R>(size)
|
||||
fastForEach { target += transform(it) }
|
||||
return target
|
||||
}
|
||||
@@ -17,11 +17,10 @@
|
||||
|
||||
package com.android.compose.ui.util
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
// TODO(b/272311106): this is a fork from material. Unfork it when MathHelpers.kt reaches material3.
|
||||
|
||||
/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
|
||||
fun lerp(start: Float, stop: Float, fraction: Float): Float {
|
||||
return (1 - fraction) * start + fraction * stop
|
||||
@@ -36,3 +35,11 @@ fun lerp(start: Int, stop: Int, fraction: Float): Int {
|
||||
fun lerp(start: Long, stop: Long, fraction: Float): Long {
|
||||
return start + ((stop - start) * fraction.toDouble()).roundToLong()
|
||||
}
|
||||
|
||||
/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
|
||||
fun lerp(start: IntSize, stop: IntSize, fraction: Float): IntSize {
|
||||
return IntSize(
|
||||
lerp(start.width, stop.width, fraction),
|
||||
lerp(start.height, stop.height, fraction)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ android_test {
|
||||
"androidx.compose.runtime_runtime",
|
||||
"androidx.compose.ui_ui-test-junit4",
|
||||
"androidx.compose.ui_ui-test-manifest",
|
||||
|
||||
"truth-prebuilt",
|
||||
],
|
||||
|
||||
kotlincflags: ["-Xjvm-default=enable"],
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ObservableTransitionStateTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testObservableTransitionState() = runTest {
|
||||
val state = SceneTransitionLayoutState(TestScenes.SceneA)
|
||||
|
||||
// Collect the current observable state into [observableState].
|
||||
// TODO(b/290184746): Use collectValues {} once it is extracted into a library that can be
|
||||
// reused by non-SystemUI testing code.
|
||||
var observableState: ObservableTransitionState? = null
|
||||
backgroundScope.launch {
|
||||
state.observableTransitionState().collect { observableState = it }
|
||||
}
|
||||
|
||||
fun observableState(): ObservableTransitionState {
|
||||
runCurrent()
|
||||
return observableState!!
|
||||
}
|
||||
|
||||
fun ObservableTransitionState.Transition.progress(): Float {
|
||||
var lastProgress = -1f
|
||||
backgroundScope.launch { progress.collect { lastProgress = it } }
|
||||
runCurrent()
|
||||
return lastProgress
|
||||
}
|
||||
|
||||
rule.testTransition(
|
||||
from = TestScenes.SceneA,
|
||||
to = TestScenes.SceneB,
|
||||
transitionLayout = { currentScene, onChangeScene ->
|
||||
SceneTransitionLayout(
|
||||
currentScene,
|
||||
onChangeScene,
|
||||
EmptyTestTransitions,
|
||||
state = state,
|
||||
) {
|
||||
scene(TestScenes.SceneA) {}
|
||||
scene(TestScenes.SceneB) {}
|
||||
}
|
||||
}
|
||||
) {
|
||||
before {
|
||||
assertThat(observableState())
|
||||
.isEqualTo(ObservableTransitionState.Idle(TestScenes.SceneA))
|
||||
}
|
||||
at(0) {
|
||||
val state = observableState()
|
||||
assertThat(state).isInstanceOf(ObservableTransitionState.Transition::class.java)
|
||||
assertThat((state as ObservableTransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(state.toScene).isEqualTo(TestScenes.SceneB)
|
||||
assertThat(state.progress()).isEqualTo(0f)
|
||||
}
|
||||
at(TestTransitionDuration / 2) {
|
||||
val state = observableState()
|
||||
assertThat((state as ObservableTransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(state.toScene).isEqualTo(TestScenes.SceneB)
|
||||
assertThat(state.progress()).isEqualTo(0.5f)
|
||||
}
|
||||
after {
|
||||
assertThat(observableState())
|
||||
.isEqualTo(ObservableTransitionState.Idle(TestScenes.SceneB))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.assertHeightIsEqualTo
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onChild
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.compose.test.subjects.DpOffsetSubject
|
||||
import com.android.compose.test.subjects.assertThat
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SceneTransitionLayoutTest {
|
||||
companion object {
|
||||
private val LayoutSize = 300.dp
|
||||
}
|
||||
|
||||
private var currentScene by mutableStateOf(TestScenes.SceneA)
|
||||
private val layoutState = SceneTransitionLayoutState(currentScene)
|
||||
|
||||
// We use createAndroidComposeRule() here and not createComposeRule() because we need an
|
||||
// activity for testBack().
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
/** The content under test. */
|
||||
@Composable
|
||||
private fun TestContent() {
|
||||
SceneTransitionLayout(
|
||||
currentScene,
|
||||
{ currentScene = it },
|
||||
EmptyTestTransitions,
|
||||
state = layoutState,
|
||||
modifier = Modifier.size(LayoutSize),
|
||||
) {
|
||||
scene(
|
||||
TestScenes.SceneA,
|
||||
userActions = mapOf(Back to TestScenes.SceneB),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
SharedFoo(size = 50.dp, childOffset = 0.dp, Modifier.align(Alignment.TopEnd))
|
||||
Text("SceneA")
|
||||
}
|
||||
}
|
||||
scene(TestScenes.SceneB) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
SharedFoo(
|
||||
size = 100.dp,
|
||||
childOffset = 50.dp,
|
||||
Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
Text("SceneB")
|
||||
}
|
||||
}
|
||||
scene(TestScenes.SceneC) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
SharedFoo(
|
||||
size = 150.dp,
|
||||
childOffset = 100.dp,
|
||||
Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
Text("SceneC")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SceneScope.SharedFoo(size: Dp, childOffset: Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.background(Color.Red)
|
||||
.element(TestElements.Foo)
|
||||
.testTag(TestElements.Foo.name)
|
||||
) {
|
||||
// Offset the single child of Foo by some animated shared offset.
|
||||
val offset by animateSharedDpAsState(childOffset, TestValues.Value1, TestElements.Foo)
|
||||
|
||||
Box(
|
||||
Modifier.offset {
|
||||
val pxOffset = offset.roundToPx()
|
||||
IntOffset(pxOffset, pxOffset)
|
||||
}
|
||||
.size(30.dp)
|
||||
.background(Color.Blue)
|
||||
.testTag(TestElements.Bar.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnlyCurrentSceneIsDisplayed() {
|
||||
rule.setContent { TestContent() }
|
||||
|
||||
// Only scene A is displayed.
|
||||
rule.onNodeWithText("SceneA").assertIsDisplayed()
|
||||
rule.onNodeWithText("SceneB").assertDoesNotExist()
|
||||
rule.onNodeWithText("SceneC").assertDoesNotExist()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// Change to scene B. Only that scene is displayed.
|
||||
currentScene = TestScenes.SceneB
|
||||
rule.onNodeWithText("SceneA").assertDoesNotExist()
|
||||
rule.onNodeWithText("SceneB").assertIsDisplayed()
|
||||
rule.onNodeWithText("SceneC").assertDoesNotExist()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBack() {
|
||||
rule.setContent { TestContent() }
|
||||
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
rule.activity.onBackPressed()
|
||||
rule.waitForIdle()
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTransitionState() {
|
||||
rule.setContent { TestContent() }
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// We will advance the clock manually.
|
||||
rule.mainClock.autoAdvance = false
|
||||
|
||||
// Change the current scene. Until composition is triggered, this won't change the layout
|
||||
// state.
|
||||
currentScene = TestScenes.SceneB
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// On the next frame, we will recompose because currentScene changed, which will start the
|
||||
// transition (i.e. it will change the transitionState to be a Transition) in a
|
||||
// LaunchedEffect.
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
|
||||
val transition = layoutState.transitionState as TransitionState.Transition
|
||||
assertThat(transition.fromScene).isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
|
||||
assertThat(transition.progress).isEqualTo(0f)
|
||||
|
||||
// Then, on the next frame, the animator we started gets its initial value and clock
|
||||
// starting time. We are now at progress = 0f.
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((layoutState.transitionState as TransitionState.Transition).progress)
|
||||
.isEqualTo(0f)
|
||||
|
||||
// The test transition lasts 480ms. 240ms after the start of the transition, we are at
|
||||
// progress = 0.5f.
|
||||
rule.mainClock.advanceTimeBy(TestTransitionDuration / 2)
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((layoutState.transitionState as TransitionState.Transition).progress)
|
||||
.isEqualTo(0.5f)
|
||||
|
||||
// (240-16) ms later, i.e. one frame before the transition is finished, we are at
|
||||
// progress=(480-16)/480.
|
||||
rule.mainClock.advanceTimeBy(TestTransitionDuration / 2 - 16)
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((layoutState.transitionState as TransitionState.Transition).progress)
|
||||
.isEqualTo((TestTransitionDuration - 16) / 480f)
|
||||
|
||||
// one frame (16ms) later, the transition is finished and we are in the idle state in scene
|
||||
// B.
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSharedElement() {
|
||||
rule.setContent { TestContent() }
|
||||
|
||||
// In scene A, the shared element SharedFoo() is at the top end of the layout and has a size
|
||||
// of 50.dp.
|
||||
var sharedFoo = rule.onNodeWithTag(TestElements.Foo.name, useUnmergedTree = true)
|
||||
sharedFoo.assertWidthIsEqualTo(50.dp)
|
||||
sharedFoo.assertHeightIsEqualTo(50.dp)
|
||||
sharedFoo.assertPositionInRootIsEqualTo(
|
||||
expectedTop = 0.dp,
|
||||
expectedLeft = LayoutSize - 50.dp,
|
||||
)
|
||||
|
||||
// The shared offset of the single child of SharedFoo() is 0dp in scene A.
|
||||
assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo)).isEqualTo(DpOffset(0.dp, 0.dp))
|
||||
|
||||
// Pause animations to test the state mid-transition.
|
||||
rule.mainClock.autoAdvance = false
|
||||
|
||||
// Go to scene B and let the animation start. See [testLayoutState()] and
|
||||
// [androidx.compose.ui.test.MainTestClock] to understand why we need to advance the clock
|
||||
// by 2 frames to be at the start of the animation.
|
||||
currentScene = TestScenes.SceneB
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
|
||||
// Advance to the middle of the animation.
|
||||
rule.mainClock.advanceTimeBy(TestTransitionDuration / 2)
|
||||
|
||||
// We need to use onAllNodesWithTag().onFirst() here given that shared elements are
|
||||
// composed and laid out in both scenes (but drawn only in one).
|
||||
sharedFoo = rule.onAllNodesWithTag(TestElements.Foo.name).onFirst()
|
||||
|
||||
// In scene B, foo is at the top start (x = 0, y = 0) of the layout and has a size of
|
||||
// 100.dp. We pause at the middle of the transition, so it should now be 75.dp given that we
|
||||
// use a linear interpolator. Foo was at (x = layoutSize - 50dp, y = 0) in SceneA and is
|
||||
// going to (x = 0, y = 0), so the offset should now be half what it was.
|
||||
assertThat((layoutState.transitionState as TransitionState.Transition).progress)
|
||||
.isEqualTo(0.5f)
|
||||
sharedFoo.assertWidthIsEqualTo(75.dp)
|
||||
sharedFoo.assertHeightIsEqualTo(75.dp)
|
||||
sharedFoo.assertPositionInRootIsEqualTo(
|
||||
expectedTop = 0.dp,
|
||||
expectedLeft = (LayoutSize - 50.dp) / 2
|
||||
)
|
||||
|
||||
// The shared offset of the single child of SharedFoo() is 50dp in scene B and 0dp in Scene
|
||||
// A, so it should be 25dp now.
|
||||
assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo))
|
||||
.isWithin(DpOffsetSubject.DefaultTolerance)
|
||||
.of(DpOffset(25.dp, 25.dp))
|
||||
|
||||
// Animate to scene C, let the animation start then go to the middle of the transition.
|
||||
currentScene = TestScenes.SceneC
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeBy(TestTransitionDuration / 2)
|
||||
|
||||
// In Scene C, foo is at the bottom start of the layout and has a size of 150.dp. The
|
||||
// transition scene B => scene C is using a FastOutSlowIn interpolator.
|
||||
val interpolatedProgress = FastOutSlowInEasing.transform(0.5f)
|
||||
val expectedTop = (LayoutSize - 150.dp) * interpolatedProgress
|
||||
val expectedLeft = 0.dp
|
||||
val expectedSize = 100.dp + (150.dp - 100.dp) * interpolatedProgress
|
||||
|
||||
sharedFoo = rule.onAllNodesWithTag(TestElements.Foo.name).onFirst()
|
||||
assertThat((layoutState.transitionState as TransitionState.Transition).progress)
|
||||
.isEqualTo(interpolatedProgress)
|
||||
sharedFoo.assertWidthIsEqualTo(expectedSize)
|
||||
sharedFoo.assertHeightIsEqualTo(expectedSize)
|
||||
sharedFoo.assertPositionInRootIsEqualTo(expectedLeft, expectedTop)
|
||||
|
||||
// The shared offset of the single child of SharedFoo() is 50dp in scene B and 100dp in
|
||||
// Scene C.
|
||||
val expectedOffset = 50.dp + (100.dp - 50.dp) * interpolatedProgress
|
||||
assertThat(sharedFoo.onChild().offsetRelativeTo(sharedFoo))
|
||||
.isWithin(DpOffsetSubject.DefaultTolerance)
|
||||
.of(DpOffset(expectedOffset, expectedOffset))
|
||||
|
||||
// Go back to scene A. This should happen instantly (once the animation started, i.e. after
|
||||
// 2 frames) given that we use a snap() animation spec.
|
||||
currentScene = TestScenes.SceneA
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
}
|
||||
|
||||
private fun SemanticsNodeInteraction.offsetRelativeTo(
|
||||
other: SemanticsNodeInteraction,
|
||||
): DpOffset {
|
||||
val node = fetchSemanticsNode()
|
||||
val bounds = node.boundsInRoot
|
||||
val otherBounds = other.fetchSemanticsNode().boundsInRoot
|
||||
return with(node.layoutInfo.density) {
|
||||
DpOffset(
|
||||
x = (bounds.left - otherBounds.left).toDp(),
|
||||
y = (bounds.top - otherBounds.top).toDp(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeWithVelocity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SwipeToSceneTest {
|
||||
companion object {
|
||||
private val LayoutWidth = 200.dp
|
||||
private val LayoutHeight = 400.dp
|
||||
|
||||
/** The middle of the layout, in pixels. */
|
||||
private val Density.middle: Offset
|
||||
get() = Offset((LayoutWidth / 2).toPx(), (LayoutHeight / 2).toPx())
|
||||
}
|
||||
|
||||
private var currentScene by mutableStateOf(TestScenes.SceneA)
|
||||
private val layoutState = SceneTransitionLayoutState(currentScene)
|
||||
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
/** The content under test. */
|
||||
@Composable
|
||||
private fun TestContent() {
|
||||
SceneTransitionLayout(
|
||||
currentScene,
|
||||
{ currentScene = it },
|
||||
EmptyTestTransitions,
|
||||
state = layoutState,
|
||||
modifier = Modifier.size(LayoutWidth, LayoutHeight).testTag(TestElements.Foo.name),
|
||||
) {
|
||||
scene(
|
||||
TestScenes.SceneA,
|
||||
userActions =
|
||||
mapOf(
|
||||
Swipe.Left to TestScenes.SceneB,
|
||||
Swipe.Down to TestScenes.SceneC,
|
||||
),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
}
|
||||
scene(
|
||||
TestScenes.SceneB,
|
||||
userActions = mapOf(Swipe.Right to TestScenes.SceneA),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
}
|
||||
scene(
|
||||
TestScenes.SceneC,
|
||||
userActions = mapOf(Swipe.Down to TestScenes.SceneA),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDragWithPositionalThreshold() {
|
||||
// The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
|
||||
// detected as a drag event.
|
||||
var touchSlop = 0f
|
||||
rule.setContent {
|
||||
touchSlop = LocalViewConfiguration.current.touchSlop
|
||||
TestContent()
|
||||
}
|
||||
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// Drag left (i.e. from right to left) by 55dp. We pick 55dp here because 56dp is the
|
||||
// positional threshold from which we commit the gesture.
|
||||
rule.onRoot().performTouchInput {
|
||||
down(middle)
|
||||
|
||||
// We use a high delay so that the velocity of the gesture is slow (otherwise it would
|
||||
// commit the gesture, even if we are below the positional threshold).
|
||||
moveBy(Offset(-55.dp.toPx() - touchSlop, 0f), delayMillis = 1_000)
|
||||
}
|
||||
|
||||
// We should be at a progress = 55dp / LayoutWidth given that we use the layout size in
|
||||
// the gesture axis as swipe distance.
|
||||
var transition = layoutState.transitionState
|
||||
assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((transition as TransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
|
||||
assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.progress).isEqualTo(55.dp / LayoutWidth)
|
||||
|
||||
// Release the finger. We should now be animating back to A (currentScene = SceneA) given
|
||||
// that 55dp < positional threshold.
|
||||
rule.onRoot().performTouchInput { up() }
|
||||
transition = layoutState.transitionState
|
||||
assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((transition as TransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
|
||||
assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.progress).isEqualTo(55.dp / LayoutWidth)
|
||||
|
||||
// Wait for the animation to finish. We should now be in scene A.
|
||||
rule.waitForIdle()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// Now we do the same but vertically and with a drag distance of 56dp, which is >=
|
||||
// positional threshold.
|
||||
rule.onRoot().performTouchInput {
|
||||
down(middle)
|
||||
moveBy(Offset(0f, 56.dp.toPx() + touchSlop), delayMillis = 1_000)
|
||||
}
|
||||
|
||||
// Drag is in progress, so currentScene = SceneA and progress = 56dp / LayoutHeight
|
||||
transition = layoutState.transitionState
|
||||
assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((transition as TransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneC)
|
||||
assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.progress).isEqualTo(56.dp / LayoutHeight)
|
||||
|
||||
// Release the finger. We should now be animating to C (currentScene = SceneC) given
|
||||
// that 56dp >= positional threshold.
|
||||
rule.onRoot().performTouchInput { up() }
|
||||
transition = layoutState.transitionState
|
||||
assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((transition as TransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneC)
|
||||
assertThat(transition.currentScene).isEqualTo(TestScenes.SceneC)
|
||||
assertThat(transition.progress).isEqualTo(56.dp / LayoutHeight)
|
||||
|
||||
// Wait for the animation to finish. We should now be in scene C.
|
||||
rule.waitForIdle()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSwipeWithVelocityThreshold() {
|
||||
// The draggable touch slop, i.e. the min px distance a touch pointer must move before it is
|
||||
// detected as a drag event.
|
||||
var touchSlop = 0f
|
||||
rule.setContent {
|
||||
touchSlop = LocalViewConfiguration.current.touchSlop
|
||||
TestContent()
|
||||
}
|
||||
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// Swipe left (i.e. from right to left) using a velocity of 124 dp/s. We pick 124 dp/s here
|
||||
// because 125 dp/s is the velocity threshold from which we commit the gesture. We also use
|
||||
// a swipe distance < 56dp, the positional threshold, to make sure that we don't commit
|
||||
// the gesture because of a large enough swipe distance.
|
||||
rule.onRoot().performTouchInput {
|
||||
swipeWithVelocity(
|
||||
start = middle,
|
||||
end = middle - Offset(55.dp.toPx() + touchSlop, 0f),
|
||||
endVelocity = 124.dp.toPx(),
|
||||
)
|
||||
}
|
||||
|
||||
// We should be animating back to A (currentScene = SceneA) given that 124 dp/s < velocity
|
||||
// threshold.
|
||||
var transition = layoutState.transitionState
|
||||
assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((transition as TransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneB)
|
||||
assertThat(transition.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.progress).isEqualTo(55.dp / LayoutWidth)
|
||||
|
||||
// Wait for the animation to finish. We should now be in scene A.
|
||||
rule.waitForIdle()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneA)
|
||||
|
||||
// Now we do the same but vertically and with a swipe velocity of 126dp, which is >
|
||||
// velocity threshold. Note that in theory we could have used 125 dp (= velocity threshold)
|
||||
// but it doesn't work reliably with how swipeWithVelocity() computes move events to get to
|
||||
// the target velocity, probably because of float rounding errors.
|
||||
rule.onRoot().performTouchInput {
|
||||
swipeWithVelocity(
|
||||
start = middle,
|
||||
end = middle + Offset(0f, 55.dp.toPx() + touchSlop),
|
||||
endVelocity = 126.dp.toPx(),
|
||||
)
|
||||
}
|
||||
|
||||
// We should be animating to C (currentScene = SceneC).
|
||||
transition = layoutState.transitionState
|
||||
assertThat(transition).isInstanceOf(TransitionState.Transition::class.java)
|
||||
assertThat((transition as TransitionState.Transition).fromScene)
|
||||
.isEqualTo(TestScenes.SceneA)
|
||||
assertThat(transition.toScene).isEqualTo(TestScenes.SceneC)
|
||||
assertThat(transition.currentScene).isEqualTo(TestScenes.SceneC)
|
||||
assertThat(transition.progress).isEqualTo(55.dp / LayoutHeight)
|
||||
|
||||
// Wait for the animation to finish. We should now be in scene C.
|
||||
rule.waitForIdle()
|
||||
assertThat(layoutState.transitionState).isInstanceOf(TransitionState.Idle::class.java)
|
||||
assertThat(layoutState.transitionState.currentScene).isEqualTo(TestScenes.SceneC)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
|
||||
@DslMarker annotation class TransitionTestDsl
|
||||
|
||||
@TransitionTestDsl
|
||||
interface TransitionTestBuilder {
|
||||
/**
|
||||
* Assert on the state of the layout before the transition starts.
|
||||
*
|
||||
* This should be called maximum once, before [at] or [after] is called.
|
||||
*/
|
||||
fun before(builder: TransitionTestAssertionScope.() -> Unit)
|
||||
|
||||
/**
|
||||
* Assert on the state of the layout during the transition at [timestamp].
|
||||
*
|
||||
* This should be called after [before] is called and before [after] is called. Successive calls
|
||||
* to [at] must be called with increasing [timestamp].
|
||||
*
|
||||
* Important: [timestamp] must be a multiple of 16 (the duration of a frame on the JVM/Android).
|
||||
* There is no intermediary state between `t` and `t + 16` , so testing transitions outside of
|
||||
* `t = 0`, `t = 16`, `t = 32`, etc does not make sense.
|
||||
*/
|
||||
fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit)
|
||||
|
||||
/**
|
||||
* Assert on the state of the layout after the transition finished.
|
||||
*
|
||||
* This should be called maximum once, after [before] or [at] is called.
|
||||
*/
|
||||
fun after(builder: TransitionTestAssertionScope.() -> Unit)
|
||||
}
|
||||
|
||||
@TransitionTestDsl
|
||||
interface TransitionTestAssertionScope {
|
||||
/** Assert on [element]. */
|
||||
fun onElement(element: ElementKey): SemanticsNodeInteraction
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the transition between [fromSceneContent] and [toSceneContent] at different points in time.
|
||||
*
|
||||
* @sample com.android.compose.animation.scene.transformation.TranslateTest
|
||||
*/
|
||||
fun ComposeContentTestRule.testTransition(
|
||||
fromSceneContent: @Composable SceneScope.() -> Unit,
|
||||
toSceneContent: @Composable SceneScope.() -> Unit,
|
||||
transition: TransitionBuilder.() -> Unit,
|
||||
layoutModifier: Modifier = Modifier,
|
||||
builder: TransitionTestBuilder.() -> Unit,
|
||||
) {
|
||||
testTransition(
|
||||
from = TestScenes.SceneA,
|
||||
to = TestScenes.SceneB,
|
||||
transitionLayout = { currentScene, onChangeScene ->
|
||||
SceneTransitionLayout(
|
||||
currentScene,
|
||||
onChangeScene,
|
||||
transitions { from(TestScenes.SceneA, to = TestScenes.SceneB, transition) },
|
||||
layoutModifier.fillMaxSize(),
|
||||
) {
|
||||
scene(TestScenes.SceneA, content = fromSceneContent)
|
||||
scene(TestScenes.SceneB, content = toSceneContent)
|
||||
}
|
||||
},
|
||||
builder,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the transition between two scenes of [transitionLayout][SceneTransitionLayout] at different
|
||||
* points in time.
|
||||
*/
|
||||
fun ComposeContentTestRule.testTransition(
|
||||
from: SceneKey,
|
||||
to: SceneKey,
|
||||
transitionLayout:
|
||||
@Composable
|
||||
(
|
||||
currentScene: SceneKey,
|
||||
onChangeScene: (SceneKey) -> Unit,
|
||||
) -> Unit,
|
||||
builder: TransitionTestBuilder.() -> Unit,
|
||||
) {
|
||||
val test = transitionTest(builder)
|
||||
val assertionScope =
|
||||
object : TransitionTestAssertionScope {
|
||||
override fun onElement(element: ElementKey): SemanticsNodeInteraction {
|
||||
return this@testTransition.onNodeWithTag(element.name)
|
||||
}
|
||||
}
|
||||
|
||||
var currentScene by mutableStateOf(from)
|
||||
setContent { transitionLayout(currentScene, { currentScene = it }) }
|
||||
|
||||
// Wait for the UI to be idle then test the before state.
|
||||
waitForIdle()
|
||||
test.before(assertionScope)
|
||||
|
||||
// Manually advance the clock to the start of the animation.
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
// Change the current scene.
|
||||
currentScene = to
|
||||
|
||||
// Advance by a frame to trigger recomposition, which will start the transition (i.e. it will
|
||||
// change the transitionState to be a Transition) in a LaunchedEffect.
|
||||
mainClock.advanceTimeByFrame()
|
||||
|
||||
// Advance by another frame so that the animator we started gets its initial value and clock
|
||||
// starting time. We are now at progress = 0f.
|
||||
mainClock.advanceTimeByFrame()
|
||||
waitForIdle()
|
||||
|
||||
// Test the assertions at specific points in time.
|
||||
test.timestamps.forEach { tsAssertion ->
|
||||
if (tsAssertion.timestampDelta > 0L) {
|
||||
mainClock.advanceTimeBy(tsAssertion.timestampDelta)
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
tsAssertion.assertion(assertionScope)
|
||||
}
|
||||
|
||||
// Go to the end state and test it.
|
||||
mainClock.autoAdvance = true
|
||||
waitForIdle()
|
||||
test.after(assertionScope)
|
||||
}
|
||||
|
||||
private fun transitionTest(builder: TransitionTestBuilder.() -> Unit): TransitionTest {
|
||||
// Collect the assertion lambdas in [TransitionTest]. Note that the ordering is forced by the
|
||||
// builder, e.g. `before {}` must be called before everything else, then `at {}` (in increasing
|
||||
// order of timestamp), then `after {}`. That way the test code is run with the same order as it
|
||||
// is written, to avoid confusion.
|
||||
|
||||
val impl =
|
||||
object : TransitionTestBuilder {
|
||||
var before: (TransitionTestAssertionScope.() -> Unit)? = null
|
||||
var after: (TransitionTestAssertionScope.() -> Unit)? = null
|
||||
val timestamps = mutableListOf<TimestampAssertion>()
|
||||
|
||||
private var currentTimestamp = 0L
|
||||
|
||||
override fun before(builder: TransitionTestAssertionScope.() -> Unit) {
|
||||
check(before == null) { "before {} must be called maximum once" }
|
||||
check(after == null) { "before {} must be called before after {}" }
|
||||
check(timestamps.isEmpty()) { "before {} must be called before at(...) {}" }
|
||||
|
||||
before = builder
|
||||
}
|
||||
|
||||
override fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit) {
|
||||
check(after == null) { "at(...) {} must be called before after {}" }
|
||||
check(timestamp >= currentTimestamp) {
|
||||
"at(...) must be called with timestamps in increasing order"
|
||||
}
|
||||
check(timestamp % 16 == 0L) {
|
||||
"timestamp must be a multiple of the frame time (16ms)"
|
||||
}
|
||||
|
||||
val delta = timestamp - currentTimestamp
|
||||
currentTimestamp = timestamp
|
||||
|
||||
timestamps.add(TimestampAssertion(delta, builder))
|
||||
}
|
||||
|
||||
override fun after(builder: TransitionTestAssertionScope.() -> Unit) {
|
||||
check(after == null) { "after {} must be called maximum once" }
|
||||
after = builder
|
||||
}
|
||||
}
|
||||
.apply(builder)
|
||||
|
||||
return TransitionTest(
|
||||
before = impl.before ?: {},
|
||||
timestamps = impl.timestamps,
|
||||
after = impl.after ?: {},
|
||||
)
|
||||
}
|
||||
|
||||
private class TransitionTest(
|
||||
val before: TransitionTestAssertionScope.() -> Unit,
|
||||
val after: TransitionTestAssertionScope.() -> Unit,
|
||||
val timestamps: List<TimestampAssertion>,
|
||||
)
|
||||
|
||||
private class TimestampAssertion(
|
||||
val timestampDelta: Long,
|
||||
val assertion: TransitionTestAssertionScope.() -> Unit,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
|
||||
/** Scenes keys that can be reused by tests. */
|
||||
object TestScenes {
|
||||
val SceneA = SceneKey("SceneA")
|
||||
val SceneB = SceneKey("SceneB")
|
||||
val SceneC = SceneKey("SceneC")
|
||||
}
|
||||
|
||||
/** Element keys that can be reused by tests. */
|
||||
object TestElements {
|
||||
val Foo = ElementKey("Foo")
|
||||
val Bar = ElementKey("Bar")
|
||||
}
|
||||
|
||||
/** Value keys that can be reused by tests. */
|
||||
object TestValues {
|
||||
val Value1 = ValueKey("Value1")
|
||||
}
|
||||
|
||||
// We use a transition duration of 480ms here because it is a multiple of 16, the time of a frame in
|
||||
// C JVM/Android. Doing so allows us for instance to test the state at progress = 0.5f given that t
|
||||
// = 240ms is also a multiple of 16.
|
||||
val TestTransitionDuration = 480L
|
||||
|
||||
/** A definition of empty transitions between [TestScenes], using different animation specs. */
|
||||
val EmptyTestTransitions = transitions {
|
||||
from(TestScenes.SceneA, to = TestScenes.SceneB) {
|
||||
spec = tween(durationMillis = TestTransitionDuration.toInt(), easing = LinearEasing)
|
||||
}
|
||||
|
||||
from(TestScenes.SceneB, to = TestScenes.SceneC) {
|
||||
spec = tween(durationMillis = TestTransitionDuration.toInt(), easing = FastOutSlowInEasing)
|
||||
}
|
||||
|
||||
from(TestScenes.SceneC, to = TestScenes.SceneA) { spec = snap() }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.compose.animation.scene.TestElements
|
||||
import com.android.compose.animation.scene.testTransition
|
||||
import com.android.compose.test.assertSizeIsEqualTo
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AnchoredSizeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testAnchoredSizeEnter() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = { Box(Modifier.size(100.dp, 100.dp).element(TestElements.Foo)) },
|
||||
toSceneContent = {
|
||||
Box(Modifier.size(50.dp, 50.dp).element(TestElements.Foo))
|
||||
Box(Modifier.size(200.dp, 60.dp).element(TestElements.Bar))
|
||||
},
|
||||
transition = {
|
||||
// Scale during 4 frames.
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
anchoredSize(TestElements.Bar, TestElements.Foo)
|
||||
},
|
||||
) {
|
||||
// Bar is entering. It starts at the same size as Foo in scene A in and scales to its
|
||||
// final size in scene B.
|
||||
before { onElement(TestElements.Bar).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Bar).assertSizeIsEqualTo(100.dp, 100.dp) }
|
||||
at(16) { onElement(TestElements.Bar).assertSizeIsEqualTo(125.dp, 90.dp) }
|
||||
at(32) { onElement(TestElements.Bar).assertSizeIsEqualTo(150.dp, 80.dp) }
|
||||
at(48) { onElement(TestElements.Bar).assertSizeIsEqualTo(175.dp, 70.dp) }
|
||||
at(64) { onElement(TestElements.Bar).assertSizeIsEqualTo(200.dp, 60.dp) }
|
||||
after { onElement(TestElements.Bar).assertSizeIsEqualTo(200.dp, 60.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAnchoredSizeExit() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = {
|
||||
Box(Modifier.size(100.dp, 100.dp).element(TestElements.Foo))
|
||||
Box(Modifier.size(100.dp, 100.dp).element(TestElements.Bar))
|
||||
},
|
||||
toSceneContent = { Box(Modifier.size(200.dp, 60.dp).element(TestElements.Foo)) },
|
||||
transition = {
|
||||
// Scale during 4 frames.
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
anchoredSize(TestElements.Bar, TestElements.Foo)
|
||||
},
|
||||
) {
|
||||
// Bar is leaving. It starts at 100dp x 100dp in scene A and is scaled to 200dp x 60dp,
|
||||
// the size of Foo in scene B.
|
||||
before { onElement(TestElements.Bar).assertSizeIsEqualTo(100.dp, 100.dp) }
|
||||
at(0) { onElement(TestElements.Bar).assertSizeIsEqualTo(100.dp, 100.dp) }
|
||||
at(16) { onElement(TestElements.Bar).assertSizeIsEqualTo(125.dp, 90.dp) }
|
||||
at(32) { onElement(TestElements.Bar).assertSizeIsEqualTo(150.dp, 80.dp) }
|
||||
at(48) { onElement(TestElements.Bar).assertSizeIsEqualTo(175.dp, 70.dp) }
|
||||
after { onElement(TestElements.Bar).assertDoesNotExist() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.compose.animation.scene.TestElements
|
||||
import com.android.compose.animation.scene.testTransition
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AnchoredTranslateTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testAnchoredTranslateExit() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = {
|
||||
Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
|
||||
Box(Modifier.offset(20.dp, 40.dp).element(TestElements.Bar))
|
||||
},
|
||||
toSceneContent = { Box(Modifier.offset(30.dp, 10.dp).element(TestElements.Foo)) },
|
||||
transition = {
|
||||
// Anchor Bar to Foo, which is moving from (10dp, 50dp) to (30dp, 10dp).
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
anchoredTranslate(TestElements.Bar, TestElements.Foo)
|
||||
},
|
||||
) {
|
||||
// Bar moves by (20dp, -40dp), like Foo.
|
||||
before { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
|
||||
at(0) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
|
||||
at(16) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(25.dp, 30.dp) }
|
||||
at(32) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(30.dp, 20.dp) }
|
||||
at(48) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(35.dp, 10.dp) }
|
||||
after { onElement(TestElements.Bar).assertDoesNotExist() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAnchoredTranslateEnter() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = { Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo)) },
|
||||
toSceneContent = {
|
||||
Box(Modifier.offset(30.dp, 10.dp).element(TestElements.Foo))
|
||||
Box(Modifier.offset(20.dp, 40.dp).element(TestElements.Bar))
|
||||
},
|
||||
transition = {
|
||||
// Anchor Bar to Foo, which is moving from (10dp, 50dp) to (30dp, 10dp).
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
anchoredTranslate(TestElements.Bar, TestElements.Foo)
|
||||
},
|
||||
) {
|
||||
// Bar moves by (20dp, -40dp), like Foo.
|
||||
before { onElement(TestElements.Bar).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(0.dp, 80.dp) }
|
||||
at(16) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(5.dp, 70.dp) }
|
||||
at(32) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(10.dp, 60.dp) }
|
||||
at(48) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(15.dp, 50.dp) }
|
||||
at(64) { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
|
||||
after { onElement(TestElements.Bar).assertPositionInRootIsEqualTo(20.dp, 40.dp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.compose.animation.scene.Edge
|
||||
import com.android.compose.animation.scene.TestElements
|
||||
import com.android.compose.animation.scene.TransitionTestBuilder
|
||||
import com.android.compose.animation.scene.testTransition
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EdgeTranslateTest {
|
||||
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private fun testEdgeTranslate(
|
||||
edge: Edge,
|
||||
startsOutsideLayoutBounds: Boolean,
|
||||
builder: TransitionTestBuilder.() -> Unit,
|
||||
) {
|
||||
rule.testTransition(
|
||||
// The layout under test is 300dp x 300dp.
|
||||
layoutModifier = Modifier.size(300.dp),
|
||||
fromSceneContent = {},
|
||||
toSceneContent = {
|
||||
// Foo is 100dp x 100dp in the center of the layout, so at offset = (100dp, 100dp)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.size(100.dp).element(TestElements.Foo).align(Alignment.Center))
|
||||
}
|
||||
},
|
||||
transition = {
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
translate(TestElements.Foo, edge, startsOutsideLayoutBounds)
|
||||
},
|
||||
builder = builder,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromTop_startsOutsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Top, startsOutsideLayoutBounds = true) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, (-100).dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 0.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromTop_startsInsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Top, startsOutsideLayoutBounds = false) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 0.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 50.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromBottom_startsOutsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Bottom, startsOutsideLayoutBounds = true) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 300.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 200.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromBottom_startsInsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Bottom, startsOutsideLayoutBounds = false) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 200.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 150.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromLeft_startsOutsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Left, startsOutsideLayoutBounds = true) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo((-100).dp, 100.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 100.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromLeft_startsInsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Left, startsOutsideLayoutBounds = false) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 100.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(50.dp, 100.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromRight_startsOutsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Right, startsOutsideLayoutBounds = true) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(300.dp, 100.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(200.dp, 100.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEntersFromRight_startsInsideLayoutBounds() {
|
||||
testEdgeTranslate(Edge.Right, startsOutsideLayoutBounds = false) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(200.dp, 100.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(150.dp, 100.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.compose.animation.scene.TestElements
|
||||
import com.android.compose.animation.scene.testTransition
|
||||
import com.android.compose.test.assertSizeIsEqualTo
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ScaleSizeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testScaleSize() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = {},
|
||||
toSceneContent = { Box(Modifier.size(100.dp).element(TestElements.Foo)) },
|
||||
transition = {
|
||||
// Scale during 4 frames.
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
scaleSize(TestElements.Foo, width = 2f, height = 0.5f)
|
||||
},
|
||||
) {
|
||||
// Foo is entering, is 100dp x 100dp at rest and is scaled by (2, 0.5) during the
|
||||
// transition so it starts at 200dp x 50dp.
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertSizeIsEqualTo(200.dp, 50.dp) }
|
||||
at(16) { onElement(TestElements.Foo).assertSizeIsEqualTo(175.dp, 62.5.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertSizeIsEqualTo(150.dp, 75.dp) }
|
||||
at(48) { onElement(TestElements.Foo).assertSizeIsEqualTo(125.dp, 87.5.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertSizeIsEqualTo(100.dp, 100.dp) }
|
||||
after { onElement(TestElements.Foo).assertSizeIsEqualTo(100.dp, 100.dp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.animation.scene.transformation
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.android.compose.animation.scene.TestElements
|
||||
import com.android.compose.animation.scene.testTransition
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslateTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testTranslateExit() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = {
|
||||
// Foo is at (10dp, 50dp) and is exiting.
|
||||
Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
|
||||
},
|
||||
toSceneContent = {},
|
||||
transition = {
|
||||
// Foo is translated by (20dp, -40dp) during 4 frames.
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
translate(TestElements.Foo, x = 20.dp, y = (-40).dp)
|
||||
},
|
||||
) {
|
||||
before { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
|
||||
at(16) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(15.dp, 40.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(20.dp, 30.dp) }
|
||||
at(48) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(25.dp, 20.dp) }
|
||||
after { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslateEnter() {
|
||||
rule.testTransition(
|
||||
fromSceneContent = {},
|
||||
toSceneContent = {
|
||||
// Foo is entering to (10dp, 50dp)
|
||||
Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo))
|
||||
},
|
||||
transition = {
|
||||
// Foo is translated from (10dp, 50) + (20dp, -40dp) during 4 frames.
|
||||
spec = tween(16 * 4, easing = LinearEasing)
|
||||
translate(TestElements.Foo, x = 20.dp, y = (-40).dp)
|
||||
},
|
||||
) {
|
||||
before { onElement(TestElements.Foo).assertDoesNotExist() }
|
||||
at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(30.dp, 10.dp) }
|
||||
at(16) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(25.dp, 20.dp) }
|
||||
at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(20.dp, 30.dp) }
|
||||
at(48) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(15.dp, 40.dp) }
|
||||
at(64) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
|
||||
after { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(10.dp, 50.dp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.test
|
||||
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.assertHeightIsEqualTo
|
||||
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
fun SemanticsNodeInteraction.assertSizeIsEqualTo(expectedWidth: Dp, expectedHeight: Dp) {
|
||||
assertWidthIsEqualTo(expectedWidth)
|
||||
assertHeightIsEqualTo(expectedHeight)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.compose.test.subjects
|
||||
|
||||
import androidx.compose.ui.test.assertIsEqualTo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import com.google.common.truth.FailureMetadata
|
||||
import com.google.common.truth.Subject
|
||||
import com.google.common.truth.Subject.Factory
|
||||
import com.google.common.truth.Truth.assertAbout
|
||||
|
||||
/** Assert on a [DpOffset]. */
|
||||
fun assertThat(dpOffset: DpOffset): DpOffsetSubject {
|
||||
return assertAbout(DpOffsetSubject.dpOffsets()).that(dpOffset)
|
||||
}
|
||||
|
||||
/** A Truth subject to assert on [DpOffset] with some tolerance. Inspired by FloatSubject. */
|
||||
class DpOffsetSubject(
|
||||
metadata: FailureMetadata,
|
||||
private val actual: DpOffset,
|
||||
) : Subject(metadata, actual) {
|
||||
fun isWithin(tolerance: Dp): TolerantDpOffsetComparison {
|
||||
return object : TolerantDpOffsetComparison {
|
||||
override fun of(expected: DpOffset) {
|
||||
actual.x.assertIsEqualTo(expected.x, "offset.x", tolerance)
|
||||
actual.y.assertIsEqualTo(expected.y, "offset.y", tolerance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TolerantDpOffsetComparison {
|
||||
fun of(expected: DpOffset)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DefaultTolerance = Dp(.5f)
|
||||
|
||||
fun dpOffsets() =
|
||||
Factory<DpOffsetSubject, DpOffset> { metadata, actual ->
|
||||
DpOffsetSubject(metadata, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,10 @@ import androidx.compose.ui.res.integerResource
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.compose.animation.Easings
|
||||
import com.android.compose.modifiers.thenIf
|
||||
import com.android.internal.R
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
|
||||
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
|
||||
import com.android.systemui.compose.modifiers.thenIf
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@@ -75,7 +75,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.compose.animation.Easings
|
||||
import com.android.compose.grid.VerticalGrid
|
||||
import com.android.internal.R.id.image
|
||||
import com.android.compose.modifiers.thenIf
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance
|
||||
import com.android.systemui.bouncer.ui.viewmodel.EnteredKey
|
||||
@@ -83,7 +83,6 @@ import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
|
||||
import com.android.systemui.common.shared.model.ContentDescription
|
||||
import com.android.systemui.common.shared.model.Icon
|
||||
import com.android.systemui.common.ui.compose.Icon
|
||||
import com.android.systemui.compose.modifiers.thenIf
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
Reference in New Issue
Block a user