Paging Image Carousel with RecyclerView

David Ferrand
Holler Developers
Published in
12 min readDec 11, 2020

--

Recently at Holler we were working on a new screen that presents images in a carousel. Our UI/UX team came up with this design:

The implementation turned out to be quite complex, with subtle tweaks needed in order to reach a really smooth UX. In this blog post, we’ll be implementing the carousel step by step and I’ll comment on some of the choices I’ve made, such as: ViewPager, ViewPager2 or RecyclerView?

Before diving in, let’s just set a few goals, most of which are inferred from the animation above:

Goals

  1. The carousel is paged: a user scrolls through it full page by full page. Each page snaps into position.
  2. When an image is in focus it is centered, its neighbors partially visible on the side
  3. The carousel can mix images with different aspect ratios (tall, wide and/or square images)
  4. Neighbors of the central image are scaled down progressively the further they get from the center
  5. The carousel opens directly on the image that’s been clicked
  6. Stretch goal: tap an image to scroll to it
  7. Stretch goal: when an image is in focus, its overlay appears

Companion app

I made a sample app to accompany us throughout this article and show the evolution of the solution step by step: https://github.com/dadouf/PagingImageGallery. Whenever in this article you’ll see…

💾 Companion app checkpoint: v0

… you can refer to the code for v0 on GitHub by checking its package, and also test it by selecting v0 in the dropdown in the app. All implementations exist side by side so you can easily compare them.

ViewPager-based approach: a dead-end ⛔️

Naturally, I turned to ViewPager directly after seeing the mock animation. Every time I’ve had to implement a paging component, ViewPager did the job — that’s what it’s for! Not this time though…

I may write about my trials and tribulations with the ViewPager (and ViewPager2) in a separate article but the short of it is: it works fine for a basic version and gets extremely tedious when you get into more advanced things like aspect ratio and scaling with the scroll offset.

RecyclerView is more flexible. After tweaking it gradually, I managed to get a perfectly working solution with RecyclerView. Buckle up, we’re going!

RecyclerView-based approach ✅

v0: setup the base

Let’s setup a RecyclerView with a horizontal LinearLayoutManager. Each item is a simple ImageView with width=match_parent and height=match_parent. We apply an even 16dp spacing between all items via an ItemDecoration. The companion app contains a set of sample image URLs that we simply load with Glide.

This will be our starting point:

It’s lonely in the center

💾 Companion app checkpoint: v0

v1: add aspect ratio handling

The point of respecting each image’s aspect ratio is that we don’t want extra whitespace between items. Currently, each item view takes the full width of the RecyclerView, meaning that a tall image has a lot of extra whitespace on its left and right. We want images to be next to each other, only separated by the 16dp spacing we’ve defined in v0.

If we just change the width of each item view to wrap_content, we make a step towards the desired effect pretty quickly: aspect ratio is respected. However there are two deal breakers:

  1. Scroll is really janky. We’re relying on Glide to adjust the width: it does it more or less correctly but only after the image has been loaded.
  2. Although aspect ratio is respected, wide images are not visible entirely because their height is match_parent and their width extends beyond the screen width.

💾 Companion app checkpoint: v1-alpha1

So, not great. But we’re on the right track and modifying the width is indeed the correct approach. What we need to do instead of a simple wrap_content is to pre-determine each item view’s width in onBindViewHolder. For this we need two ingredients. First we have to know the aspect ratio of our images prior to loading them.

data class Image(val url: String, val width: Int, val height: Int) {
val aspectRatio: Float
get() = width.toFloat() / height.toFloat()
}

Then we need to know our RecyclerView’s width and height (in pixels) before we start binding items. A good place for this is onCreateViewHolder because the RecyclerView has already been measured when the method gets called:

Now we are able to compare the aspect ratio of the RecyclerView with the aspect ratio of each image and resize accordingly, before the ViewHolder is attached and before the image loads:

Scroll is smooth and all images gets displayed in full with a correct aspect ratio. Yay!

💾 Companion app checkpoint: v1-alpha2

Let’s go just a little bit further before wrapping up the aspect ratio chapter. Currently a wide image takes the full width of the RecyclerView, but we’d like to be able to see part of its left and right neighbors. Good news, it just takes a simple tweak: limit the image width to be at most 75% of the total width:

maxImageWidth = (parent.width * 0.75f).roundToInt()

And this is what we get:

Slide Away

💾 Companion app checkpoint: v1-final

It’s starting to look like something! But we’re not completely there yet. Let’s implement paging.

v2: add paging

Our carousel is very much free-rolling, but we’d like it to snap to each individual image after scrolling or flinging.

