user924
user924

Reputation: 12283

Implementing change order (drag and drop) with animations for items (cards) in LazyColumn similar to AppleWallet?

Here's the example how it works in AppleWallet: https://www.youtube.com/shorts/X-vcgG-WBCk

Basically the move animation happens like this: first the card smoothly moves down and then up

So I have made Jetpack Compose sample example (full code) with this reorderable library (edited a couple of code lines), but for now it uses default Modifier.animateItem() animation for items:


private val CARD_HEIGHT = 500.dp

@Composable
fun ScreenContent(modifier: Modifier = Modifier) {
    var items by remember { mutableStateOf((0..5).map { getRandomColor() }) }

    val lazyListState = rememberLazyListState()

    val cardPctHeight = 0.1f
    val cardLastPctHeight = 0.5f

    // https://github.com/Calvin-LL/Reorderable
    val reorderableLazyListState = rememberReorderableLazyListState(
        lazyListState = lazyListState,
        shouldItemMoveThreshold = (CARD_HEIGHT * cardPctHeight) / 2f
    ) { from, to ->
        items = items.toMutableList().apply {
            add(to.index, removeAt(from.index))
        }
    }

    LazyColumn(
        state = lazyListState,
        modifier = modifier.fillMaxSize(),
        contentPadding = PaddingValues(top = 10.dp),
        verticalArrangement = Arrangement.Bottom
    ) {
        itemsIndexed(
            items = items,
            key = { _, item -> item.toArgb() }
        ) { index, item ->
            ReorderableItem(reorderableLazyListState, key = item.toArgb()) {
                Box(
                    modifier = Modifier
                        .padding(horizontal = 10.dp)
                        .draggableHandle()
                        .layout { measurable, constraints ->
                            val placeable = measurable.measure(constraints)
                            val placeableHeight = if (index != items.lastIndex) {
                                placeable.height * cardPctHeight
                            } else {
                                placeable.height * cardLastPctHeight
                            }
                            layout(placeable.width, placeableHeight.roundToInt()) {
                                placeable.place(0, 0)
                            }
                        }
                        .fillMaxWidth()
                        .clip(RoundedCornerShape(12.dp))
                        .height(CARD_HEIGHT)
                        .background(color = item)
                ) {
                    //
                }
            }
        }
    }
}

private fun getRandomColor(): Color {
    return Color(
        red = Random.nextFloat(),
        green = Random.nextFloat(),
        blue = Random.nextFloat(),
        alpha = 1f
    )
}

Right now it works like this with the default animation:

https://youtube.com/shorts/gMDc6acLGrg

That reorderable library specifies default animation here

@Composable
fun LazyItemScope.ReorderableItem(
    ...
    animateItemModifier: Modifier = Modifier.animateItem(),

UPDATE:

I was able to get the same animation (with exit and enter behavior) using AnimatedVisibility (instead of LazyItemScope.animateItem) but it only works normally if we dragging the item slowly, because there are Coroutine delays for the animation switching (enter/exit), and also if we move the dragging item faster we can see that other items are clipped to their visible height when they are moved.

Video: https://youtube.com/shorts/E_2Edsgz7Cg

Code:

private val CARD_HEIGHT = 500.dp

@Composable
fun AnimatedReorderableItem(
    visible: Boolean,
    content: @Composable () -> Unit
) {
    AnimatedVisibility(
        visible = visible,
        enter = slideInVertically(
            // The item slides in from below
            initialOffsetY = { fullHeight -> fullHeight }
        ) + fadeIn(animationSpec = tween(durationMillis = 300)),
        exit = slideOutVertically(
            // The item slides out downward
            targetOffsetY = { fullHeight -> fullHeight }
        ) + fadeOut(animationSpec = tween(durationMillis = 300))
    ) {
        content()
    }
}

@Composable
fun ScreenContent(modifier: Modifier = Modifier) {
    Column {
        var items by remember { mutableStateOf((0..5).map { getRandomColor() }) }

        val itemVisibility = remember { mutableStateMapOf<Color, Boolean>() }

        val lazyListState = rememberLazyListState()

        val cardPctHeight = 0.1f
        val cardLastPctHeight = 0.5f

        // https://github.com/Calvin-LL/Reorderable
        val reorderableLazyListState = rememberReorderableLazyListState(
            lazyListState = lazyListState,
            shouldItemMoveThreshold = (CARD_HEIGHT * cardPctHeight) / 2f
        ) { from, to ->
            val item = items[to.index]

            itemVisibility[item] = false

            // TODO: but the problem here, we can't move the dragging item too fast (before animation ends)
            delay(300)

            itemVisibility[item] = true

            items = items.toMutableList().apply {
                add(to.index, removeAt(from.index))
            }
        }

        LazyColumn(
            state = lazyListState,
            modifier = modifier
                .fillMaxWidth()
                .weight(1f),
            contentPadding = PaddingValues(top = 10.dp),
            verticalArrangement = Arrangement.Bottom
        ) {
            itemsIndexed(
                items = items,
                key = { _, item -> item.toArgb() }
            ) { index, item ->
                AnimatedReorderableItem(visible = itemVisibility[item] ?: true) {
                    ReorderableItem(
                        state = reorderableLazyListState,
                        key = item.toArgb(),
                        animateItemModifier = Modifier // disable default animation Modifier.animateItem()
                    ) {
                        Box(
                            modifier = Modifier
                                .padding(horizontal = 10.dp)
                                .longPressDraggableHandle()
                                .layout { measurable, constraints ->
                                    val placeable = measurable.measure(constraints)
                                    val placeableHeight = if (index != items.lastIndex) {
                                        placeable.height * cardPctHeight
                                    } else {
                                        placeable.height * cardLastPctHeight
                                    }
                                    layout(placeable.width, placeableHeight.roundToInt()) {
                                        placeable.place(0, 0)
                                    }
                                }
                                .fillMaxWidth()
                                .clip(RoundedCornerShape(12.dp))
                                .height(CARD_HEIGHT)
                                .background(color = item)
                        ) {
                            //
                        }
                    }
                }
            }
        }
    }
}

As I understand the Modifier.animateItem() is limited and only allows one transition animation for placement, we can't just set animation for item enter/exit as with AnimatedVisiblity.

Upvotes: 0

Views: 73

Answers (0)

Related Questions