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

Commit f5a9dfdc authored by Andreas Miko's avatar Andreas Miko Committed by Android (Google) Code Review
Browse files

Merge changes from topic "NestedSTL" into main

* changes:
  Improve shared element tests and test framework
  Introduce SharedElement transitions with NestedSTLs
parents d32ceb93 5e09cf78
Loading
Loading
Loading
Loading
+66 −99
Original line number Diff line number Diff line
@@ -53,10 +53,10 @@ 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
import com.android.compose.modifiers.thenIf
import com.android.compose.ui.graphics.drawInContainer
import com.android.compose.ui.util.IntIndexedMap
import com.android.compose.ui.util.lerp
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@@ -69,6 +69,14 @@ internal class Element(val key: ElementKey) {
    // are first seen by composition then layout/drawing code. See b/316901148#comment2 for details.
    val stateByContent = SnapshotStateMap<ContentKey, State>()

    /**
     * A sorted map of nesting depth (key) to content key (value). For shared elements it is used to
     * determine which content this element should be rendered by. The nesting depth refers to the
     * number of STLs nested within each other, starting at 0 for the parent STL and increasing by
     * one for each nested [NestedSceneTransitionLayout].
     */
    val renderAuthority = IntIndexedMap<ContentKey>()

    /**
     * The last transition that was used when computing the state (size, position and alpha) of this
     * element in any content, or `null` if it was last laid out when idle.
@@ -232,9 +240,8 @@ internal class ElementNode(
    private val element: Element
        get() = _element!!

    private var _stateInContent: Element.State? = null
    private val stateInContent: Element.State
        get() = _stateInContent!!
        get() = element.stateByContent.getValue(content.key)

    override val traverseKey: Any = ElementTraverseKey

@@ -248,9 +255,13 @@ internal class ElementNode(
        val element =
            layoutImpl.elements[key] ?: Element(key).also { layoutImpl.elements[key] = it }
        _element = element
        _stateInContent =
            element.stateByContent[content.key]
                ?: Element.State(content.key).also { element.stateByContent[content.key] = it }
        addToRenderAuthority(element)
        if (!element.stateByContent.contains(content.key)) {
            val elementState = Element.State(content.key)
            element.stateByContent[content.key] = elementState

            layoutImpl.ancestorContentKeys.forEach { element.stateByContent[it] = elementState }
        }
    }

    private fun addNodeToContentState() {
@@ -272,8 +283,20 @@ internal class ElementNode(
        removeNodeFromContentState()
        maybePruneMaps(layoutImpl, element, stateInContent)

        removeFromRenderAuthority()
        _element = null
        _stateInContent = null
    }

    private fun addToRenderAuthority(element: Element) {
        val nestingDepth = layoutImpl.ancestorContentKeys.size
        element.renderAuthority[nestingDepth] = content.key
    }

    private fun removeFromRenderAuthority() {
        val nestingDepth = layoutImpl.ancestorContentKeys.size
        if (element.renderAuthority[nestingDepth] == content.key) {
            element.renderAuthority.remove(nestingDepth)
        }
    }

    private fun removeNodeFromContentState() {
@@ -346,15 +369,17 @@ internal class ElementNode(
        val elementState = elementState(layoutImpl, element, currentTransitionStates)
        if (elementState == null) {
            // If the element is not part of any transition, place it normally in its idle scene.
            // This is the case if for example a transition between two overlays is ongoing where
            // sharedElement isn't part of either but the element is still rendered as part of
            // the underlying scene that is currently not being transitioned.
            val currentState = currentTransitionStates.last()
            val placeInThisContent =
            val shouldPlaceInThisContent =
                elementContentWhenIdle(
                    layoutImpl,
                    currentState,
                    isInContent = { it in element.stateByContent },
                ) == content.key

            return if (placeInThisContent) {
            return if (shouldPlaceInThisContent) {
                placeNormally(measurable, constraints)
            } else {
                doNotPlace(measurable, constraints)
@@ -536,7 +561,9 @@ internal class ElementNode(

        stateInContent.clearLastPlacementValues()
        traverseDescendants(ElementTraverseKey) { node ->
            (node as ElementNode)._stateInContent?.clearLastPlacementValues()
            if ((node as ElementNode)._element != null) {
                node.stateInContent.clearLastPlacementValues()
            }
            TraversableNode.Companion.TraverseDescendantsAction.ContinueTraversal
        }
    }
@@ -569,23 +596,31 @@ internal class ElementNode(
            element: Element,
            stateInContent: Element.State,
        ) {
            // If element is not composed in this content anymore, remove the content values. This
            // works because [onAttach] is called before [onDetach], so if an element is moved from
            // the UI tree we will first add the new code location then remove the old one.
            fun pruneForContent(contentKey: ContentKey) {
                // If element is not composed in this content anymore, remove the content values.
                // This works because [onAttach] is called before [onDetach], so if an element is
                // moved from the UI tree we will first add the new code location then remove the
                // old one.
                if (
                    stateInContent.nodes.isEmpty() &&
                    element.stateByContent[stateInContent.content] == stateInContent
                        element.stateByContent[contentKey] == stateInContent
                ) {
                element.stateByContent.remove(stateInContent.content)
                    element.stateByContent.remove(contentKey)

                // If the element is not composed in any content, remove it from the elements map.
                    // If the element is not composed in any content, remove it from the elements
                    // map.
                    if (
                    element.stateByContent.isEmpty() && layoutImpl.elements[element.key] == element
                        element.stateByContent.isEmpty() &&
                            layoutImpl.elements[element.key] == element
                    ) {
                        layoutImpl.elements.remove(element.key)
                    }
                }
            }

            pruneForContent(stateInContent.content)
            layoutImpl.ancestorContentKeys.forEach { content -> pruneForContent(content) }
        }
    }
}

@@ -890,7 +925,8 @@ private fun shouldPlaceElement(
    val transition =
        when (elementState) {
            is TransitionState.Idle -> {
                return content ==
                return element.shouldBeRenderedBy(content) &&
                    content ==
                        elementContentWhenIdle(
                            layoutImpl,
                            elementState,
@@ -925,76 +961,7 @@ private fun shouldPlaceElement(
        return true
    }

    return shouldPlaceOrComposeSharedElement(
        layoutImpl,
        content,
        element.key,
        transition,
        isInContent = { it in element.stateByContent },
    )
}

internal inline fun shouldPlaceOrComposeSharedElement(
    layoutImpl: SceneTransitionLayoutImpl,
    content: ContentKey,
    element: ElementKey,
    transition: TransitionState.Transition,
    isInContent: (ContentKey) -> Boolean,
): Boolean {
    val overscrollContent = transition.currentOverscrollSpec?.content
    if (overscrollContent != null) {
        return when (transition) {
            // If we are overscrolling between scenes, only place/compose the element in the
            // overscrolling scene.
            is TransitionState.Transition.ChangeScene -> content == overscrollContent

            // If we are overscrolling an overlay, place/compose the element if [content] is the
            // overscrolling content or if [content] is the current scene and the overscrolling
            // overlay does not contain the element.
            is TransitionState.Transition.ReplaceOverlay,
            is TransitionState.Transition.ShowOrHideOverlay ->
                content == overscrollContent ||
                    (content == transition.currentScene && !isInContent(overscrollContent))
        }
    }

    val scenePicker = element.contentPicker
    val pickedScene =
        scenePicker.contentDuringTransition(
            element = element,
            transition = transition,
            fromContentZIndex = layoutImpl.content(transition.fromContent).zIndex,
            toContentZIndex = layoutImpl.content(transition.toContent).zIndex,
        )

    return pickedScene == content
}

private fun isSharedElementEnabled(
    element: ElementKey,
    transition: TransitionState.Transition,
): Boolean {
    return sharedElementTransformation(element, transition)?.transformation?.enabled ?: true
}

internal fun sharedElementTransformation(
    element: ElementKey,
    transition: TransitionState.Transition,
): TransformationWithRange<SharedElementTransformation>? {
    val transformationSpec = transition.transformationSpec
    val sharedInFromContent =
        transformationSpec.transformations(element, transition.fromContent).shared
    val sharedInToContent = transformationSpec.transformations(element, transition.toContent).shared

    // The sharedElement() transformation must either be null or be the same in both contents.
    if (sharedInFromContent != sharedInToContent) {
        error(
            "Different sharedElement() transformations matched $element " +
                "(from=$sharedInFromContent to=$sharedInToContent)"
        )
    }

    return sharedInFromContent
    return shouldPlaceSharedElement(layoutImpl, content, element.key, transition)
}

/**
+39 −3
Original line number Diff line number Diff line
@@ -196,18 +196,54 @@ private fun shouldComposeMovableElement(
        is TransitionState.Transition -> {
            // During transitions, always compose movable elements in the scene picked by their
            // content picker.
            val contents = element.contentPicker.contents
            shouldPlaceOrComposeSharedElement(
            shouldComposeMoveableElement(
                layoutImpl,
                content,
                element,
                elementState,
                isInContent = { contents.contains(it) },
                element.contentPicker.contents,
            )
        }
    }
}

private fun shouldComposeMoveableElement(
    layoutImpl: SceneTransitionLayoutImpl,
    content: ContentKey,
    elementKey: ElementKey,
    transition: TransitionState.Transition,
    containingContents: Set<ContentKey>,
): Boolean {
    val overscrollContent = transition.currentOverscrollSpec?.content
    if (overscrollContent != null) {
        return when (transition) {
            // If we are overscrolling between scenes, only place/compose the element in the
            // overscrolling scene.
            is TransitionState.Transition.ChangeScene -> content == overscrollContent

            // If we are overscrolling an overlay, place/compose the element if [content] is the
            // overscrolling content or if [content] is the current scene and the overscrolling
            // overlay does not contain the element.
            is TransitionState.Transition.ReplaceOverlay,
            is TransitionState.Transition.ShowOrHideOverlay ->
                content == overscrollContent ||
                    (content == transition.currentScene &&
                        !containingContents.contains(overscrollContent))
        }
    }

    val scenePicker = elementKey.contentPicker
    val pickedScene =
        scenePicker.contentDuringTransition(
            element = elementKey,
            transition = transition,
            fromContentZIndex = layoutImpl.content(transition.fromContent).zIndex,
            toContentZIndex = layoutImpl.content(transition.toContent).zIndex,
        )

    return pickedScene == content
}

private fun movableElementState(
    element: MovableElementKey,
    transitionStates: List<TransitionState>,
+38 −1
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
@@ -68,7 +69,7 @@ fun SceneTransitionLayout(
        swipeDetector,
        transitionInterceptionThreshold,
        onLayoutImpl = null,
        builder,
        builder = builder,
    )
}

@@ -261,6 +262,18 @@ interface BaseContentScope : ElementStateScope {
     * lists keep a constant size during transitions even if its elements are growing/shrinking.
     */
    fun Modifier.noResizeDuringTransitions(): Modifier

    /**
     * A [NestedSceneTransitionLayout] will share its elements with its ancestor STLs therefore
     * enabling sharedElement transitions between them.
     */
    // TODO(b/380070506): Add more parameters when default params are supported in Kotlin 2.0.21
    @Composable
    fun NestedSceneTransitionLayout(
        state: SceneTransitionLayoutState,
        modifier: Modifier,
        builder: SceneTransitionLayoutScope.() -> Unit,
    )
}

