diff options
39 files changed, 4505 insertions, 6 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/AnimateSharedAsState.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/AnimateSharedAsState.kt new file mode 100644 index 000000000000..566967f920d3 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/AnimateSharedAsState.kt @@ -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 + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/AnimateToScene.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/AnimateToScene.kt new file mode 100644 index 000000000000..753672820e28 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/AnimateToScene.kt @@ -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 diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Element.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Element.kt new file mode 100644 index 000000000000..0cc259ab7015 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Element.kt @@ -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) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Key.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Key.kt new file mode 100644 index 000000000000..c3f44f8b1069 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Key.kt @@ -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)" + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/ObservableTransitionState.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/ObservableTransitionState.kt new file mode 100644 index 000000000000..a625250d1e51 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/ObservableTransitionState.kt @@ -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() +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Scene.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Scene.kt new file mode 100644 index 000000000000..b44c8efc7ee2 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/Scene.kt @@ -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, + ) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayout.kt new file mode 100644 index 000000000000..39173d98538f --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayout.kt @@ -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, +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt new file mode 100644 index 000000000000..350b9c2550c8 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt @@ -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) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt new file mode 100644 index 000000000000..47e3d5add27b --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt @@ -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 + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitions.kt new file mode 100644 index 000000000000..9752f53fbd49 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SceneTransitions.kt @@ -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>?, +) diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SwipeToScene.kt new file mode 100644 index 000000000000..d9a45cd663c2 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/SwipeToScene.kt @@ -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 diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/TransitionDsl.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/TransitionDsl.kt new file mode 100644 index 000000000000..fb12b90d7d3e --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/TransitionDsl.kt @@ -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, +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/TransitionDslImpl.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/TransitionDslImpl.kt new file mode 100644 index 000000000000..afd49b4fde09 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/TransitionDslImpl.kt @@ -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)) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt new file mode 100644 index 000000000000..d4ed697f1757 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt @@ -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) + } + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt new file mode 100644 index 000000000000..8a5bd746dced --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt @@ -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, + ) + } + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt new file mode 100644 index 000000000000..5cdce9489772 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt @@ -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) + } + } + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Fade.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Fade.kt new file mode 100644 index 000000000000..0a5ac5413b38 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Fade.kt @@ -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 + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/PunchHole.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/PunchHole.kt new file mode 100644 index 000000000000..31e7d7c7c6ba --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/PunchHole.kt @@ -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, + ) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/ScaleSize.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/ScaleSize.kt new file mode 100644 index 000000000000..ce754dc76adc --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/ScaleSize.kt @@ -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(), + ) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Transformation.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Transformation.kt new file mode 100644 index 000000000000..ce6749da2711 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Transformation.kt @@ -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 + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Translate.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Translate.kt new file mode 100644 index 000000000000..8abca61bab20 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/scene/transformation/Translate.kt @@ -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(), + ) + } + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/ConditionalModifiers.kt b/packages/SystemUI/compose/core/src/com/android/compose/modifiers/ConditionalModifiers.kt index 83071d78c64d..135a6e4ec4e4 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/ConditionalModifiers.kt +++ b/packages/SystemUI/compose/core/src/com/android/compose/modifiers/ConditionalModifiers.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.systemui.compose.modifiers +package com.android.compose.modifiers import androidx.compose.ui.Modifier diff --git a/packages/SystemUI/compose/core/src/com/android/compose/ui/util/ListUtils.kt b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/ListUtils.kt new file mode 100644 index 000000000000..741f00d9f19b --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/ListUtils.kt @@ -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 +} diff --git a/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt index c1defb722077..eb1a634ff491 100644 --- a/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt +++ b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt @@ -17,11 +17,10 @@ package com.android.compose.ui.util +import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt import kotlin.math.roundToLong -// TODO(b/272311106): this is a fork from material. Unfork it when MathHelpers.kt reaches material3. - /** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */ fun lerp(start: Float, stop: Float, fraction: Float): Float { return (1 - fraction) * start + fraction * stop @@ -36,3 +35,11 @@ fun lerp(start: Int, stop: Int, fraction: Float): Int { fun lerp(start: Long, stop: Long, fraction: Float): Long { return start + ((stop - start) * fraction.toDouble()).roundToLong() } + +/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */ +fun lerp(start: IntSize, stop: IntSize, fraction: Float): IntSize { + return IntSize( + lerp(start.width, stop.width, fraction), + lerp(start.height, stop.height, fraction) + ) +} diff --git a/packages/SystemUI/compose/core/tests/Android.bp b/packages/SystemUI/compose/core/tests/Android.bp index 06d94ac5400e..5a8a374b4b7f 100644 --- a/packages/SystemUI/compose/core/tests/Android.bp +++ b/packages/SystemUI/compose/core/tests/Android.bp @@ -42,6 +42,8 @@ android_test { "androidx.compose.runtime_runtime", "androidx.compose.ui_ui-test-junit4", "androidx.compose.ui_ui-test-manifest", + + "truth-prebuilt", ], kotlincflags: ["-Xjvm-default=all"], diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/ObservableTransitionStateTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/ObservableTransitionStateTest.kt new file mode 100644 index 000000000000..04b3f8a1dfe7 --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/ObservableTransitionStateTest.kt @@ -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)) + } + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt new file mode 100644 index 000000000000..8bd654585f29 --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt @@ -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(), + ) + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt new file mode 100644 index 000000000000..cb2607a2845e --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt @@ -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) + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/TestTransition.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/TestTransition.kt new file mode 100644 index 000000000000..275149a05abf --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/TestTransition.kt @@ -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, +) diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/TestValues.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/TestValues.kt new file mode 100644 index 000000000000..83572620c88a --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/TestValues.kt @@ -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() } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/AnchoredSizeTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/AnchoredSizeTest.kt new file mode 100644 index 000000000000..8ef6757d33bd --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/AnchoredSizeTest.kt @@ -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() } + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/AnchoredTranslateTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/AnchoredTranslateTest.kt new file mode 100644 index 000000000000..d1205e727cf9 --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/AnchoredTranslateTest.kt @@ -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) } + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt new file mode 100644 index 000000000000..2a27763f1d5c --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/EdgeTranslateTest.kt @@ -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) } + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/ScaleSizeTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/ScaleSizeTest.kt new file mode 100644 index 000000000000..384355ca951f --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/ScaleSizeTest.kt @@ -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) } + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/TranslateTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/TranslateTest.kt new file mode 100644 index 000000000000..1d559fd6bd8a --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/animation/scene/transformation/TranslateTest.kt @@ -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) } + } + } +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/test/SizeAssertions.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/test/SizeAssertions.kt new file mode 100644 index 000000000000..fbd1b512c50a --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/test/SizeAssertions.kt @@ -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) +} diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/test/subjects/DpOffsetSubject.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/test/subjects/DpOffsetSubject.kt new file mode 100644 index 000000000000..bf7bf98878e6 --- /dev/null +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/test/subjects/DpOffsetSubject.kt @@ -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) + } + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt index b3d2e350ed50..63a3eca4695d 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -43,10 +43,10 @@ import androidx.compose.ui.res.integerResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.compose.animation.Easings +import com.android.compose.modifiers.thenIf import com.android.internal.R import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel -import com.android.systemui.compose.modifiers.thenIf import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index 85178bc26a62..bef0b3df36c2 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -75,7 +75,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.compose.animation.Easings import com.android.compose.grid.VerticalGrid -import com.android.internal.R.id.image +import com.android.compose.modifiers.thenIf import com.android.systemui.R import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance import com.android.systemui.bouncer.ui.viewmodel.EnteredKey @@ -83,7 +83,6 @@ import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.common.shared.model.ContentDescription import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon -import com.android.systemui.compose.modifiers.thenIf import kotlin.time.Duration.Companion.milliseconds import kotlin.time.DurationUnit import kotlinx.coroutines.async |