✨ Subscribe for component library

Page Flip animation in Jetpack Compose

Made in Compose: Flipboard page fold animation

Page Flip animation in Jetpack Compose

Years ago, I laid my eyes on Flipboard's page turn animation and it was truly awe-inspiring. This one interaction made the app my go-to news source for some time.

Final page flip animation


Today, we shall recreate this in Jetpack Compose. Full sample app code available on Github.

Setup

🐘
We shall be using the latest Jetpack Compose beta (1.7.0-beta02) for this tutorial.

Our FlipPager will animate in vertically as well as horizontally. For this, let us set up a sealed class for defining our orientation.

sealed class FlipPagerOrientation {  
    data object Vertical : FlipPagerOrientation()  
    data object Horizontal : FlipPagerOrientation()  
}

This will be used in our calculations while creating the internals of the FlipPager and will be passed in as a parameter.
So wherever we need it, we can call the FlipPager like this:

val state = rememberPagerState { PAGER_SIZE }  
  
FlipPager(  
    state = state,  
    modifier = Modifier.fillMaxSize(),  
    orientation = FlipPagerOrientation.Vertical,  
) { page ->  
    // PAGE CONTENT 
}

Inside FlipPager we will use this argument to determine which Pager we show.

when (orientation) {  
    FlipPagerOrientation.Vertical -> {  
        VerticalPager(  
            state = state,
            pageContent = { Content(...) }  
        )  
    }  
  
    FlipPagerOrientation.Horizontal -> {  
        HorizontalPager(  
            state = state,
            pageContent = { Content(...) }  
        )  
    }  
}

If you are not familiar with Pager in Jetpack Compose, here is an article explaining the basics.

Setting up ViewPager in Jetpack Compose
How to get started using a ViewPager in Jetpack Compose.

Now, let's dive into FlipPager.

Disable Pager

By default, Jetpack Compose translates the Pager contents from right to left or down to up. To achieve the flip animation, we first need to keep all pages in the exact same position. I covered this in an earlier article on Pager animations.

Creating Pager Animations in Jetpack Compose
How to create 3 unique Pager animations.

We can do this using the graphicsLayer modifier and translate the whole page by however much it has moved.

Box(  
    Modifier  
        .fillMaxSize()
        .graphicsLayer {  
            val pageOffset = state.offsetForPage(page)  
            when (orientation) {  
                FlipPagerOrientation.Vertical -> translationY = size.height * pageOffset  
                FlipPagerOrientation.Horizontal -> translationX = size.width * pageOffset  
            }  
        },
) {
	...
}

We translate over the X or Y axis, depending on the orientation of our FlipPager.

Default Pager sliding is disabled

Bitmap Recording

We will implement the page flip animation by rotating two copies of our page based on how much the user has dragged it. That being said, rendering multiple versions of our content into composition would lead to various behavioral and performance issues. Instead, we will take a bitmap snapshot of the page and manipulate that instead.

val graphicsLayer = rememberGraphicsLayer()  
Box(modifier = Modifier
    .drawWithContent {  
        graphicsLayer.record {  
            this@drawWithContent.drawContent()  
        }  
        drawLayer(graphicsLayer)  
    }  
) {  
    pageContent(page)  
}
If you do not have the rememberGraphicsLayer() function, make sure you are running the latest version of Jetpack Compose (currently 1.7.0-beta02).

With the code above surrounding our pageContent, we can grab a bitmap of the page like so:

var imageBitmap: ImageBitmap? by remember { mutableStateOf(null) }
...
LaunchedEffect(state.isScrollInProgress) {  
    if (state.isScrollInProgress)  
        while (true) {  
            if (graphicsLayer.size.width != 0)  
                imageBitmap = graphicsLayer.toImageBitmap()  
            delay(16)  
        }  
}

At the moment, I am capturing a bitmap regularly only when the Pager is being scrolled. If you are having any issues with performance, try increasing the delay between bitmap capture.
Or, on the other hand, if you need to re-capture a bitmap when another state in your app is changed, you can do that here.

Bitmap Rotating

We have four types of "page sections" that we need to define their individual rotation: top and bottom, left and right. Let's represent this in a sealed class.

internal sealed class PageFlapType(val shape: Shape) {  
    data object Top : PageFlapType(TopShape)  
    data object Bottom : PageFlapType(BottomShape)  
    data object Left : PageFlapType(LeftShape)  
    data object Right : PageFlapType(RightShape)  
}
val TopShape: Shape = object : Shape {  
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density) =  
        Outline.Rectangle(Rect(0f, 0f, size.width, size.height / 2))  
}  
  
