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:

  1. 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.
  2. 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.
  3. Our Pager will scroll either horizontally or vertically, so we accept a parameter to indicate the scrolling orientation.
  4. We also allow callers to specify which item will be initially centered in the Pager.
  5. 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.
  6. This parameter indicates, in DPs, the space between items.
  7. 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.
  8. 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.
  9. 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 a Composable that we will display in the Pager.
  10. 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 Pageritems; 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 remembered 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 DPs 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:

  1. 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.
  2. 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.
  3. Now that we have our Constraints it’s time to measure our content, so we walk the Placeables and measure them with the loose constraints.
  4. After we measure the Placeables we check their max size (height for horizontal scroll, width for vertical scroll).
  5. 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.
  6. Now it’s time to lay out the items, we use the dimensions we just calculated.
  7. 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.
  8. 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.
  9. 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.
  10. Now it’s time to place the items, we loop over the visible items, in the range firstItem to lastItem.
  11. 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 the Pager will recompose, this is what we will use for dragging and flinging.
  12. 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:

  1. We define an animation spec for the fling behaviour, we use a Spring animation that will slow down and come to a rest. We specify LowBouncy so that there is a bit of wobble when coming to a rest, but not much, and a StiffnessLow so that the Pager does not come to rest too soon.
  2. We create an extension function on Modifier for the input handling, the one we saw used in the Layout composable a bit earlier.
  3. 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.
  4. The next helper function calculates the currently selected item and notifies the listener whenever this value changes.
  5. 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.
  6. 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.
  7. Once a gesture is detected we install a pointer input block that awaits input events.
  8. 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.
  9. 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.
  10. 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.
  11. Once the pointer event has finished the horizontalDrag or verticalDrag 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.
  12. The function to animate the fling is a suspend function therefore we launch a coroutine so that we can call it.
  13. 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.
  14. Finally, when we know where to animate the drag offset to, we run the animation with our animation spec.
  15. 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 Pagers 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.

Senior Android Developer

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store