blob: 9604026651d1db34497eb7df17bbabfb5aa934cd [file]
/*
* Copyright 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.
*/
@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalAnimationApi::class)
package androidx.compose.animation
import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.animatedSize
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.RemeasureToBounds
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.ScaleToBounds
import androidx.compose.animation.core.LinearEasing
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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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
import androidx.compose.ui.Alignment.Companion.BottomEnd
import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterEnd
import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Alignment.Companion.TopCenter
import androidx.compose.ui.Alignment.Companion.TopEnd
import androidx.compose.ui.Alignment.Companion.TopStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ScaleFactor
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
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
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import junit.framework.TestCase.assertTrue
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.random.Random
import kotlinx.coroutines.runBlocking
import leakcanary.DetectLeaksAfterTestSuccess
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class SharedTransitionTest {
val rule = createComposeRule()
// Detect leaks BEFORE and AFTER compose rule work
@get:Rule
val ruleChain: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()).around(rule)
@Test
fun transitionInterruption() {
var visible by mutableStateOf(true)
val boundsTransform = BoundsTransform { _, _ -> tween(500, easing = LinearEasing) }
val positions = mutableListOf(Offset.Zero, Offset.Zero, Offset.Zero, Offset.Zero)
val sizes = mutableListOf(IntSize(-1, -1), IntSize(-1, -1), IntSize.Zero, IntSize.Zero)
var transitionScope: SharedTransitionScope? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout {
transitionScope = this@SharedTransitionLayout as SharedTransitionScopeImpl
AnimatedVisibility(visible = visible) {
Column {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "cat"),
this@AnimatedVisibility,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[0] =
lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[0] = it.size
}
.size(200.dp)
)
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "dog"),
this@AnimatedVisibility,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[1] =
lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[1] = it.size
}
.size(50.dp)
)
}
}
AnimatedVisibility(visible = !visible) {
Row {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "dog"),
this@AnimatedVisibility,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[2] =
lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[2] = it.size
}
.size(50.dp)
)
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "cat"),
this@AnimatedVisibility,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[3] =
lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[3] = it.size
}
.size(200.dp)
)
}
}
}
}
}
rule.waitForIdle()
rule.runOnIdle {
assertFalse(transitionScope!!.isTransitionActive)
assertEquals(IntSize(200, 200), sizes[0])
assertEquals(IntSize(50, 50), sizes[1])
assertEquals(Offset(0f, 0f), positions[0])
assertEquals(Offset(0f, 200f), positions[1])
}
rule.mainClock.autoAdvance = false
visible = false
repeat(20) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
val offsetTolerance = Offset(5f, 5f)
val tolerance = IntSize(5, 5)
assertEquals(positions[0], positions[3], offsetTolerance)
assertEquals(positions[1], positions[2], offsetTolerance)
assertEquals(sizes[0], sizes[3], tolerance)
assertEquals(sizes[1], sizes[2], tolerance)
assertTrue(transitionScope!!.isTransitionActive)
// Interrupt
visible = true
val lastSizes = mutableListOf<IntSize>().also { it.addAll(sizes) }
val lastPositions = mutableListOf<Offset>().also { it.addAll(positions) }
while (transitionScope?.isTransitionActive != false) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
// Shared bounds are in sync with each other's bounds
assertEquals(positions[0], positions[3], offsetTolerance)
assertEquals(positions[1], positions[2], offsetTolerance)
assertEquals(sizes[0], sizes[3], tolerance)
assertEquals(sizes[1], sizes[2], tolerance)
// Expect size[0] to grow and size[1] to shrink from the point of interruption
// And that size always changes continuously
assertTrue(sizes[0].width >= lastSizes[0].width)
assertTrue(sizes[0].height >= lastSizes[0].height)
assertEquals(sizes[0], lastSizes[0], IntSize(10, 10))
assertTrue(sizes[1].width <= lastSizes[1].width)
assertTrue(sizes[1].height <= lastSizes[1].height)
assertEquals(sizes[1], lastSizes[1], IntSize(10, 10))
// Expect positions to change gradually.
assertEquals(positions[0], lastPositions[0], Offset(20f, 20f))
assertEquals(positions[1], lastPositions[1], Offset(40f, 40f))
assertEquals(0f, positions[0].y)
assertEquals(0f, positions[1].x)
lastSizes.clear()
lastSizes.addAll(sizes)
lastPositions.clear()
lastPositions.addAll(positions)
}
// Animation finished
assertEquals(IntSize(200, 200), sizes[0])
assertEquals(IntSize(50, 50), sizes[1])
assertEquals(Offset(0f, 0f), positions[0])
assertEquals(Offset(0f, 200f), positions[1])
}
@Test
fun transitionInterruptionSelfManagedVisibility() {
var visible by mutableStateOf(true)
val boundsTransform = BoundsTransform { _, _ -> tween(500, easing = LinearEasing) }
val positions = mutableListOf(Offset.Zero, Offset.Zero, Offset.Zero, Offset.Zero)
val sizes = mutableListOf(IntSize(-1, -1), IntSize(-1, -1), IntSize.Zero, IntSize.Zero)
var transitionScope: SharedTransitionScopeImpl? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout {
transitionScope = this@SharedTransitionLayout as SharedTransitionScopeImpl
Column {
Box(
Modifier.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = "cat"),
visible = visible,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[0] = lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[0] = it.size
}
.size(200.dp)
)
Box(
Modifier.sharedBoundsWithCallerManagedVisibility(
rememberSharedContentState(key = "dog"),
visible = visible,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[1] = lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[1] = it.size
}
.size(50.dp)
)
}
Row {
Box(
Modifier.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = "dog"),
visible = !visible,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[2] = lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[2] = it.size
}
.size(50.dp)
)
Box(
Modifier.sharedBoundsWithCallerManagedVisibility(
rememberSharedContentState(key = "cat"),
visible = !visible,
boundsTransform = boundsTransform
)
.onGloballyPositioned {
positions[3] = lookaheadRoot.localPositionOf(it, Offset.Zero)
sizes[3] = it.size
}
.size(200.dp)
)
}
}
}
}
rule.waitForIdle()
rule.runOnIdle {
assertFalse(transitionScope!!.isTransitionActive)
assertEquals(IntSize(200, 200), sizes[0])
assertEquals(IntSize(50, 50), sizes[1])
assertEquals(Offset(0f, 0f), positions[0])
assertEquals(Offset(0f, 200f), positions[1])
}
rule.mainClock.autoAdvance = false
visible = false
repeat(20) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
val offsetTolerance = Offset(5f, 5f)
val tolerance = IntSize(5, 5)
assertEquals(positions[0], positions[3], offsetTolerance)
assertEquals(positions[1], positions[2], offsetTolerance)
assertEquals(sizes[0], sizes[3], tolerance)
assertEquals(sizes[1], sizes[2], tolerance)
assertTrue(transitionScope!!.isTransitionActive)
// Interrupt
visible = true
val lastSizes = mutableListOf<IntSize>().also { it.addAll(sizes) }
val lastPositions = mutableListOf<Offset>().also { it.addAll(positions) }
while (transitionScope?.isTransitionActive != false) {
// Shared bounds are in sync with each other's bounds
assertEquals(positions[0], positions[3], offsetTolerance)
assertEquals(positions[1], positions[2], offsetTolerance)
assertEquals(sizes[0], sizes[3], tolerance)
assertEquals(sizes[1], sizes[2], tolerance)
// Expect size[0] to grow and size[1] to shrink from the point of interruption
// And that size always changes continuously
assertTrue(sizes[0].width >= lastSizes[0].width)
assertTrue(sizes[0].height >= lastSizes[0].height)
assertEquals(sizes[0], lastSizes[0], IntSize(10, 10))
assertTrue(sizes[1].width <= lastSizes[1].width)
assertTrue(sizes[1].height <= lastSizes[1].height)
assertEquals(sizes[1], lastSizes[1], IntSize(10, 10))
// Expect positions to change gradually.
assertEquals(positions[0], lastPositions[0], Offset(20f, 20f))
assertEquals(positions[1], lastPositions[1], Offset(40f, 40f))
assertEquals(0f, positions[0].y)
assertEquals(0f, positions[1].x)
lastSizes.clear()
lastSizes.addAll(sizes)
lastPositions.clear()
lastPositions.addAll(positions)
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Animation finished
assertEquals(IntSize(200, 200), sizes[0])
assertEquals(IntSize(50, 50), sizes[1])
assertEquals(Offset(0f, 0f), positions[0])
assertEquals(Offset(0f, 200f), positions[1])
}
@Test
fun testKeyMatch() {
val key1 = Any()
val set = mutableSetOf<SharedTransitionScope.SharedContentState>()
var showRow by mutableStateOf(false)
var visible by mutableStateOf(true)
rule.setContent {
SharedTransitionLayout {
this@SharedTransitionLayout as SharedTransitionScopeImpl
Column {
Box(
Modifier.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = key1).also { set.add(it) },
visible = visible,
)
.size(200.dp)
)
Box(
Modifier.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = 2).also { set.add(it) },
visible = visible,
)
.size(200.dp)
)
Box(
Modifier.sharedBoundsWithCallerManagedVisibility(
rememberSharedContentState(key = "cat").also { set.add(it) },
visible = visible,
)
.size(200.dp)
)
Box(
Modifier.sharedBoundsWithCallerManagedVisibility(
rememberSharedContentState(key = Unit).also { set.add(it) },
visible = visible,
)
.size(200.dp)
)
}
if (showRow) {
Row {
Box(
Modifier.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = key1).also { set.add(it) },
visible = !visible,
)
.size(50.dp)
)
Box(
Modifier.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = 2).also { set.add(it) },
visible = !visible,
)
.size(50.dp)
)
Box(
Modifier.sharedBoundsWithCallerManagedVisibility(
rememberSharedContentState(key = "cat").also { set.add(it) },
visible = !visible,
)
.size(50.dp)
)
Box(
Modifier.sharedBoundsWithCallerManagedVisibility(
rememberSharedContentState(key = Unit).also { set.add(it) },
visible = !visible,
)
.size(50.dp)
)
}
}
}
}
rule.waitForIdle()
assertEquals(4, set.size)
set.forEach { assertFalse(it.isMatchFound) }
// Show row to add matched shared elements into composition
showRow = true
rule.runOnIdle {
assertEquals(8, set.size)
set.forEach { assertTrue(it.isMatchFound) }
}
visible = false
rule.runOnIdle { set.forEach { assertTrue(it.isMatchFound) } }
set.clear()
showRow = false
rule.runOnIdle { assertEquals(4, set.size) }
rule.runOnIdle { set.forEach { assertFalse(it.isMatchFound) } }
}
@Test
fun testMatchFoundUpdatedPromptly() {
val key1 = Any()
val set = mutableSetOf<SharedTransitionScope.SharedContentState>()
val seekableTransition = SeekableTransitionState(1)
var target by mutableStateOf(1)
var firstFrame = true
rule.setContent {
LaunchedEffect(target) {
if (seekableTransition.currentState != target) {
seekableTransition.animateTo(target)
}
}
val transition = rememberTransition(transitionState = seekableTransition)
SharedTransitionLayout {
val state1 = rememberSharedContentState(key = key1)
val state2 = rememberSharedContentState(key = key1)
transition.AnimatedContent {
when (it) {
1 ->
Box(Modifier.sharedElement(state1, this).size(200.dp)) {
DisposableEffect(key1 = Unit) {
set.add(state1)
onDispose { set.remove(state1) }
}
}
2 ->
Box(Modifier.sharedElement(state2, this).size(600.dp)) {
DisposableEffect(key1 = Unit) {
set.add(state2)
onDispose { set.remove(state2) }
}
}
else ->
Box(Modifier.size(200.dp)) {
if (firstFrame) {
firstFrame = false
}
}
}
}
}
}
rule.waitForIdle()
assertEquals(1, set.size)
set.forEach { assertFalse(it.isMatchFound) }
// Show row to add matched shared elements into composition
rule.runOnIdle { target = 2 }
rule.waitUntil { set.size == 2 }
repeat(5) { rule.mainClock.advanceTimeByFrame() }
assertEquals(2, set.size)
// Now we expect two shared elements to be matched
set.forEach { assertTrue(it.isMatchFound) }
target = 3
rule.waitUntil { !firstFrame }
set.forEach {
assertEquals(2, set.size)
assertFalse(it.isMatchFound)
}
rule.mainClock.advanceTimeBy(50000L)
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testBothContentShowing() {
var visible by mutableStateOf(false)
val tween = tween<Float>(100, easing = LinearEasing)
var transitionScope: SharedTransitionScope? = null
var exitTransition: Transition<*>? = null
var enterTransition: Transition<*>? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.requiredSize(100.dp).testTag("scope").background(Color.White)
) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
enterTransition = transition
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
fadeIn(tween),
fadeOut(tween)
)
.fillMaxSize()
) {
Box(
Modifier.fillMaxHeight()
.fillMaxWidth(0.5f)
.background(Color.Red)
.align(Alignment.CenterStart)
)
}
}
AnimatedVisibility(
visible = !visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
exitTransition = transition
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
fadeIn(tween),
fadeOut(tween)
)
.fillMaxSize()
) {
Box(
Modifier.fillMaxHeight()
.fillMaxWidth(0.5f)
.background(Color.Blue)
.align(Alignment.CenterEnd)
)
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.mainClock.autoAdvance = false
visible = true
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
val playTime = exitTransition!!.playTimeNanos / 1000_000L
val fraction = (playTime.toFloat() / 100f).coerceIn(0f, 1f)
val enterPlayTime = enterTransition!!.playTimeNanos / 1000_000L
val enterFraction = (enterPlayTime.toFloat() / 100f).coerceIn(0f, 1f)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
assertPixels {
if (it.x < width / 2) {
Color.Red.copy(alpha = enterFraction).compositeOver(Color.White)
} else if (it.x > width / 2) {
Color.Blue.copy(alpha = 1f - fraction).compositeOver(Color.White)
} else null
}
}
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun testObserverScopeClearedAfterDisposing() {
var visible by mutableStateOf(false)
val tween = tween<Float>(100, easing = LinearEasing)
var transitionScope: SharedTransitionScopeImpl? = null
var shouldDispose by mutableStateOf(false)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
if (!shouldDispose) {
SharedTransitionLayout(
Modifier.requiredSize(100.dp).testTag("scope").background(Color.White)
) {
transitionScope = this as SharedTransitionScopeImpl
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
fadeIn(tween),
fadeOut(tween)
)
.fillMaxSize()
) {
Box(
Modifier.fillMaxHeight()
.fillMaxWidth(0.5f)
.background(Color.Red)
.align(Alignment.CenterStart)
)
}
}
AnimatedVisibility(
visible = !visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
fadeIn(tween),
fadeOut(tween)
)
.fillMaxSize()
) {
Box(
Modifier.fillMaxHeight()
.fillMaxWidth(0.5f)
.background(Color.Blue)
.align(Alignment.CenterEnd)
)
}
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
visible = true
rule.waitForIdle()
shouldDispose = true
rule.waitForIdle()
assertTrue(transitionScope!!.disposed)
transitionScope!!.observerForTest.clearIf {
error("Scope $it is not cleared from SharedTransitionScopeObserver")
}
}
@SdkSuppress(minSdkVersion = 26)
@OptIn(ExperimentalAnimationApi::class)
@Test
fun testOnlyVisibleContentShowingInSharedElement() {
var visible by mutableStateOf(false)
val tween = tween<Float>(100, easing = LinearEasing)
var transitionScope: SharedTransitionScope? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.requiredSize(100.dp).testTag("scope").background(Color.White)
) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween),
exit = fadeOut(tween)
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
)
.fillMaxSize()
) {
Box(
Modifier.fillMaxHeight()
.fillMaxWidth(0.5f)
.background(Color.Red)
.align(Alignment.CenterStart)
)
}
}
AnimatedVisibility(
visible = !visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
)
.fillMaxSize()
) {
Box(
Modifier.fillMaxHeight()
.fillMaxWidth(0.5f)
.background(Color.Blue)
.align(Alignment.CenterEnd)
)
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
assertPixels {
if (it.x < width / 2 - 2) {
Color.White
} else if (it.x > width / 2 + 2) {
Color.Blue
} else null
}
}
}
rule.mainClock.autoAdvance = false
visible = true
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
assertPixels {
if (it.x < width / 2 - 2) {
Color.Red
} else if (it.x > width / 2 + 2) {
Color.White
} else null
}
}
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testOverlayClip() {
// Set a clip shape on the shared element that is change both size and position, and check
// that the shape is being updated per frame.
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var size: IntSize? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.requiredSize(100.dp).testTag("scope").background(Color.White)
) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
boundsTransform =
BoundsTransform { _, _ ->
tween(100, easing = LinearEasing)
}
)
.fillMaxSize()
)
}
AnimatedVisibility(
modifier = Modifier.fillMaxSize(),
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.fillMaxSize(0.5f)
.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
clipInOverlayDuringTransition = OverlayClip(CircleShape),
boundsTransform =
BoundsTransform { _, _ ->
tween(100, easing = LinearEasing)
}
)
.onGloballyPositioned { size = it.size }
.background(Color.Blue)
)
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.White } }
}
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Set aside 5 pixel width for anti-aliasing
val widthTolerance = 4
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
// Check clipping
assertPixels {
val distanceToCenter =
sqrt(
(it.x - width / 2) * (it.x - width / 2).toFloat() +
(it.y - height / 2) * (it.y - height / 2)
)
if (
it.x < width / 2 && distanceToCenter < size!!.width / 2 - widthTolerance
) {
Color.Blue
} else if (distanceToCenter > size!!.width / 2 + widthTolerance) {
Color.White
} else null
}
}
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testOverlayClipInheritedByChildren() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.requiredSize(100.dp).testTag("scope").background(Color.White)
) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
)
.fillMaxSize()
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility
)
.fillMaxHeight()
.fillMaxWidth(0.5f)
.align(Alignment.CenterStart)
)
}
}
AnimatedVisibility(
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
clipInOverlayDuringTransition =
OverlayClip(clipShape = CircleShape)
)
.fillMaxSize()
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility
)
.fillMaxHeight()
.fillMaxWidth(0.5f)
.align(Alignment.CenterStart)
.background(Color.Blue)
)
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.White } }
}
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Set aside 5 pixel width for anti-aliasing
val widthTolerance = 5
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
// Check clipping
assertPixels {
val distanceToCenter =
sqrt(
(it.x - width / 2) * (it.x - width / 2).toFloat() +
(it.y - height / 2) * (it.y - height / 2)
)
if (it.x < width / 2 && distanceToCenter < width / 2 - widthTolerance) {
Color.Blue
} else if (
it.x > width / 2 + 5 || distanceToCenter > width / 2 + widthTolerance
) {
Color.White
} else null
}
}
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@OptIn(ExperimentalAnimationApi::class)
@Test
fun testBoundsTransform() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var parentSize: IntSize? = null
var parentPosition: Offset? = null
var childSize: IntSize? = null
var childPosition: Offset? = null
var exitTransition: Transition<*>? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(Modifier.requiredSize(100.dp).background(Color.White)) {
transitionScope = this@SharedTransitionLayout as SharedTransitionScopeImpl
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
Modifier.fillMaxSize(0.5f)
.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility
)
)
}
}
AnimatedVisibility(
modifier = Modifier.fillMaxSize(0.5f).offset(x = 25.dp, y = 25.dp),
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
exitTransition = this.transition
Box(
Modifier.offset(20.dp)
.sharedBounds(
rememberSharedContentState(key = "test"),
this@AnimatedVisibility,
fadeIn(),
fadeOut(),
boundsTransform =
BoundsTransform { _, _ ->
tween(100, easing = LinearEasing)
},
resizeMode = RemeasureToBounds
)
.onGloballyPositioned {
parentPosition = lookaheadRoot.localPositionOf(it, Offset.Zero)
parentSize = it.size
}
) {
Box(
Modifier.offset(-20.dp)
.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
boundsTransform = { initialBounds, targetBounds ->
assertEquals(initialBounds, targetBounds)
spring()
}
)
.onGloballyPositioned {
childPosition =
lookaheadRoot.localPositionOf(it, Offset.Zero)
childSize = it.size
}
.fillMaxSize()
.background(Color.Blue)
)
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
// Expect size (100, 100) -> (50, 50), position -> (0, 0) -> (45, 45)
val fraction = ((exitTransition!!.playTimeNanos / 1000_000L) / 100f).coerceIn(0f, 1f)
val expectedSize =
(50 * fraction + 100 * (1 - fraction)).roundToInt().let { IntSize(it, it) }
val expectedPosition = Offset(45f * fraction, 25f * fraction)
assertEquals(expectedSize, parentSize!!, IntSize(3, 3))
assertEquals(expectedPosition, parentPosition!!, Offset(3f, 3f))
// Child is expected to hold in place throughout the transition
assertEquals(IntSize(50, 50), childSize!!)
assertEquals(Offset(25f, 25f), childPosition!!)
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun testPlaceHolderSize() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var parent1Size: IntSize? = null
var parent2Size: IntSize? = null
var expectedSize by mutableStateOf(IntSize.Zero)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(Modifier.requiredSize(100.dp).background(Color.White)) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.fillMaxSize().wrapContentSize().onGloballyPositioned {
parent1Size = it.size
},
contentAlignment = Alignment.Center
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility
)
.fillMaxSize(0.5f)
)
}
}
AnimatedVisibility(
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.onGloballyPositioned { parent2Size = it.size }
.offset(-20.dp)
.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
placeHolderSize =
SharedTransitionScope.PlaceHolderSize { _, _ ->
expectedSize
}
)
.fillMaxSize()
.background(Color.Blue)
)
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
// Expect parent1 to stay at contentSize and parent2 to change size
assertEquals(IntSize(50, 50), parent1Size!!)
assertEquals(expectedSize, parent2Size!!)
expectedSize = IntSize(Random.nextInt(0, 100), Random.nextInt(0, 100))
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@OptIn(ExperimentalAnimationApi::class)
@SdkSuppress(minSdkVersion = 26)
@Test
fun testRenderInOverlayEqualsFalse() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var exit: Transition<*>? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.testTag("scope").requiredSize(100.dp).background(Color.White)
) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility
)
.background(Color.Red)
.fillMaxSize()
)
}
AnimatedVisibility(
visible = !visible,
enter = fadeIn(tween(100, easing = LinearEasing)),
exit = ExitTransition.None
) {
exit = transition
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
renderInOverlayDuringTransition = false
)
.background(Color.Blue)
.fillMaxSize()
)
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.Red } }
}
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
val fraction = ((exit!!.playTimeNanos / 1000_000L) / 100f).coerceIn(0f, 1f)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
assertPixels { Color.Blue.copy(alpha = fraction).compositeOver(Color.White) }
}
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testZIndexInOverlay() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var greenZIndex by mutableStateOf(0f)
var redZIndex by mutableStateOf(0f)
var blueZIndex by mutableStateOf(0f)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.testTag("scope").requiredSize(100.dp).background(Color.White)
) {
transitionScope = this
Box(
Modifier.renderInSharedTransitionScopeOverlay(zIndexInOverlay = greenZIndex)
.background(Color.Green)
.fillMaxSize()
)
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = fadeOut(tween(100, easing = LinearEasing)),
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
enter = EnterTransition.None,
exit = ExitTransition.None,
zIndexInOverlay = redZIndex
)
.background(Color.Red)
.fillMaxSize()
)
}
AnimatedVisibility(
visible = !visible,
enter = fadeIn(tween(100, easing = LinearEasing)),
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
enter = EnterTransition.None,
exit = ExitTransition.None,
zIndexInOverlay = blueZIndex
)
.background(Color.Blue)
.fillMaxSize()
)
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.Red } }
}
rule.mainClock.autoAdvance = false
visible = false
greenZIndex = 0f
redZIndex = 0f
blueZIndex = 1f
var expectedTopColor = Color.Blue
var i = 0
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { expectedTopColor } }
}
greenZIndex = i.toFloat()
redZIndex = i.toFloat()
blueZIndex = i.toFloat()
when (i) {
0 -> {
redZIndex++
expectedTopColor = Color.Red
}
1 -> {
greenZIndex++
expectedTopColor = Color.Green
}
2 -> {
blueZIndex++
expectedTopColor = Color.Blue
}
}
i = (i + 1) % 3
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun testSkipToLookahead() {
// Set a clip shape on the shared element that is change both size and position, and check
// that the shape is being updated per frame.
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var size: IntSize? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.requiredSize(100.dp).testTag("scope").background(Color.White)
) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
boundsTransform =
BoundsTransform { _, _ ->
tween(100, easing = LinearEasing)
}
)
.fillMaxSize()
)
}
AnimatedVisibility(
modifier = Modifier.fillMaxSize(),
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.padding(25.dp)
.sharedElement(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
clipInOverlayDuringTransition = OverlayClip(CircleShape),
boundsTransform =
BoundsTransform { _, _ ->
tween(100, easing = LinearEasing)
}
)
.skipToLookaheadSize()
.onGloballyPositioned { size = it.size }
.background(Color.Blue)
)
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
assertNull(size)
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
// Check that child size has skipped to lookahead size
assertEquals(IntSize(50, 50), size)
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testRenderInOverlay() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.testTag("scope").requiredSize(100.dp).background(Color.White)
) {
transitionScope = this
Box(
Modifier.renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f)
.background(Color.Green)
.fillMaxSize()
)
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.fillMaxSize().wrapContentSize(align = Alignment.BottomEnd),
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility
)
.fillMaxSize(0.5f)
.background(Color.Red)
)
}
}
AnimatedVisibility(
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "child"),
this@AnimatedVisibility,
)
.fillMaxSize(0.5f)
.background(Color.Blue)
)
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
// tolerance due to AA
val tolerance = 2
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
assertPixels {
if (it.x > width / 2 + tolerance && it.y > height / 2 + tolerance) {
Color.Red
} else if (it.x < width / 2 - tolerance || it.y < height / 2 - tolerance) {
Color.Green
} else null
}
}
}
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
// Check clipping
assertPixels { Color.Green }
}
}
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun testSharedContentStateClipPathInOverlay() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var parentSharedContentState: SharedTransitionScope.SharedContentState? = null
var childSharedContentState: SharedTransitionScope.SharedContentState? = null
var clippedParentSharedContentState: SharedTransitionScope.SharedContentState? = null
var clippedChildSharedContentState: SharedTransitionScope.SharedContentState? = null
val predefinedPath = Path().apply { addRect(Rect(5f, 5f, 6f, 6f)) }
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(Modifier.requiredSize(100.dp).background(Color.White)) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "parent").also {
parentSharedContentState = it
},
this@AnimatedVisibility,
)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
Modifier.fillMaxSize(0.5f)
.sharedElement(
rememberSharedContentState(key = "child").also {
childSharedContentState = it
},
this@AnimatedVisibility
)
)
}
}
AnimatedVisibility(
modifier = Modifier.fillMaxSize(0.5f).offset(x = 25.dp, y = 25.dp),
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.offset(20.dp)
.sharedBounds(
rememberSharedContentState(key = "parent").also {
clippedParentSharedContentState = it
},
this@AnimatedVisibility,
clipInOverlayDuringTransition =
object : SharedTransitionScope.OverlayClip {
override fun getClipPath(
state: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density
): Path? {
return predefinedPath
}
}
)
) {
Box(
Modifier.offset(-20.dp)
.sharedElement(
rememberSharedContentState(key = "child").also {
clippedChildSharedContentState = it
},
this@AnimatedVisibility,
)
.fillMaxSize(),
)
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Pulse another frame to ensure the rendering has happened
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
// Check that the custom clip is picked up by both parent and child shared states
assertEquals(predefinedPath, clippedParentSharedContentState!!.clipPathInOverlay)
assertEquals(predefinedPath, clippedChildSharedContentState!!.clipPathInOverlay)
assertEquals(null, parentSharedContentState!!.clipPathInOverlay)
assertEquals(null, childSharedContentState!!.clipPathInOverlay)
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun testParentSharedContentState() {
var transitionScope: SharedTransitionScope? = null
var visible by mutableStateOf(true)
var parentSharedContentState: SharedTransitionScope.SharedContentState? = null
var childSharedContentState: SharedTransitionScope.SharedContentState? = null
var clippedParentSharedContentState: SharedTransitionScope.SharedContentState? = null
var clippedChildSharedContentState: SharedTransitionScope.SharedContentState? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(Modifier.requiredSize(100.dp).background(Color.White)) {
transitionScope = this
AnimatedVisibility(
visible = visible,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "parent").also {
parentSharedContentState = it
},
this@AnimatedVisibility,
)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
Modifier.fillMaxSize(0.5f)
.sharedElement(
rememberSharedContentState(key = "child").also {
childSharedContentState = it
},
this@AnimatedVisibility
)
)
}
}
AnimatedVisibility(
modifier = Modifier.fillMaxSize(0.5f).offset(x = 25.dp, y = 25.dp),
visible = !visible,
enter = fadeIn(tween(100)),
exit = ExitTransition.None
) {
Box(
Modifier.offset(20.dp)
.sharedBounds(
rememberSharedContentState(key = "parent").also {
clippedParentSharedContentState = it
},
this@AnimatedVisibility,
)
) {
Box(
Modifier.offset(-20.dp)
.sharedElement(
rememberSharedContentState(key = "child").also {
clippedChildSharedContentState = it
},
this@AnimatedVisibility,
)
.fillMaxSize(),
)
}
}
}
}
}
rule.waitForIdle()
assertFalse(transitionScope!!.isTransitionActive)
rule.mainClock.autoAdvance = false
visible = false
while (transitionScope?.isTransitionActive != true) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
// Pulse another frame to ensure the rendering has happened
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
// Now shared bounds transition started
while (transitionScope?.isTransitionActive != false) {
// Check that the custom clip is picked up by both parent and child shared states
assertNotNull(parentSharedContentState)
assertEquals(
parentSharedContentState,
childSharedContentState!!.parentSharedContentState
)
assertNotNull(clippedParentSharedContentState)
assertEquals(
clippedParentSharedContentState,
clippedChildSharedContentState!!.parentSharedContentState
)
assertTrue(parentSharedContentState!!.isMatchFound)
assertTrue(childSharedContentState!!.isMatchFound)
assertTrue(clippedParentSharedContentState!!.isMatchFound)
assertTrue(clippedChildSharedContentState!!.isMatchFound)
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testScaleTransition() {
var isSquare by mutableStateOf(true)
val boundsTransform = BoundsTransform { _, _ -> tween(100, easing = LinearEasing) }
var transition: Transition<*>? = null
rule.setContent {
SharedTransitionLayout(Modifier.testTag("scope").background(Color.White)) {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
AnimatedContent(
isSquare,
transitionSpec = {
EnterTransition.None togetherWith ExitTransition.None using null
}
) { isSquare ->
if (this.transition.targetState == EnterExitState.Visible) {
transition = this.transition
}
if (isSquare) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
enter = EnterTransition.None,
exit = ExitTransition.None,
resizeMode = ScaleToBounds(ContentScale.Fit),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(80.dp)
.background(Color.Red)
)
} else {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
enter = EnterTransition.None,
exit = ExitTransition.None,
resizeMode = ScaleToBounds(ContentScale.Fit),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(40.dp, 160.dp)
.background(Color.Gray)
)
}
}
}
}
}
rule.waitForIdle()
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.Red } }
}
rule.mainClock.autoAdvance = false
isSquare = false
// Wait for transition to start
while (transition!!.targetState == transition!!.currentState) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
// Anti alias tolerance
val tolerance = 2
while (transition!!.targetState != transition!!.currentState) {
// animating from 80, 80 -> 40, 160
rule.onNodeWithTag("scope").run {
val fraction = (transition!!.playTimeNanos / 1000_000L) / 100f
val expectedBoundsWidth = (1f - fraction) * 80f + fraction * 40f
val expectedBoundsHeight = (1f - fraction) * 80f + fraction * 160f
if (fraction > 0) {
captureToImage().run {
assertEquals(expectedBoundsWidth.roundToInt(), width)
assertEquals(expectedBoundsHeight.roundToInt(), height)
assertPixels { (x, y) ->
val greyMin = width / 2 - height / 8
val greyMax = width / 2 + height / 8
if (x > greyMin + tolerance && x < greyMax - tolerance) {
Color.Gray
} else if (x < greyMin - tolerance || x > greyMax + tolerance) {
// This should be either red or white depending on height
if (
y > height / 2f - width / 2f + tolerance &&
y < height / 2f + width / 2f - tolerance
) {
Color.Red
} else null
} else null
}
}
}
rule.mainClock.advanceTimeByFrame()
}
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testScaleTransitionFillHeightAndFillWidth() {
var isSquare by mutableStateOf(true)
val boundsTransform = BoundsTransform { _, _ -> tween(100, easing = LinearEasing) }
var transition: Transition<*>? = null
rule.setContent {
SharedTransitionLayout(Modifier.testTag("scope").background(Color.White)) {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
AnimatedContent(
isSquare,
transitionSpec = {
EnterTransition.None togetherWith ExitTransition.None using null
}
) { isSquare ->
if (this.transition.targetState == EnterExitState.Visible) {
transition = this.transition
}
if (isSquare) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
EnterTransition.None,
ExitTransition.None,
resizeMode = ScaleToBounds(ContentScale.FillHeight),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(80.dp)
.background(Color.Red)
)
} else {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
EnterTransition.None,
ExitTransition.None,
resizeMode = ScaleToBounds(ContentScale.FillWidth),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(40.dp, 160.dp)
.background(Color.Gray)
)
}
}
}
}
}
rule.waitForIdle()
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.Red } }
}
rule.mainClock.autoAdvance = false
isSquare = false
// Wait for transition to start
while (transition!!.targetState == transition!!.currentState) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
while (transition!!.targetState != transition!!.currentState) {
// animating from 80, 80 -> 40, 160
rule.onNodeWithTag("scope").run {
val fraction = (transition!!.playTimeNanos / 1000_000L) / 100f
val expectedBoundsWidth = (1f - fraction) * 80f + fraction * 40f
val expectedBoundsHeight = (1f - fraction) * 80f + fraction * 160f
if (fraction > 0) {
captureToImage().run {
assertEquals(expectedBoundsWidth.roundToInt(), width)
assertEquals(expectedBoundsHeight.roundToInt(), height)
assertPixels { Color.Gray }
}
}
rule.mainClock.advanceTimeByFrame()
}
}
rule.waitForIdle()
isSquare = true
// Wait for transition to start
while (transition!!.targetState == transition!!.currentState) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
while (transition!!.targetState != transition!!.currentState) {
// animating from 80, 80 -> 40, 160
rule.onNodeWithTag("scope").run {
val fraction = (transition!!.playTimeNanos / 1000_000L) / 100f
val expectedBoundsWidth = (1f - fraction) * 40f + fraction * 80f
val expectedBoundsHeight = (1f - fraction) * 160f + fraction * 80f
if (fraction > 0) {
captureToImage().run {
assertEquals(expectedBoundsWidth.roundToInt(), width)
assertEquals(expectedBoundsHeight.roundToInt(), height)
assertPixels { Color.Red }
}
}
rule.mainClock.advanceTimeByFrame()
}
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testScaleTransitionAlignment() {
var isSquare by mutableStateOf(true)
val boundsTransform = BoundsTransform { _, _ -> tween(100, easing = LinearEasing) }
var transition: Transition<*>? = null
rule.setContent {
SharedTransitionLayout(Modifier.testTag("scope").background(Color.White)) {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
AnimatedContent(
isSquare,
transitionSpec = {
EnterTransition.None togetherWith ExitTransition.None using null
}
) { isSquare ->
if (this.transition.targetState == EnterExitState.Visible) {
transition = this.transition
}
if (isSquare) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
EnterTransition.None,
ExitTransition.None,
resizeMode =
ScaleToBounds(ContentScale.Fit, Alignment.TopStart),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(80.dp)
.background(Color.Red)
)
} else {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
EnterTransition.None,
ExitTransition.None,
resizeMode =
ScaleToBounds(ContentScale.Fit, Alignment.BottomStart),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(40.dp, 160.dp)
.background(Color.Gray)
)
}
}
}
}
}
rule.waitForIdle()
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run { assertPixels { Color.Red } }
}
rule.mainClock.autoAdvance = false
isSquare = false
// Wait for transition to start
while (transition!!.targetState == transition!!.currentState) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
// Anti alias tolerance
val tolerance = 2
while (transition!!.targetState != transition!!.currentState) {
// animating from 80, 80 -> 40, 160
rule.onNodeWithTag("scope").run {
val fraction = (transition!!.playTimeNanos / 1000_000L) / 100f
val expectedBoundsWidth = (1f - fraction) * 80f + fraction * 40f
val expectedBoundsHeight = (1f - fraction) * 80f + fraction * 160f
if (fraction > 0) {
captureToImage().run {
assertEquals(expectedBoundsWidth.roundToInt(), width)
assertEquals(expectedBoundsHeight.roundToInt(), height)
assertPixels { (x, y) ->
val greyMax = height / 4
if (x >= 0 && x < greyMax - tolerance) {
Color.Gray
} else if (x > greyMax + tolerance) {
// This should be either red or white depending on height
if (y > 0 + tolerance && y < width - tolerance) {
Color.Red
} else if (y > width + tolerance) {
Color.White
} else null
} else null
}
}
}
rule.mainClock.advanceTimeByFrame()
}
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testScaleTransitionCrop() {
var isSquare by mutableStateOf(true)
val boundsTransform = BoundsTransform { _, _ -> tween(100, easing = LinearEasing) }
var transition: Transition<*>? = null
rule.setContent {
CompositionLocalProvider(LocalDensity provides Density(1f)) {
SharedTransitionLayout(
Modifier.testTag("scope").background(Color.White).padding(20.dp)
) {
AnimatedContent(
isSquare,
transitionSpec = {
EnterTransition.None togetherWith ExitTransition.None using null
}
) { isSquare ->
if (this.transition.targetState == EnterExitState.Visible) {
transition = this.transition
}
if (isSquare) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
EnterTransition.None,
ExitTransition.None,
resizeMode = ScaleToBounds(ContentScale.Crop),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(80.dp)
.background(Color.Red)
)
} else {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test"),
this,
EnterTransition.None,
ExitTransition.None,
resizeMode = ScaleToBounds(ContentScale.FillWidth),
boundsTransform = boundsTransform,
placeHolderSize = animatedSize
)
.size(40.dp, 160.dp)
.background(Color.Gray)
)
}
}
}
}
}
rule.waitForIdle()
rule.onNodeWithTag("scope").run {
assertExists("Error: Node doesn't exist")
captureToImage().run {
assertPixels { (x, y) ->
if (x > 20 && x < width - 20 && y > 20 && y < height - 20) {
Color.Red
} else if (x < 20 || x > width - 20 || y < 20 && y > height - 20) {
Color.White
} else null
}
}
}
rule.mainClock.autoAdvance = false
isSquare = false
// Wait for transition to start
while (transition!!.targetState == transition!!.currentState) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
while (transition!!.targetState != transition!!.currentState) {
// animating from 80, 80 -> 40, 160
rule.onNodeWithTag("scope").run {
val fraction = (transition!!.playTimeNanos / 1000_000L) / 100f
val expectedBoundsWidth = (1f - fraction) * 80f + fraction * 40f + 40
val expectedBoundsHeight = (1f - fraction) * 80f + fraction * 160f + 40
if (fraction > 0) {
captureToImage().run {
assertEquals(expectedBoundsWidth.roundToInt(), width)
assertEquals(expectedBoundsHeight.roundToInt(), height)
assertPixels { (x, y) ->
if (x > 20 && x < width - 20) {
// Expect gray to draw in the padding area since it does not crop
val verticalDistTimes2 = abs(2 * y - height)
if (verticalDistTimes2 > (width - 40) * 4) {
Color.White
} else if (verticalDistTimes2 < (width - 40) * 4) {
Color.Gray
} else null
} else if (x < 20 || x > width + 20) {
Color.White
} else null
}
}
}
rule.mainClock.advanceTimeByFrame()
}
}
rule.waitForIdle()
isSquare = true
// Wait for transition to start
while (transition!!.targetState == transition!!.currentState) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
while (transition!!.targetState != transition!!.currentState) {
// animating from 80, 80 -> 40, 160
rule.onNodeWithTag("scope").run {
val fraction = (transition!!.playTimeNanos / 1000_000L) / 100f
val expectedBoundsWidth = (1f - fraction) * 40f + fraction * 80f + 40
val expectedBoundsHeight = (1f - fraction) * 160f + fraction * 80f + 40
if (fraction > 0) {
captureToImage().run {
assertEquals(expectedBoundsWidth.roundToInt(), width)
assertEquals(expectedBoundsHeight.roundToInt(), height)
assertPixels { (x, y) ->
if (x > 20 && x < width - 20) {
// Excluded left & right padding
if (y < 20 || y > height - 20) {
// expect grey to be rendered out of bounds since it doesn't
// clip and it's fillWidth
// Expect gray to draw in the padding area since it does not
// crop
val verticalDistTimes2 = abs(2 * y - height)
if (verticalDistTimes2 > (width - 40) * 4) {
Color.White
} else if (verticalDistTimes2 < (width - 40) * 4) {
Color.Gray
} else null
} else if (y > 20 && y < height - 20) {
Color.Red
} else null
} else if (x < 20 || x > width - 20) {
// Left and right padding
Color.White
} else null
}
}
}
rule.mainClock.advanceTimeByFrame()
}
}
}
@Test
fun testScaleTransitionInterruption() {
var showList by mutableStateOf(false)
var selected by mutableIntStateOf(1)
val scaleX = FloatArray(5) { 0f }
val scaleY = FloatArray(5) { 0f }
var detailScaleX: Float = 0f
var detailScaleY: Float = 0f
val boundsTransform = BoundsTransform { _, _ -> tween(160, easing = LinearEasing) }
rule.setContent {
SharedTransitionLayout {
AnimatedContent(
targetState = showList,
transitionSpec = { fadeIn() togetherWith fadeOut() using null }
) {
if (it) {
LazyColumn {
repeat(5) { id ->
item {
Box(Modifier.fillMaxWidth()) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = id),
this@AnimatedContent,
boundsTransform = boundsTransform,
resizeMode = ScaleToBounds(ContentScale.Fit)
)
.size(200.dp, 50.dp)
.background(
if (selected == id) Color.Red else Color.White
)
.onPlaced {
val matrix = Matrix()
it.transformFrom(
it.parentLayoutCoordinates!!,
matrix
)
scaleX[id] = matrix.values[Matrix.ScaleX]
scaleY[id] = matrix.values[Matrix.ScaleY]
}
)
}
}
}
}
} else {
Box(Modifier.fillMaxSize()) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = selected),
this@AnimatedContent,
boundsTransform = boundsTransform,
resizeMode = ScaleToBounds(ContentScale.Fit)
)
.size(100.dp)
.onPlaced {
val matrix = Matrix()
it.transformFrom(it.parentLayoutCoordinates!!, matrix)
detailScaleX = matrix.values[Matrix.ScaleX]
detailScaleY = matrix.values[Matrix.ScaleY]
}
)
}
}
}
}
}
rule.runOnIdle {
assertEquals(1f, detailScaleX)
assertEquals(1f, detailScaleY)
}
rule.mainClock.autoAdvance = false
// detail -> list -> detail interruption
showList = true
while (detailScaleX == 1f) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
var lastDetailScaleX = detailScaleX
var lastListScaleX = scaleX[selected]
// Move forward 3 frames
repeat(6) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
assertEquals(scaleX[selected], scaleY[selected])
assertEquals(detailScaleX, detailScaleY)
// Gradually decreasing with limited change
assertTrue(lastListScaleX - scaleX[selected] > 0.05f)
assertTrue(lastListScaleX - scaleX[selected] < 0.2f)
lastListScaleX = scaleX[selected]
// Gradually increasing with limited change
assertTrue(detailScaleX - lastDetailScaleX > 0.05f)
assertTrue(detailScaleX - lastDetailScaleX < 0.2f)
lastDetailScaleX = detailScaleX
}
// Interruption
selected = 3
showList = false
while (scaleX[selected] == 1f) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
lastListScaleX = scaleX[selected]
lastDetailScaleX = detailScaleX
repeat(6) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
assertEquals(scaleX[selected], scaleY[selected])
assertEquals(detailScaleX, detailScaleY)
// Gradually increasing with limited change
assertTrue(scaleX[selected] - lastListScaleX > 0.05f)
assertTrue(scaleX[selected] - lastListScaleX < 0.2f)
lastListScaleX = scaleX[selected]
// Gradually decreasing with limited change
assertTrue(lastDetailScaleX - detailScaleX > 0.05f)
assertTrue(lastDetailScaleX - detailScaleX < 0.2f)
lastDetailScaleX = detailScaleX
}
rule.mainClock.autoAdvance = true
rule.waitForIdle()
assertEquals(1f, detailScaleX)
assertEquals(1f, detailScaleY)
}
@Test
fun testFoundMatchBeforeSharedContentComposed() {
var isSquare by mutableStateOf(true)
var box2Composed: Boolean = false
rule.setContent {
SharedTransitionLayout(
Modifier.testTag("scope").background(Color.White).padding(20.dp)
) {
AnimatedContent(
isSquare,
transitionSpec = {
EnterTransition.None togetherWith ExitTransition.None using null
}
) { isSquare ->
if (isSquare) {
Box(
Modifier.sharedBounds(
rememberSharedContentState(key = "test").also {
if (transition.currentState == transition.targetState) {
assertFalse(it.isMatchFound)
} else {
assertEquals(box2Composed, it.isMatchFound)
}
},
this,
)
.size(80.dp)
.background(Color.Red)
)
} else {
val key = rememberSharedContentState(key = "test")
if (transition.currentState != transition.targetState) {
// isMatchFound returns false when created, and next frame return true
assertEquals(box2Composed, key.isMatchFound)
}
box2Composed = true
Box(
Modifier.sharedBounds(key, this)
.size(40.dp, 160.dp)
.background(Color.Gray)
)
}
}
}
}
isSquare = !isSquare
rule.waitForIdle()
}
sealed class Screen {
abstract val key: Int
object List : Screen() {
override val key = 1
}
data class Details(val item: Int) : Screen() {
override val key = 2
}
}
@SdkSuppress(minSdkVersion = 26)
@Test
fun testRandomScrollingWithInterruption() {
@Suppress("PrimitiveInCollection")
val colors =
listOf(Color(0xffff6f69), Color(0xffffcc5c), Color(0xff2a9d84), Color(0xff264653))
var state by mutableStateOf<Screen>(Screen.List)
val lazyListState = LazyListState()
rule.setContent {
SharedTransitionLayout(modifier = Modifier.fillMaxWidth().height(800.dp)) {
AnimatedContent(
state,
label = "",
contentKey = { it.key },
transitionSpec = {
if (initialState == Screen.List) {
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
} else {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
}
}
) {
when (it) {
Screen.List -> {
LazyColumn(state = lazyListState) {
items(50) { item ->
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier =
Modifier.size(100.dp)
.then(
Modifier.sharedElement(
rememberSharedContentState(
key = "item-image$item"
),
this@AnimatedContent,
)
)
.background(colors[item % 4]),
)
Spacer(Modifier.size(15.dp))
}
}
}
}
is Screen.Details -> {
val item = it.item
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier.sharedElement(
rememberSharedContentState(key = "item-image$item"),
this@AnimatedContent,
)
.background(colors[item % 4])
.fillMaxWidth(),
)
}
}
}
}
}
}
rule.waitForIdle()
rule.mainClock.autoAdvance = false
state = Screen.Details(5)
repeat(3) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
state = Screen.List
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
repeat(3) {
repeat(5) {
rule.runOnIdle { runBlocking { lazyListState.scrollToItem(40 - it * 10) } }
}
rule.mainClock.advanceTimeByFrame()
repeat(10) { rule.runOnIdle { runBlocking { lazyListState.scrollToItem(it * 5) } } }
rule.mainClock.advanceTimeByFrame()
rule.runOnIdle { runBlocking { lazyListState.scrollToItem(0) } }
}
rule.mainClock.autoAdvance = false
}
@Test
fun vigorouslyScrollingSharedElementsInLazyList() {
var state by mutableStateOf<Screen>(Screen.List)
val lazyListState = LazyListState()
@Suppress("PrimitiveInCollection")
val colors =
listOf(Color(0xffff6f69), Color(0xffffcc5c), Color(0xff2a9d84), Color(0xff264653))
rule.setContent {
SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = lazyListState) {
items(50) { item ->
AnimatedVisibility(visible = (state as? Screen.Details)?.item != item) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier =
Modifier.size(100.dp)
.then(
Modifier.sharedElement(
rememberSharedContentState(
key = "item-image$item"
),
this@AnimatedVisibility,
)
)
.background(colors[item % 4]),
)
Spacer(Modifier.size(15.dp))
}
}
}
}
AnimatedVisibility(visible = state is Screen.Details) {
var item: Int? by remember { mutableStateOf(null) }
if (state is Screen.Details) {
item = (state as Screen.Details).item
}
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
Modifier.sharedElement(
rememberSharedContentState(key = "item-image$item"),
this@AnimatedVisibility,
)
.fillMaxWidth()
.background(colors[item!! % 4])
)
}
}
}
}
rule.waitForIdle()
rule.mainClock.autoAdvance = false
state = Screen.Details(5)
repeat(5) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
state = Screen.List
repeat(3) {
rule.waitForIdle()
rule.mainClock.advanceTimeByFrame()
}
repeat(5) {
rule.runOnIdle { runBlocking { lazyListState.scrollToItem(it + 1) } }
rule.mainClock.advanceTimeByFrame()
}
repeat(20) {
rule.runOnIdle {
val id = Random.nextInt(0, 20)
runBlocking { lazyListState.scrollToItem(id) }
}
rule.mainClock.advanceTimeByFrame()
}
}
@Test
fun testUserModifierInSharedTransitionLayout() {
var scope: SharedTransitionScope? = null
rule.setContent {
SharedTransitionLayout(Modifier.offset { IntOffset(65, 536) }) {
scope = this
Box(Modifier.fillMaxSize())
}
}
rule.runOnIdle {
val pos = (scope as SharedTransitionScopeImpl).root.positionInWindow()
assertEquals(Offset(65f, 536f), pos)
}
}
@Test
fun testScaleToBoundsCaching() {
val alignments =
listOf(
TopStart,
TopCenter,
TopEnd,
CenterStart,
Center,
CenterEnd,
BottomStart,
BottomCenter,
BottomEnd
)
val contentScales =
listOf(
ContentScale.FillWidth,
ContentScale.FillHeight,
ContentScale.FillBounds,
ContentScale.Fit,
ContentScale.Crop,
ContentScale.None,
ContentScale.Inside
)
var prev: SharedTransitionScope.ResizeMode? = null
alignments.forEach { alignment ->
contentScales.forEach { contentScale ->
assertTrue(
ScaleToBounds(contentScale, alignment) ===
ScaleToBounds(contentScale, alignment)
)
assertFalse(prev === ScaleToBounds(contentScale, alignment))
prev = ScaleToBounds(contentScale, alignment)
}
}
val customAlignment =
object : Alignment {
override fun align(
size: IntSize,
space: IntSize,
layoutDirection: LayoutDirection
): IntOffset {
return Alignment.Center.align(size, space, layoutDirection)
}
}
val customContentScale =
object : ContentScale {
override fun computeScaleFactor(srcSize: Size, dstSize: Size): ScaleFactor {
return ContentScale.Crop.computeScaleFactor(srcSize, dstSize)
}
}
assertFalse(ScaleToBounds(customContentScale) === ScaleToBounds(customContentScale))
assertFalse(
ScaleToBounds(alignment = customAlignment) ===
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) {
assertEquals(a.width.toFloat(), b.width.toFloat(), delta.width.toFloat())
assertEquals(a.height.toFloat(), b.height.toFloat(), delta.height.toFloat())
}
private fun assertEquals(a: Offset, b: Offset, delta: Offset) {
assertEquals(a.x, b.x, delta.x)
assertEquals(a.y, b.y, delta.y)
}