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

Commit 676b0c69 authored by Andreas Miko's avatar Andreas Miko
Browse files

Introduce SharedElement transitions with NestedSTLs

This is a first iteration to support shared elements across nestedSTLs.
This version has several limitations that may be addressed in future
CLs.

The main mechanism to drive this is by sharing the elements map and the
lookaheadScope of all nestedSTLs. This means that they also share
Element.stateByContent. Each child writes a reference of its state to
its own content but also one into each contentKey of where it is
composed in its parent. Before `stateByContent` would hold a state
for each content that would compose an element, after this change
it also holds a state when any of the nestedSTLs compose this element.
This means that always the STL with the lowest nestingDepth has to
render the element as the element is not actually placed in the
other scene of that transition.

Known limitations are:
- Interruptions are not supported (will jump cut)
- Side-by-side NestedSTLs are not supported. That means if we have a
ParentSTL that contains NestedSTL1 and NestedSTL2 then a shared element
can be transitioned between the ParentSTL and the NestedSTLs but not
between NestedSTL1 and NestedSTL2. Multiple nesting layers are supported
though, so ParentSTL > NestedSTL1 > NestedNestedSTL1 would support
sharing between any of them.
- If the sharedElement appears more than two times on the screen
(due to multiple transitions of multiple STLs transitioning at the same
time) the behavior is not supported.
- A sharedElement can't be present twice in the same hierachy. Meaning
that ParentSTL sceneA contains an element. There can't be a NestedSTL
on the same scene that would contain the same element in any of its
scenes. This would result in a conflict of the idle state.
- Currently the sharedElement is always rendered in the ParentSTL during
transitions. We don't support more control for this behavior as we do
for normal sharedElements yet.
- If you disable a sharedElement transition for a specific transition
you can only reference the element in the parentSTL to define a
custom transition. This is a general limitation where you can't
reference elements within NestedSTLs in transition DSL.
- NestedSTls are not allowed to share the same contentkeys

Test: Manual test app added + Units tests added
Bug: b/376659778
Flag: NONE not in production yet
Change-Id: I0478d4f73f189e506ace39f2b3544ea6acc75ead
parent b00f0cd9
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