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

Commit fca32d54 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Add support for movable elements

This CL adds a new MovableElement API on SceneScope to create shared
elements whose content is moved instead of being duplicated between
scenes. This allows to keep any kind of internal state during
transitions.

See b/291053742#comment5 for details and videos.

Bug: 291053742
Test: atest MovableElementTest
Change-Id: Ib62baf6764d389f18d352670c1699bbf23b0506a
parent ff9ad4ed
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -59,6 +60,17 @@ internal class Element(val key: ElementKey) {
    /** The mapping between a scene and the values/state this element has in that scene, if any. */
    val sceneValues = SnapshotStateMap<SceneKey, TargetValues>()

    /**
     * The movable content of this element, if this element is composed using
     * [SceneScope.MovableElement].
     */
    val movableContent by
        // This is only accessed from the composition (main) thread, so no need to use the default
        // lock of lazy {} to synchronize.
        lazy(mode = LazyThreadSafetyMode.NONE) {
            movableContentOf { content: @Composable () -> Unit -> content() }
        }

    override fun toString(): String {
        return "Element(key=$key)"
    }
+180 −0
Original line number 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 android.graphics.Picture
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.draw
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize

private const val TAG = "MovableElement"

private object MovableElementScopeImpl : MovableElementScope

@Composable
internal fun MovableElement(
    layoutImpl: SceneTransitionLayoutImpl,
    scene: Scene,
    key: ElementKey,
    modifier: Modifier,
    content: @Composable MovableElementScope.() -> Unit,
) {
    Box(modifier.element(layoutImpl, scene, key)) {
        // Get the Element from the map. It will always be the same and we don't want to recompose
        // every time an element is added/removed from SceneTransitionLayoutImpl.elements, so we
        // disable read observation during the look-up in that map.
        val element = Snapshot.withoutReadObservation { layoutImpl.elements.getValue(key) }

        // The [Picture] to which we save the last drawing commands of this element. This is
        // necessary because the content of this element might not be composed in this scene, in
        // which case we still need to draw it.
        val picture = remember { Picture() }

        if (shouldComposeMovableElement(layoutImpl, scene.key, element)) {
            Box(
                Modifier.drawWithCache {
                    val width = size.width.toInt()
                    val height = size.height.toInt()

                    onDrawWithContent {
                        // Save the draw commands into [picture] for later to draw the last content
                        // even when this movable content is not composed.
                        val pictureCanvas = Canvas(picture.beginRecording(width, height))
                        draw(this, this.layoutDirection, pictureCanvas, this.size) {
                            this@onDrawWithContent.drawContent()
                        }
                        picture.endRecording()

                        // Draw the content.
                        drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
                    }
                }
            ) {
                element.movableContent { MovableElementScopeImpl.content() }
            }
        } else {
            // If we are not composed, we draw the previous drawing commands at the same size as the
            // movable content when it was composed in this scene.
            val sceneValues = element.sceneValues.getValue(scene.key)

            Spacer(
                Modifier.layout { measurable, _ ->
                        val size =
                            sceneValues.targetSize.takeIf { it != Element.SizeUnspecified }
                                ?: IntSize.Zero
                        val placeable =
                            measurable.measure(Constraints.fixed(size.width, size.height))
                        layout(size.width, size.height) { placeable.place(0, 0) }
                    }
                    .drawBehind {
                        drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
                    }
            )
        }
    }
}

private fun shouldComposeMovableElement(
    layoutImpl: SceneTransitionLayoutImpl,
    scene: SceneKey,
    element: Element,
): Boolean {
    val transitionState = layoutImpl.state.transitionState

    // If we are idle, there is only one [scene] that is composed so we can compose our movable
    // content here.
    if (transitionState is TransitionState.Idle) {
        check(transitionState.currentScene == scene)
        return true
    }

    val fromScene = (transitionState as TransitionState.Transition).fromScene
    val toScene = transitionState.toScene
    if (fromScene == toScene) {
        check(fromScene == scene)
        return true
    }

    val fromReady = layoutImpl.isSceneReady(fromScene)
    val toReady = layoutImpl.isSceneReady(toScene)

    val otherScene =
        when (scene) {
            fromScene -> toScene
            toScene -> fromScene
            else ->
                error(
                    "shouldComposeMovableElement(scene=$scene) called with fromScene=$fromScene " +
                        "and toScene=$toScene"
                )
        }

    val isShared = otherScene in element.sceneValues

    if (isShared && !toReady && !fromReady) {
        // This should usually not happen given that fromScene should be ready, but let's log a
        // warning here in case it does so it helps debugging flicker issues caused by this part of
        // the code.
        Log.w(
            TAG,
            "MovableElement $element might have to be composed for the first time in both " +
                "fromScene=$fromScene and toScene=$toScene. This will probably lead to a flicker " +
                "where the size of the element will jump from IntSize.Zero to its actual size " +
                "during the transition."
        )
    }

    // Element is not shared in this transition.
    if (!isShared) {
        return true
    }

    // toScene is not ready (because we are composing it for the first time), so we compose it there
    // first. This is the most common scenario when starting a transition that has a shared movable
    // element.
    if (!toReady) {
        return scene == toScene
    }

    // This should usually not happen, but if we are also composing for the first time in fromScene
    // then we should compose it there only.
    if (!fromReady) {
        return scene == fromScene
    }

    // If we are ready in both scenes, then compose in the scene that has the highest zIndex (unless
    // it is a background) given that this is the one that is going to be drawn.
    val isHighestScene = layoutImpl.scene(scene).zIndex > layoutImpl.scene(otherScene).zIndex
    return if (element.key.isBackground) {
        !isHighestScene
    } else {
        isHighestScene
    }
}
+9 −0
Original line number Diff line number Diff line
@@ -90,4 +90,13 @@ private class SceneScopeImpl(
            canOverflow,
        )
    }

    @Composable
    override fun MovableElement(
        key: ElementKey,
        modifier: Modifier,
        content: @Composable MovableElementScope.() -> Unit,
    ) {
        MovableElement(layoutImpl, scene, key, modifier, content)
    }
}
+36 −0
Original line number Diff line number Diff line
@@ -85,6 +85,13 @@ interface SceneTransitionLayoutScope {
    )
}

