Skip to content
This repository has been archived by the owner on Dec 27, 2024. It is now read-only.
This repository has been archived by the owner on Dec 27, 2024. It is now read-only.

MotionLayout's custom properties return invalid values on state restoration #862

Open
@mars885

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:

  1. Collapse the toolbar so that the progress becomes 1f. Notice that the background color of the image is set to blue because of the customColor("cover", Color.Blue) property set on the end1 state.
  2. Exit the app.
  3. Create a condition where a process is killed. I usually achieve that by enabling the "Don't keep activities" in the developer settings.
  4. Enter the app again.
  5. Since we have used rememberSaveable for the toolbar height, the progress is retained and set to 1f. So far, so good. However, notice that the background color of the image is now transparent instead of blue. The following code val customColor = customColor("image", "cover") returns Color.Transparent instead of Color.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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions