diff options
| author | 2024-11-13 09:53:48 +0000 | |
|---|---|---|
| committer | 2024-11-13 09:53:48 +0000 | |
| commit | 2ebb6622eb6c2fd002800bc723c2f1ea7de7e4d9 (patch) | |
| tree | 7f785fdd84addb18800bd3966920fd26e10cd6bd | |
| parent | f2aecfd2e9bd9e95887efe0c98cbcb9ea41ad7bd (diff) | |
| parent | a97625ec860ed982784e7b89d7b6a5a1c3dd9aed (diff) | |
Merge changes I91a27f82,I62688e42 into main
* changes:
Introduce CustomPropertyTransformation
Remove Transformation.reversed()
17 files changed, 403 insertions, 65 deletions
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt index d976e8e62f3c..44f60cb6f0a6 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.util.lerp import com.android.compose.animation.scene.content.Content import com.android.compose.animation.scene.content.state.TransitionState +import com.android.compose.animation.scene.transformation.CustomPropertyTransformation +import com.android.compose.animation.scene.transformation.InterpolatedPropertyTransformation import com.android.compose.animation.scene.transformation.PropertyTransformation import com.android.compose.animation.scene.transformation.SharedElementTransformation import com.android.compose.animation.scene.transformation.TransformationWithRange @@ -1308,7 +1310,14 @@ private inline fun <T> computeValue( checkNotNull(if (currentContent == toContent) toState else fromState) val idleValue = contentValue(overscrollState) val targetValue = - with(propertySpec.transformation) { + with( + propertySpec.transformation.requireInterpolatedTransformation( + element, + transition, + ) { + "Custom transformations in overscroll specs should not be possible" + } + ) { layoutImpl.propertyTransformationScope.transform( currentContent, element.key, @@ -1390,7 +1399,7 @@ private inline fun <T> computeValue( // fromContent or toContent during interruptions. val content = contentState.content - val transformation = + val transformationWithRange = transformation(transition.transformationSpec.transformations(element.key, content)) val previewTransformation = @@ -1403,7 +1412,14 @@ private inline fun <T> computeValue( val idleValue = contentValue(contentState) val isEntering = content == toContent val previewTargetValue = - with(previewTransformation.transformation) { + with( + previewTransformation.transformation.requireInterpolatedTransformation( + element, + transition, + ) { + "Custom transformations in preview specs should not be possible" + } + ) { layoutImpl.propertyTransformationScope.transform( content, element.key, @@ -1413,8 +1429,15 @@ private inline fun <T> computeValue( } val targetValueOrNull = - transformation?.let { transformation -> - with(transformation.transformation) { + transformationWithRange?.let { transformation -> + with( + transformation.transformation.requireInterpolatedTransformation( + element, + transition, + ) { + "Custom transformations are not allowed for properties with a preview" + } + ) { layoutImpl.propertyTransformationScope.transform( content, element.key, @@ -1461,7 +1484,7 @@ private inline fun <T> computeValue( lerp( lerp(previewTargetValue, targetValueOrNull ?: idleValue, previewRangeProgress), idleValue, - transformation?.range?.progress(transition.progress) ?: transition.progress, + transformationWithRange?.range?.progress(transition.progress) ?: transition.progress, ) } else { if (targetValueOrNull == null) { @@ -1474,22 +1497,39 @@ private inline fun <T> computeValue( lerp( lerp(idleValue, previewTargetValue, previewRangeProgress), targetValueOrNull, - transformation.range?.progress(transition.progress) ?: transition.progress, + transformationWithRange.range?.progress(transition.progress) + ?: transition.progress, ) } } } - if (transformation == null) { + if (transformationWithRange == null) { // 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() } + val transformation = transformationWithRange.transformation + when (transformation) { + is CustomPropertyTransformation -> + return with(transformation) { + layoutImpl.propertyTransformationScope.transform( + content, + element.key, + transition, + transition.coroutineScope, + ) + } + is InterpolatedPropertyTransformation -> { + /* continue */ + } + } + val idleValue = contentValue(contentState) val targetValue = - with(transformation.transformation) { + with(transformation) { layoutImpl.propertyTransformationScope.transform( content, element.key, @@ -1506,7 +1546,7 @@ private inline fun <T> computeValue( val progress = transition.progress // TODO(b/290184746): Make sure that we don't overflow transformations associated to a range. - val rangeProgress = transformation.range?.progress(progress) ?: progress + val rangeProgress = transformationWithRange.range?.progress(progress) ?: progress // Interpolate between the value at rest and the value before entering/after leaving. val isEntering = @@ -1523,6 +1563,22 @@ private inline fun <T> computeValue( } } +private inline fun <T> PropertyTransformation<T>.requireInterpolatedTransformation( + element: Element, + transition: TransitionState.Transition, + errorMessage: () -> String, +): InterpolatedPropertyTransformation<T> { + return when (this) { + is InterpolatedPropertyTransformation -> this + is CustomPropertyTransformation -> { + val elem = element.key.debugName + val fromContent = transition.fromContent + val toContent = transition.toContent + error("${errorMessage()} (element=$elem fromContent=$fromContent toContent=$toContent)") + } + } +} + private inline fun <T> interpolateSharedElement( transition: TransitionState.Transition, contentValue: (Element.State) -> T, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt index 72b29ee8848a..3c01dfed891b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutState.kt @@ -32,6 +32,7 @@ import kotlin.math.absoluteValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** @@ -466,9 +467,9 @@ internal class MutableSceneTransitionLayoutStateImpl( return } - // Make sure that this transition settles in case it was force finished, for instance by - // calling snapToScene(). - transition.freezeAndAnimateToCurrentState() + // Make sure that this transition is cancelled in case it was force finished, for instance + // if snapToScene() is called. + transition.coroutineScope.cancel() val transitionStates = this.transitionStates if (!transitionStates.contains(transition)) { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt index b083f79aebf5..569593c3eb59 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt @@ -26,18 +26,18 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.util.fastForEach import com.android.compose.animation.scene.content.state.TransitionState -import com.android.compose.animation.scene.transformation.AnchoredSize -import com.android.compose.animation.scene.transformation.AnchoredTranslate -import com.android.compose.animation.scene.transformation.DrawScale -import com.android.compose.animation.scene.transformation.EdgeTranslate -import com.android.compose.animation.scene.transformation.Fade -import com.android.compose.animation.scene.transformation.OverscrollTranslate +import com.android.compose.animation.scene.transformation.CustomAlphaTransformation +import com.android.compose.animation.scene.transformation.CustomOffsetTransformation +import com.android.compose.animation.scene.transformation.CustomScaleTransformation +import com.android.compose.animation.scene.transformation.CustomSizeTransformation +import com.android.compose.animation.scene.transformation.InterpolatedAlphaTransformation +import com.android.compose.animation.scene.transformation.InterpolatedOffsetTransformation +import com.android.compose.animation.scene.transformation.InterpolatedScaleTransformation +import com.android.compose.animation.scene.transformation.InterpolatedSizeTransformation import com.android.compose.animation.scene.transformation.PropertyTransformation -import com.android.compose.animation.scene.transformation.ScaleSize import com.android.compose.animation.scene.transformation.SharedElementTransformation import com.android.compose.animation.scene.transformation.Transformation import com.android.compose.animation.scene.transformation.TransformationWithRange -import com.android.compose.animation.scene.transformation.Translate /** The transitions configuration of a [SceneTransitionLayout]. */ class SceneTransitions @@ -359,35 +359,34 @@ internal class TransformationSpecImpl( transformationWithRange as TransformationWithRange<SharedElementTransformation> } - is Translate, - is OverscrollTranslate, - is EdgeTranslate, - is AnchoredTranslate -> { + is InterpolatedOffsetTransformation, + is CustomOffsetTransformation -> { throwIfNotNull(offset, element, name = "offset") offset = transformationWithRange as TransformationWithRange<PropertyTransformation<Offset>> } - is ScaleSize, - is AnchoredSize -> { + is InterpolatedSizeTransformation, + is CustomSizeTransformation -> { throwIfNotNull(size, element, name = "size") size = transformationWithRange as TransformationWithRange<PropertyTransformation<IntSize>> } - is DrawScale -> { + is InterpolatedScaleTransformation, + is CustomScaleTransformation -> { throwIfNotNull(drawScale, element, name = "drawScale") drawScale = transformationWithRange as TransformationWithRange<PropertyTransformation<Scale>> } - is Fade -> { + is InterpolatedAlphaTransformation, + is CustomAlphaTransformation -> { throwIfNotNull(alpha, element, name = "alpha") alpha = transformationWithRange as TransformationWithRange<PropertyTransformation<Float>> } - else -> error("Unknown transformation: $transformation") } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt index dc26b6b382b4..1fdfca9d9509 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDsl.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.compose.animation.scene.content.state.TransitionState +import com.android.compose.animation.scene.transformation.CustomPropertyTransformation import kotlin.math.tanh /** Define the [transitions][SceneTransitions] to be used with a [SceneTransitionLayout]. */ @@ -527,6 +528,16 @@ interface PropertyTransformationBuilder { anchorWidth: Boolean = true, anchorHeight: Boolean = true, ) + + /** + * Apply a [CustomPropertyTransformation] to one or more elements. + * + * @see com.android.compose.animation.scene.transformation.CustomSizeTransformation + * @see com.android.compose.animation.scene.transformation.CustomOffsetTransformation + * @see com.android.compose.animation.scene.transformation.CustomAlphaTransformation + * @see com.android.compose.animation.scene.transformation.CustomScaleTransformation + */ + fun transformation(transformation: CustomPropertyTransformation<*>) } /** This converter lets you change a linear progress into a function of your choice. */ diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt index e461f9ccc295..79f8cd47d07d 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/TransitionDslImpl.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.Dp import com.android.compose.animation.scene.content.state.TransitionState import com.android.compose.animation.scene.transformation.AnchoredSize import com.android.compose.animation.scene.transformation.AnchoredTranslate +import com.android.compose.animation.scene.transformation.CustomPropertyTransformation import com.android.compose.animation.scene.transformation.DrawScale import com.android.compose.animation.scene.transformation.EdgeTranslate import com.android.compose.animation.scene.transformation.Fade @@ -173,7 +174,7 @@ internal abstract class BaseTransitionBuilderImpl : BaseTransitionBuilder { range = null } - protected fun transformation(transformation: Transformation) { + protected fun addTransformation(transformation: Transformation) { val transformationWithRange = TransformationWithRange(transformation, range) transformations.add( if (reversed) { @@ -185,11 +186,11 @@ internal abstract class BaseTransitionBuilderImpl : BaseTransitionBuilder { } override fun fade(matcher: ElementMatcher) { - transformation(Fade(matcher)) + addTransformation(Fade(matcher)) } override fun translate(matcher: ElementMatcher, x: Dp, y: Dp) { - transformation(Translate(matcher, x, y)) + addTransformation(Translate(matcher, x, y)) } override fun translate( @@ -197,19 +198,19 @@ internal abstract class BaseTransitionBuilderImpl : BaseTransitionBuilder { edge: Edge, startsOutsideLayoutBounds: Boolean, ) { - transformation(EdgeTranslate(matcher, edge, startsOutsideLayoutBounds)) + addTransformation(EdgeTranslate(matcher, edge, startsOutsideLayoutBounds)) } override fun anchoredTranslate(matcher: ElementMatcher, anchor: ElementKey) { - transformation(AnchoredTranslate(matcher, anchor)) + addTransformation(AnchoredTranslate(matcher, anchor)) } override fun scaleSize(matcher: ElementMatcher, width: Float, height: Float) { - transformation(ScaleSize(matcher, width, height)) + addTransformation(ScaleSize(matcher, width, height)) } override fun scaleDraw(matcher: ElementMatcher, scaleX: Float, scaleY: Float, pivot: Offset) { - transformation(DrawScale(matcher, scaleX, scaleY, pivot)) + addTransformation(DrawScale(matcher, scaleX, scaleY, pivot)) } override fun anchoredSize( @@ -218,7 +219,12 @@ internal abstract class BaseTransitionBuilderImpl : BaseTransitionBuilder { anchorWidth: Boolean, anchorHeight: Boolean, ) { - transformation(AnchoredSize(matcher, anchor, anchorWidth, anchorHeight)) + addTransformation(AnchoredSize(matcher, anchor, anchorWidth, anchorHeight)) + } + + override fun transformation(transformation: CustomPropertyTransformation<*>) { + check(range == null) { "Custom transformations can not be applied inside a range" } + addTransformation(transformation) } } @@ -257,7 +263,7 @@ internal class TransitionBuilderImpl(override val transition: TransitionState.Tr "(${transition.toContent.debugName})" } - transformation(SharedElementTransformation(matcher, enabled, elevateInContent)) + addTransformation(SharedElementTransformation(matcher, enabled, elevateInContent)) } override fun timestampRange( @@ -288,6 +294,6 @@ internal open class OverscrollBuilderImpl : BaseTransitionBuilderImpl(), Overscr x: OverscrollScope.() -> Float, y: OverscrollScope.() -> Float, ) { - transformation(OverscrollTranslate(matcher, x, y)) + addTransformation(OverscrollTranslate(matcher, x, y)) } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt index e3118d67b434..75584ba707ba 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt @@ -35,6 +35,8 @@ import com.android.compose.animation.scene.SceneTransitionLayoutImpl import com.android.compose.animation.scene.TransformationSpec import com.android.compose.animation.scene.TransformationSpecImpl import com.android.compose.animation.scene.TransitionKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch /** The state associated to a [SceneTransitionLayout] at some specific point in time. */ @@ -279,8 +281,24 @@ sealed interface TransitionState { */ private var interruptionDecay: Animatable<Float, AnimationVector1D>? = null - /** Whether this transition was already started. */ - private var wasStarted = false + /** + * The coroutine scope associated to this transition. + * + * This coroutine scope can be used to launch animations associated to this transition, + * which will not finish until at least one animation/job is still running in the scope. + * + * Important: Make sure to never launch long-running jobs in this scope, otherwise the + * transition will never be considered as finished. + */ + internal val coroutineScope: CoroutineScope + get() = + _coroutineScope + ?: error( + "Transition.coroutineScope can only be accessed once the transition was " + + "started " + ) + + private var _coroutineScope: CoroutineScope? = null init { check(fromContent != toContent) @@ -341,10 +359,11 @@ sealed interface TransitionState { abstract fun freezeAndAnimateToCurrentState() internal suspend fun runInternal() { - check(!wasStarted) { "A Transition can be started only once." } - wasStarted = true - - run() + check(_coroutineScope == null) { "A Transition can be started only once." } + coroutineScope { + _coroutineScope = this + run() + } } internal fun updateOverscrollSpecs( diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt index 0ddeb7c7445f..85bb5336d574 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredSize.kt @@ -28,7 +28,7 @@ internal class AnchoredSize( private val anchor: ElementKey, private val anchorWidth: Boolean, private val anchorHeight: Boolean, -) : PropertyTransformation<IntSize> { +) : InterpolatedSizeTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt index 47508b41633c..04cd68344252 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/AnchoredTranslate.kt @@ -26,7 +26,7 @@ import com.android.compose.animation.scene.content.state.TransitionState internal class AnchoredTranslate( override val matcher: ElementMatcher, private val anchor: ElementKey, -) : PropertyTransformation<Offset> { +) : InterpolatedOffsetTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt index 8488ae5178b0..45d6d4037c49 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/DrawScale.kt @@ -32,7 +32,7 @@ internal class DrawScale( private val scaleX: Float, private val scaleY: Float, private val pivot: Offset = Offset.Unspecified, -) : PropertyTransformation<Scale> { +) : InterpolatedScaleTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt index 884aae4b8b1a..21d66d784e2d 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/EdgeTranslate.kt @@ -28,7 +28,7 @@ internal class EdgeTranslate( override val matcher: ElementMatcher, private val edge: Edge, private val startsOutsideLayoutBounds: Boolean = true, -) : PropertyTransformation<Offset> { +) : InterpolatedOffsetTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt index ef769e7d0c19..d942273ab9ab 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Fade.kt @@ -22,7 +22,7 @@ import com.android.compose.animation.scene.ElementMatcher import com.android.compose.animation.scene.content.state.TransitionState /** Fade an element in or out. */ -internal class Fade(override val matcher: ElementMatcher) : PropertyTransformation<Float> { +internal class Fade(override val matcher: ElementMatcher) : InterpolatedAlphaTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt index ef3654b65b0a..5f3cdab3c572 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/ScaleSize.kt @@ -31,7 +31,7 @@ internal class ScaleSize( override val matcher: ElementMatcher, private val width: Float = 1f, private val height: Float = 1f, -) : PropertyTransformation<IntSize> { +) : InterpolatedSizeTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt index 74a3ead3fbd7..d5143d729f2e 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Transformation.kt @@ -18,7 +18,9 @@ package com.android.compose.animation.scene.transformation import androidx.compose.animation.core.Easing import androidx.compose.animation.core.LinearEasing +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceAtMost @@ -27,7 +29,9 @@ import com.android.compose.animation.scene.ContentKey import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.ElementMatcher import com.android.compose.animation.scene.ElementStateScope +import com.android.compose.animation.scene.Scale import com.android.compose.animation.scene.content.state.TransitionState +import kotlinx.coroutines.CoroutineScope /** A transformation applied to one or more elements during a transition. */ sealed interface Transformation { @@ -35,12 +39,6 @@ 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 reversed(): Transformation = this } internal class SharedElementTransformation( @@ -50,7 +48,13 @@ internal class SharedElementTransformation( ) : Transformation /** A transformation that changes the value of an element property, like its size or offset. */ -interface PropertyTransformation<T> : Transformation { +sealed interface PropertyTransformation<T> : Transformation + +/** + * A transformation to a target/transformed value that is automatically interpolated using the + * transition progress and transformation range. + */ +sealed interface InterpolatedPropertyTransformation<T> : PropertyTransformation<T> { /** * Return the transformed value for the given property, i.e.: * - the value at progress = 0% for elements that are entering the layout (i.e. elements in the @@ -58,8 +62,8 @@ interface PropertyTransformation<T> : Transformation { * - the value at progress = 100% for elements that are leaving the layout (i.e. elements in the * content we are transitioning from). * - * The returned value will be interpolated using the [transition] progress and [idleValue], the - * value of the property when we are idle. + * The returned value will be automatically interpolated using the [transition] progress, the + * transformation range and [idleValue], the value of the property when we are idle. */ fun PropertyTransformationScope.transform( content: ContentKey, @@ -69,6 +73,50 @@ interface PropertyTransformation<T> : Transformation { ): T } +/** An [InterpolatedPropertyTransformation] applied to the size of one or more elements. */ +interface InterpolatedSizeTransformation : InterpolatedPropertyTransformation<IntSize> + +/** An [InterpolatedPropertyTransformation] applied to the offset of one or more elements. */ +interface InterpolatedOffsetTransformation : InterpolatedPropertyTransformation<Offset> + +/** An [InterpolatedPropertyTransformation] applied to the alpha of one or more elements. */ +interface InterpolatedAlphaTransformation : InterpolatedPropertyTransformation<Float> + +/** An [InterpolatedPropertyTransformation] applied to the scale of one or more elements. */ +interface InterpolatedScaleTransformation : InterpolatedPropertyTransformation<Scale> + +sealed interface CustomPropertyTransformation<T> : PropertyTransformation<T> { + /** + * Return the value that the property should have in the current frame for the given [content] + * and [element]. + * + * This transformation can use [transitionScope] to launch animations associated to + * [transition], which will not finish until at least one animation/job is still running in the + * scope. + * + * Important: Make sure to never launch long-running jobs in [transitionScope], otherwise + * [transition] will never be considered as finished. + */ + fun PropertyTransformationScope.transform( + content: ContentKey, + element: ElementKey, + transition: TransitionState.Transition, + transitionScope: CoroutineScope, + ): T +} + +/** A [CustomPropertyTransformation] applied to the size of one or more elements. */ +interface CustomSizeTransformation : CustomPropertyTransformation<IntSize> + +/** A [CustomPropertyTransformation] applied to the offset of one or more elements. */ +interface CustomOffsetTransformation : CustomPropertyTransformation<Offset> + +/** A [CustomPropertyTransformation] applied to the alpha of one or more elements. */ +interface CustomAlphaTransformation : CustomPropertyTransformation<Float> + +/** A [CustomPropertyTransformation] applied to the scale of one or more elements. */ +interface CustomScaleTransformation : CustomPropertyTransformation<Scale> + interface PropertyTransformationScope : Density, ElementStateScope { /** The current [direction][LayoutDirection] of the layout. */ val layoutDirection: LayoutDirection @@ -101,7 +149,7 @@ data class TransformationRange(val start: Float, val end: Float, val easing: Eas } /** Reverse this range. */ - fun reversed() = + internal fun reversed() = TransformationRange(start = reverseBound(end), end = reverseBound(start), easing = easing) /** Get the progress of this range given the global [transitionProgress]. */ @@ -128,6 +176,6 @@ data class TransformationRange(val start: Float, val end: Float, val easing: Eas } companion object { - const val BoundUnspecified = Float.MIN_VALUE + internal const val BoundUnspecified = Float.MIN_VALUE } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt index 356ed9969458..d756c86f680c 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/transformation/Translate.kt @@ -30,7 +30,7 @@ internal class Translate( override val matcher: ElementMatcher, private val x: Dp = 0.dp, private val y: Dp = 0.dp, -) : PropertyTransformation<Offset> { +) : InterpolatedOffsetTransformation { override fun PropertyTransformationScope.transform( content: ContentKey, element: ElementKey, @@ -45,7 +45,7 @@ internal class OverscrollTranslate( override val matcher: ElementMatcher, val x: OverscrollScope.() -> Float = { 0f }, val y: OverscrollScope.() -> Float = { 0f }, -) : PropertyTransformation<Offset> { +) : InterpolatedOffsetTransformation { private val cachedOverscrollScope = CachedOverscrollScope() override fun PropertyTransformationScope.transform( diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt index 2b7090876bad..ce1c8f889e98 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutStateTest.kt @@ -35,12 +35,15 @@ import com.android.compose.test.runMonotonicClockTest import com.android.compose.test.transition import com.google.common.truth.Truth.assertThat import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Rule @@ -601,4 +604,47 @@ class SceneTransitionLayoutStateTest { runBlocking { state.startTransition(transition) } } } + + @Test + fun transitionFinishedWhenScopeIsEmpty() = runTest { + val state = MutableSceneTransitionLayoutState(SceneA) + + // Start a transition. + val transition = transition(from = SceneA, to = SceneB) + state.startTransitionImmediately(backgroundScope, transition) + assertThat(state.transitionState).isSceneTransition() + + // Start a job in the transition scope. + val jobCompletable = CompletableDeferred<Unit>() + transition.coroutineScope.launch { jobCompletable.await() } + + // Finish the transition (i.e. make its #run() method return). The transition should not be + // considered as finished yet given that there is a job still running in its scope. + transition.finish() + runCurrent() + assertThat(state.transitionState).isSceneTransition() + + // Finish the job in the scope. Now the transition should be considered as finished. + jobCompletable.complete(Unit) + runCurrent() + assertThat(state.transitionState).isIdle() + } + + @Test + fun transitionScopeIsCancelledWhenTransitionIsForceFinished() = runTest { + val state = MutableSceneTransitionLayoutState(SceneA) + + // Start a transition. + val transition = transition(from = SceneA, to = SceneB) + state.startTransitionImmediately(backgroundScope, transition) + assertThat(state.transitionState).isSceneTransition() + + // Start a job in the transition scope that never finishes. + val job = transition.coroutineScope.launch { awaitCancellation() } + + // Force snap state to SceneB to force finish all current transitions. + state.snapToScene(SceneB) + assertThat(state.transitionState).isIdle() + assertThat(job.isCancelled).isTrue() + } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt index d31711496ff0..1f9ba9ee9372 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/TransitionDslTest.kt @@ -22,17 +22,22 @@ import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.unit.IntSize import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.compose.animation.scene.TestScenes.SceneA import com.android.compose.animation.scene.TestScenes.SceneB import com.android.compose.animation.scene.TestScenes.SceneC import com.android.compose.animation.scene.content.state.TransitionState +import com.android.compose.animation.scene.transformation.CustomSizeTransformation import com.android.compose.animation.scene.transformation.OverscrollTranslate +import com.android.compose.animation.scene.transformation.PropertyTransformationScope import com.android.compose.animation.scene.transformation.TransformationRange import com.android.compose.animation.scene.transformation.TransformationWithRange import com.android.compose.test.transition import com.google.common.truth.Correspondence import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Test @@ -343,6 +348,33 @@ class TransitionDslTest { assertThat(transitionPassedToBuilder).isSameInstanceAs(transition) } + @Test + fun customTransitionsAreNotSupportedInRanges() = runTest { + val transitions = transitions { + from(SceneA, to = SceneB) { + fractionRange { + transformation( + object : CustomSizeTransformation { + override val matcher: ElementMatcher = TestElements.Foo + + override fun PropertyTransformationScope.transform( + content: ContentKey, + element: ElementKey, + transition: TransitionState.Transition, + transitionScope: CoroutineScope, + ): IntSize = IntSize.Zero + } + ) + } + } + } + + val state = MutableSceneTransitionLayoutState(SceneA, transitions) + assertThrows(IllegalStateException::class.java) { + runBlocking { state.startTransition(transition(from = SceneA, to = SceneB)) } + } + } + companion object { private val TRANSFORMATION_RANGE = Correspondence.transforming<TransformationWithRange<*>, TransformationRange?>( diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/CustomTransformationTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/CustomTransformationTest.kt new file mode 100644 index 000000000000..487b0992c3e5 --- /dev/null +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/CustomTransformationTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 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.geometry.Offset +import androidx.compose.ui.test.assertPositionInRootIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.compose.animation.scene.ContentKey +import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.ElementMatcher +import com.android.compose.animation.scene.TestElements +import com.android.compose.animation.scene.content.state.TransitionState +import com.android.compose.animation.scene.testTransition +import com.android.compose.test.assertSizeIsEqualTo +import kotlinx.coroutines.CoroutineScope +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CustomTransformationTest { + @get:Rule val rule = createComposeRule() + + @Test + fun customSize() { + /** A size transformation that adds [add] to the size of the transformed element(s). */ + class AddSizeTransformation(override val matcher: ElementMatcher, private val add: Dp) : + CustomSizeTransformation { + override fun PropertyTransformationScope.transform( + content: ContentKey, + element: ElementKey, + transition: TransitionState.Transition, + transitionScope: CoroutineScope, + ): IntSize { + val idleSize = checkNotNull(element.targetSize(content)) + val progress = 1f - transition.progressTo(content) + val addPx = (add * progress).roundToPx() + return IntSize(width = idleSize.width + addPx, height = idleSize.height + addPx) + } + } + + rule.testTransition( + fromSceneContent = { Box(Modifier.element(TestElements.Foo).size(40.dp, 20.dp)) }, + toSceneContent = {}, + transition = { + spec = tween(16 * 4, easing = LinearEasing) + + // Add 80dp to the width and height of Foo. + transformation(AddSizeTransformation(TestElements.Foo, 80.dp)) + }, + ) { + before { onElement(TestElements.Foo).assertSizeIsEqualTo(40.dp, 20.dp) } + at(0) { onElement(TestElements.Foo).assertSizeIsEqualTo(40.dp, 20.dp) } + at(16) { onElement(TestElements.Foo).assertSizeIsEqualTo(60.dp, 40.dp) } + at(32) { onElement(TestElements.Foo).assertSizeIsEqualTo(80.dp, 60.dp) } + at(48) { onElement(TestElements.Foo).assertSizeIsEqualTo(100.dp, 80.dp) } + after { onElement(TestElements.Foo).assertDoesNotExist() } + } + } + + @Test + fun customOffset() { + /** An offset transformation that adds [add] to the offset of the transformed element(s). */ + class AddOffsetTransformation(override val matcher: ElementMatcher, private val add: Dp) : + CustomOffsetTransformation { + override fun PropertyTransformationScope.transform( + content: ContentKey, + element: ElementKey, + transition: TransitionState.Transition, + transitionScope: CoroutineScope, + ): Offset { + val idleOffset = checkNotNull(element.targetOffset(content)) + val progress = 1f - transition.progressTo(content) + val addPx = (add * progress).toPx() + return Offset(x = idleOffset.x + addPx, y = idleOffset.y + addPx) + } + } + + rule.testTransition( + fromSceneContent = { Box(Modifier.element(TestElements.Foo)) }, + toSceneContent = {}, + transition = { + spec = tween(16 * 4, easing = LinearEasing) + + // Add 80dp to the offset of Foo. + transformation(AddOffsetTransformation(TestElements.Foo, 80.dp)) + }, + ) { + before { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 0.dp) } + at(0) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(0.dp, 0.dp) } + at(16) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(20.dp, 20.dp) } + at(32) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(40.dp, 40.dp) } + at(48) { onElement(TestElements.Foo).assertPositionInRootIsEqualTo(60.dp, 60.dp) } + after { onElement(TestElements.Foo).assertDoesNotExist() } + } + } +} |