I have an HorizontalPager in Compose and users are able to remove any item of Pager. When the user removes any item i want other elements move to empty place with an animation.
This is actually a default animation for RecylerView(ListAdapter) or even LazyColumn uses animateItem to manage that or with deprecated name animateItemPlacement.
I'm wondering how can I achieve the same concept in HorizontalPager?
@Composable
fun HomePageBanner(
bannerList: List<BannerUIModel>,
modifier: Modifier = Modifier,
onButtonClicked: (index: Int) -> Unit = {},
onItemRemoved: (index: Int) -> Unit = {},
) {
val pageCount = bannerList.size
val pagerState = rememberPagerState(pageCount = { pageCount })
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = PaddingValues(start = 16.dp, end = 12.dp),
pageSpacing = 0.dp,
) { index ->
val banner = bannerList[index]
BannerCardItem(
bannerItem = banner,
modifier = Modifier.fillMaxWidth().padding(12.dp),
onButtonClicked = { onButtonClicked(index) },
onItemRemoved = {
// When this is called, the item will be removed
// and HomePageBanner will be called with the new list
onItemRemoved(index)
},
)
}
}
Modifier.animateItem()
is a scoped Modifier defined in LazyItemScope
. There is no default PagerScope modifier, actually nothing in PagerScope yet. You can open a feature request for Pager as well.
/**
* Receiver scope for [Pager].
* Note: This is empty now, but we may add new members in the future if needed.
*/
sealed interface PagerScope
internal object PagerScopeImpl : PagerScope
But You can apply snapping behavior to LazyRow/Column
you can snap as Pager
does and use Modifer.animateItem()
Data class must have unique keys for matching which items to animate for placement, which ones for appear and which ones for disappear and must be used inside key = {item-> item.id}
.
data class MyData(val id: String = UUID.randomUUID().toString(), val value: Int)
class MyViewModel : ViewModel() {
val list =
mutableStateListOf<MyData>().apply {
repeat(6) {
add(
MyData(value = it)
)
}
}
fun removeItem(index: Int) {
list.removeAt(index)
}
}
@Preview
@Composable
fun LazyRowSnapAndDeleteAnimation() {
val viewModel = remember {
MyViewModel()
}
val lazyListState = rememberLazyListState()
Column(
modifier = Modifier.fillMaxSize().background(backgroundColor).padding(vertical = 32.dp)
) {
val list = viewModel.list
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 12.dp),
flingBehavior = rememberSnapFlingBehavior(lazyListState, snapPosition = SnapPosition.Start),
state = lazyListState
) {
itemsIndexed(
items = list,
key = { _, item ->
item.id
}
) { page, item ->
Column(
modifier = Modifier
.animateItem()
.fillParentMaxWidth()
.height(200.dp)
.shadow(2.dp, RoundedCornerShape(16.dp))
.background(Color.White)
.padding(32.dp)
) {
Text("Item")
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
viewModel.removeItem(page)
}
) {
Text("Remove ${item.value}")
}
}
}
}
}
}
If using LazyRow is out of question you can write a Modifier that does that but it's not easy and there are several cases to handle since we are building similar Modifier
as animateItem
manually.
Basically, you need to set Modifier.layout
with layer
, also HorizontalPager has fixed page size, do not let items shrink you need to handle item scrolling. In addition, in your case, you should also handle cases for previous and next items because of content padding makes them partially visible.
When manually animating deleted items first step is using a flag to make them eligible for animation then after animation finishes because removin items list removes them from composition, so they must be removed at the end.
Or you can use another list to compare as LazyList does to check which items to change position, which items to disappear and which ones to appear.
Updated ViewModel and data like this
data class MyData(val id: String = UUID.randomUUID().toString(), val value: Int, val isAlive: Boolean = true)
class MyViewModel : ViewModel() {
val list =
mutableStateListOf<MyData>().apply {
repeat(6) {
add(
MyData(value = it)
)
}
}
fun updateStatus(index: Int) {
val newItem = list[index].copy(isAlive = false)
list[index] = newItem
println("Update Status: $index")
}
fun removeItem(index: Int) {
println("🔥 Remove item: $index")
list.removeAt(index)
}
}
In this step using an Animatable
to track progress and with Modifier.layout
to change scale, alpha, etc, depending on preference but you must invoke scroll because Pagers have fixed page sizes.
fun Modifier.animatePagerItem( animate: Boolean, page: Int, list: List, pagerState: PagerState, onStart: (page: Int) -> Unit, onFinish: (page: Int) -> Unit, ) = composed {
val animatable = remember {
Animatable(1f)
}
LaunchedEffect(animate) {
if (animate) {
val animationSpec = tween<Float>(1000)
onStart(page)
launch {
try {
animatable.animateTo(
targetValue = 0f,
animationSpec = animationSpec,
)
onFinish(page)
} catch (e: CancellationException) {
println("CANCELED $page, ${e.message}")
onFinish(page)
}
}
if (list.size > 1 && page != list.lastIndex) {
launch {
pagerState.animateScrollToPage(
page = page + 1,
animationSpec = animationSpec
)
}
} else if (page == list.lastIndex) {
pagerState.animateScrollToPage(
page = page - 1,
animationSpec = animationSpec
)
}
}
}
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val difference = -constraints.maxWidth * (1 - animatable.value)
layout(placeable.width, placeable.height) {
placeable.placeRelativeWithLayer(0, 0) {
transformOrigin = TransformOrigin(0f, .5f)
translationX = if (list.size > 1 && page != list.lastIndex) {
-difference
} else if (list.size > 1 && page == list.lastIndex) {
difference
} else 0f
translationY = -300f * (1 - animatable.value)
alpha = animatable.value
// Other animations
// scaleY = (animatable.value).coerceAtLeast(.8f) cameraDistance = (1 - animatable.value) * 100 // rotationY = -30f * (1 - animatable.value) // scaleX = animatable.value
}
}
}
}
I didn't cover only having one item, removing item at the end and animating previous and next items if they are partially visible when content padding is used or width smaller than HorizontalPager width. Need to set scroll position to page - 1 when last item is removed and getting partially visible item indices to set offsetX for them as well if needed.
Usage
@Preview
@Composable
fun PagerRemoveAnimationSample() {
val viewModel = remember {
MyViewModel()
}
val list = viewModel.list
Column(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
// .padding(16.dp)
) {
val pagerState = rememberPagerState {
list.size
}
var userGestureEnabled by remember {
mutableStateOf(true)
}
HorizontalPager(
modifier = Modifier.fillMaxWidth().height(200.dp),
state = pagerState,
pageSpacing = 16.dp,
// contentPadding = PaddingValues(start = 16.dp, end = 12.dp),
userScrollEnabled = userGestureEnabled,
key = {
list[it].id
}
) { page: Int ->
list.getOrNull(page)?.let { item ->
val animate = item.isAlive.not()
Column(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.animatePagerItem(
animate = animate,
page = page,
list = list,
pagerState = pagerState,
onStart = {
userGestureEnabled = false
},
onFinish = {
userGestureEnabled = true
viewModel.removeItem(it)
}
)
.shadow(2.dp, RoundedCornerShape(16.dp))
.background(Color.White)
.padding(32.dp)
) {
Text(
"value: ${item.value}\n" +
"isScrollInProgress: ${pagerState.isScrollInProgress}\n" +
"fraction: ${pagerState.currentPageOffsetFraction}\n" +
"currentPage: ${pagerState.currentPage}\n" +
"settledPage: ${pagerState.settledPage}"
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (userGestureEnabled) {
viewModel.updateStatus(page)
}
}
) {
Text("Remove ${item.value}")
}
}
}
}
}
}
Covered deleting last item and only when there is one item. Only missing thing if you are showing partially visible items on each side to handle their offset while scroll pushes them left or right. Also changed animation type different than first for demonstration.
fun <T> Modifier.animatePagerItem(
animate: Boolean,
page: Int,
list: List<T>,
pagerState: PagerState,
onStart: (page: Int) -> Unit,
onFinish: (page: Int) -> Unit,
) = composed {
val animatable = remember {
Animatable(1f)
}
LaunchedEffect(animate) {
if (animate) {
val animationSpec = tween<Float>(1000)
onStart(page)
launch {
try {
animatable.animateTo(
targetValue = 0f,
animationSpec = animationSpec,
)
onFinish(page)
} catch (e: CancellationException) {
println("CANCELED $page, ${e.message}")
onFinish(page)
}
}
if (list.size > 1 && page != list.lastIndex) {
launch {
pagerState.animateScrollToPage(
page = page + 1,
animationSpec = animationSpec
)
}
} else if (page == list.lastIndex) {
pagerState.animateScrollToPage(
page = page - 1,
animationSpec = animationSpec
)
}
}
}
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val difference = -constraints.maxWidth * (1 - animatable.value)
layout(placeable.width, placeable.height) {
placeable.placeRelativeWithLayer(0, 0) {
transformOrigin = TransformOrigin(0f, .5f)
translationX = if (list.size > 1 && page != list.lastIndex) {
-difference
} else if (list.size > 1 && page == list.lastIndex) {
difference
} else 0f
translationY = -300f * (1 - animatable.value)
alpha = animatable.value
// Other animations
// scaleY = (animatable.value).coerceAtLeast(.8f)
cameraDistance = (1 - animatable.value) * 100
// rotationY = -30f * (1 - animatable.value)
// scaleX = animatable.value
}
}
}
}