/**
 * A DSL marker to prevent people from nesting calls to Modifier.element() inside a MovableElement,
 * which is not supported.
 */
@DslMarker annotation class ElementDsl

@ElementDsl
interface SceneScope {
    /**
     * Tag an element identified by [key].
@@ -95,11 +102,36 @@ interface SceneScope {
     * Additionally, this [key] will be used to detect elements that are shared between scenes to
     * automatically interpolate their size, offset and [shared values][animateSharedValueAsState].
     *
     * Note that shared elements tagged using this function will be duplicated in each scene they
     * are part of, so any **internal** state (e.g. state created using `remember {
     * mutableStateOf(...) }`) will be lost. If you need to preserve internal state, you should use
     * [MovableElement] instead.
     *
     * @see MovableElement
     *
     * TODO(b/291566282): Migrate this to the new Modifier Node API and remove the @Composable
     *   constraint.
     */
    @Composable fun Modifier.element(key: ElementKey): Modifier

    /**
     * Create a *movable* element identified by [key].
     *
     * This creates an element that will be automatically shared when present in multiple scenes and
     * that can be transformed during transitions, the same way that [element] does. The major
     * difference with [element] is that elements created with [MovableElement] will be "moved" and
     * composed only once during transitions (as opposed to [element] that duplicates shared
     * elements) so that any internal state is preserved during and after the transition.
     *
     * @see element
     */
    @Composable
    fun MovableElement(
        key: ElementKey,
        modifier: Modifier,
        content: @Composable MovableElementScope.() -> Unit,
    )

    /**
     * Animate some value of a shared element.
     *
@@ -126,6 +158,10 @@ interface SceneScope {
    ): State<T>
}

// TODO(b/291053742): Add animateSharedValueAsState(targetValue) without any ValueKey and ElementKey
// arguments to allow sharing values inside a movable element.
@ElementDsl interface MovableElementScope

/** An action performed by the user. */
sealed interface UserAction

+2 −0
Original line number Diff line number Diff line
@@ -199,4 +199,6 @@ internal class SceneTransitionLayoutImpl(
        return readyScenes.containsKey(transition.fromScene) &&
            readyScenes.containsKey(transition.toScene)
    }

    internal fun isSceneReady(scene: SceneKey): Boolean = readyScenes.containsKey(scene)
}
Loading