typealias SceneScope = ContentScope
@@ -677,6 +690,9 @@ internal fun SceneTransitionLayoutForTesting(
    swipeDetector: SwipeDetector = DefaultSwipeDetector,
    transitionInterceptionThreshold: Float = 0f,
    onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null,
    sharedElementMap: MutableMap<ElementKey, Element> = remember { mutableMapOf() },
    ancestorContentKeys: List<ContentKey> = emptyList(),
    lookaheadScope: LookaheadScope? = null,
    builder: SceneTransitionLayoutScope.() -> Unit,
) {
    val density = LocalDensity.current
@@ -691,6 +707,9 @@ internal fun SceneTransitionLayoutForTesting(
                transitionInterceptionThreshold = transitionInterceptionThreshold,
                builder = builder,
                animationScope = animationScope,
                elements = sharedElementMap,
                ancestorContentKeys = ancestorContentKeys,
                lookaheadScope = lookaheadScope,
            )
            .also { onLayoutImpl?.invoke(it) }
    }
@@ -706,6 +725,24 @@ internal fun SceneTransitionLayoutForTesting(
                    " that was used when creating it, which is not supported"
            )
        }
        if (layoutImpl.elements != sharedElementMap) {
            error(
                "This SceneTransitionLayout was bound to a different elements map that was used " +
                    "when creating it, which is not supported"
            )
        }
        if (layoutImpl.ancestorContentKeys != ancestorContentKeys) {
            error(
                "This SceneTransitionLayout was bound to a different ancestorContents that was " +
                    "used when creating it, which is not supported"
            )
        }
        if (lookaheadScope != null && layoutImpl.lookaheadScope != lookaheadScope) {
            error(
                "This SceneTransitionLayout was bound to a different lookaheadScope that was " +
                    "used when creating it, which is not supported"
            )
        }

        layoutImpl.density = density
        layoutImpl.layoutDirection = layoutDirection