val BottomShape: Shape = object : Shape {  
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density) =  
        Outline.Rectangle(Rect(0f, size.height / 2, size.width, size.height))  
}  
  
val LeftShape: Shape = object : Shape {  
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density) =  
        Outline.Rectangle(Rect(0f, 0f, size.width / 2, size.height))  
}  
  
val RightShape: Shape = object : Shape {  
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density) =  
        Outline.Rectangle(Rect(size.width / 2, 0f, size.width, size.height))  
}

For each type, we have a shape that defines the clip area that we will apply to the bitmap.
With this information, we can start by adding a Canvas over our content with the same size.

Canvas(  
    modifier  
        .size(size)  
        .align(Alignment.TopStart)  
        .graphicsLayer {  
            shape = pageFlap.shape  
            clip = true
	        ...
	    }
) { ... }

Here we use the shape we defined in the PageFlapType and set clipto true. This will cut the bitmap to the exact half that we want visible.

Next, we increase the cameraDistance to make the rotation look more appealing and then do the actual rotation.

.graphicsLayer {  
    shape = pageFlap.shape  
    clip = true  
  
    cameraDistance = 65f  
    transformOrigin = TransformOrigin(.5f, .5f)  
    when (pageFlap) {  
        is PageFlapType.Top -> {  
            rotationX = (state.endOffsetForPage(page) * 180f).coerceIn(-90f..0f) 
        }  
  
        is PageFlapType.Bottom -> {  
            rotationX = (state.startOffsetForPage(page) * 180f).coerceIn(0f..90f)
        }  
  
        is PageFlapType.Left -> {  
            rotationY = -(state.endOffsetForPage(page) * 180f).coerceIn(-90f..0f)  
        }  
  
        is PageFlapType.Right -> {  
            rotationY = -(state.startOffsetForPage(page) * 180f).coerceIn(0f..90f)
        }  
    }  
}

As you can see, we rotate each type differently. The top and bottom are rotated around the X axis, while the left and right are rotated around the Y axis.
All rotations are coerced within a 90º angle so that a page disappears exactly when another is coming into view.
Inside the Canvas we simply draw the bitmap.

drawImage(imageBitmap)
Bitmaps are rotated as the user drags (but this has a bug) ⚠️

Fix Z Order

At the moment, things are not running the way we expect. Pages seem to be appearing below or above others.

This is because of the draw z-order of the pages. The Pager changes the z-order based on the current page but we do not want this. What we need is for the currently turning page to be on the highest z-index.

var zIndex by remember { mutableFloatStateOf(0f) }  
LaunchedEffect(Unit) {  
    snapshotFlow { state.offsetForPage(page) }.collect {  
        zIndex = when (state.offsetForPage(page)) {  
            in -.5f..(.5f) -> 3f  
            in -1f..1f -> 2f  
            else -> 1f  
        }  
    }  
}

Box(  
    Modifier  
        .fillMaxSize()  
        .zIndex(zIndex)  
        .graphicsLayer {  
            ... 
        },
) { ... }

Same place we added the graphicsLayer modifier earlier, we shall specify the zIndex. Any page with an offset between -.5f and .5f is currently being turned and should stay at the top.

Fixed animation by re-ordering the z index

Extra Details

First, let's add a shadow to really sell the 3D page turning effect. On top of the bitmap we rendered, we will add a dark overlay that increases in opacity as the page turns.

drawImage(  
    imageBitmap,  
    colorFilter = ColorFilter.tint(  
        Color.Black.copy(  
            alpha = when (pageFlap) {  
                PageFlapType.Top, PageFlapType.Left -> 
                    (state.endOffsetForPage(page).absoluteValue * .9f).coerceIn(  
                        0f..1f  
                    ) 
  
                PageFlapType.Bottom, PageFlapType.Right -> 
                    (state.startOffsetForPage(page) * .9f).coerceIn(  
                        0f..1f  
                    ) 
            },  
        )  
    )  
)

Added shadow to increase the 3D effect

Last detail is to add an over-scroll effect. If the user gets to the end of the Pager and tries to scroll further, the default Android stretchy effect would feel very out of place.
Especially after our lovely page flip animations.
Instead, we can implement an animation that flips the page slightly, but with a high friction to indicate to the user that there is no more content.

Over-scroll animation when the Pager reaches the end

If you are curious as to how I achieved this, check the code here.
But in an upcoming article, I will go over all the details with creating over-scroll effects for Pagers and LazyLists.

Thanks for reading and good luck!

Mastodon