Home > Software engineering >  Jetpack compose animation with images weird issue
Jetpack compose animation with images weird issue

Time:09-17

I am seeing a weird image flickering issue with Jetpack compose. It is a simple two card deck where the top image is animated off-screen to reveal the second card. The second card displays fine for a second and then the first image flashes on the screen. I have tried with Coil, Fresco and Glide and they all behave the same way.

import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import coil.compose.rememberImagePainter
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt

class MainViewModel : ViewModel() {
    var images = MutableLiveData(listOf(
        "https://images.pexels.com/photos/212286/pexels-photo-212286.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/163016/crash-test-collision-60-km-h-distraction-163016.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/1366944/pexels-photo-1366944.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/5878501/pexels-photo-5878501.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/3846022/pexels-photo-3846022.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
    ))
}

class MainActivity : ComponentActivity() {
    private val model by viewModels<MainViewModel>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen(model)
        }
    }
}

@Composable
fun MainScreen(model: MainViewModel) {
    val images: List<String> by model.images.observeAsState(listOf())

    Box(
        modifier = Modifier.fillMaxSize()
    ) {
        images?.take(2).reversed().forEach {
            Card(url = it) {
                val d = model.images.value?.toMutableList()
                d?.let {
                    it.removeFirst()
                    model.images.value = it
                }
            }
        }
    }
}


@Composable
fun Card(
    url: String,
    advance: ()-> Unit = {},
){
    val coroutineScope = rememberCoroutineScope()
    var offsetX = remember(url) { Animatable(0f) }

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .fillMaxSize()
            .background(color = Color.White)
            .clickable {
                coroutineScope.launch {
                    offsetX.animateTo(
                        targetValue = 3000F
                    )
                }
                coroutineScope.launch {
                    delay(400)
                    advance()
                }
            }
    ) {
        Image(
            painter = rememberImagePainter(
                data = url,
            ),
            contentDescription = null,
            modifier = Modifier
                .size(400.dp, 400.dp)
        )
    }
}

Also threw it on a github here in case anyone wants to try it out: https://github.com/studentjet/learncompose

CodePudding user response:

The problem is this: you have two card views. After deleting the top card, compose reuses them and updates them with the new data. And while the top card loads the second image, it still shows the first one.

You could disable caching, but in that case the image would still flash because it would show an empty space first. Instead, you need to have the second card's view reused for the first one.

To do this, you have to go to the deepest level of Compose layout: SubcomposeLayout. It's used to build the LazyColumn and it's the only place where you can specify a key for the composable for future reuse with subcompose.


Also some offtopic suggestions to your code.

  1. You can use mutableStateListOf instead of LiveData<List>: updating it will be much easier.

  2. You don't need to run two coroutines and wait for the animation to finish with delay(400). animateTo is also a suspend function, it will give control to the coroutine at the end of the animation, so you can remove the element right after.

@Composable
fun MainScreen(model: MainViewModel) {
    val images = model.images
    SubcomposeLayout(
        modifier = Modifier.fillMaxSize()
    ) { constraints ->
        val placeables = images.take(2).reversed().map { image ->
            // this is essential line. I'm using `image` as id for `subcompose`
            // so content will be reused after removing an item
            subcompose(image) {
                Card(url = image) {
                    model.images.removeFirst()
                }
            }.first().measure(constraints)
        }
        layout(
            width = placeables.maxOf { it.width },
            height = placeables.maxOf { it.height },
        ) {
            placeables.forEach {
                it.place(IntOffset.Zero)
            }
        }
    }
}

class MainViewModel : ViewModel() {
    var images = mutableStateListOf(
        "https://images.pexels.com/photos/1366944/pexels-photo-1366944.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/163016/crash-test-collision-60-km-h-distraction-163016.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/212286/pexels-photo-212286.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/3846022/pexels-photo-3846022.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
        "https://images.pexels.com/photos/5878501/pexels-photo-5878501.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500",
    )
}

@Composable
fun Card(
    url: String,
    advance: () -> Unit = {},
) {
    val coroutineScope = rememberCoroutineScope()
    var offsetX = remember(url) { Animatable(0f) }

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .background(color = Color.White)
            .clickable {
                coroutineScope.launch {
                    offsetX.animateTo(
                        targetValue = 3000F,
                        animationSpec = tween(1000)
                    )
                    advance()
                }
            }
    ) {
        val painter = rememberImagePainter(
            data = url,
        )
        println("$url ${painter.state}")
        Image(
            painter = painter,
            contentDescription = null,
            modifier = Modifier
                .size(400.dp, 400.dp)
        )
    }
}

  • Related