Thankfully, RecyclerView comes with helpers to regulate scrolls and flings called SnapHelpers. In particular, there’s one called PagerSnapHelper that does exactly what we want: it gives the Recycler-View a ViewPager-like behavior, ensuring that each item you scroll to or fling to settles in the center. It’s very simple to set up:

PagerSnapHelper().attachToRecyclerView(recyclerView)

💾 Companion app checkpoint: v2-alpha1

We still have a tiny problem for the first and the last item: they don’t get centered by default because they are anchored to the RecyclerView’s left or right end. To solve it, we simply offset them via an ItemDecoration that adds extra whitespace on the item’s left or right. Note that this decoration only affects the first and the last item:

Now let’s talk UX for a minute. In our app, the images are first presented in a grid. Clicking any of these images in the grid opens the carousel as a “detail view”. Currently the carousel always opens at position 0, but we want it to open on the image that’s been clicked. Sounds easy? It is, mostly, but it takes a little trick.

If you just call layoutManager.scrollToPosition (for example in onCreate), the carousel loads immediately around the target position, but it’s a little off. What happens is it scrolls to the correct item but without taking the effect of the PagerSnapHelper into account, so the target item gets anchored to the RecyclerView’s left.

💾 Companion app checkpoint: v2-alpha2

To solve it, we manually adjust the scroll position as soon as the RecyclerView and its items are laid out. We don’t have to do any of the complicated offset calculation: the SnapHelper takes care of it for us.

Once all the paging elements are put together, this is what we get:

COOL! 😎 🛹

💾 Companion app checkpoint: v2-final

Note: you could achieve a similar “centering” effect with a combination of padding left/right and clipToPadding=false. You’ll find several solutions using this technique on StackOverflow, and I even initially tried that approach. Although I got it to work, it complicated a bunch of things throughout: you need to find the correct (large enough) padding value, it overthrows when trying to snap to the previous view, it complicates the initial scroll calculation, etc. In the end I’ve found that the simple BoundsOffsetDecoration avoids these problems, so I don’t touch the RecyclerView’s padding or clipToPadding at all.

v3: add a cool shrink/grow animation

At this point we already have a completely functional image carousel. Everything we add on top of this is a visual and/or usability refinement.

Let’s add the effect we saw in the mock: a shrink/grow animation that’s a little reminiscent of the late iTunes cover flow (RIP). We want images to scale down progressively the further they get from the center.

The best place for modifying each individual item’s properties (scale, translationX, …) is literally the class that take care of laying them out: LayoutManager. So let’s create a subclass of LinearLayoutManager that we’ll call ProminentLayoutManager: its job is to make the central item more prominent than its neighbors. In it, we’ll iterate through all children and adjust their scale after calculating their distance to the center.

Because scaleChildren gets called every time the RecyclerView has scrolled by some pixels horizontally, scaling looks progressive and smooth. The parameters minScaleDistanceFactor and scaleDownBy influence by how much items scale down and where they reach their minimum size. Feel free to play with them to tweak the animation.

So now are items are scaling down and pretty smoothly, yay!

💾 Companion app checkpoint: v3-alpha1

But one undesirable effect of scaling down as we do is that it creates extra horizontal whitespace: the view scales but its bounds don’t. Like we stated before, we want to maintain an even spacing of 16dp between each item, and this should remain true regardless of the scale.

We can easily fix it, in the same method, by modifying the items’ translationX. We offset the whitespace created by the scaling so that each neighbor remains “anchored” to the central view:

💾 Companion app checkpoint: v3-alpha2

💡 Tip: it’s much easier to understand the effect of all this scaling and translating once you turn on “Show layout bounds” in your Developer settings.

This looks better. But let’s take the same problem one step further. When the central item is at position N, its direct neighbors (at N-1 and N+1) are anchored correctly, no extra whitespace, good. But as soon as you start dragging towards the right, the space between the two left-hand items grows beyond 16dp (and vice-versa). This is even more obvious if your phone is in landscape and you’re looking at tall images:

Bumper cars

This is, again, extra horizontal space created by the scale: this time between two scaled down items. And again, all we need to do is offset translationX with some fancy calculation. The fancy calculation in question happens while iterating through the children, just like before. While we’re looking at the child at position N:

  1. We calculate the extra whitespace created by the scaling of N
  2. We edit N-1 to add that whitespace to its translationX
  3. We pass it forward to N+1 to add that whitespace to its translationX

💾 Companion app checkpoint: v3-alpha3

If you’re using “Show layout bounds”, you’ll notice that this causes images to be drawn beyond their bounds in order to maintain the even spacing. While this is indeed what we want, it means that some views which are considered out of view are really not, because our translationX “drags” them back towards the center. When a view is considered out of view, the RecyclerView may decide not to attach it yet, or to recycle it. You may notice that views on the edges appear or disappear abruptly: this is the reason.

