Fix SharedTransitionScope not drawing items in certain conditions
Originally, there was nothing within SharedTransitionScope that could explicitly cause an invalidation, so in some cases, after at least a transition with different content has ocurred, there may be nothing (as in, an obvservable state) that would cause the SharedTransitionScope to draw.
So nothing would get drawn on the overlay during transition.
Although the measure/layout and corresponding animation would still happen within the layout hierarchy.
This fixes it by making the rendering target list (for the overlay) a MutableStateList. Which is updated as the content changes for a transition.
Bug: 347520198
Test: testSharedTransitionScopeIsInvalidated
Change-Id: Id65abf8cc50fc08e246c0a85581df32be1983ecc
diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
index b76bd88..9604026 100644
--- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
+++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
@@ -25,9 +25,12 @@
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.rememberTransition
+import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -52,6 +55,7 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertContainsColor
import androidx.compose.testutils.assertPixels
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomCenter
@@ -81,6 +85,7 @@
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -2713,6 +2718,106 @@
ScaleToBounds(alignment = customAlignment)
)
}
+
+ // Regression test for b/347520198, SharedTransitionLayout onDraw would not get invalidated
+ // in some cases.
+ @SdkSuppress(minSdkVersion = 26)
+ @Test
+ fun testSharedTransitionScopeIsInvalidated() {
+ var state by mutableIntStateOf(0)
+
+ val animDurationMillis = 500
+
+ val parentTag = "STL"
+ val clickTarget = "click-target"
+
+ rule.setContent {
+ SharedTransitionLayout(Modifier.size(100.dp).testTag(parentTag)) {
+ // This outer AnimatedContent doesn't do anything, and the issue only triggers
+ // when it's present
+ AnimatedContent(targetState = true) {
+ @Suppress("UNUSED_EXPRESSION")
+ it // Need to reference the unused outer AnimatedContent's target state
+
+ AnimatedContent(
+ targetState = state,
+ transitionSpec = {
+ // Add a delay to the animation just so that it takes a known time to
+ // complete
+ fadeIn(snap()).togetherWith(fadeOut(snap(animDurationMillis)))
+ }
+ ) { currentState ->
+ val innerAnimatedContentScope = this
+ Box(
+ // This will cycle from Green -> Blue -> Red -> Blue -> Red...
+ Modifier.testTag(clickTarget)
+ .clickable(
+ // Don't let the clickable paint anything, it may interfere with
+ // the test.
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {
+ state =
+ when (currentState) {
+ 0 -> 2
+ 1 -> 2
+ else -> 1
+ }
+ }
+ .fillMaxSize()
+ ) {
+ val color =
+ when (currentState) {
+ 0 -> Color.Green
+ 1 -> Color.Red
+ else -> Color.Blue
+ }
+ Box(
+ Modifier
+ // Using shared bounds so that we control when the item enters
+ // and leaves in every case. Particularly, we want the target to
+ // show immediately
+ .sharedBounds(
+ rememberSharedContentState(
+ key =
+ if (currentState == 0) "no match"
+ else "matching key"
+ ),
+ animatedVisibilityScope = innerAnimatedContentScope,
+ enter = fadeIn(snap()),
+ exit = fadeOut(snap())
+ )
+ .background(color)
+ .fillMaxSize()
+ )
+ }
+ }
+ }
+ }
+ }
+ rule.waitForIdle()
+ // Start off with a Green box
+ rule.onNodeWithTag(parentTag).captureToImage().assertContainsColor(Color.Green)
+
+ fun clickAndAssertColorDuringTransition(color: Color) {
+ rule.mainClock.autoAdvance = false
+ rule.onNodeWithTag(clickTarget).performClick()
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.mainClock.advanceTimeBy(animDurationMillis / 2L)
+
+ rule.onNodeWithTag(parentTag).captureToImage().assertContainsColor(color)
+
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+ }
+
+ // Transition into a Blue box
+ clickAndAssertColorDuringTransition(Color.Blue)
+
+ // Transition into a Red box
+ clickAndAssertColorDuringTransition(Color.Red)
+ }
}
private fun assertEquals(a: IntSize, b: IntSize, delta: IntSize) {
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt
index 9b1735d..3e86ab6 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedTransitionScope.kt
@@ -43,6 +43,7 @@
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -1038,7 +1039,7 @@
internal var nullableLookaheadRoot: LayoutCoordinates? = null
// TODO: Use MutableObjectList and impl sort
- private val renderers = mutableListOf<LayerRenderer>()
+ private val renderers = mutableStateListOf<LayerRenderer>()
private val sharedElements = MutableScatterMap<Any, SharedElement>()