Detecting snap changes with Android’s RecyclerView SnapHelper

Extending SnapHelper by adding a means of listening to changes in snap position

Nick Rout
Over Engineering
Published in
4 min readAug 31, 2018

--

SnapHelper was a great addition to the AndroidX (previously Support Library) RecyclerView package. In short, it can be used to change the behavior of a RecyclerView such that items snap to discrete positions.

There are currently two standard implementations of the base SnapHelper class; LinearSnapHelper and PagerSnapHelper, each offering slightly different functionality. Both support horizontal and vertical orientations.

LinearSnapHelper is intended for smaller items and snaps the center of the target child view to the center of the RecyclerView:

LinearSnapHelper

PagerSnapHelper is intended for full-screen items and behaves similarly to a ViewPager:

PagerSnapHelper

The API to use these classes is exceedingly simple:

val snapHelper = LinearSnapHelper() // Or PagerSnapHelper
snapHelper.attachToRecyclerView(recyclerView)

The case of the missing API 🕵️‍♀️

What if we wanted to know when the snap position has changed? For example, perhaps we are using a PagerSnapHelper and want to show a page indicator.

Unfortunately, at the time of this writing, no such API exists. There is even an open issue for such a callback that has existed for some time.

How might we go about implementing this? The SnapHelper classes are complex and not very modular, and thus extending them (or writing a new subclass) would be painful. Thankfully, we can make use of existing RecyclerView classes and some Kotlin magic to achieve this.

Finding the current snap position 🔍

The first thing we need is a way to determine the current snap position. Again, no such SnapHelper function currently exists and we will have to implement this ourselves.

What SnapHelper does offer is a way to find the current snap View. We have to pass in the LayoutManager used by the RecyclerView that our SnapHelper is attached to:

val layoutManager = recyclerView.layoutManager
val
snapView = snapHelper.findSnapView(layoutManager)

We can then use this LayoutManager to determine the position of this View:

val snapPosition = layoutManager.getPosition(snapView)

We can wrap this up neatly for reusability in a Kotlin extension function, while also taking into account some of the nullability aspects:

An extension function for finding the current SnapHelper snap position

Listening for snap position changes 👂

Before we dive into how we will determine snap position changes, let’s first define a simple callback interface:

A simple interface for listening to snap position changes

Determining snap position changes

We know that the snap position will only change during scrolling. Thus, to determine changes we are going to combine the getSnapPosition function we defined earlier with a custom subclass of OnScrollListener. It is important to note that we only want to know when the snap position changes, so our class needs to keep a reference to the last known position so that the callback is only triggered when this is different. The key functionality:

private var snapPosition = RecyclerView.NO_POSITION

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
maybeNotifySnapPositionChange(recyclerView)
}

private fun maybeNotifySnapPositionChange(recyclerView: RecyclerView) {
val snapPosition = snapHelper.getSnapPosition(recyclerView)
val snapPositionChanged = this.snapPosition != snapPosition
if (snapPositionChanged) {
onSnapPositionChangeListener
.onSnapPositionChange(snapPosition)
this.snapPosition = snapPosition
}
}
Listening to snap position changes on scroll

Adding an option to notify when scrolling finishes

The above implementation will notify us of all snap position changes during a scroll event, particularly when using a LinearSnapHelper. Perhaps we only wish to know what the final snap position is (i.e. When scroll state becomes idle)?

First up, let’s define an enum class to specify these two options:

enum class Behavior {
NOTIFY_ON_SCROLL,
NOTIFY_ON_SCROLL_STATE_IDLE
}

We then make use of the second OnScrollListener callback to implement this:

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
maybeNotifySnapPositionChange(recyclerView)
}
}
Listening to snap position changes on scroll state idle

The final class

Our final class incorporates all of the above functionality, while also making use of nullability and default parameters:

The final class for determining snap position changes

We connect our new class with our existing RecyclerView and SnapHelper like so:

val snapOnScrollListener = SnapOnScrollListener(snapHelper, behavior, onSnapPositionChangeListener)
recyclerView.addOnScrollListener(snapOnScrollListener)

Adding a convenient extension function 🤓

Our current implementation works well, but we could reduce the boilerplate code required for setup through the use of another extension function.

We want to ensure consistent setup of the new OnSnapPositionChangeListener and SnapOnScrollListener classes. We also want to keep the Behavior option available:

We now have an easy way to link up a RecyclerView with a SnapHelper while also listening for snap position changes:

recyclerView.attachSnapHelperWithListener(snapHelper, behavior, onSnapPositionChangeListener)

I hope this post has provided some insight into SnapHelper and how one might extend it to allow for listening to changes in the snap position. If you have any questions, thoughts or suggestions then I’d love to hear from you!

Find me on Twitter @ricknout

--

--

Nick Rout
Over Engineering

Principal Android Engineer at GoDaddy | Ex-Google | Google Developer Expert for Android