Customize Compose Pager in Android with fun indicators and transitions

paper-logo

Android customizes Compose Pager with fun indicators and transitions

Google recently added Pager controls in compose, HorizontalPager and VerticalPager.
pager-effect

Transition between pages

The docs cover the basics of how far to scroll from the “snap” position on the visited page. We can use this information to create transitions between pages.

For example, if we wanted to create a simple fade between pages, we could apply modifiers to the graphicsLayer of our page composable to adjust its alpha and translationX:

val pagerState = rememberPagerState()
HorizontalPager(
    pageCount = 10,
    modifier = modifier.fillMaxSize(),
    state = pagerState
) {<!-- --> page ->
    Box(Modifier
        .graphicsLayer {<!-- -->
            val pageOffset = pagerState. calculateCurrentOffsetForPage(page)
            // translate the contents by the size of the page, to prevent the pages from sliding in from left or right and stays in the center
            translationX = pageOffset * size.width
            // apply an alpha to fade the current page in and the old page out
            alpha = 1 - pageOffset.absoluteValue
        }
        .fillMaxSize()) {<!-- -->
        Image(
            painter = rememberAsyncImagePainter(model = rememberRandomSampleImageUrl
                (width = 1200)),
            contentDescription = null,
            contentScale = ContentScale. Crop,
            modifier = Modifier.fillMaxSize()
                .padding(16.dp)
                .clip(RoundedCornerShape(16.dp)),
        )
    }
}

// extension method for current page offset
@OptIn(ExperimentalFoundationApi::class)
fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {<!-- -->
    return (currentPage - page) + currentPageOffsetFraction
}

We can then extract this graphicsLayer modifier into a reusable modifier that we can use on other HorizontalPager instances:

fun Modifier.pagerFadeTransition(page: Int, pagerState: PagerState) =
    graphicsLayer {<!-- -->
        val pageOffset = pagerState. calculateCurrentOffsetForPage(page)
        translationX = pageOffset * size.width
        alpha = 1 - pageOffset.absoluteValue
    }

pager-effect
This is awesome, we can achieve the same effect that we were able to achieve before in the view ViewPager.

Other interesting transition effects

Some of the more common effects you can achieve with ViewPager can also be achieved with Pager in Compose, for example:

Cube Depth Effect

Cube depth effect

Cube Depth Effect
Cube depth effect

Fidget spinner effect
Fidget spinner effect

For more special effects, please check the github repo code

https://github.com/riggaroo/compose-playtime#custom-pager-transformations

The beauty of Compose is that our PagerState can also access content inside the page. So we can use this information to perform interesting effects like driving animations, hiding/zooming content or showing content based on the scrolling state of the page.

The following implements the effect of the music player interface

First, we create a static version of this screen component by creating a HorizontalPager inside aBox and a staticImage and Text that can Combined items to represent each page.

@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DribbbleInspirationPager() {<!-- -->
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(0xFFECECEC))
    ) {<!-- -->
        val pagerState = rememberPagerState()
        HorizontalPager(
            pageCount = 10,
            pageSpacing = 16.dp,
            beyondBoundsPageCount = 2,
            state = pagerState,
            modifier = Modifier.fillMaxSize()
        ) {<!-- --> page ->
            Box(modifier = Modifier. fillMaxSize()) {<!-- -->
                // Contains Image and Text composables
                SongInformationCard(
                    modifier = Modifier
                        .padding(32.dp)
                        .align(Alignment. Center),
                    pagerState = pagerState,
                    page = page
                )
            }

        }
    }
}

Now that we have a static version of the song, we can further analyze the design to see which parts of the composable are animated. The first thing you’ll probably notice is that the image inside the card gets bigger depending on whether it’s the currently selected item or not. The next change to scrolling is that the card expands in size and displays “drag to listen” text when expanded. To implement these two elements, we can use the same value pagerState.currentPageOffsetFraction and pagerState.currentPage use these values in different parts of the page composable content.

