MotionLayout's custom properties return invalid values on state restoration #862
Description
When rememberSaveable
is used to retain the progress of the MotionLayout
and state restoration happens, customColor
and every other custom*
method of the MotionLayoutScope
always returns the initial value (progress == 0f
).
I've created a small sample to reproduce this issue by modifying the CollapsingToolbar example.
package com.paulrybitskyi.motionlayoutbugdemo
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.constraintlayout.compose.Dimension
import androidx.constraintlayout.compose.ExperimentalMotionApi
import androidx.constraintlayout.compose.MotionLayout
import androidx.constraintlayout.compose.MotionScene
import com.paulrybitskyi.motionlayoutbugdemo.ui.theme.MotionLayoutBugDemoTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MotionLayoutBugDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Content(modifier = Modifier.padding(innerPadding))
}
}
}
}
}
@OptIn(ExperimentalMotionApi::class)
@Composable
private fun Content(modifier: Modifier) {
val big = 250.dp
val small = 50.dp
val scene = MotionScene {
val title = createRefFor("title")
val image = createRefFor("image")
val icon = createRefFor("icon")
val start1 = constraintSet {
constrain(title) {
bottom.linkTo(image.bottom)
start.linkTo(image.start)
}
constrain(image) {
width = Dimension.matchParent
height = Dimension.value(big)
top.linkTo(parent.top)
customColor("cover", Color.Transparent)
}
constrain(icon) {
top.linkTo(image.top, 16.dp)
start.linkTo(image.start, 16.dp)
alpha = 0f
}
}
val end1 = constraintSet {
constrain(title) {
bottom.linkTo(image.bottom)
start.linkTo(icon.end)
centerVerticallyTo(image)
scaleX = 0.7f
scaleY = 0.7f
}
constrain(image) {
width = Dimension.matchParent
height = Dimension.value(small)
top.linkTo(parent.top)
customColor("cover", Color.Blue)
}
constrain(icon) {
top.linkTo(image.top, 16.dp)
start.linkTo(image.start, 16.dp)
}
}
transition(start1, end1, "default") {}
}
val maxPx = with(LocalDensity.current) { big.roundToPx().toFloat() }
val minPx = with(LocalDensity.current) { small.roundToPx().toFloat() }
val toolbarHeight = rememberSaveable { mutableStateOf(maxPx) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val height = toolbarHeight.value
if (height + available.y > maxPx) {
toolbarHeight.value = maxPx
return Offset(0f, maxPx - height)
}
if (height + available.y < minPx) {
toolbarHeight.value = minPx
return Offset(0f, minPx - height)
}
toolbarHeight.value += available.y
return Offset(0f, available.y)
}
}
}
val progress = 1 - (toolbarHeight.value - minPx) / (maxPx - minPx)
Column(modifier = modifier) {
MotionLayout(
motionScene = scene,
progress = progress
) {
val customColor = customColor("image", "cover")
.also { color ->
Log.e("bug", "progress = $progress, isColorBlue = ${color == Color.Blue}, isColorTransparent = ${color == Color.Transparent}")
}
Image(
modifier = Modifier.layoutId("image"),
painter = painterResource(R.drawable.bridge),
contentDescription = null,
contentScale = ContentScale.Crop,
colorFilter = ColorFilter.tint(customColor, BlendMode.SrcOver),
)
Image(
modifier = Modifier.layoutId("icon"),
painter = painterResource(R.drawable.menu),
contentDescription = null
)
Text(
modifier = Modifier.layoutId("title"),
text = "San Francisco",
fontSize = 30.sp,
color = Color.White
)
}
LazyColumn(
Modifier
.fillMaxWidth()
.nestedScroll(nestedScrollConnection)
) {
items(100) {
Text(text = "item $it", modifier = Modifier.padding(4.dp))
}
}
}
}
To reproduce:
- Collapse the toolbar so that the
progress
becomes1f
. Notice that the background color of the image is set to blue because of thecustomColor("cover", Color.Blue)
property set on theend1
state. - Exit the app.
- Create a condition where a process is killed. I usually achieve that by enabling the "Don't keep activities" in the developer settings.
- Enter the app again.
- Since we have used
rememberSaveable
for the toolbar height, the progress is retained and set to1f
. So far, so good. However, notice that the background color of the image is now transparent instead of blue. The following codeval customColor = customColor("image", "cover")
returnsColor.Transparent
instead ofColor.Blue
on state restoration &progress == 1f
, which is incorrect and is the core of the bug.
The logging code also confirms this:
MotionLayout(
motionScene = scene,
progress = progress
) {
val customColor = customColor("image", "cover")
.also { color ->
Log.e("bug", "progress = $progress, isColorBlue = ${color == Color.Blue}, isColorTransparent = ${color == Color.Transparent}")
}
Image(
modifier = Modifier.layoutId("image"),
painter = painterResource(R.drawable.bridge),
contentDescription = null,
contentScale = ContentScale.Crop,
colorFilter = ColorFilter.tint(customColor, BlendMode.SrcOver),
)
produces the following:
03:27:31.827 E progress = 1.0, isColorBlue = true, isColorTransparent = false
03:28:06.071 E progress = 1.0, isColorBlue = false, isColorTransparent = true
At 03:27:31.827
is the correct log, where for given progress = 1.0
, we get the blue color. However, after state restoration at 03:28:06.071
, for given progress = 1.0
, we get transparent color instead of blue.
I've also shot a video for easier understanding what is going on:
bug_demo.mp4
Version: androidx.constraintlayout:constraintlayout-compose:1.1.0-rc01
Activity