+38 −17
Original line number Diff line number Diff line
@@ -70,7 +70,39 @@ internal class SceneTransitionLayoutImpl(
     * animations.
     */
    internal val animationScope: CoroutineScope,

    /**
     * The map of [Element]s.
     *
     * Important: [Element]s from this map should never be accessed during composition because the
     * Elements are added when the associated Modifier.element() node is attached to the Modifier
     * tree, i.e. after composition.
     */
    internal val elements: MutableMap<ElementKey, Element> = mutableMapOf(),

    /**
     * When this STL is a [NestedSceneTransitionLayout], this is a list of [ContentKey]s of where
     * this STL is composed in within its ancestors.
     *
     * The root STL holds an emptyList. With each nesting level the parent is supposed to add
     * exactly one scene to the list, therefore the size of this list is equal to the nesting depth
     * of this STL.
     *
     * This is used to know in which content of the ancestors a sharedElement appears in.
     */
    internal val ancestorContentKeys: List<ContentKey> = emptyList(),
    lookaheadScope: LookaheadScope? = null,
) {

    /**
     * The [LookaheadScope] of this layout, that can be used to compute offsets relative to the
     * layout. For [NestedSceneTransitionLayout]s this scope is the scope of the root STL, such that
     * offset computations can be shared among all children.
     */
    private var _lookaheadScope: LookaheadScope? = lookaheadScope
    internal val lookaheadScope: LookaheadScope
        get() = _lookaheadScope!!

    /**
     * The map of [Scene]s.
     *
@@ -88,15 +120,6 @@ internal class SceneTransitionLayoutImpl(
    private val overlays
        get() = _overlays ?: SnapshotStateMap<OverlayKey, Overlay>().also { _overlays = it }

    /**
     * The map of [Element]s.
     *
     * Important: [Element]s from this map should never be accessed during composition because the
     * Elements are added when the associated Modifier.element() node is attached to the Modifier
     * tree, i.e. after composition.
     */
    internal val elements = mutableMapOf<ElementKey, Element>()

    /**
     * The map of contents of movable elements.
     *
@@ -138,13 +161,6 @@ internal class SceneTransitionLayoutImpl(
                    _userActionDistanceScope = it
                }

    /**
     * The [LookaheadScope] of this layout, that can be used to compute offsets relative to the
     * layout.
     */
    internal lateinit var lookaheadScope: LookaheadScope
        private set

    internal var lastSize: IntSize = IntSize.Zero

    init {
@@ -347,7 +363,12 @@ internal class SceneTransitionLayoutImpl(
                .then(LayoutElement(layoutImpl = this))
        ) {
            LookaheadScope {
                lookaheadScope = this
                if (_lookaheadScope == null) {
                    // We can't init this in a SideEffect as other NestedSTLs are already calling
                    // this during composition. However, when composition is canceled
                    // SceneTransitionLayoutImpl is discarded as well. So it's fine to do this here.
                    _lookaheadScope = this
                }

                BackHandler()
                Scenes()
+113 −0
Original line number Diff line number Diff line
/*
 * 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

import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.transformation.SharedElementTransformation
import com.android.compose.animation.scene.transformation.TransformationWithRange

/**
 * Whether this element should be rendered by the given [content]. This method returns true only for
 * exactly one content at any given time.
 */
internal fun Element.shouldBeRenderedBy(content: ContentKey): Boolean {
    // The current strategy is that always the content with the lowest nestingDepth has authority.
    // This content is supposed to render the shared element because this is also the level at which
    // the transition is running. If the [renderAuthority.size] is 1 it means that that this element
    // is currently composed only in one nesting level, which means that the render authority
    // is determined by "classic" shared element code.
    return renderAuthority.size == 1 || renderAuthority.first() == content
}

/**
 * Whether this element is currently composed in multiple [SceneTransitionLayout]s.
 *
 * Note: Shared elements across [NestedSceneTransitionLayout]s side-by-side are not supported.
 */
internal fun Element.isPresentInMultipleStls(): Boolean {
    return renderAuthority.size > 1
}

internal fun shouldPlaceSharedElement(
    layoutImpl: SceneTransitionLayoutImpl,
    content: ContentKey,
    elementKey: ElementKey,
    transition: TransitionState.Transition,
): Boolean {
    val element = layoutImpl.elements.getValue(elementKey)
    if (element.isPresentInMultipleStls()) {
        // If the element is present in multiple STLs we require the highest STL to render it and
        // we don't want contentPicker to potentially return false for the highest STL.
        return element.shouldBeRenderedBy(content)
    }

    val overscrollContent = transition.currentOverscrollSpec?.content
    if (overscrollContent != null) {
        return when (transition) {
            // If we are overscrolling between scenes, only place/compose the element in the
            // overscrolling scene.
            is TransitionState.Transition.ChangeScene -> content == overscrollContent

            // If we are overscrolling an overlay, place/compose the element if [content] is the
            // overscrolling content or if [content] is the current scene and the overscrolling
            // overlay does not contain the element.
            is TransitionState.Transition.ReplaceOverlay,
            is TransitionState.Transition.ShowOrHideOverlay ->
                content == overscrollContent ||
                    (content == transition.currentScene &&
                        overscrollContent !in element.stateByContent)
        }
    }

    val scenePicker = elementKey.contentPicker
    val pickedScene =
        scenePicker.contentDuringTransition(
            element = elementKey,
            transition = transition,
            fromContentZIndex = layoutImpl.content(transition.fromContent).zIndex,
            toContentZIndex = layoutImpl.content(transition.toContent).zIndex,
        )

    return pickedScene == content
}

internal fun isSharedElementEnabled(
    element: ElementKey,
    transition: TransitionState.Transition,
): Boolean {
    return sharedElementTransformation(element, transition)?.transformation?.enabled ?: true
}

internal fun sharedElementTransformation(
    element: ElementKey,
    transition: TransitionState.Transition,
): TransformationWithRange<SharedElementTransformation>? {
    val transformationSpec = transition.transformationSpec
    val sharedInFromContent =
        transformationSpec.transformations(element, transition.fromContent).shared
    val sharedInToContent = transformationSpec.transformations(element, transition.toContent).shared

    // The sharedElement() transformation must either be null or be the same in both contents.
    if (sharedInFromContent != sharedInToContent) {
        error(
            "Different sharedElement() transformations matched $element " +
                "(from=$sharedInFromContent to=$sharedInToContent)"
        )
    }

    return sharedInFromContent
}
Loading