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