To fix it we need to instruct the RecyclerView to both load views earlier and retain them longer so that images don’t pop in and out of view. We do so in two places:

  1. In onCreate, set recyclerView.setItemViewCacheSize(4) to retain views for longer before recycling them
  2. In our custom ProminentLayoutManager, there’s a method we can override to tell it to lay out extra items out of view, getExtraLayoutSpace:
override fun getExtraLayoutSpace(state: RecyclerView.State): Int {
// The more we scale down, the more extra space we need
return (width / (1 - scaleDownBy)).roundToInt()
}

These values are somewhat arbitrary: I simply tested several and picked what works.

If you test now, you shouldn’t see views abruptly appearing or disappearing on the edges!

Next. Next. Next. Skip tutorial.

💾 Companion app checkpoint: v3-final

v4: tap image to scroll

We’re starting to get into the stretch goals here, which is a good sign! I want to address smoothScrollToPosition because it’s quite a common use case. In our app, we want to scroll to the next image just by tapping it. That way we’ll allow users to not only scroll or fling, but also tap to navigate.

A basic implementation takes just three lines in onBindViewHolder:

vh.imageView.setOnClickListener {
val rv = (vh.imageView.parent) as RecyclerView
rv.smoothScrollToPosition(position)
}

It works in most cases. But try it on tall images (with your phone in landscape) and you’ll see an issue: the RecyclerView starts to scroll to the view that was tapped but then it snaps another view into position. What it does in fact is scroll a bit, then find the view that is closest to the center and snap to it.

The Resistance

💾 Companion app checkpoint: v4-alpha1

So we need to help the RecyclerView. When you call ReyclerView.smoothScrollToPosition, what it does internally is delegate to the LayoutManager which ends up creating and using a LinearSmoothScroller to perform the scroll. By default, the LinearSmoothScroller will scroll just enough to make the item visible, so the item ends up at the start of the RecyclerView (if it was out of bounds to the start), or at the end of the RecyclerView (if it was out of bounds to the end).

The default behavior is easy to override thanks to two constants: SNAP_TO_START or SNAP_TO_END. But what we want is to snap to center, not start nor end, and unfortunately there’s no SNAP_TO_CENTER constant.

With a simple trick though, we can get the desired behavior. If dS is the distance to a view’s start and dE the distance to its end, then the distance to its center is simply (dS+dE) / 2 (halfway through). Good news, calculating the scroll distance (dx) is exactly what LinearSmoothScroller.calculateDxToMakeVisible does and we can override it.

We sum this all up in a nice extension method that we call from the click listener:

Tada! Even tall images snap into the correct position when they’re tapped.

The End of The Resistance

💾 Companion app checkpoint: v4-final

v5: detect when a view becomes prominent

Now let’s say we want to detect when an image becomes prominent. In our app, we’ll show a share button overlay when it becomes prominent, and hide it when it’s no longer prominent.

First let’s define prominent as close to the center, for instance within 72dp of the center. Our ProminentLayoutManager is a good place to determine this, since it’s already checking distance to determine scale.

Then, we’ll change each item from a simple ImageView to a custom OverlayableImageView that is just a ViewGroup of one ImageView + one ImageButton. We’ll use the existing Android view property isActivated to mark a view as prominent or not.

The ProminentLayoutManager now simply needs to set isActivated accordingly:

And that’s it! No hurdle here.

From 8 to 0 miles

💾 Companion app checkpoint: v5-final

Note: There are other ways to go about detecting the prominent view. Maybe the most natural way is to do it with a scroll listener and call SnapHelper.findSnapView once the RecyclerView has stopped scrolling. The main difference with our method is a UX difference: the overlay would get shown later, since it takes some time for the view to settle in place. In the end, it all depends on the definition of prominent that’s right for your app.

Final version

To finish, we just add some visual touches (gradients on the edges and rounded corners) and an action for the share button. This is now ready to ship, good job!

💾 Companion app checkpoint: vfinal

Conclusion

I was trying to make a list of “lessons learnt” but I honestly keep getting to the same item in bold at the top. If there’s one principle to retain from all this, it’s:

Do not be afraid to tinker with pixel distances, translationX and other seemingly “low-level” view properties. First of all, because it’s key in understanding and tweaking how scrolls and animations work. And second because that’s what the Android framework does under the hood anyway.

Earlier I mentioned iTunes cover flow as an inspiration, but the actual cover flow effect was more “3D-like” than what we’ve implemented: it doesn’t simply scale down but also rotates and stacks views together. That said, if you apply the same mindset and start playing with scale and rotation properties, it’s very likely that you’ll manage to get a similar effect.

Have fun!

Find the full code on: https://github.com/dadouf/PagingImageGallery

--

--