Creating a ViewPager in Jetpack Compose
Introduction
In this article we will find out how to write a ViewPager
composable in Jetpack Compose. Here’s what we want our composable to offer:
- The selected item will be centered on the container.
- The pager can scroll horizontally or vertically.
- Items can occupy a fraction of the container size, so that we can have neighbouring items peaking on the sides.
- It’s possible to overshoot items so that the user can scroll past the first or last item, but the pager then resets back to centering the item.
- We can specify a separation between items.
- We can indicate which item will be initially centered.
From our set of requirements above it is clear we will need to use the Layout
composable to create our Pager
, this is akin to creating a custom ViewGroup
in the view system — this will give us the flexibility to control the measuring and positioning of the children in the Pager
.
The Pager signature
Now that we have a clear set of requirements, let’s define the signature of our Pager
composable function:
Let’s have a look at this in detail:
- Our composable Pager takes first a list of items, of type
T
. Our pager will accept items of the same type; we will see later how we can modify this to accept heterogeneous types. - It is a good practise for any composable to accept a
Modifier
so that we can customize it at the composition site, so we do that here as well. - Our Pager will scroll either horizontally or vertically, so we accept a parameter to indicate the scrolling orientation.
- We also allow callers to specify which item will be initially centered in the
Pager
. - The
itemFraction
parameter indicates which fraction of the overall width or height of the container the items will take. This fraction defaults to 1, so items will be as wide (horizontal scroll) or as tall (vertical scroll) as the container if no value is provided. - This parameter indicates, in
DP
s, the space between items. overshootFraction
indicates, as a fraction of the parent width or height, how much the first and last items can be scrolled off position. A default value of 0.5f means the first and last items can be pushed so that they are half way off from their resting positions.- This is a callback that will get called whenever an item becomes selected (in the central position). This will allow clients of the
Pager
to be notified of scroll events. - The last parameter is the content factory, this will build the items for our
Pager
— this factory will be called with the items in the list provided as the 1st argument and for each item we will receive aComposable
that we will display in thePager
. - Finally, we do some sanity check before we build our
Pager
, we check that the fractions are within range and that the selected index is within bounds.
Setting up some helper methods
As we have to handle both horizontal and vertical scroll, we will need to check the orientation parameter during the composition process, and based on its value either get a width or a height. To make our code more readable we will extract this into extensions functions, so let’s define those now so that we can reference them later. These are the functions that we will use throughout the Pager
composition:
This function returns the maximum dimension on our Constraints
based on our scroll orientation.
This function makes a copy of our Constraints
where the minimum dimension not in the scroll direction is set to 0, while on the scroll direction we set the minimum dimension to be equal to the maximum dimension. Note also that we are making use of the itemFraction
to constrain the Pager
items to a fraction of the container dimension in the scroll direction.
Our next helper function gets the size of our Parcelables
based on the scroll direction. If we are scrolling horizontally, then we want to know the maximum height of the Pager
items; otherwise, if we are scrolling vertically, we want to calculate the maximum width.
Next we have an extension function on VelocityTracker
that will provide the calculated velocity based on our scroll axis.
Our final helper method, similar to the previous ones, calculates the pointer input change based on our scroll direction.
Creating the Pager state
Now that we have our Pager
signature defined and our helper functions it’s time to create our Pager
state. For simple composables we can keep state within the composable function itself, but when composables are a bit more complex, like this one, it is preferable to create a class to host the state and reference that class from within the composable. This state is remember
ed in the composable, leveraging the remember
functions in Jetpack Compose.
Let’s see our state class:
Our state closely maps the arguments we pass to our Pager
composable. We will reference these elements during composition and animation, and as our Pager
composable needs to react to changes to them, we wrap them in mutableStateOf
. Worth of note is the dragOffset
that we will use to allow the user to drag the Pager
items and fling them; the dragOffset
will indicate, in pixels, how much the user has dragged the Pager
content.
When we create a state class it is also customary to create a remember
function that wraps our state into a remember
lambda, and call that from the composable. Let’s add this function and see how we are calling it from our composable:
This is fairly straightforward, I’ll just point out that, by convention, Composable
functions that return a value, like in this case, should not use Pascal case.
And finally let’s see how we call this from our composable:
So after the sanity checks we get an instance of our state and we initialize it with the arguments we receive. We are converting the item spacing from DP
s to pixels as all measurements take place in pixels; converting it here means we don’t have to do it multiple times later.
We also get our coroutine scope as we will need it for the dragging and flinging operations.
A small thing worth of note is that our state listener takes an index representing the position of the selected item, but the composable caller instead expects the item, so we map the index to an item when calling the listener.
Measuring and laying the content
Next we will see how we measure the items to display on the Pager
and how to position them, based on the parameters we receive and the current drag offset. For this we will use the Layout
composable; this composable takes 3 arguments — the content to display, a Modifier
and a lambda that does the actual measuring and laying of the content. Let’s look at our code and then we’ll break it down into these sections:
Let’s have a look at each block now, let’s start with the content:
Here we are using the content factory to generate the composables for each of the pages in our Pager
. We wrap each composable in a Box
so that we can ensure that the pager item will fill the parent, either horizontally (for horizontal scroll), or vertically (for vertical scroll). In case the content is not set to fill the parent, we use the Alignment.Center
to ensure the actual content is centered in the Box
.
Next the modifier:
There are 2 attributes here, first we clip the content to the bounds (so that if the user is dragging, the content does not draw outside the container box), and we apply an inputModifier
from our state — we have not seen that yet, but this is what will handle dragging and calculate the drag offset to display our Pager
items. For now, let’s just take it at face value that this will update the drag offset, we will see in a bit how this works.
Finally, let’s look at the measure block, this is where we measure and place the children in our Pager
composable:
This section is a bit more complex, so let’s walk it in detail:
- We first get the size of our composable, using our helper method. Here we check the orientation and we get the max width or height from the
Constraints
. - Next we make a copy of our incoming
Constraints
with the min and max on the scroll direction set to the same value, and the min on the other direction set to 0. - Now that we have our
Constraints
it’s time to measure our content, so we walk thePlaceable
s and measure them with the loose constraints. - After we measure the
Placeable
s we check their max size (height for horizontal scroll, width for vertical scroll). - Based on our
Constraints
, we calculate how wide or tall the children will be, from the fraction we received as argument. We store this value in the state as it will be used for dragging. - Now it’s time to lay out the items, we use the dimensions we just calculated.
- As we want to place the selected item in the center of the container, we calculate the offset an item will need to display centered.
- We create a couple of variables to access our drag offset, a float and an integer so that accessing these values is a bit more readable.
- We use the drag offset to calculate what will be the first and last visible items in the
Pager
, this will allow us to optimize the rendering by only displaying those items that are visible. - Now it’s time to place the items, we loop over the visible items, in the range
firstItem
tolastItem
. - For each item we calculate its position based on how much the user has dragged the content. There is a bit of math involved here, but basically the offset for an item is the size of the items preceding it (including the separator), minus the drag offset, plus the offset needed to center an item on the screen. It’s important to note here that whenever the
dragOffset
updates thePager
will recompose, this is what we will use for dragging and flinging. - Finally, once we have calculated our positioning coordinates, we place each item. If we are scrolling horizontally the horizontal (x) coordinate is our calculated offset and the vertical coordinate (y) is 0. Otherwise, if we are scrolling vertically, our horizontal coordinate is 0 and the vertical coordinate is our offset.
With this our Pager
is ready to display our content. If we were to run this (removing the state’s inputModifier
as it’s not yet defined) we would see the items displayed with the selected item centered and, if the item fraction is less than 1, the neighbouring items peeking out on the sides.
Our next steps is to add the logic to drag and fling the content. Let’s check that out.
Input handling
Now that we have the measuring and laying out logic in place we need to add the necessary logic to handle dragging and flinging. We will do this in our state class:
Let’s have a detailed look at how we handle input events on the Pager
:
- We define an animation spec for the fling behaviour, we use a
Spring
animation that will slow down and come to a rest. We specifyLowBouncy
so that there is a bit of wobble when coming to a rest, but not much, and aStiffnessLow
so that thePager
does not come to rest too soon. - We create an extension function on
Modifier
for the input handling, the one we saw used in theLayout
composable a bit earlier. - Here we define a set of helper functions scoped to our modifier, this one calculates which item index is currently selected based on our dragoffset; whichever item is closest to the center is considered the selected item.
- The next helper function calculates the currently selected item and notifies the listener whenever this value changes.
- The final helper function calculates how much can we overshoot on drag based on the container size and the overshoot fraction. This will be used to restrict how far the first and last items in the
Pager
can be pushed off center. - Here we start the input handling logic. This is a
suspend
function that will suspend until a gesture is detected, at which point the lambda within is executed. - Once a gesture is detected we install a pointer input block that awaits input events.
- Once a pointer input is detected, we initialize some variables that we will use to calculate the velocity and the overscroll range, then we await for the “pointer down” event.
- Once we are notified of a “pointer down” event, we create a drag handler that will update our drag offset based on the drag events. As we have to handle both horizontal and vertical scroll we define the handler here so that we can use it in both horizontal and vertical scenarios. The drag handler will also update our velocity tracker and recalculate the centered item in the
Pager
. - Based on our orientation we listen for either horizontal or vertical drag events and process those in the handler we just discussed. As long as the pointer is down the handler will continue to be called for each drag event.
- Once the pointer event has finished the
horizontalDrag
orverticalDrag
functions complete — this means the user has lifted the finger so it’s now time to calculate the velocity and fling the content. We use one of our extensions functions here which will give us the velocity on the scroll axis. - The function to animate the fling is a
suspend
function therefore we launch a coroutine so that we can call it. - Here we calculate which item will be visible based on the fling direction and the velocity. As we always want the selected item to be centered, once we have the final drag offset based on the velocity and initial drag offset, we calculate which item is closest to that final position, then update the final drag offset so that it corresponds to that item being centered on the screen.
- Finally, when we know where to animate the drag offset to, we run the animation with our animation spec.
- As a last step, at each update of the animation we calculate which item is currently centered and update our listener accordingly.
With this we are almost done, there is just one last item to handle.
Selecting an initial item
One of our requirements was to be able to select which item would be initially selected in the Pager
. For that, we will add a helper method in our state to snap to that position:
In this method we calculate the drag offset for the item at the requested position, then set our drag offset to that value immediately, without animation, using the snapTo
method.
The last step is to call this from our Pager
composable. Because this is something we only want to do once, on first composition, we will use an Effect
, specifically a LaunchedEffect
— these trigger upon initial composition, and whenever the keys change:
Because our initially selected index depends on the items and the index itself, we use those as keys for the effect.
Completed Pager
We have now our Pager
fulfilling all the requirements we described a while back. The overall implementation is shown below:
This is how we can use it:
and this is the final result:
Using heterogeneous items
This implementation of the Pager
takes a factory that builds the composables that we will display in the pager. Let’s update our Pager
to instead accept a set of Composables
. The changes are fairly straightforward, only the Pager
itself needs to change, the state remains the same. Let’s see how we can update our Pager
to accommodate this scenario:
There are only some small changes here:
- Our
Pager
is no longer typed. - We’ve removed the
items
argument. - The item selected listener now returns the index, instead of the item.
- We are no longer building the composables with a factory, simply using the provided lambda in our
Pager
.
With this update, we can call our pager like so:
where we alternate between a Box
composable and an Image
composable in our Pager
.
There is however a lot of common code between the 2 Pager
variants, so we can consolidate much of it by calling one Pager
from the other. We will refactor our original Pager
to call the new one:
So we have refactored our original Pager
by calling the new Pager
with the content being a lambda that calls our factory method. We also provide an item selected listener that translates the index into the corresponding item.
With this our work is complete, we have 2 different Pager
implementations that satisfy different needs.
The final code with the 2 Pager
s is available on this gist.
Final thoughts
This Pager
is not a full replacement for the ViewPager
in the view system, some of the functionality is missing, for instance item transformations and updates on scroll state (idle, settling, scrolling) to name a couple.
This implementation, while somewhat basic, handles the core pager functionality and offers some customization parameters that will hopefully satisfy a wide range of use cases.
With this article I intended to showcase how to build new components in Jetpack Compose and showcase how to handle animations and user input in those components, I hope you found that useful and I’ll see you on the next article.