Introduce overlays in SceneTransitionLayout (1/2)
This CL adds a first basic support for overlays. Overlays can be either
shown or hidden, and STLState.currentOverlays holds the "effective" set
of current overlays.
There is no overlay transitions in this CL yet, they will be added in
ag/28576084.
See go/sysui-stl-overlay for more details about overlays.
Bug: 353679003
Test: atest OverlayTest
Flag: com.android.systemui.scene_container
Change-Id: Ic0b26a2b7bff41829c409aed3293c47da47db3f5
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt
index fc4a8a5..1921624 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettings.kt
@@ -121,6 +121,8 @@
)
}
}
+ is TransitionState.Transition.OverlayTransition ->
+ TODO("b/359173565: Handle overlay transitions")
}
}
@@ -212,7 +214,8 @@
addView(view)
}
},
- // When the view changes (e.g. due to a theme change), this will be recomposed
+ // When the view changes (e.g. due to a theme change), this will be
+ // recomposed
// if needed and the new view will be attached to the FrameLayout here.
update = {
qsSceneAdapter.setState(state())
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt
index ea708a5..7eef5d6 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt
@@ -393,7 +393,8 @@
transition: TransitionState.Transition?,
): T? {
if (transition == null) {
- return sharedValue[layoutImpl.state.transitionState.currentScene]
+ return sharedValue[content]
+ ?: sharedValue[layoutImpl.state.transitionState.currentScene]
}
val fromValue = sharedValue[transition.fromContent]
@@ -424,10 +425,12 @@
val targetValues = sharedValue.targetValues
val transition =
if (element != null) {
- layoutImpl.elements[element]?.stateByContent?.let { sceneStates ->
- layoutImpl.state.currentTransitions.fastLastOrNull { transition ->
- transition.fromContent in sceneStates || transition.toContent in sceneStates
- }
+ layoutImpl.elements[element]?.let { element ->
+ elementState(
+ layoutImpl.state.transitionStates,
+ isInContent = { it in element.stateByContent },
+ )
+ as? TransitionState.Transition
}
} else {
layoutImpl.state.currentTransitions.fastLastOrNull { transition ->
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt
index f2c2a36..8aa0690 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateToScene.kt
@@ -43,12 +43,15 @@
}
return when (transitionState) {
- is TransitionState.Idle -> {
+ is TransitionState.Idle,
+ is TransitionState.Transition.ShowOrHideOverlay,
+ is TransitionState.Transition.ReplaceOverlay -> {
animateToScene(
layoutState,
target,
transitionKey,
isInitiatedByUserInput = false,
+ fromScene = transitionState.currentScene,
replacedTransition = null,
)
}
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 7dac2e4..6ea0285 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
@@ -46,7 +46,7 @@
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.round
import androidx.compose.ui.util.fastCoerceIn
-import androidx.compose.ui.util.fastLastOrNull
+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
@@ -145,8 +145,9 @@
// layout/drawing.
// TODO(b/341072461): Revert this and read the current transitions in ElementNode directly once
// we can ensure that SceneTransitionLayoutImpl will compose new contents first.
- val currentTransitions = layoutImpl.state.currentTransitions
- return then(ElementModifier(layoutImpl, currentTransitions, content, key)).testTag(key.testTag)
+ val currentTransitionStates = layoutImpl.state.transitionStates
+ return then(ElementModifier(layoutImpl, currentTransitionStates, content, key))
+ .testTag(key.testTag)
}
/**
@@ -155,20 +156,21 @@
*/
private data class ElementModifier(
private val layoutImpl: SceneTransitionLayoutImpl,
- private val currentTransitions: List<TransitionState.Transition>,
+ private val currentTransitionStates: List<TransitionState>,
private val content: Content,
private val key: ElementKey,
) : ModifierNodeElement<ElementNode>() {
- override fun create(): ElementNode = ElementNode(layoutImpl, currentTransitions, content, key)
+ override fun create(): ElementNode =
+ ElementNode(layoutImpl, currentTransitionStates, content, key)
override fun update(node: ElementNode) {
- node.update(layoutImpl, currentTransitions, content, key)
+ node.update(layoutImpl, currentTransitionStates, content, key)
}
}
internal class ElementNode(
private var layoutImpl: SceneTransitionLayoutImpl,
- private var currentTransitions: List<TransitionState.Transition>,
+ private var currentTransitionStates: List<TransitionState>,
private var content: Content,
private var key: ElementKey,
) : Modifier.Node(), DrawModifierNode, ApproachLayoutModifierNode, TraversableNode {
@@ -226,12 +228,12 @@
fun update(
layoutImpl: SceneTransitionLayoutImpl,
- currentTransitions: List<TransitionState.Transition>,
+ currentTransitionStates: List<TransitionState>,
content: Content,
key: ElementKey,
) {
check(layoutImpl == this.layoutImpl && content == this.content)
- this.currentTransitions = currentTransitions
+ this.currentTransitionStates = currentTransitionStates
removeNodeFromContentState()
@@ -287,31 +289,72 @@
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
- val transitions = currentTransitions
- val transition = elementTransition(layoutImpl, element, transitions)
+ 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.
+ val currentState = currentTransitionStates.last()
+ val placeInThisContent =
+ elementContentWhenIdle(
+ layoutImpl,
+ currentState.currentScene,
+ currentState.currentOverlays,
+ isInContent = { it in element.stateByContent },
+ ) == content.key
- // If this element is not supposed to be laid out now, either because it is not part of any
- // ongoing transition or the other content of its transition is overscrolling, then lay out
- // the element normally and don't place it.
+ return if (placeInThisContent) {
+ placeNormally(measurable, constraints)
+ } else {
+ doNotPlace(measurable, constraints)
+ }
+ }
+
+ val transition = elementState as? TransitionState.Transition
+
+ // If this element is not supposed to be laid out now because the other content of its
+ // transition is overscrolling, then lay out the element normally and don't place it.
val overscrollScene = transition?.currentOverscrollSpec?.scene
val isOtherSceneOverscrolling = overscrollScene != null && overscrollScene != content.key
- val isNotPartOfAnyOngoingTransitions = transitions.isNotEmpty() && transition == null
- if (isNotPartOfAnyOngoingTransitions || isOtherSceneOverscrolling) {
- recursivelyClearPlacementValues()
- stateInContent.lastSize = Element.SizeUnspecified
-
- val placeable = measurable.measure(constraints)
- return layout(placeable.width, placeable.height) { /* Do not place */ }
+ if (isOtherSceneOverscrolling) {
+ return doNotPlace(measurable, constraints)
}
val placeable =
measure(layoutImpl, element, transition, stateInContent, measurable, constraints)
stateInContent.lastSize = placeable.size()
- return layout(placeable.width, placeable.height) { place(transition, placeable) }
+ return layout(placeable.width, placeable.height) { place(elementState, placeable) }
+ }
+
+ private fun ApproachMeasureScope.doNotPlace(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ recursivelyClearPlacementValues()
+ stateInContent.lastSize = Element.SizeUnspecified
+
+ val placeable = measurable.measure(constraints)
+ return layout(placeable.width, placeable.height) { /* Do not place */ }
+ }
+
+ private fun ApproachMeasureScope.placeNormally(
+ measurable: Measurable,
+ constraints: Constraints
+ ): MeasureResult {
+ val placeable = measurable.measure(constraints)
+ stateInContent.lastSize = placeable.size()
+ return layout(placeable.width, placeable.height) {
+ coordinates?.let {
+ with(layoutImpl.lookaheadScope) {
+ stateInContent.lastOffset =
+ lookaheadScopeCoordinates.localPositionOf(it, Offset.Zero)
+ }
+ }
+
+ placeable.place(0, 0)
+ }
}
private fun Placeable.PlacementScope.place(
- transition: TransitionState.Transition?,
+ elementState: TransitionState,
placeable: Placeable,
) {
with(layoutImpl.lookaheadScope) {
@@ -321,11 +364,12 @@
coordinates ?: error("Element ${element.key} does not have any coordinates")
// No need to place the element in this content if we don't want to draw it anyways.
- if (!shouldPlaceElement(layoutImpl, content.key, element, transition)) {
+ if (!shouldPlaceElement(layoutImpl, content.key, element, elementState)) {
recursivelyClearPlacementValues()
return
}
+ val transition = elementState as? TransitionState.Transition
val currentOffset = lookaheadScopeCoordinates.localPositionOf(coords, Offset.Zero)
val targetOffset =
computeValue(
@@ -391,11 +435,15 @@
return@placeWithLayer
}
- val transition = elementTransition(layoutImpl, element, currentTransitions)
- if (!shouldPlaceElement(layoutImpl, content.key, element, transition)) {
+ val elementState = elementState(layoutImpl, element, currentTransitionStates)
+ if (
+ elementState == null ||
+ !shouldPlaceElement(layoutImpl, content.key, element, elementState)
+ ) {
return@placeWithLayer
}
+ val transition = elementState as? TransitionState.Transition
alpha = elementAlpha(layoutImpl, element, transition, stateInContent)
compositingStrategy = CompositingStrategy.ModulateAlpha
}
@@ -425,7 +473,9 @@
override fun ContentDrawScope.draw() {
element.wasDrawnInAnyContent = true
- val transition = elementTransition(layoutImpl, element, currentTransitions)
+ val transition =
+ elementState(layoutImpl, element, currentTransitionStates)
+ as? TransitionState.Transition
val drawScale = getDrawScale(layoutImpl, element, transition, stateInContent)
if (drawScale == Scale.Default) {
drawContent()
@@ -468,21 +518,15 @@
}
}
-/**
- * The transition that we should consider for [element]. This is the last transition where one of
- * its contents contains the element.
- */
-private fun elementTransition(
+/** The [TransitionState] that we should consider for [element]. */
+private fun elementState(
layoutImpl: SceneTransitionLayoutImpl,
element: Element,
- transitions: List<TransitionState.Transition>,
-): TransitionState.Transition? {
- val transition =
- transitions.fastLastOrNull { transition ->
- transition.fromContent in element.stateByContent ||
- transition.toContent in element.stateByContent
- }
+ transitionStates: List<TransitionState>,
+): TransitionState? {
+ val state = elementState(transitionStates, isInContent = { it in element.stateByContent })
+ val transition = state as? TransitionState.Transition
val previousTransition = element.lastTransition
element.lastTransition = transition
@@ -497,7 +541,66 @@
}
}
- return transition
+ return state
+}
+
+internal inline fun elementState(
+ transitionStates: List<TransitionState>,
+ isInContent: (ContentKey) -> Boolean,
+): TransitionState? {
+ val lastState = transitionStates.last()
+ if (lastState is TransitionState.Idle) {
+ check(transitionStates.size == 1)
+ return lastState
+ }
+
+ // Find the last transition with a content that contains the element.
+ transitionStates.fastForEachReversed { state ->
+ val transition = state as TransitionState.Transition
+ if (isInContent(transition.fromContent) || isInContent(transition.toContent)) {
+ return transition
+ }
+ }
+
+ return null
+}
+
+internal inline fun elementContentWhenIdle(
+ layoutImpl: SceneTransitionLayoutImpl,
+ idle: TransitionState.Idle,
+ isInContent: (ContentKey) -> Boolean,
+): ContentKey {
+ val currentScene = idle.currentScene
+ val overlays = idle.currentOverlays
+ return elementContentWhenIdle(layoutImpl, currentScene, overlays, isInContent)
+}
+
+private inline fun elementContentWhenIdle(
+ layoutImpl: SceneTransitionLayoutImpl,
+ currentScene: SceneKey,
+ overlays: Set<OverlayKey>,
+ isInContent: (ContentKey) -> Boolean,
+): ContentKey {
+ if (overlays.isEmpty()) {
+ return currentScene
+ }
+
+ // Find the overlay with highest zIndex that contains the element.
+ // TODO(b/353679003): Should we cache enabledOverlays into a List<> to avoid a lot of
+ // allocations here?
+ var currentOverlay: OverlayKey? = null
+ for (overlay in overlays) {
+ if (
+ isInContent(overlay) &&
+ (currentOverlay == null ||
+ (layoutImpl.overlay(overlay).zIndex >
+ layoutImpl.overlay(currentOverlay).zIndex))
+ ) {
+ currentOverlay = overlay
+ }
+ }
+
+ return currentOverlay ?: currentScene
}
private fun prepareInterruption(
@@ -693,12 +796,20 @@
layoutImpl: SceneTransitionLayoutImpl,
content: ContentKey,
element: Element,
- transition: TransitionState.Transition?,
+ elementState: TransitionState,
): Boolean {
- // Always place the element if we are idle.
- if (transition == null) {
- return true
- }
+ val transition =
+ when (elementState) {
+ is TransitionState.Idle -> {
+ return content ==
+ elementContentWhenIdle(
+ layoutImpl,
+ elementState,
+ isInContent = { it in element.stateByContent },
+ )
+ }
+ is TransitionState.Transition -> elementState
+ }
// Don't place the element in this content if this content is not part of the current element
// transition.
@@ -741,16 +852,12 @@
val scenePicker = element.contentPicker
val pickedScene =
- when (transition) {
- is TransitionState.Transition.ChangeCurrentScene -> {
- scenePicker.contentDuringTransition(
- element = element,
- transition = transition,
- fromContentZIndex = layoutImpl.scene(transition.fromScene).zIndex,
- toContentZIndex = layoutImpl.scene(transition.toScene).zIndex,
- )
- }
- }
+ scenePicker.contentDuringTransition(
+ element = element,
+ transition = transition,
+ fromContentZIndex = layoutImpl.content(transition.fromContent).zIndex,
+ toContentZIndex = layoutImpl.content(transition.toContent).zIndex,
+ )
return pickedScene == content
}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
index acb436e..3f8f5e7 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Key.kt
@@ -63,6 +63,18 @@
}
}
+/** Key for an overlay. */
+class OverlayKey(
+ debugName: String,
+ identity: Any = Object(),
+) : ContentKey(debugName, identity) {
+ override val testTag: String = "overlay:$debugName"
+
+ override fun toString(): String {
+ return "OverlayKey(debugName=$debugName)"
+ }
+}
+
/** Key for an element. */
open class ElementKey(
debugName: String,
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt
index 63d51f9..715222c 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt
@@ -26,7 +26,6 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.util.fastLastOrNull
import com.android.compose.animation.scene.content.Content
import com.android.compose.animation.scene.content.state.TransitionState
@@ -58,6 +57,13 @@
modifier: Modifier,
content: @Composable ElementScope<MovableElementContentScope>.() -> Unit,
) {
+ check(key.contentPicker.contents.contains(sceneOrOverlay.key)) {
+ val elementName = key.debugName
+ val contentName = sceneOrOverlay.key.debugName
+ "MovableElement $elementName was composed in content $contentName but the " +
+ "MovableElementKey($elementName).contentPicker.contents does not contain $contentName"
+ }
+
Box(modifier.element(layoutImpl, sceneOrOverlay, key)) {
val contentScope = sceneOrOverlay.scope
val boxScope = this
@@ -153,13 +159,20 @@
// size* as its movable content, i.e. the same *size when idle*. During transitions,
// this size will be used to interpolate the transition size, during the intermediate
// layout pass.
+ //
+ // Important: Like in Modifier.element(), we read the transition states during
+ // composition then pass them to Layout to make sure that composition sees new states
+ // before layout and drawing.
+ val transitionStates = layoutImpl.state.transitionStates
Layout { _, _ ->
// No need to measure or place anything.
val size =
placeholderContentSize(
- layoutImpl,
- contentKey,
- layoutImpl.elements.getValue(element),
+ layoutImpl = layoutImpl,
+ content = contentKey,
+ element = layoutImpl.elements.getValue(element),
+ elementKey = element,
+ transitionStates = transitionStates,
)
layout(size.width, size.height) {}
}
@@ -172,28 +185,43 @@
content: ContentKey,
element: MovableElementKey,
): Boolean {
- val transitions = layoutImpl.state.currentTransitions
- if (transitions.isEmpty()) {
- // If we are idle, there is only one [scene] that is composed so we can compose our
- // movable content here. We still check that [scene] is equal to the current idle scene, to
- // make sure we only compose it there.
- return layoutImpl.state.transitionState.currentScene == content
+ return when (
+ val elementState = movableElementState(element, layoutImpl.state.transitionStates)
+ ) {
+ null -> false
+ is TransitionState.Idle ->
+ movableElementContentWhenIdle(layoutImpl, element, elementState) == content
+ is TransitionState.Transition -> {
+ // During transitions, always compose movable elements in the scene picked by their
+ // content picker.
+ shouldPlaceOrComposeSharedElement(
+ layoutImpl,
+ content,
+ element,
+ elementState,
+ )
+ }
}
+}
- // The current transition for this element is the last transition in which either fromScene or
- // toScene contains the element.
+private fun movableElementState(
+ element: MovableElementKey,
+ transitionStates: List<TransitionState>,
+): TransitionState? {
+ val content = element.contentPicker.contents
+ return elementState(transitionStates, isInContent = { content.contains(it) })
+}
+
+private fun movableElementContentWhenIdle(
+ layoutImpl: SceneTransitionLayoutImpl,
+ element: MovableElementKey,
+ elementState: TransitionState.Idle,
+): ContentKey {
val contents = element.contentPicker.contents
- val transition =
- transitions.fastLastOrNull { transition ->
- transition.fromContent in contents || transition.toContent in contents
- } ?: return false
-
- // Always compose movable elements in the scene picked by their scene picker.
- return shouldPlaceOrComposeSharedElement(
+ return elementContentWhenIdle(
layoutImpl,
- content,
- element,
- transition,
+ elementState,
+ isInContent = { contents.contains(it) },
)
}
@@ -205,6 +233,8 @@
layoutImpl: SceneTransitionLayoutImpl,
content: ContentKey,
element: Element,
+ elementKey: MovableElementKey,
+ transitionStates: List<TransitionState>,
): IntSize {
// If the content of the movable element was already composed in this scene before, use that
// target size.
@@ -213,20 +243,21 @@
return targetValueInScene
}
- // This code is only run during transitions (otherwise the content would be composed and the
- // placeholder would not), so it's ok to cast the state into a Transition directly.
- val transition =
- layoutImpl.state.transitionState as TransitionState.Transition.ChangeCurrentScene
+ // If the element content was already composed in the other overlay/scene, we use that
+ // target size assuming it doesn't change between scenes.
+ // TODO(b/317026105): Provide a way to give a hint size/content for cases where this is
+ // not true.
+ val otherContent =
+ when (val state = movableElementState(elementKey, transitionStates)) {
+ null -> return IntSize.Zero
+ is TransitionState.Idle -> movableElementContentWhenIdle(layoutImpl, elementKey, state)
+ is TransitionState.Transition ->
+ if (state.fromContent == content) state.toContent else state.fromContent
+ }
- // If the content was already composed in the other scene, we use that target size assuming it
- // doesn't change between scenes.
- // TODO(b/317026105): Provide a way to give a hint size/content for cases where this is not
- // true.
- val otherScene =
- if (transition.fromScene == content) transition.toScene else transition.fromScene
- val targetValueInOtherScene = element.stateByContent[otherScene]?.targetSize
- if (targetValueInOtherScene != null && targetValueInOtherScene != Element.SizeUnspecified) {
- return targetValueInOtherScene
+ val targetValueInOtherContent = element.stateByContent[otherContent]?.targetSize
+ if (targetValueInOtherContent != null && targetValueInOtherContent != Element.SizeUnspecified) {
+ return targetValueInOtherContent
}
return IntSize.Zero
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt
index 5071a7f..236e202 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/ObservableTransitionState.kt
@@ -43,7 +43,9 @@
fun currentScene(): Flow<SceneKey> {
return when (this) {
is Idle -> flowOf(currentScene)
- is Transition -> currentScene
+ is Transition.ChangeCurrentScene -> currentScene
+ is Transition.ShowOrHideOverlay -> flowOf(currentScene)
+ is Transition.ReplaceOverlay -> flowOf(currentScene)
}
}
@@ -51,10 +53,11 @@
data class Idle(val currentScene: SceneKey) : ObservableTransitionState
/** There is a transition animating between two scenes. */
- class Transition(
- val fromScene: SceneKey,
- val toScene: SceneKey,
- val currentScene: Flow<SceneKey>,
+ sealed class Transition(
+ // TODO(b/353679003): Rename these to fromContent and toContent.
+ open val fromScene: ContentKey,
+ open val toScene: ContentKey,
+ val currentOverlays: Flow<Set<OverlayKey>>,
val progress: Flow<Float>,
/**
@@ -76,10 +79,10 @@
val isUserInputOngoing: Flow<Boolean>,
/** Current progress of the preview part of the transition */
- val previewProgress: Flow<Float> = flowOf(0f),
+ val previewProgress: Flow<Float>,
/** Whether the transition is currently in the preview stage or not */
- val isInPreviewStage: Flow<Boolean> = flowOf(false),
+ val isInPreviewStage: Flow<Boolean>,
) : ObservableTransitionState {
override fun toString(): String =
"""Transition
@@ -89,13 +92,109 @@
| isUserInputOngoing=$isUserInputOngoing
|)"""
.trimMargin()
+
+ /** A transition animating between [fromScene] and [toScene]. */
+ class ChangeCurrentScene(
+ override val fromScene: SceneKey,
+ override val toScene: SceneKey,
+ val currentScene: Flow<SceneKey>,
+ currentOverlays: Flow<Set<OverlayKey>>,
+ progress: Flow<Float>,
+ isInitiatedByUserInput: Boolean,
+ isUserInputOngoing: Flow<Boolean>,
+ previewProgress: Flow<Float>,
+ isInPreviewStage: Flow<Boolean>,
+ ) :
+ Transition(
+ fromScene,
+ toScene,
+ currentOverlays,
+ progress,
+ isInitiatedByUserInput,
+ isUserInputOngoing,
+ previewProgress,
+ isInPreviewStage,
+ )
+
+ /** The [overlay] is either showing from [currentScene] or hiding into [currentScene]. */
+ class ShowOrHideOverlay(
+ val overlay: OverlayKey,
+ fromContent: ContentKey,
+ toContent: ContentKey,
+ val currentScene: SceneKey,
+ currentOverlays: Flow<Set<OverlayKey>>,
+ progress: Flow<Float>,
+ isInitiatedByUserInput: Boolean,
+ isUserInputOngoing: Flow<Boolean>,
+ previewProgress: Flow<Float>,
+ isInPreviewStage: Flow<Boolean>,
+ ) :
+ Transition(
+ fromContent,
+ toContent,
+ currentOverlays,
+ progress,
+ isInitiatedByUserInput,
+ isUserInputOngoing,
+ previewProgress,
+ isInPreviewStage,
+ )
+
+ /** We are transitioning from [fromOverlay] to [toOverlay]. */
+ class ReplaceOverlay(
+ val fromOverlay: OverlayKey,
+ val toOverlay: OverlayKey,
+ val currentScene: SceneKey,
+ currentOverlays: Flow<Set<OverlayKey>>,
+ progress: Flow<Float>,
+ isInitiatedByUserInput: Boolean,
+ isUserInputOngoing: Flow<Boolean>,
+ previewProgress: Flow<Float>,
+ isInPreviewStage: Flow<Boolean>,
+ ) :
+ Transition(
+ fromOverlay,
+ toOverlay,
+ currentOverlays,
+ progress,
+ isInitiatedByUserInput,
+ isUserInputOngoing,
+ previewProgress,
+ isInPreviewStage,
+ )
+
+ companion object {
+ operator fun invoke(
+ fromScene: SceneKey,
+ toScene: SceneKey,
+ currentScene: Flow<SceneKey>,
+ progress: Flow<Float>,
+ isInitiatedByUserInput: Boolean,
+ isUserInputOngoing: Flow<Boolean>,
+ previewProgress: Flow<Float> = flowOf(0f),
+ isInPreviewStage: Flow<Boolean> = flowOf(false),
+ currentOverlays: Flow<Set<OverlayKey>> = flowOf(emptySet()),
+ ): ChangeCurrentScene {
+ return ChangeCurrentScene(
+ fromScene,
+ toScene,
+ currentScene,
+ currentOverlays,
+ progress,
+ isInitiatedByUserInput,
+ isUserInputOngoing,
+ previewProgress,
+ isInPreviewStage,
+ )
+ }
+ }
}
fun isIdle(scene: SceneKey?): Boolean {
return this is Idle && (scene == null || this.currentScene == scene)
}
- fun isTransitioning(from: SceneKey? = null, to: SceneKey? = null): Boolean {
+ fun isTransitioning(from: ContentKey? = null, to: ContentKey? = null): Boolean {
return this is Transition &&
(from == null || this.fromScene == from) &&
(to == null || this.toScene == to)
@@ -112,15 +211,44 @@
when (val state = transitionState) {
is TransitionState.Idle -> ObservableTransitionState.Idle(state.currentScene)
is TransitionState.Transition.ChangeCurrentScene -> {
- ObservableTransitionState.Transition(
+ ObservableTransitionState.Transition.ChangeCurrentScene(
fromScene = state.fromScene,
toScene = state.toScene,
currentScene = snapshotFlow { state.currentScene },
+ currentOverlays = flowOf(state.currentOverlays),
progress = snapshotFlow { state.progress },
isInitiatedByUserInput = state.isInitiatedByUserInput,
isUserInputOngoing = snapshotFlow { state.isUserInputOngoing },
previewProgress = snapshotFlow { state.previewProgress },
- isInPreviewStage = snapshotFlow { state.isInPreviewStage }
+ isInPreviewStage = snapshotFlow { state.isInPreviewStage },
+ )
+ }
+ is TransitionState.Transition.ShowOrHideOverlay -> {
+ check(state.fromOrToScene == state.currentScene)
+ ObservableTransitionState.Transition.ShowOrHideOverlay(
+ overlay = state.overlay,
+ fromContent = state.fromContent,
+ toContent = state.toContent,
+ currentScene = state.currentScene,
+ currentOverlays = snapshotFlow { state.currentOverlays },
+ progress = snapshotFlow { state.progress },
+ isInitiatedByUserInput = state.isInitiatedByUserInput,
+ isUserInputOngoing = snapshotFlow { state.isUserInputOngoing },
+ previewProgress = snapshotFlow { state.previewProgress },
+ isInPreviewStage = snapshotFlow { state.isInPreviewStage },
+ )
+ }
+ is TransitionState.Transition.ReplaceOverlay -> {
+ ObservableTransitionState.Transition.ReplaceOverlay(
+ fromOverlay = state.fromOverlay,
+ toOverlay = state.toOverlay,
+ currentScene = state.currentScene,
+ currentOverlays = snapshotFlow { state.currentOverlays },
+ progress = snapshotFlow { state.progress },
+ isInitiatedByUserInput = state.isInitiatedByUserInput,
+ isUserInputOngoing = snapshotFlow { state.isUserInputOngoing },
+ previewProgress = snapshotFlow { state.previewProgress },
+ isInPreviewStage = snapshotFlow { state.isInPreviewStage },
)
}
}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index 65a7367..aaa2546 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -49,7 +49,7 @@
* if any.
* @param transitionInterceptionThreshold used during a scene transition. For the scene to be
* intercepted, the progress value must be above the threshold, and below (1 - threshold).
- * @param scenes the configuration of the different scenes of this layout.
+ * @param builder the configuration of the different scenes and overlays of this layout.
*/
@Composable
fun SceneTransitionLayout(
@@ -58,7 +58,7 @@
swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
swipeDetector: SwipeDetector = DefaultSwipeDetector,
@FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0.05f,
- scenes: SceneTransitionLayoutScope.() -> Unit,
+ builder: SceneTransitionLayoutScope.() -> Unit,
) {
SceneTransitionLayoutForTesting(
state,
@@ -67,7 +67,7 @@
swipeDetector,
transitionInterceptionThreshold,
onLayoutImpl = null,
- scenes,
+ builder,
)
}
@@ -86,6 +86,31 @@
userActions: Map<UserAction, UserActionResult> = emptyMap(),
content: @Composable ContentScope.() -> Unit,
)
+
+ /**
+ * Add an overlay to this layout, identified by [key].
+ *
+ * Overlays are displayed above scenes and can be toggled using
+ * [MutableSceneTransitionLayoutState.showOverlay] and
+ * [MutableSceneTransitionLayoutState.hideOverlay].
+ *
+ * Overlays will have a maximum size that is the size of the layout without overlays, i.e. an
+ * overlay can be fillMaxSize() to match the layout size but it won't make the layout bigger.
+ *
+ * By default overlays are centered in their layout but they can be aligned differently using
+ * [alignment].
+ *
+ * Important: overlays must be defined after all scenes. Overlay order along the z-axis follows
+ * call order. Calling overlay(A) followed by overlay(B) will mean that overlay B renders
+ * after/above overlay A.
+ */
+ // TODO(b/353679003): Allow to specify user actions. When overlays are shown, the user actions
+ // of the top-most overlay in currentOverlays will be used.
+ fun overlay(
+ key: OverlayKey,
+ alignment: Alignment = Alignment.Center,
+ content: @Composable ContentScope.() -> Unit,
+ )
}
/**
@@ -239,7 +264,7 @@
/**
* Animate some value at the content level.
*
- * @param value the value of this shared value in the current scene.
+ * @param value the value of this shared value in the current content.
* @param key the key of this shared value.
* @param type the [SharedValueType] of this animated value.
* @param canOverflow whether this value can overflow past the values it is interpolated
@@ -292,7 +317,7 @@
/**
* Animate some value associated to this element.
*
- * @param value the value of this shared value in the current scene.
+ * @param value the value of this shared value in the current content.
* @param key the key of this shared value.
* @param type the [SharedValueType] of this animated value.
* @param canOverflow whether this value can overflow past the values it is interpolated
@@ -509,7 +534,7 @@
swipeDetector: SwipeDetector = DefaultSwipeDetector,
transitionInterceptionThreshold: Float = 0f,
onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null,
- scenes: SceneTransitionLayoutScope.() -> Unit,
+ builder: SceneTransitionLayoutScope.() -> Unit,
) {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
@@ -521,7 +546,7 @@
layoutDirection = layoutDirection,
swipeSourceDetector = swipeSourceDetector,
transitionInterceptionThreshold = transitionInterceptionThreshold,
- builder = scenes,
+ builder = builder,
coroutineScope = coroutineScope,
)
.also { onLayoutImpl?.invoke(it) }
@@ -529,7 +554,7 @@
// TODO(b/317014852): Move this into the SideEffect {} again once STLImpl.scenes is not a
// SnapshotStateMap anymore.
- layoutImpl.updateScenes(scenes, layoutDirection)
+ layoutImpl.updateContents(builder, layoutDirection)
SideEffect {
if (state != layoutImpl.state) {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
index 392ff7e..21f11e4 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -18,10 +18,12 @@
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshots.SnapshotStateMap
+import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ApproachLayoutModifierNode
@@ -36,7 +38,9 @@
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastForEachReversed
+import androidx.compose.ui.zIndex
import com.android.compose.animation.scene.content.Content
+import com.android.compose.animation.scene.content.Overlay
import com.android.compose.animation.scene.content.Scene
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.ui.util.lerp
@@ -60,7 +64,17 @@
*
* TODO(b/317014852): Make this a normal MutableMap instead.
*/
- internal val scenes = SnapshotStateMap<SceneKey, Scene>()
+ private val scenes = SnapshotStateMap<SceneKey, Scene>()
+
+ /**
+ * The map of [Overlays].
+ *
+ * Note: We lazily create this map to avoid instantiation an expensive SnapshotStateMap in the
+ * common case where there is no overlay in this layout.
+ */
+ private var _overlays: MutableMap<OverlayKey, Overlay>? = null
+ private val overlays
+ get() = _overlays ?: SnapshotStateMap<OverlayKey, Overlay>().also { _overlays = it }
/**
* The map of [Element]s.
@@ -119,7 +133,7 @@
private set
init {
- updateScenes(builder, layoutDirection)
+ updateContents(builder, layoutDirection)
// DraggableHandlerImpl must wait for the scenes to be initialized, in order to access the
// current scene (required for SwipeTransition).
@@ -152,22 +166,32 @@
return scenes[key] ?: error("Scene $key is not configured")
}
+ internal fun sceneOrNull(key: SceneKey): Scene? = scenes[key]
+
+ internal fun overlay(key: OverlayKey): Overlay {
+ return overlays[key] ?: error("Overlay $key is not configured")
+ }
+
internal fun content(key: ContentKey): Content {
return when (key) {
is SceneKey -> scene(key)
+ is OverlayKey -> overlay(key)
}
}
- internal fun updateScenes(
+ internal fun updateContents(
builder: SceneTransitionLayoutScope.() -> Unit,
layoutDirection: LayoutDirection,
) {
- // Keep a reference of the current scenes. After processing [builder], the scenes that were
- // not configured will be removed.
+ // Keep a reference of the current contents. After processing [builder], the contents that
+ // were not configured will be removed.
val scenesToRemove = scenes.keys.toMutableSet()
+ val overlaysToRemove =
+ if (_overlays == null) mutableSetOf() else overlays.keys.toMutableSet()
// The incrementing zIndex of each scene.
var zIndex = 0f
+ var overlaysDefined = false
object : SceneTransitionLayoutScope {
override fun scene(
@@ -175,6 +199,8 @@
userActions: Map<UserAction, UserActionResult>,
content: @Composable ContentScope.() -> Unit,
) {
+ require(!overlaysDefined) { "all scenes must be defined before overlays" }
+
scenesToRemove.remove(key)
val resolvedUserActions =
@@ -199,10 +225,42 @@
zIndex++
}
+
+ override fun overlay(
+ key: OverlayKey,
+ alignment: Alignment,
+ content: @Composable (ContentScope.() -> Unit)
+ ) {
+ overlaysDefined = true
+ overlaysToRemove.remove(key)
+
+ val overlay = overlays[key]
+ if (overlay != null) {
+ // Update an existing overlay.
+ overlay.content = content
+ overlay.zIndex = zIndex
+ overlay.alignment = alignment
+ } else {
+ // New overlay.
+ overlays[key] =
+ Overlay(
+ key,
+ this@SceneTransitionLayoutImpl,
+ content,
+ // TODO(b/353679003): Allow to specify user actions
+ actions = emptyMap(),
+ zIndex,
+ alignment,
+ )
+ }
+
+ zIndex++
+ }
}
.builder()
scenesToRemove.forEach { scenes.remove(it) }
+ overlaysToRemove.forEach { overlays.remove(it) }
}
@Composable
@@ -220,8 +278,8 @@
lookaheadScope = this
BackHandler()
-
- scenesToCompose().fastForEach { scene -> key(scene.key) { scene.Content() } }
+ Scenes()
+ Overlays()
}
}
}
@@ -233,6 +291,11 @@
PredictiveBackHandler(state, coroutineScope, targetSceneForBack)
}
+ @Composable
+ private fun Scenes() {
+ scenesToCompose().fastForEach { scene -> key(scene.key) { scene.Content() } }
+ }
+
private fun scenesToCompose(): List<Scene> {
val transitions = state.currentTransitions
return if (transitions.isEmpty()) {
@@ -253,15 +316,74 @@
maybeAdd(transition.toScene)
maybeAdd(transition.fromScene)
}
+ is TransitionState.Transition.ShowOrHideOverlay ->
+ maybeAdd(transition.fromOrToScene)
+ is TransitionState.Transition.ReplaceOverlay -> {}
}
}
+
+ // Make sure that the current scene is always composed.
+ maybeAdd(transitions.last().currentScene)
}
}
}
+ @Composable
+ private fun BoxScope.Overlays() {
+ val overlaysOrderedByZIndex = overlaysToComposeOrderedByZIndex()
+ if (overlaysOrderedByZIndex.isEmpty()) {
+ return
+ }
+
+ // We put the overlays inside a Box that is matching the layout size so that overlays are
+ // measured after all scenes and that their max size is the size of the layout without the
+ // overlays.
+ Box(Modifier.matchParentSize().zIndex(overlaysOrderedByZIndex.first().zIndex)) {
+ overlaysOrderedByZIndex.fastForEach { overlay ->
+ key(overlay.key) { overlay.Content(Modifier.align(overlay.alignment)) }
+ }
+ }
+ }
+
+ private fun overlaysToComposeOrderedByZIndex(): List<Overlay> {
+ if (_overlays == null) return emptyList()
+
+ val transitions = state.currentTransitions
+ return if (transitions.isEmpty()) {
+ state.transitionState.currentOverlays.map { overlay(it) }
+ } else {
+ buildList {
+ val visited = mutableSetOf<OverlayKey>()
+ fun maybeAdd(key: OverlayKey) {
+ if (visited.add(key)) {
+ add(overlay(key))
+ }
+ }
+
+ transitions.fastForEach { transition ->
+ when (transition) {
+ is TransitionState.Transition.ChangeCurrentScene -> {}
+ is TransitionState.Transition.ShowOrHideOverlay ->
+ maybeAdd(transition.overlay)
+ is TransitionState.Transition.ReplaceOverlay -> {
+ maybeAdd(transition.fromOverlay)
+ maybeAdd(transition.toOverlay)
+ }
+ }
+ }
+
+ // Make sure that all current overlays are composed.
+ transitions.last().currentOverlays.forEach { maybeAdd(it) }
+ }
+ }
+ .sortedBy { it.zIndex }
+ }
+
internal fun setScenesTargetSizeForTest(size: IntSize) {
scenes.values.forEach { it.targetSize = size }
}
+
+ internal fun overlaysOrNullForTest(): Map<OverlayKey, Overlay>? = _overlays
}
private data class LayoutElement(private val layoutImpl: SceneTransitionLayoutImpl) :
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 f37ded0..74cd136 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
@@ -39,6 +39,21 @@
@Stable
sealed interface SceneTransitionLayoutState {
/**
+ * The current effective scene. If a new transition is triggered, it will start from this scene.
+ */
+ val currentScene: SceneKey
+
+ /**
+ * The current set of overlays. This represents the set of overlays that will be visible on
+ * screen once all [currentTransitions] are finished.
+ *
+ * @see MutableSceneTransitionLayoutState.showOverlay
+ * @see MutableSceneTransitionLayoutState.hideOverlay
+ * @see MutableSceneTransitionLayoutState.replaceOverlay
+ */
+ val currentOverlays: Set<OverlayKey>
+
+ /**
* The current [TransitionState]. All values read here are backed by the Snapshot system.
*
* To observe those values outside of Compose/the Snapshot system, use
@@ -110,7 +125,50 @@
): TransitionState.Transition?
/** Immediately snap to the given [scene]. */
- fun snapToScene(scene: SceneKey)
+ fun snapToScene(
+ scene: SceneKey,
+ currentOverlays: Set<OverlayKey> = transitionState.currentOverlays,
+ )
+
+ /**
+ * Request to show [overlay] so that it animates in from [currentScene] and ends up being
+ * visible on screen.
+ *
+ * After this returns, this overlay will be included in [currentOverlays]. This does nothing if
+ * [overlay] is already in [currentOverlays].
+ */
+ fun showOverlay(
+ overlay: OverlayKey,
+ animationScope: CoroutineScope,
+ transitionKey: TransitionKey? = null,
+ )
+
+ /**
+ * Request to hide [overlay] so that it animates out to [currentScene] and ends up *not* being
+ * visible on screen.
+ *
+ * After this returns, this overlay will not be included in [currentOverlays]. This does nothing
+ * if [overlay] is not in [currentOverlays].
+ */
+ fun hideOverlay(
+ overlay: OverlayKey,
+ animationScope: CoroutineScope,
+ transitionKey: TransitionKey? = null,
+ )
+
+ /**
+ * Replace [from] by [to] so that [from] ends up not being visible on screen and [to] ends up
+ * being visible.
+ *
+ * This throws if [from] is not currently in [currentOverlays] or if [to] is already in
+ * [currentOverlays].
+ */
+ fun replaceOverlay(
+ from: OverlayKey,
+ to: OverlayKey,
+ animationScope: CoroutineScope,
+ transitionKey: TransitionKey? = null,
+ )
}
/**
@@ -128,6 +186,7 @@
fun MutableSceneTransitionLayoutState(
initialScene: SceneKey,
transitions: SceneTransitions = SceneTransitions.Empty,
+ initialOverlays: Set<OverlayKey> = emptySet(),
canChangeScene: (SceneKey) -> Boolean = { true },
stateLinks: List<StateLink> = emptyList(),
enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
@@ -135,6 +194,7 @@
return MutableSceneTransitionLayoutStateImpl(
initialScene,
transitions,
+ initialOverlays,
canChangeScene,
stateLinks,
enableInterruptions,
@@ -145,6 +205,7 @@
internal class MutableSceneTransitionLayoutStateImpl(
initialScene: SceneKey,
override var transitions: SceneTransitions = transitions {},
+ initialOverlays: Set<OverlayKey> = emptySet(),
internal val canChangeScene: (SceneKey) -> Boolean = { true },
private val stateLinks: List<StateLink> = emptyList(),
@@ -158,13 +219,18 @@
* 1. A list with a single [TransitionState.Idle] element, when we are idle.
* 2. A list with one or more [TransitionState.Transition], when we are transitioning.
*/
- @VisibleForTesting
internal var transitionStates: List<TransitionState> by
- mutableStateOf(listOf(TransitionState.Idle(initialScene)))
+ mutableStateOf(listOf(TransitionState.Idle(initialScene, initialOverlays)))
private set
+ override val currentScene: SceneKey
+ get() = transitionState.currentScene
+
+ override val currentOverlays: Set<OverlayKey>
+ get() = transitionState.currentOverlays
+
override val transitionState: TransitionState
- get() = transitionStates.last()
+ get() = transitionStates[transitionStates.lastIndex]
override val currentTransitions: List<TransitionState.Transition>
get() {
@@ -233,6 +299,11 @@
) {
checkThread()
+ // Set the current scene and overlays on the transition.
+ val currentState = transitionState
+ transition.currentSceneWhenTransitionStarted = currentState.currentScene
+ transition.currentOverlaysWhenTransitionStarted = currentState.currentOverlays
+
// Compute the [TransformationSpec] when the transition starts.
val fromScene = transition.fromScene
val toScene = transition.toScene
@@ -356,6 +427,7 @@
transition.activeTransitionLinks[stateLink] = linkedTransition
}
}
+ else -> error("transition links are not supported with overlays yet")
}
}
@@ -408,23 +480,28 @@
// If all transitions are finished, we are idle.
if (i == nStates) {
check(finishedTransitions.isEmpty())
- this.transitionStates = listOf(TransitionState.Idle(lastTransition.currentScene))
+ this.transitionStates =
+ listOf(
+ TransitionState.Idle(
+ lastTransition.currentScene,
+ lastTransition.currentOverlays,
+ )
+ )
} else if (i > 0) {
this.transitionStates = transitionStates.subList(fromIndex = i, toIndex = nStates)
}
}
- override fun snapToScene(scene: SceneKey) {
+ override fun snapToScene(scene: SceneKey, currentOverlays: Set<OverlayKey>) {
checkThread()
// Force finish all transitions.
while (currentTransitions.isNotEmpty()) {
- val transition = transitionStates[0] as TransitionState.Transition
- finishTransition(transition)
+ finishTransition(transitionStates[0] as TransitionState.Transition)
}
check(transitionStates.size == 1)
- transitionStates = listOf(TransitionState.Idle(scene))
+ transitionStates = listOf(TransitionState.Idle(scene, currentOverlays))
}
private fun finishActiveTransitionLinks(transition: TransitionState.Transition) {
@@ -466,6 +543,57 @@
false
}
}
+
+ override fun showOverlay(
+ overlay: OverlayKey,
+ animationScope: CoroutineScope,
+ transitionKey: TransitionKey?
+ ) {
+ checkThread()
+
+ // Overlay is already shown, do nothing.
+ if (overlay in transitionState.currentOverlays) {
+ return
+ }
+
+ // TODO(b/353679003): Animate the overlay instead of instantly snapping to an Idle state.
+ snapToScene(transitionState.currentScene, transitionState.currentOverlays + overlay)
+ }
+
+ override fun hideOverlay(
+ overlay: OverlayKey,
+ animationScope: CoroutineScope,
+ transitionKey: TransitionKey?
+ ) {
+ checkThread()
+
+ // Overlay is not shown, do nothing.
+ if (!transitionState.currentOverlays.contains(overlay)) {
+ return
+ }
+
+ // TODO(b/353679003): Animate the overlay instead of instantly snapping to an Idle state.
+ snapToScene(transitionState.currentScene, transitionState.currentOverlays - overlay)
+ }
+
+ override fun replaceOverlay(
+ from: OverlayKey,
+ to: OverlayKey,
+ animationScope: CoroutineScope,
+ transitionKey: TransitionKey?
+ ) {
+ checkThread()
+ require(from in currentOverlays) {
+ "Overlay ${from.debugName} is not shown so it can't be replaced by ${to.debugName}"
+ }
+ require(to !in currentOverlays) {
+ "Overlay ${to.debugName} is already shown so it can't replace ${from.debugName}"
+ }
+
+ // TODO(b/353679003): Animate from into to instead of hiding/showing the overlays
+ // separately.
+ snapToScene(transitionState.currentScene, transitionState.currentOverlays - from + to)
+ }
}
private const val TAG = "SceneTransitionLayoutState"
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt
index 0f66804..9851b32 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/UserActionDistanceScopeImpl.kt
@@ -35,7 +35,7 @@
}
override fun SceneKey.targetSize(): IntSize? {
- return layoutImpl.scenes[this]?.targetSize.takeIf { it != IntSize.Zero }
+ return layoutImpl.sceneOrNull(this)?.targetSize.takeIf { it != IntSize.Zero }
}
}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Overlay.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Overlay.kt
new file mode 100644
index 0000000..ccec9e8
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Overlay.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.content
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.OverlayKey
+import com.android.compose.animation.scene.SceneTransitionLayoutImpl
+import com.android.compose.animation.scene.UserAction
+import com.android.compose.animation.scene.UserActionResult
+
+/** An overlay defined in a [SceneTransitionLayout]. */
+@Stable
+internal class Overlay(
+ override val key: OverlayKey,
+ layoutImpl: SceneTransitionLayoutImpl,
+ content: @Composable ContentScope.() -> Unit,
+ actions: Map<UserAction.Resolved, UserActionResult>,
+ zIndex: Float,
+ alignment: Alignment,
+) : Content(key, layoutImpl, content, actions, zIndex) {
+ var alignment by mutableStateOf(alignment)
+
+ override fun toString(): String {
+ return "Overlay(key=$key)"
+ }
+}
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 22df34b..fdb019f 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
@@ -21,7 +21,11 @@
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
import com.android.compose.animation.scene.ContentKey
+import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
+import com.android.compose.animation.scene.OverlayKey
import com.android.compose.animation.scene.OverscrollScope
import com.android.compose.animation.scene.OverscrollSpecImpl
import com.android.compose.animation.scene.ProgressVisibilityThreshold
@@ -49,9 +53,20 @@
*/
val currentScene: SceneKey
+ /**
+ * The current set of overlays. This represents the set of overlays that will be visible on
+ * screen once all transitions are finished.
+ *
+ * @see MutableSceneTransitionLayoutState.showOverlay
+ * @see MutableSceneTransitionLayoutState.hideOverlay
+ * @see MutableSceneTransitionLayoutState.replaceOverlay
+ */
+ val currentOverlays: Set<OverlayKey>
+
/** The scene [currentScene] is idle. */
data class Idle(
override val currentScene: SceneKey,
+ override val currentOverlays: Set<OverlayKey> = emptySet(),
) : TransitionState
sealed class Transition(
@@ -69,7 +84,125 @@
/** The transition that `this` transition is replacing, if any. */
replacedTransition: Transition? = null,
- ) : Transition(fromScene, toScene, replacedTransition)
+ ) : Transition(fromScene, toScene, replacedTransition) {
+ final override val currentOverlays: Set<OverlayKey>
+ get() {
+ // The set of overlays does not change in a [ChangeCurrentScene] transition.
+ return currentOverlaysWhenTransitionStarted
+ }
+ }
+
+ /**
+ * A transition that is animating one or more overlays and for which [currentOverlays] will
+ * change over the course of the transition.
+ */
+ sealed class OverlayTransition(
+ fromContent: ContentKey,
+ toContent: ContentKey,
+ replacedTransition: Transition?,
+ ) : Transition(fromContent, toContent, replacedTransition) {
+ final override val currentScene: SceneKey
+ get() {
+ // The current scene does not change during overlay transitions.
+ return currentSceneWhenTransitionStarted
+ }
+
+ // Note: We use deriveStateOf() so that the computed set is cached and reused when the
+ // inputs of the computations don't change, to avoid recomputing and allocating a new
+ // set every time currentOverlays is called (which is every frame and for each element).
+ final override val currentOverlays: Set<OverlayKey> by derivedStateOf {
+ computeCurrentOverlays()
+ }
+
+ protected abstract fun computeCurrentOverlays(): Set<OverlayKey>
+ }
+
+ /** The [overlay] is either showing from [fromOrToScene] or hiding into [fromOrToScene]. */
+ abstract class ShowOrHideOverlay(
+ val overlay: OverlayKey,
+ val fromOrToScene: SceneKey,
+ fromContent: ContentKey,
+ toContent: ContentKey,
+ replacedTransition: Transition? = null,
+ ) : OverlayTransition(fromContent, toContent, replacedTransition) {
+ /**
+ * Whether [overlay] is effectively shown. For instance, this will be `false` when
+ * starting a swipe transition to show [overlay] and will be `true` only once the swipe
+ * transition is committed.
+ */
+ protected abstract val isEffectivelyShown: Boolean
+
+ init {
+ check(
+ (fromContent == fromOrToScene && toContent == overlay) ||
+ (fromContent == overlay && toContent == fromOrToScene)
+ )
+ }
+
+ final override fun computeCurrentOverlays(): Set<OverlayKey> {
+ return if (isEffectivelyShown) {
+ currentOverlaysWhenTransitionStarted + overlay
+ } else {
+ currentOverlaysWhenTransitionStarted - overlay
+ }
+ }
+ }
+
+ /** We are transitioning from [fromOverlay] to [toOverlay]. */
+ abstract class ReplaceOverlay(
+ val fromOverlay: OverlayKey,
+ val toOverlay: OverlayKey,
+ replacedTransition: Transition? = null,
+ ) :
+ OverlayTransition(
+ fromContent = fromOverlay,
+ toContent = toOverlay,
+ replacedTransition,
+ ) {
+ /**
+ * The current effective overlay, either [fromOverlay] or [toOverlay]. For instance,
+ * this will be [fromOverlay] when starting a swipe transition that replaces
+ * [fromOverlay] by [toOverlay] and will [toOverlay] once the swipe transition is
+ * committed.
+ */
+ protected abstract val effectivelyShownOverlay: OverlayKey
+
+ init {
+ check(fromOverlay != toOverlay)
+ }
+
+ final override fun computeCurrentOverlays(): Set<OverlayKey> {
+ return when (effectivelyShownOverlay) {
+ fromOverlay ->
+ computeCurrentOverlays(include = fromOverlay, exclude = toOverlay)
+ toOverlay -> computeCurrentOverlays(include = toOverlay, exclude = fromOverlay)
+ else ->
+ error(
+ "effectivelyShownOverlay=$effectivelyShownOverlay, should be " +
+ "equal to fromOverlay=$fromOverlay or toOverlay=$toOverlay"
+ )
+ }
+ }
+
+ private fun computeCurrentOverlays(
+ include: OverlayKey,
+ exclude: OverlayKey
+ ): Set<OverlayKey> {
+ return buildSet {
+ addAll(currentOverlaysWhenTransitionStarted)
+ remove(exclude)
+ add(include)
+ }
+ }
+ }
+
+ /**
+ * The current scene and overlays observed right when this transition started. These are set
+ * when this transition is started in
+ * [com.android.compose.animation.scene.MutableSceneTransitionLayoutStateImpl.startTransition].
+ */
+ internal lateinit var currentSceneWhenTransitionStarted: SceneKey
+ internal lateinit var currentOverlaysWhenTransitionStarted: Set<OverlayKey>
/**
* The key of this transition. This should usually be null, but it can be specified to use a
@@ -163,6 +296,11 @@
isTransitioning(from = other, to = content)
}
+ /** Whether we are transitioning from or to [content]. */
+ fun isTransitioningFromOrTo(content: ContentKey): Boolean {
+ return fromContent == content || toContent == content
+ }
+
/**
* Force this transition to finish and animate to an [Idle] state.
*
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt
index 01895c9..8ebb42a 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/AnimatedSharedAsStateTest.kt
@@ -168,10 +168,7 @@
assertThat(lastValueInTo).isEqualTo(expectedValues)
}
- after {
- assertThat(lastValueInFrom).isEqualTo(toValues)
- assertThat(lastValueInTo).isEqualTo(toValues)
- }
+ after { assertThat(lastValueInTo).isEqualTo(toValues) }
}
}
@@ -229,10 +226,7 @@
assertThat(lastValueInTo).isEqualTo(lerp(fromValues, toValues, fraction = 0.75f))
}
- after {
- assertThat(lastValueInFrom).isEqualTo(toValues)
- assertThat(lastValueInTo).isEqualTo(toValues)
- }
+ after { assertThat(lastValueInTo).isEqualTo(toValues) }
}
}
@@ -288,10 +282,7 @@
assertThat(lastValueInTo).isEqualTo(expectedValues)
}
- after {
- assertThat(lastValueInFrom).isEqualTo(toValues)
- assertThat(lastValueInTo).isEqualTo(toValues)
- }
+ after { assertThat(lastValueInTo).isEqualTo(toValues) }
}
}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
index 72a16b7..25be3f9 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
@@ -65,19 +65,19 @@
var layoutDirection = LayoutDirection.Rtl
set(value) {
field = value
- layoutImpl.updateScenes(scenesBuilder, layoutDirection)
+ layoutImpl.updateContents(scenesBuilder, layoutDirection)
}
var mutableUserActionsA = mapOf(Swipe.Up to SceneB, Swipe.Down to SceneC)
set(value) {
field = value
- layoutImpl.updateScenes(scenesBuilder, layoutDirection)
+ layoutImpl.updateContents(scenesBuilder, layoutDirection)
}
var mutableUserActionsB = mapOf(Swipe.Up to SceneC, Swipe.Down to SceneA)
set(value) {
field = value
- layoutImpl.updateScenes(scenesBuilder, layoutDirection)
+ layoutImpl.updateContents(scenesBuilder, layoutDirection)
}
private val scenesBuilder: SceneTransitionLayoutScope.() -> Unit = {
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt
index b7f50fd..a549d03 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MovableElementTest.kt
@@ -106,7 +106,7 @@
rule
.onNode(
hasText("count: 3") and
- hasParent(isElement(TestElements.Foo, scene = SceneA))
+ hasParent(isElement(TestElements.Foo, content = SceneA))
)
.assertExists()
.assertIsNotDisplayed()
@@ -114,7 +114,7 @@
rule
.onNode(
hasText("count: 0") and
- hasParent(isElement(TestElements.Foo, scene = SceneB))
+ hasParent(isElement(TestElements.Foo, content = SceneB))
)
.assertIsDisplayed()
.assertSizeIsEqualTo(75.dp, 75.dp)
@@ -213,7 +213,7 @@
rule
.onNode(
hasText("count: 3") and
- hasParent(isElement(TestElements.Foo, scene = SceneA))
+ hasParent(isElement(TestElements.Foo, content = SceneA))
)
.assertIsDisplayed()
.assertSizeIsEqualTo(75.dp, 75.dp)
@@ -234,7 +234,7 @@
rule
.onNode(
hasText("count: 3") and
- hasParent(isElement(TestElements.Foo, scene = SceneB))
+ hasParent(isElement(TestElements.Foo, content = SceneB))
)
.assertIsDisplayed()
@@ -324,7 +324,7 @@
fun movableElementScopeExtendsBoxScope() {
val key = MovableElementKey("Foo", contents = setOf(SceneA))
rule.setContent {
- TestContentScope {
+ TestContentScope(currentScene = SceneA) {
MovableElement(key, Modifier.size(200.dp)) {
content {
Box(Modifier.testTag("bottomEnd").align(Alignment.BottomEnd))
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt
new file mode 100644
index 0000000..d4391e0
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/OverlayTest.kt
@@ -0,0 +1,319 @@
+/*
+ * 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 androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestOverlays.OverlayA
+import com.android.compose.animation.scene.TestOverlays.OverlayB
+import com.android.compose.animation.scene.TestScenes.SceneA
+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 OverlayTest {
+ @get:Rule val rule = createComposeRule()
+
+ @Composable
+ private fun ContentScope.Foo() {
+ Box(Modifier.element(TestElements.Foo).size(100.dp))
+ }
+
+ @Test
+ fun showThenHideOverlay() {
+ val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) }
+ lateinit var coroutineScope: CoroutineScope
+ rule.setContent {
+ coroutineScope = rememberCoroutineScope()
+ SceneTransitionLayout(state, Modifier.size(200.dp)) {
+ scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo() } }
+ overlay(OverlayA) { Foo() }
+ }
+ }
+
+ // Initial state: overlay A is not shown, so Foo is displayed at the top left in scene A.
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertIsDisplayed()
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
+
+ // Show overlay A: Foo is now centered on screen and placed in overlay A. It is not placed
+ // in scene A.
+ rule.runOnUiThread { state.showOverlay(OverlayA, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertExists()
+ .assertIsNotDisplayed()
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+
+ // Hide overlay A: back to initial state, top-left in scene A.
+ rule.runOnUiThread { state.hideOverlay(OverlayA, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertIsDisplayed()
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
+ }
+
+ @Test
+ fun multipleOverlays() {
+ val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) }
+ lateinit var coroutineScope: CoroutineScope
+ rule.setContent {
+ coroutineScope = rememberCoroutineScope()
+ SceneTransitionLayout(state, Modifier.size(200.dp)) {
+ scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo() } }
+ overlay(OverlayA) { Foo() }
+ overlay(OverlayB) { Foo() }
+ }
+ }
+
+ // Initial state.
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertIsDisplayed()
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
+ rule.onNode(isElement(TestElements.Foo, content = OverlayB)).assertDoesNotExist()
+
+ // Show overlay A.
+ rule.runOnUiThread { state.showOverlay(OverlayA, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertExists()
+ .assertIsNotDisplayed()
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+ rule.onNode(isElement(TestElements.Foo, content = OverlayB)).assertDoesNotExist()
+
+ // Replace overlay A by overlay B.
+ rule.runOnUiThread { state.replaceOverlay(OverlayA, OverlayB, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertExists()
+ .assertIsNotDisplayed()
+ rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayB))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+
+ // Show overlay A: Foo is still placed in B because it has a higher zIndex, but it now
+ // exists in A as well.
+ rule.runOnUiThread { state.showOverlay(OverlayA, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertExists()
+ .assertIsNotDisplayed()
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertExists()
+ .assertIsNotDisplayed()
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayB))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+
+ // Hide overlay B.
+ rule.runOnUiThread { state.hideOverlay(OverlayB, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertExists()
+ .assertIsNotDisplayed()
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+ rule.onNode(isElement(TestElements.Foo, content = OverlayB)).assertDoesNotExist()
+
+ // Hide overlay A.
+ rule.runOnUiThread { state.hideOverlay(OverlayA, coroutineScope) }
+ rule
+ .onNode(isElement(TestElements.Foo, content = SceneA))
+ .assertIsDisplayed()
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ rule.onNode(isElement(TestElements.Foo, content = OverlayA)).assertDoesNotExist()
+ rule.onNode(isElement(TestElements.Foo, content = OverlayB)).assertDoesNotExist()
+ }
+
+ @Test
+ fun movableElement() {
+ val key = MovableElementKey("MovableBar", contents = setOf(SceneA, OverlayA, OverlayB))
+ val elementChildTag = "elementChildTag"
+
+ fun elementChild(content: ContentKey) = hasTestTag(elementChildTag) and inContent(content)
+
+ @Composable
+ fun ContentScope.MovableBar() {
+ MovableElement(key, Modifier) {
+ content { Box(Modifier.testTag(elementChildTag).size(100.dp)) }
+ }
+ }
+
+ val state = rule.runOnUiThread { MutableSceneTransitionLayoutState(SceneA) }
+ lateinit var coroutineScope: CoroutineScope
+ rule.setContent {
+ coroutineScope = rememberCoroutineScope()
+ SceneTransitionLayout(state, Modifier.size(200.dp)) {
+ scene(SceneA) { Box(Modifier.fillMaxSize()) { MovableBar() } }
+ overlay(OverlayA) { MovableBar() }
+ overlay(OverlayB) { MovableBar() }
+ }
+ }
+
+ // Initial state.
+ rule
+ .onNode(elementChild(content = SceneA))
+ .assertIsDisplayed()
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ rule.onNode(elementChild(content = OverlayA)).assertDoesNotExist()
+ rule.onNode(elementChild(content = OverlayB)).assertDoesNotExist()
+
+ // Show overlay A: movable element child only exists (is only composed) in overlay A.
+ rule.runOnUiThread { state.showOverlay(OverlayA, coroutineScope) }
+ rule.onNode(elementChild(content = SceneA)).assertDoesNotExist()
+ rule
+ .onNode(elementChild(content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+ rule.onNode(elementChild(content = OverlayB)).assertDoesNotExist()
+
+ // Replace overlay A by overlay B: element child is only in overlay B.
+ rule.runOnUiThread { state.replaceOverlay(OverlayA, OverlayB, coroutineScope) }
+ rule.onNode(elementChild(content = SceneA)).assertDoesNotExist()
+ rule.onNode(elementChild(content = OverlayA)).assertDoesNotExist()
+ rule
+ .onNode(elementChild(content = OverlayB))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+
+ // Show overlay A: element child still only exists in overlay B because it has a higher
+ // zIndex.
+ rule.runOnUiThread { state.showOverlay(OverlayA, coroutineScope) }
+ rule.onNode(elementChild(content = SceneA)).assertDoesNotExist()
+ rule.onNode(elementChild(content = OverlayA)).assertDoesNotExist()
+ rule
+ .onNode(elementChild(content = OverlayB))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+
+ // Hide overlay B: element child is in overlay A.
+ rule.runOnUiThread { state.hideOverlay(OverlayB, coroutineScope) }
+ rule.onNode(elementChild(content = SceneA)).assertDoesNotExist()
+ rule
+ .onNode(elementChild(content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+ rule.onNode(elementChild(content = OverlayB)).assertDoesNotExist()
+
+ // Hide overlay A: element child is in scene A.
+ rule.runOnUiThread { state.hideOverlay(OverlayA, coroutineScope) }
+ rule
+ .onNode(elementChild(content = SceneA))
+ .assertIsDisplayed()
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 0.dp)
+ rule.onNode(elementChild(content = OverlayA)).assertDoesNotExist()
+ rule.onNode(elementChild(content = OverlayB)).assertDoesNotExist()
+ }
+
+ @Test
+ fun overlayAlignment() {
+ val state =
+ rule.runOnUiThread {
+ MutableSceneTransitionLayoutState(SceneA, initialOverlays = setOf(OverlayA))
+ }
+ var alignment by mutableStateOf(Alignment.Center)
+ rule.setContent {
+ SceneTransitionLayout(state, Modifier.size(200.dp)) {
+ scene(SceneA) { Box(Modifier.fillMaxSize()) { Foo() } }
+ overlay(OverlayA, alignment) { Foo() }
+ }
+ }
+
+ // Initial state: 100x100dp centered in 200x200dp layout.
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(50.dp, 50.dp)
+
+ // BottomStart.
+ alignment = Alignment.BottomStart
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(0.dp, 100.dp)
+
+ // TopEnd.
+ alignment = Alignment.TopEnd
+ rule
+ .onNode(isElement(TestElements.Foo, content = OverlayA))
+ .assertSizeIsEqualTo(100.dp)
+ .assertPositionInRootIsEqualTo(100.dp, 0.dp)
+ }
+
+ @Test
+ fun overlayMaxSizeIsCurrentSceneSize() {
+ val state =
+ rule.runOnUiThread {
+ MutableSceneTransitionLayoutState(SceneA, initialOverlays = setOf(OverlayA))
+ }
+
+ val contentTag = "overlayContent"
+ rule.setContent {
+ SceneTransitionLayout(state) {
+ scene(SceneA) { Box(Modifier.size(100.dp)) { Foo() } }
+ overlay(OverlayA) { Box(Modifier.testTag(contentTag).fillMaxSize()) }
+ }
+ }
+
+ // Max overlay size is the size of the layout without overlays, not the (max) possible size
+ // of the layout.
+ rule.onNodeWithTag(contentTag).assertSizeIsEqualTo(100.dp)
+ }
+}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
index e97c27e..b8e13da 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneTransitionLayoutTest.kt
@@ -500,4 +500,19 @@
assertThat(keyInB).isEqualTo(SceneB)
assertThat(keyInC).isEqualTo(SceneC)
}
+
+ @Test
+ fun overlaysMapIsNotAllocatedWhenNoOverlayIsDefined() {
+ lateinit var layoutImpl: SceneTransitionLayoutImpl
+ rule.setContent {
+ SceneTransitionLayoutForTesting(
+ remember { MutableSceneTransitionLayoutState(SceneA) },
+ onLayoutImpl = { layoutImpl = it },
+ ) {
+ scene(SceneA) { Box(Modifier.fillMaxSize()) }
+ }
+ }
+
+ assertThat(layoutImpl.overlaysOrNullForTest()).isNull()
+ }
}
diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestContentScope.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestContentScope.kt
index 00adefb..5cccfb1 100644
--- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestContentScope.kt
+++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestContentScope.kt
@@ -26,9 +26,9 @@
@Composable
fun TestContentScope(
modifier: Modifier = Modifier,
+ currentScene: SceneKey = remember { SceneKey("current") },
content: @Composable ContentScope.() -> Unit,
) {
- val currentScene = remember { SceneKey("current") }
val state = remember { MutableSceneTransitionLayoutState(currentScene) }
SceneTransitionLayout(state, modifier) { scene(currentScene, content = content) }
}
diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestMatchers.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestMatchers.kt
index 6d063a0..22450d3 100644
--- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestMatchers.kt
+++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestMatchers.kt
@@ -20,11 +20,16 @@
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
-/** A [SemanticsMatcher] that matches [element], optionally restricted to scene [scene]. */
-fun isElement(element: ElementKey, scene: SceneKey? = null): SemanticsMatcher {
- return if (scene == null) {
+/** A [SemanticsMatcher] that matches [element], optionally restricted to content [content]. */
+fun isElement(element: ElementKey, content: ContentKey? = null): SemanticsMatcher {
+ return if (content == null) {
hasTestTag(element.testTag)
} else {
- hasTestTag(element.testTag) and hasAnyAncestor(hasTestTag(scene.testTag))
+ hasTestTag(element.testTag) and inContent(content)
}
}
+
+/** A [SemanticsMatcher] that matches anything inside [content]. */
+fun inContent(content: ContentKey): SemanticsMatcher {
+ return hasAnyAncestor(hasTestTag(content.testTag))
+}
diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestValues.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestValues.kt
index b83705a..f39dd67 100644
--- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestValues.kt
+++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestValues.kt
@@ -21,7 +21,7 @@
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
-/** Scenes keys that can be reused by tests. */
+/** Scene keys that can be reused by tests. */
object TestScenes {
val SceneA = SceneKey("SceneA")
val SceneB = SceneKey("SceneB")
@@ -29,6 +29,12 @@
val SceneD = SceneKey("SceneD")
}
+/** Overlay keys that can be reused by tests. */
+object TestOverlays {
+ val OverlayA = OverlayKey("OverlayA")
+ val OverlayB = OverlayKey("OverlayB")
+}
+
/** Element keys that can be reused by tests. */
object TestElements {
val Foo = ElementKey("Foo")
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
index c15a4e5..b30861c 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/SceneFrameworkIntegrationTest.kt
@@ -493,7 +493,9 @@
private fun getCurrentSceneInUi(): SceneKey {
return when (val state = transitionState.value) {
is ObservableTransitionState.Idle -> state.currentScene
- is ObservableTransitionState.Transition -> state.fromScene
+ is ObservableTransitionState.Transition.ChangeCurrentScene -> state.fromScene
+ is ObservableTransitionState.Transition.ShowOrHideOverlay -> state.currentScene
+ is ObservableTransitionState.Transition.ReplaceOverlay -> state.currentScene
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneContainerOcclusionInteractor.kt b/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneContainerOcclusionInteractor.kt
index 2d510e1..ea61bd3 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneContainerOcclusionInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneContainerOcclusionInteractor.kt
@@ -118,8 +118,11 @@
get() =
when (this) {
is ObservableTransitionState.Idle -> currentScene.canBeOccluded
- is ObservableTransitionState.Transition ->
+ is ObservableTransitionState.Transition.ChangeCurrentScene ->
fromScene.canBeOccluded && toScene.canBeOccluded
+ is ObservableTransitionState.Transition.ReplaceOverlay,
+ is ObservableTransitionState.Transition.ShowOrHideOverlay ->
+ TODO("b/359173565: Handle overlay transitions")
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneInteractor.kt b/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneInteractor.kt
index 5885193..6e7df7e 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/domain/interactor/SceneInteractor.kt
@@ -124,7 +124,15 @@
*/
val transitioningTo: StateFlow<SceneKey?> =
transitionState
- .map { state -> (state as? ObservableTransitionState.Transition)?.toScene }
+ .map { state ->
+ when (state) {
+ is ObservableTransitionState.Idle -> null
+ is ObservableTransitionState.Transition.ChangeCurrentScene -> state.toScene
+ is ObservableTransitionState.Transition.ShowOrHideOverlay,
+ is ObservableTransitionState.Transition.ReplaceOverlay ->
+ TODO("b/359173565: Handle overlay transitions")
+ }
+ }
.stateIn(
scope = applicationScope,
started = SharingStarted.WhileSubscribed(),
diff --git a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/ScrimStartable.kt b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/ScrimStartable.kt
index c6f51b3..ec743ba 100644
--- a/packages/SystemUI/src/com/android/systemui/scene/domain/startable/ScrimStartable.kt
+++ b/packages/SystemUI/src/com/android/systemui/scene/domain/startable/ScrimStartable.kt
@@ -112,7 +112,7 @@
// It
// happens only when unlocking or when dismissing a dismissible lockscreen.
val isTransitioningAwayFromKeyguard =
- transitionState is ObservableTransitionState.Transition &&
+ transitionState is ObservableTransitionState.Transition.ChangeCurrentScene &&
transitionState.fromScene.isKeyguard() &&
transitionState.toScene == Scenes.Gone
@@ -120,7 +120,7 @@
val isCurrentSceneShade = currentScene.isShade()
// This is true when moving into one of the shade scenes when a non-shade scene.
val isTransitioningToShade =
- transitionState is ObservableTransitionState.Transition &&
+ transitionState is ObservableTransitionState.Transition.ChangeCurrentScene &&
!transitionState.fromScene.isShade() &&
transitionState.toScene.isShade()
diff --git a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/PanelExpansionInteractorImpl.kt b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/PanelExpansionInteractorImpl.kt
index 8006e94..7d67121 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/PanelExpansionInteractorImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/PanelExpansionInteractorImpl.kt
@@ -64,7 +64,7 @@
0f
}
)
- is ObservableTransitionState.Transition ->
+ is ObservableTransitionState.Transition.ChangeCurrentScene ->
when {
state.fromScene == Scenes.Gone ->
if (state.toScene.isExpandable()) {
@@ -88,6 +88,9 @@
}
else -> flowOf(1f)
}
+ is ObservableTransitionState.Transition.ShowOrHideOverlay,
+ is ObservableTransitionState.Transition.ReplaceOverlay ->
+ TODO("b/359173565: Handle overlay transitions")
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt
index ed69e6f..f28ce6c 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt
@@ -72,7 +72,7 @@
}
private fun expandFractionForTransition(
- state: ObservableTransitionState.Transition,
+ state: ObservableTransitionState.Transition.ChangeCurrentScene,
shadeExpansion: Float,
shadeMode: ShadeMode,
qsExpansion: Float,
@@ -111,7 +111,7 @@
when (transitionState) {
is ObservableTransitionState.Idle ->
expandFractionForScene(transitionState.currentScene, shadeExpansion)
- is ObservableTransitionState.Transition ->
+ is ObservableTransitionState.Transition.ChangeCurrentScene ->
expandFractionForTransition(
transitionState,
shadeExpansion,
@@ -119,6 +119,9 @@
qsExpansion,
quickSettingsScene
)
+ is ObservableTransitionState.Transition.ShowOrHideOverlay,
+ is ObservableTransitionState.Transition.ReplaceOverlay ->
+ TODO("b/359173565: Handle overlay transitions")
}
}
.distinctUntilChanged()
@@ -216,7 +219,7 @@
}
}
-private fun ObservableTransitionState.Transition.isBetween(
+private fun ObservableTransitionState.Transition.ChangeCurrentScene.isBetween(
a: (SceneKey) -> Boolean,
b: (SceneKey) -> Boolean
): Boolean = (a(fromScene) && b(toScene)) || (b(fromScene) && a(toScene))