Detecting snap changes with Android’s RecyclerView SnapHelper
Extending SnapHelper by adding a means of listening to changes in snap position
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
:
PagerSnapHelper
is intended for full-screen items and behaves similarly to a ViewPager
:
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:
Listening for snap position changes 👂
Before we dive into how we will determine snap position changes, let’s first define a simple callback interface:
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
}
}
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)
}
}
The final class
Our final class incorporates all of the above functionality, while also making use of nullability and default parameters:
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