Skip to Content

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:

  1. There is a selected element — one item, that is fully visible inside the carousel
  2. The carousel snaps to a newly selected element whenever we move it
  3. 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:

  1. An EpoxyModel to connect a simple xml layout with the data class
  2. An EpoxyController to store the value of the currently displayed list of items and in a Carousel 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 for RecyclerViews.

  • Both classes support snapping in either vertical or horizontal orientation.

  • The PagerSnapHelper is intended to mimic the behaviour of a ViewPager — this means that LinearSnapHelper 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; whereas PagerSnapHelper 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.