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. :(
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.