Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
mars885 opened this issue Oct 24, 2024 · 0 comments
Open
Labels
bug Something isn't working

Comments

@mars885
Copy link

mars885 commented Oct 24, 2024

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

@mars885 mars885 added the bug Something isn't working label Oct 24, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

1 participant