Snapping behaviour in Epoxy Carousel and its pitfalls
During the development of one of our apps, we were tasked to develop a carousel view connected to a Google map in such a way that items displayed in the carousel would correspond to markers on the map. The details of the behaviour:
- There is a selected element — one item, that is fully visible inside the carousel
- The carousel snaps to a newly selected element whenever we move it
- Once the selected item is chosen, we move the map to it’s corresponding map marker
For the purpose of this article, we decided to skip the map entirely and simply display the information about the selected item outside the carousel. Desired behaviour:
Setup
We decided to use Airbnb’s Epoxy for the Carousel
. We are assuming the reader is already familiar with it, but if not, there are some great resources to get to know this library, for example the wiki or this guide.
For the sake of simplification, we will be using an artificial data class Item(val id: Int, val name: String)
.
We use Epoxy with a very basic setup:
- An
EpoxyModel
to connect a simple xml layout with the data class - An
EpoxyController
to store the value of the currently displayed list of items and in aCarousel
and redraw the view once the data changes:
var items: List<Item> = emptyList()
set(value) {
field = value
requestModelBuild()
}
override fun buildModels() {
val itemModels = items.map { item ->
ItemModel_()
.id(item.id)
.item(item)
}
val carouselModel = CarouselModel_()
.numViewsToShowOnScreen(1.1F)
.id("carousel")
.models(itemModels)
carouselModel.addTo(this)
}
Snapping behaviour
What we can read in the Epoxy documentation:
Snapping Support By default a LinearSnapHelper is attached to all Carousel instances. If you would like to change the default snap behavior you can call Carousel.setDefaultGlobalSnapHelperFactory(…) and pass a factory object to create your snap helper. Null can be passed to disable snapping by default.
Having that information, we started by setting the global SnapHelperFactory
from the controller’s constructor. The SnapHelper
it returns notifies the controller of any position updates when snapping to a view.
var selectedPosition = RecyclerView.NO_POSITION
init {
val snapHelper = CarouselSnapHelperFactory()
Carousel.setDefaultGlobalSnapHelperFactory(snapHelper)
}
inner class CarouselSnapHelperFactory : Carousel.SnapHelperFactory() {
override fun buildSnapHelper(context: Context): SnapHelper {
return CustomLinearSnapHelper()
}
inner class CustomLinearSnapHelper : LinearSnapHelper() { //can also extend PagerSnapHelper - see the description in the last section
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
val view = super.findSnapView(layoutManager)
if (view != null) {
val newPosition = layoutManager.getPosition(view)
if (newPosition != selectedPosition) {
onViewSnapped(newPosition)
selectedPosition = newPosition
}
}
return view
}
}
}
The problem
The solution recommended by the documentation did not work — the TextView outside of the EpoxyRecyclerView
would only change during the first visit to the fragment. When we went to another fragment and then came back, the changes in the TextView
outside EpoxyRecyclerView
would not occur.
After an investigation it turned out that during the destruction of the Fragment
, the SnapHelper
and Carousel
were not properly garbage collected. Not only that: we were also leaking memory.
None of the usual quick fixes such as setting the global factory to null
or applying WeakReferences
worked.
The solution
The recommended global setting should be replaced by providing one SnapHelper
per Carousel
. For that, we need to create a custom Epoxy ModelView
that extends the Carousel
class, and then create a LinearSnapHelper
for every instance of the class. By annotating our subclass with ModelView
Epoxy generates a new EpoxyModel
for this view. The autoLayout
param will adjust the size of the view.
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class CustomSnappingCarousel @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : Carousel(context, attrs, defStyleAttr) {
var selectedPosition = RecyclerView.NO_POSITION
fun setSnapHelperCallback(callback: (Int) -> Unit) {
val snapHelper = CustomLinearSnapHelper(callback)
//workaround - do not remove
//https://stackoverflow.com/questions/44043501/an-instance-of-onflinglistener-already-set-in-recyclerview/52850198
this.onFlingListener = null
snapHelper.attachToRecyclerView(this)
}
inner class CustomLinearSnapHelper(private val callback: (Int) -> Unit) : LinearSnapHelper() {
override fun findSnapView(layoutManager: LayoutManager): View? {
val view = super.findSnapView(layoutManager)
if (view != null) {
val newPosition = layoutManager.getPosition(view)
if (newPosition != selectedPosition) {
callback.invoke(newPosition)
selectedPosition = newPosition
}
}
return view
}
}
}
Our EpoxyController
would then have to be altered so that we can supply the callback function to the LinearSnapHelper
— we can do that onBind
of the carousel:
override fun buildModels() {
val itemModels = items.map { item ->
ItemModel_()
.id(item.id)
.item(item)
}
val carouselModel = CustomSnappingCarouselModel_()
.id("carousel")
.numViewsToShowOnScreen(numberOfViewsOnScreen)
.models(itemModels)
.onBind { _, view, _ ->
view.setSnapHelperCallback { newPosition ->
onItemSnappedCallback(newPosition) //define the callback as a constructor parameter of the EpoxyController
}
}
carouselModel.addTo(this)
}
This way, the global SnapHelperFactory
of class Carousel
remains untouched and we still achieve the desired behaviour with everything being properly garbage collected upon fragment change.
Side note: LinearSnapHelper vs. PagerSnapHelper
Our custom CustomLinearSnapHelper
can extend one of two classes: LinearSnapHelper
or PagerSnapHelper
. Comparing these two classes, there are some similarities and differences:
-
Both classes are an implementation of an abstract class
SnapHelper
which is intended to support snapping behaviour forRecyclerViews
. -
Both classes support snapping in either vertical or horizontal orientation.
-
The
PagerSnapHelper
is intended to mimic the behaviour of aViewPager
— this means thatLinearSnapHelper
will work better when there are several smaller items visible on the screen. -
When we intend to have one bigger item visible at any given time (which is our case), the difference between those two classes boils down to the fling behaviour: with
LinearSnapHelper
you can scroll through multiple items with one fling; whereasPagerSnapHelper
will only ever move to the next item in either direction.
Based on that, we decided to go with LinearSnapHelper
as we wanted to preserve the ability to scroll through multiple items at once.
Conclusion
Epoxy provides some really nifty tools to work with Carousels
— however, having in mind that the Carousel
feature is still in Beta, there are certain dangers we have to be aware of. That is why from our experience, you should avoid setting global listeners on the Carousel
, and instead modify the snapping behaviour with a custom carousel implementation.
The source code for the complete solution is available on GitHub.
Further reads
We looked at a specific behaviour of the carousel, where the snapping event occurs once the carousel settles. However, we might need the snapping to occur while we are still scrolling, providing live updates:
For this a nice read would be Nick Rout’s article about detecting snap changes, whose results could also be applied to the EpoxyCarousel
here.