To adjust images inside a composable we use Modifier.graphicsLayer { }on Image.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SongInformationCard(
    pagerState: PagerState,
    page: Int,
    modifier: Modifier = Modifier
) {<!-- -->
    Card(
        modifier = /*..*/
    ) {<!-- -->
        Column(modifier = /*..*/) {<!-- -->
            val pageOffset = pagerState. calculateCurrentOffsetForPage(page)
            Image(
                modifier = Modifier
                    /* other modifiers */
                    .graphicsLayer {<!-- -->
                        // get a scale value between 1 and 1.75f, 1.75 will be when its resting,
                        // 1f is the smallest it'll be when not the focused page
                        val scale = lerp(1f, 1.75f, pageOffset)
                        // apply the scale equally to both X and Y, to not distort the image
                        scaleX = scale
                        scaleY = scale
                    },
                //..
            )
            SongDetails()
        }
    }
}

By getting pagerState.currentPage and subtracting the number of pages the current song is on, we can know the offset of the song from the center of pager (the currently selected page). Next, we add this value to pagerState.currentPageOffsetFraction, and now we know the fraction by which the page scrolls from its aligned position. We can then scale the pageOffset between 1f and 1.75f. This value will be used to apply scaleX and scaleY so as not to distort the image. When the page is not selected, the zoom value is 1.75f, when the page is selected, the zoom value is 1f.

The result is as follows:

The next step is to expand the card to show and hide the “drag to listen” section in the card. Using the same pageOffset value, we can animate the height of the Column composition as well as animate the transparency.

@Composable
private fun DragToListen(pageOffset: Float) {<!-- -->
    Box(
        modifier = Modifier
            .height(150.dp * (1 - pageOffset))
            .fillMaxWidth()
            .graphicsLayer {<!-- -->
                alpha = 1 - pageOffset
            }
    ) {<!-- -->
        Column(
            modifier = Modifier. align(Alignment. BottomCenter),
            horizontalAlignment = Alignment. Center Horizontally
        ) {<!-- -->
            Icon(
                Icons.Rounded.MusicNote, contentDescription = "",
                modifier = Modifier
                    .padding(8.dp)
                    .size(36.dp)
            )
            Text("DRAG TO LISTEN")
            Spacer(modifier = Modifier. size(4.dp))
            DragArea()
        }
    }
}


@Composable
private fun DragArea() {<!-- -->
    Box {<!-- -->
        Canvas(
            modifier = Modifier
                .padding(0.dp)
                .fillMaxWidth()
                .height(60.dp)
                .clip(RoundedCornerShape(bottomEnd = 32.dp, bottomStart = 32.dp))
        ) {<!-- -->
            val sizeGap = 16.dp.toPx()
            val numberDotsHorizontal = size. width / sizeGap + 1
            val numberDotsVertical = size. height / sizeGap + 1
            repeat(numberDotsHorizontal. roundToInt()) {<!-- --> horizontal ->
                repeat(numberDotsVertical. roundToInt()) {<!-- --> vertical ->
                    drawCircle(
                        Color.LightGray.copy(alpha = 0.5f), radius = 2.dp.toPx
                            (), center =
                        Offset(horizontal * sizeGap + sizeGap, vertical * sizeGap + sizeGap)
                    )
                }
            }
        }
        Icon(
            Icons.Rounded.ExpandMore, "down",
            modifier = Modifier
                .size(height = 24.dp, width = 48.dp)
                .align(Alignment. Center)
                .background(Color. White)
        )
    }
}

Running this code, we can see that the height of each card is now also driven by how far the page is being dragged:
Awesome – we’ve achieved page animations just like the original design! The full source code can be found here.

https://github.com/riggaroo/compose-playtime/blob/main/app/src/main/java/dev/riggaroo/composeplaytime/pager/DribbbleInspirationPager.kt

Page indicator

Now that we’ve seen how to access PagerState and use it to transform content, another common use case for using Pager is to add an indicator to show the current page’s position in the page list. Using Compose and PagerState, getting this information is very simple.

