Home > database >  Jetpack Compose Timing Issue with Stack of Cards
Jetpack Compose Timing Issue with Stack of Cards

Time:05-24

Actually I'm not even sure if this is a timing issue, but let's begin with the code first.

I start out in my MainActivity where I prepare a simple data structure containing letters from A to Z

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val model = mutableStateListOf<Char>()
            model.addAll(('A'..'Z').toList())

            val swipeComplete = {
                model.removeFirst()
            }

            CardStack(elements = model, onSwipeComplete = { swipeComplete() })
        }
    }
}

Here I am calling CardStack, which looks like the following

@Composable
fun CardStack(elements: List<Char>, onSwipeComplete: () -> Unit) {
    elements.take(2).reversed().forEachIndexed { _, character ->
        Box {
            SwipeCard(
                character.toString(),
                onSwipeComplete = onSwipeComplete
            )
        }
    }
}

When swiping a card, I want to view the card underneath it as well. Therefore I am only taking the two top-most cards and display them. Then comes the SwipeCard itself.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeCard(text: String, onSwipeComplete: () -> Unit) {
    val color by remember {
        val random = Random()
        mutableStateOf(Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)))
    }

    val screenWidth = LocalConfiguration.current.screenWidthDp.dp.value
    val screenDensity = LocalConfiguration.current.densityDpi

    var offsetXTarget by remember { mutableStateOf(0f) }
    var offsetYTarget by remember { mutableStateOf(0f) }

    val swipeThreshold = abs(screenWidth * screenDensity / 100)

    var dragInProgress by remember {
        mutableStateOf(false)
    }

    val offsetX by animateFloatAsState(
        targetValue = offsetXTarget,
        animationSpec = tween(
            durationMillis = screenDensity / 3,
            easing = LinearEasing
        ),
        finishedListener = {
            if (!dragInProgress) {
                onSwipeComplete()
            }
        }
    )
    val offsetY by animateFloatAsState(
        targetValue = offsetYTarget,
        animationSpec = tween(
            durationMillis = screenDensity / 3,
            easing = LinearEasing
        )
    )

    val rotationZ = (offsetX / 60).coerceIn(-40f, 40f) * -1

    Card(
        shape = RoundedCornerShape(20.dp),
        elevation = 0.dp,
        backgroundColor = color,
        modifier = Modifier
            .fillMaxSize()
            .padding(50.dp)
            .graphicsLayer(
                translationX = offsetX,
                translationY = offsetY,
                rotationZ = rotationZ
            )
            .pointerInput(Unit) {
                detectDragGestures(
                    onDrag = { change, dragAmount ->
                        dragInProgress = true
                        change.consumeAllChanges()
                        offsetXTarget  = dragAmount.x
                        offsetYTarget  = dragAmount.y
                    },
                    onDragEnd = {
                        if (abs(offsetX) < swipeThreshold / 20) {
                            offsetXTarget = 0f
                            offsetYTarget = 0f
                        } else {
                            offsetXTarget = swipeThreshold
                            offsetYTarget = swipeThreshold
                            if (offsetX < 0) {
                                offsetXTarget *= -1
                            }
                        }
                        dragInProgress = false
                    }
                )
            }

    ) {
        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            Text(
                text = text,
                style = TextStyle(
                    fontWeight = FontWeight.Bold,
                    fontSize = 52.sp
                ),
                color = Color.White
            )
        }
    }
}

This is how it looks in action:

enter image description here

A few key points, let's consider the initial state with all letters from A to Z:

When I start to drag the card with letter "A", I can see card with letter "B" underneath it.

When the drag motion ends the card for letter "A" shall be animated away to either the left or the right side, depending on what side the user has chosen.

When the animation has been finished, the onSwipeComplete shall be called in order to remove the top-most element, the letter "A", of my data model.

After the top-most element has been removed from the data model I expect the stack of cards to be recomposed with letters "B" and "C".

The problem is when the card "A" is animated away, then suddenly the letter "B" is drawn on this animated card and where "B" has been is now "C".

It seems the data model is already updated while the first card with letter "A" is still being animated away.

This leaves me with only one card for letter "C" left. Underneath "C" is no other card.

For me there seems something wrong with the timing, but I can't figure out what exactly.

Here are all the imports to add:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.swipecard.ui.theme.SwipeCardTheme
import java.util.*
import kotlin.math.abs

This also requires the following dependencies

implementation "androidx.compose.runtime:runtime:1.0.1"
implementation "androidx.compose.runtime:runtime-livedata:1.0.1"

Any hints highly appreciated as always!

CodePudding user response:

When you delete an item from the array, from the Compose point of view it looks as if you deleted the last view and changed the data of the other views. The view that was view A is reused for content B, and because that view has non-zero offset values, it is not visible on the screen, so you only see view C.

Using key, you can tell Compose which view is associated with which data, so that they are reused correctly:

@Composable
fun CardStack(elements: List<Char>, onSwipeComplete: () -> Unit) {
    elements.take(2).reversed().forEach { character ->
        key(character) {
            SwipeCard(
                character.toString(),
                onSwipeComplete = onSwipeComplete
            )
        }
    }
}

p.s. some comments about your code:

  1. It's quite strange that you pass screenDensity to durationMillis. It's pretty small value in terms of millis, which makes your animation almost instant, and looks kind of strange it terms of logic.
  2. If you don't need index from forEachIndexed, just use forEach instead of specifying _ placeholder.
  3. Using Box as you did here, when you only have a single child and don't specify any modifiers for Box has no effect.
  • Related