androidkotlinandroid-jetpack-composeandroid-viewpagerandroid-jetpack-compose-pager

How to add animation when removing an item in HorizontalPager in Jetpack Compose


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)
            },
        )
    }
}

Solution

  • 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()

    Animating LazyRow item change

    enter image description here

    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}")
                        }
                    }
                }
            }
        }
    }
    

    Animating HorizontalPager item change

    enter image description here

    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.

    Step1 - Using a flag to signal animation

    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)
        }
    }
    

    Step2 - Creating a modifier to animate items

    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}")
                        }
                    }
                }
            }
        }
    }
    

    Edit

    enter image description here

    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
    
                }
            }
        }
    

    }