Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 497b75bb authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge changes from topic "scene-transition-layout" into udc-qpr-dev

* changes:
  Introduce SceneTransitionLayout (1/2)
  Fix child constraints in (Horizontal|Vertical)Grid
parents ae83ae77 5805b07c
Loading
Loading
Loading
Loading
+162 −0
Original line number Original line Diff line number Diff line
/*
 * 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
    }
}
+150 −0
Original line number Original line Diff line number Diff line
/*
 * 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
+449 −0

File added.

Preview size limit exceeded, changes collapsed.

+80 −0
Original line number Original line Diff line number Diff line
/*
 * 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)"
    }
}
+71 −0
Original line number Original line Diff line number Diff line
/*
 * 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()
}
Loading