Android customizes Compose Pager with fun indicators and transitions
Google recently added Pager controls in compose, HorizontalPager
and VerticalPager
.
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 }
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
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
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) ) } } } }
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:
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) ) }
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.