androidandroid-jetpack-compose

How to create a custom LazyLayout with stacked visible items and horizontal swipe gesture in Jetpack Compose?


I want to create a custom LazyLayout in Jetpack Compose that displays a stack of banners, similar to a card stack. The design looks like this:

Requirements:

I've implemented part of the custom LazyLayout stack with swipeable cards. The layout shows three visible banners stacked such that:

The top card is centered, and the other two peek from the sides.

Only the top card is swipeable, in the horizontal direction.

Here’s how it currently looks:

]

Problem: After swiping the top card, it comes back to the top again, instead of

Moving it to the end or Bringing the previous card to top

After the first swipe, further swipes stop working.

The stack stops updating, and animations are no longer triggered.

    @Composable
fun LazyStackLayout(
    modifier: Modifier = Modifier,
    state: LazyStackItemState,
    itemOffset: Dp = 50.dp,
    offSetItemScale: Float = 0.8f,
    offsetAlpha: Float = 0.8f,
    itemContent: LazyStackItemScope.() -> Unit
) {
    val scope = rememberCoroutineScope()
    val stackItemProvider = rememberLazyStackItemProvider(content = itemContent)
    LazyLayout(
        { stackItemProvider }, modifier.pointerInput(state.currentIndex) {
            detectHorizontalDragGestures(
                onDragEnd = {
                    val threshold = size.width / 3f
                    when {
                        state.swipeOffsetX.value > threshold -> {
                            scope.launch {
                                state.rightSwipe(size)
                            }
                        }

                        state.swipeOffsetX.value < -threshold -> {
                            scope.launch {
                                state.leftSwipe(size)
                            }
                        }

                        else -> {
                            // Not enough swipe, reset
                            scope.launch {
                                state.swipeOffsetX.animateTo(0f)
                            }
                        }
                    }
                },
                onHorizontalDrag = { change, dragAmount ->
                    change.consume()
                    scope.launch {
                        state.swipeOffsetX.snapTo(state.swipeOffsetX.value + dragAmount)
                    }
                }
            )
        },
        null
    ) { constraints ->
        val offsetValue = with(density) {
            itemOffset.toPx().roundToInt()
        }

        layout(constraints.maxWidth, constraints.maxHeight) {
            val itemsToMeasure = listOf(state.currentIndex, state.rightItemIndex, state.leftItemIndex)
            val placeables = mutableListOf<Placeable>()
            itemsToMeasure.forEach { index ->
                val placeable = measure(index, constraints)
                placeables.addAll(placeable)
            }
            val x0 = (constraints.maxWidth - placeables[0].width) / 2
            val y0 = (constraints.maxHeight - placeables[0].height) / 2
            placeables[0].placeRelativeWithLayer(
                x0,
                y0,
                2f
            ) {
                transformOrigin = TransformOrigin(0.5f,0.5f)
                 rotationY = state.swipeOffsetX.value/10
                alpha = 1f
                scaleY = 1f
                this.translationX = state.swipeOffsetX.value
            }
            val x1 = ((constraints.maxWidth - placeables[1].width) / 2) + offsetValue
            val y1 = (constraints.maxHeight - placeables[1].height) / 2

            placeables[1].placeRelativeWithLayer(x1, y1, 1f) {
                transformOrigin = TransformOrigin(0.5f,0.5f)
                 rotationY = state.swipeOffsetX.value/10
                alpha = offsetAlpha
                scaleY = offSetItemScale
            }
            val x2 = ((constraints.maxWidth - placeables[2].width) / 2 )- offsetValue
            val y2 = (constraints.maxHeight - placeables[2].height) / 2
            placeables[2].placeRelativeWithLayer(x2, y2, 1f) {
                transformOrigin = TransformOrigin(0.5f,0.5f)
                 rotationY = state.swipeOffsetX.value/10
                alpha = offsetAlpha
                scaleY = offSetItemScale
            }
        }
    }

}


