Subscribe for UI recipes
Page Flip animation in Jetpack Compose
Made in Compose: Flipboard page fold animation
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.
Today, we shall recreate this in Jetpack Compose. Full sample app code available on Github.
Setup
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.
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.
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
.
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 clip
to 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)
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.
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
)
},
)
)
)
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.
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!