Search code examples
androidkotlinandroid-jetpack-compose

Image Composable issue on LazyColumn


Intro:

I'm building a simple app in Jetpack Compose. In the Home section of the app, I display a list of items. Nothing to fancy. I do this using a custom card inside a LazyColumn.

Issue:

When the app loads, the list is shown and then the items are shown for the first time. Then I scroll down and all the items are shown as expected in the custom card.
The issue comes up when I do scroll up and then scroll down again, basically recomposing/rerendering the items. Then the image of the custom card ignores its contentScale and its modifier completely and breaks the layout. Something extremely odd.

What have I tried:

  • I tried first to build the custom card with columns and rows.
  • Then I built it with ConstraintLayout.
  • Tried to remember the modidier.

What am I trying to achieve:

  • A custom card where it has a defined height (180.dp) and a fillMaxWidth.
  • The image should be placed on the start of the custom card, its height should be fixed and the width as well (e.g. width: 124.dp, height: 180.dp).

Custom card code:

@Composable
fun ProductCard(
    data: ProductDisplayData,
    onClick: (ProductDisplayData) -> Unit = {}
) {

    val constraints = ConstraintSet {
        val imageSection = createRefFor("imageSection")
        val textSection = createRefFor("textSection")
        constrain(imageSection) {
            top.linkTo(parent.top)
            start.linkTo(parent.start)
            end.linkTo(textSection.start)
            width = Dimension.value(128.dp)
            height = Dimension.value(180.dp)
        }
        constrain(textSection) {
            top.linkTo(parent.top)
            start.linkTo(imageSection.end)
            bottom.linkTo(parent.bottom)
            end.linkTo(parent.end)
            width = Dimension.fillToConstraints
            height = Dimension.fillToConstraints
        }
    }

    ConstraintLayout(constraints, modifier = Modifier
        .fillMaxWidth()
        .clip(RoundedCornerShape(8.dp))
        .padding(vertical = 8.dp)
        .clickable { onClick(data) }
    ) {
        if (data.image != null) {
            Box(
                modifier = Modifier
                    .layoutId("imageSection")
                    .height(180.dp)
                    .width(128.dp)
                    .clip(RoundedCornerShape(8.dp))
                    .clipToBounds()
            ) {
                Image(
                    bitmap = data.image.asImageBitmap(),
                    contentDescription = "Product Image",
                    modifier = Modifier
                        .fillMaxSize()
                )
            }

        }
        Column(
            modifier = Modifier
                .layoutId("textSection")
                .fillMaxWidth()
                .padding(8.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = data.name,
                    fontWeight = FontWeight.Bold,
                    fontSize = 18.sp,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    modifier = Modifier.weight(1f)
                )
                Text(text = "${data.price}$" )
            }
            Text(text = data.brand, fontWeight = FontWeight.Bold, fontSize = 14.sp)
            Text(text = data.size, modifier = Modifier, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, fontStyle = FontStyle.Italic)
            Text(text = data.category, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
            LazyRow {
                items(data.tags) { tag ->
                    Chip(
                        name = tag,
                        isSelected = false,
                        onSelectionChanged = {}
                    )
                }
            }
            Text(
                text = data.description,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
                fontWeight = FontWeight.Light,
                fontSize = 12.sp
            )
        }
    }
}

Images and graphic explanation of what's going on:

  • This is the first time the item is shown in the LazyColumn This is the first time the item is shown in the LazyColumn

  • This is the second time, basically scrolled up and down again. In this case the contentScale has completely gone away. This is the second time, basically scrolled up and down again. In this case the contentScale has completely gone away.

  • Also look at the corner radius (.clip) of the image at the first time the item is shown. Also look at the corner radius (.clip) of the image at the first time the item is shown.

  • The corner radius (.clip) is gone on the second time the item is shown, so basically also the modifiers are gone. The corner radius (.clip) is gone on the second time the item is shown, so basically also the modifiers are gone.



MRE:

The only peculiarity of the image is that it comes in the response as a base64 string, and it is converted to a bitmap from it. That's why I'm using Image and not coil (AsyncImage) to display the image. I can not place the base64 string for this MRE due to its length.
In order to test it, please, convert an image to base64 string.
The ProductCard code is posted above.

data class ProductDisplayData(
    val id: Int,
    val name: String,
    val description: String,
    val size: String,
    val ownerName: String,
    val ownerID: Int,
    val price: String,
    val category: String,
    val brand: String,
    val tags: List<String>,
    val image: Bitmap?
)

val products = listOf(
   ProductDisplayData(
        id = 0,
        name = "Polo",
        description = "Nice slim fit polo shirt, perfect for casual wear, available in multiple colors",
        size = "Small",
        price = "120",
        image = bitmapFromBase64String,
        brand = "Boss",
        category = "Shirts",
        tags = listOf("Shirts", "Boss", "Slim fit", "Casual", "Polo", "Small"),
        ownerName = "John Doe",
        ownerID = 0
    )
    //repeat it until the list overflows...

)
@Composable
fun HomeScreen() {
   LazyColumn(
            modifier = Modifier.fillMaxWidth(),
            contentPadding = PaddingValues(vertical = 16.dp),

            ) {
            items(products) { product ->
                ProductCard(
                    data = product
                ) { productSelected ->
                    // do action... irrelevant for this issue.
                }
            }
        }
}

Very interesting detail is that, if I test it using coil (AsyncImage) applying a hardcoded url, this issue doesn't happens and it works correctly. Am I in front of a androidx.compose.foundation.Image Bug?


Solution

  • I was 2 days into this ultra odd issue. I fixed it using the Coil function rememberAsyncImagePainter. I'm not 100% sure if this is the more appropriate approach but it works as it should.
    I will be happy to read thoughts :), anyways thanks @Leviathan for your time.

    This is how the Image code looks like:

    Image(
        contentScale = ContentScale.Crop,
        painter = rememberAsyncImagePainter(it),
        contentDescription = "Product Image",
        modifier = Modifier
           .clipToBounds()
           .height(180.dp)
           .width(128.dp)
           .layoutId("imageSection")
           .clip(RoundedCornerShape(8.dp))
    )