To create a basic page indicator, we can draw a circle for each page, as the docs suggest. However, we can also create our own custom page indicators, such as a segmented line at the bottom of the screen, and we can change the drawing logic to draw a line instead of a circle, and divide the width evenly into segments.

Get inspiration from this page effect below
pager-effect

The first thing we do is create a Box inside a HorizontalPager and move our circular indicator to the bottom of the box, matching the Content overlaps:

@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicatorExample() {<!-- -->
    Box(modifier = Modifier. fillMaxSize()) {<!-- -->
        val pageCount = 5
        val pagerState = rememberPagerState()
        HorizontalPager(pageCount = pageCount,
            beyondBoundsPageCount = 2,
            state = pagerState) {<!-- -->
            PagerSampleItem(page = it)
        }
        Row(
            Modifier
                .height(50.dp)
                .fillMaxWidth()
                .align(Alignment.BottomCenter),
            horizontalArrangement = Arrangement. Center
        ) {<!-- -->
            repeat(pageCount) {<!-- --> iteration ->
                val color = if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = 0.5f)
                Box(
                    modifier = Modifier
                        .padding(4.dp)
                        .clip(CircleShape)
                        .background(color)
                        .size(16.dp)

                )
            }
        }
    }
}

indicator-effect
Next, we’ll change the circular indicator to draw a line that is a different color and size if checked versus unchecked. We initially give each row a weight of 1f.

@Preview
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicatorExample() {<!-- -->
    Box(modifier = Modifier. fillMaxSize()) {<!-- -->
        val pageCount = 5
        val pagerState = rememberPagerState()
        HorizontalPager(pageCount = pageCount,
            beyondBoundsPageCount = 2,
            state = pagerState) {<!-- -->
            PagerSampleItem(page = it,
                modifier = Modifier.pagerFadeTransition(it, pagerState = pagerState))
        }
        Row(
            Modifier
                .height(24.dp)
                .padding(start = 4.dp)
                .fillMaxWidth()
                .align(Alignment.BottomCenter),
            horizontalArrangement = Arrangement. Start
        ) {<!-- -->
            repeat(pageCount) {<!-- --> iteration ->

                val color = if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = 0.5f)
                Box(
                    modifier = Modifier
                        .padding(4.dp)
                        .clip(RoundedCornerShape(2.dp))
                        .background(color)
                        .weight(1f)
                        .height(4.dp)
                )
            }
        }
    }
}

This results in lines for each page, drawn without changing their size:

indicator-effect

Now, the lines also need to animate their length changes. If the item is selected, it should be the longest line. We’ll animate the weight for , 1f to choose between the unselected page on the right, 1.5f the selected row, and 0.5f the page to the left of the selected page. We use animateFloatAsState to animate between these weights:

repeat(pageCount) {<!-- --> iteration ->
    val lineWeight = animateFloatAsState(
        targetValue = if (pagerState. currentPage == iteration) {<!-- -->
            1.5f
        } else {<!-- -->
            if (iteration < pagerState. currentPage) {<!-- -->
                0.5f
            } else {<!-- -->
                1f
            }
        }, label = "weight", animationSpec = tween(300, easing = EaseInOut)
    )
    val color =
        if (pagerState.currentPage == iteration) Color.White else Color.White.copy(alpha = 0.5f)
    Box(
        modifier = Modifier
            .padding(4.dp)
            .clip(RoundedCornerShape(2.dp))
            .background(color)
            .weight(lineWeight.value)
            .height(4.dp)
    )
}

indicator-effect

Complete project source code:

https://github.com/riggaroo/compose-playtime/blob/main/app/src/main/java/dev/riggaroo/composeplaytime/pager/LineIndicatorExample.kt

Summary

As we explored in this blog post, we can see that using PagerState in Compose gives us the flexibility to create more complex page interactions that were previously complex. By utilizing the pagerState.currentPage and pagerState.currentPageOffsetFraction variables, we can create fairly complex UI interactions and page indicators.