class LazyStackItemState(val items: List<Any>) {
    val itemCount: Int = items.size
    var currentIndex by mutableIntStateOf(0)
        private set

    val swipeOffsetX by mutableStateOf(Animatable(0f))
    var rightItemIndex by mutableIntStateOf(currentIndex + 1)
        private set
    var leftItemIndex by mutableIntStateOf(items.lastIndex)
        private set

    suspend fun rightSwipe(size: IntSize) {
        swipeOffsetX.animateTo(size.width.toFloat())
        rightItemIndex = currentIndex
        currentIndex = (currentIndex - 1 + itemCount) % itemCount
        leftItemIndex = currentIndex - 1
        swipeOffsetX.snapTo(-size.width.toFloat())
        swipeOffsetX.animateTo(0f)
    }

    suspend fun leftSwipe(size: IntSize) {
        swipeOffsetX.animateTo(-size.width.toFloat())
        leftItemIndex = currentIndex
        currentIndex = (currentIndex + 1 + itemCount) % itemCount
        rightItemIndex = currentIndex + 1
        swipeOffsetX.snapTo(size.width.toFloat())
        swipeOffsetX.animateTo(0f)
    }
}


@Composable
fun UseLazyStackLayout(modifier: Modifier = Modifier) {
    val myDataItems = remember {
        List(10) { index -> "Item $index" } 
    }
    LazyStackLayout(
        modifier = modifier,
        state = LazyStackItemState(myDataItems)
    ) {
        items(myDataItems) { value ->
            Card(
                modifier = Modifier
                    .size(200.dp),
                elevation = CardDefaults.cardElevation(4.dp)
            ) {
                Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
                    Text(text = value, style = MaterialTheme.typography.headlineMedium)
                }
            }
        }
    }
}

My further question is how LazyRow or LazyColumn's item provider works? I have been having a mutableList inside of it which ofcourse is inefficient. ( I looked the source code but its too complex to understand). I also tried to provide a dsl block just like LazyRow/Column by creating custom scope.

 typealias LazyStackLayoutComposable = @Composable (LazyStackItemScope.(Int) -> Unit)

interface LazyStackItemScope {

    fun items(
        count: Int,
        itemContent: LazyStackLayoutComposable
    )

    fun item(content: LazyStackLayoutComposable)

    fun <T> items(
        items: List<T>,
        itemContent: @Composable (LazyStackItemScope.(T) -> Unit)
    )
}

class LazyStackItemScopeImpl() : LazyStackItemScope {
    private val _items = mutableListOf<@Composable () -> Unit>()
    val items: List<@Composable () -> Unit> get() = _items

    override fun items(
        count: Int,
        itemContent: LazyStackLayoutComposable
    ) {
        repeat(count) { index ->
            _items.add { this@LazyStackItemScopeImpl.itemContent(index) }
        }
    }

    override fun item(
        content: LazyStackLayoutComposable
    ) {
        _items.add { this@LazyStackItemScopeImpl.content(0) }

    }

    override fun <T> items(
        items: List<T>,
        itemContent: @Composable (LazyStackItemScope.(T) -> Unit)
    ) {
        items.forEach { item ->
            _items.add { this@LazyStackItemScopeImpl.itemContent(item) }
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
class LazyStackLazyLayoutItemProvider(
    private val composables: List<@Composable () -> Unit>
) : LazyLayoutItemProvider {
    override val itemCount: Int
        get() = composables.size

    @Composable
    override fun Item(index: Int, key: Any) {
        composables[index].invoke()
    }

    override fun getKey(index: Int): Any = index

}

I know i provided too much code but its the least i could give. :(


Solution

  • The actual problem was due to no recomposition of when the state change. I was maintaining three states for different index but it can be maintained by single index which resolved the issue.
    I have done lots of improvements like using key, content type and addition of intervals to support multiple calls of methods for LazyStackItemScope just like LazyRow /Column.
    enter image description here