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:
Jordan Demeulenaere
2023-07-21 07:34:45 +00:00
committed by Android (Google) Code Review
40 changed files with 4517 additions and 17 deletions

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)"
}
}

View File

@@ -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()
}

View File

@@ -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,
)
}
}

View File

@@ -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,
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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>?,
)

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}

View File

@@ -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(),
)
}
}

View File

@@ -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
}
}

View File

@@ -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(),
)
}
}
}

View File

@@ -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) {

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.android.systemui.compose.modifiers
package com.android.compose.modifiers
import androidx.compose.ui.Modifier

View File

@@ -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
}

View File

@@ -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)
)
}

View File

@@ -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"],

View File

@@ -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))
}
}
}
}

View File

@@ -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(),
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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() }
}

View File

@@ -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() }
}
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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