Farewell, ViewPager. Hello, Epoxy Carousel.

Yolanda
The Startup
Published in
5 min readOct 21, 2019

Dear ViewPager,

It’s not you. It’s me.

No. Actually, it’s you. It’s you and your inability to adapt to our children’s height. It’s you and your Fragments friends and your resistance to meeting some of my other friends. It’s you and all your boilerplate code.

This is what the end result should look like.
This is what we’re going to be building.

In all honesty, the ViewPager has served me nicely over the years. But once I realized I could do everything the ViewPager does, using the RecyclerView (thank you, amit d4vidi for the inspiration), I wondered if I could take it to the next level by applying the same concepts to Epoxy’s Carousel.

Plus… Carousels make everyone happy!

Just look at them — they look so happy. Photo by ckturistando on Unsplash.

I’ve been using Epoxy — Airbnb’s Open Source Android library for building complex screens in a RecyclerView — for little over a year now and I can’t possibly see myself going back to writing all… that… boilerplate… code.

Don’t get me wrong, the RecyclerView is undoubtedly one of the most important components the Android framework has to offer. But I love how Epoxy makes it seem as if you were just playing with constructions blocks. And those construction blocks just fit perfectly within one another and yet can maintain their expression and individuality. It’s almost as if it works better than modern society.

But enough talk. How do we achieve this?

Oh, and I forgot to mention this will be done in Kotlin.

Building the views and adding them to the Carousel

(1) We can start by creating the ViewPager item’s layout.

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:minHeight="380dp"
android:textColor="@color/rw_dark"
android:textSize="48sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Hello, World." />
</androidx.constraintlayout.widget.ConstraintLayout></androidx.cardview.widget.CardView>

(2) Now we’ll have Epoxy generate our model classes. You can do this by annotating custom views, using Android Databinding or applying the View Holder pattern, whichever you prefer.

We’ll use the View Holder pattern in this example — it’s intuitive and reminisces to our happy times with the RecyclerView.

@EpoxyModelClass(layout = R.layout.model_item_view_pager)
abstract class ViewPagerItem
: EpoxyModelWithHolder<ViewPagerItem.Holder>() {
@EpoxyAttribute
lateinit var title: String
override fun bind(holder: Holder) {
super.bind(holder)
holder.textTitle.text = title
}
class Holder : BaseEpoxyHolder() {
val textTitle: TextView by bind(R.id.textTitle)
}
}

(3) And now we build our Carousel using the generated model class we just created. I believe it goes without saying, but they are generated *after* you build the code. And they are annotated by _.

override fun buildModels(data: ViewPagerData) {
carousel {
id("This is a ViewPager.")
hasFixedSize(true)
paddingRes(R.dimen.view_pager_item_padding)
models(data.items.mapIndexed { index, item ->
ViewPagerItem_
()
.id(item)
.title(item)
}
)
}
// TODO: TabLayout goes here. We'll get there...
}

And the RecyclerView’s ItemDecoration? Gone.

We can simply set the padding to our Carousel, and it will behave just like an ItemDecoration should, by adding even spaces between all the items.

Alright, now on to the part where I almost questioned this decision.

Where’s the TabLayout!?

Taking on Epoxy’s paradigm, the TabLayout is just… You might have guessed. Another construction block. Or a model class.

So we’re back at step (1): build the layout.

No, actually no. We could. But since our TabLayout is nothing more than a FrameLayout with some dynamically inflated children depending on the Carousel’s child count, we can simply build a model class using an annotated custom view.

This is step (2): have Epoxy generate the model class. See!? We managed to skip a step! Brilliant.

@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class TabLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
addView(LinearLayout(context).also {
val params = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
params.gravity = Gravity.CENTER_HORIZONTAL
it.layoutParams = params
it.orientation = HORIZONTAL
})
}
@ModelProp
fun setCount(count: Int) {
// TODO: Inflate <count> tiny-little Check Boxes.
}
@ModelProp
fun setChecked(index: Int) {
// TODO: Check the little-tiny Check Box at <index>.
}
}

As you can see, now our model class simply extends FrameLayout — our intended view root. We’re adding a wrapped content LinearLayout child to it, just so we can have our tabs centered on the screen. There are probably better ways to do this, but I cannot be bothered at this point. Where were we…?

On to step (3): build the TabLayout using the generated model class.

override fun buildModels(data: ViewPagerData) {
// ...
TabLayoutModel_()
.id("This is the ViewPager's TabLayout.")
.count(data.items.size)
.checked(data.visibleItemIndex)
.addTo(this)
}

Epoxy offers a couple of visibility callbacks we can attach to our models to interpret their visibility status. The only thing our TabLayout needs to know is which model is currently fully visible — so we’re going to attach the following callback to each ViewPager item model on our Carousel:

onVisibilityStateChanged { _, _, visibilityState ->
if (visibilityState == FOCUSED_VISIBLE) {
// This model just became fully visible.
}
}

Easy. Now the tricky part. If you run the code at this point, you’ll notice the callback is… never… called. Why, Airbnb Engineers? Why!?

Turns out to enable visibility events, you need to attach a visibility tracker to your RecyclerView, like this:

EpoxyVisibilityTracker().attach(recyclerView)

Seriously, I had to go to the depths of the internet to find this out.

Now, whenever our event is dispatched, we’ll update our TabLayout model to highlight the tab respective to the currently visible item.

That could be it. But if our initial idea was to replicate the ViewPager’s UX, we’re not quite there yet.

Oh, Snap!

Right now you’re able to easily fling through all the items. And that’s ok — depending on what you’re trying to achieve, but it doesn’t exactly “feel” right, especially if you’re building a pager with only a handful of pages.

Again, I had to dive deep into the confined depths of the internet (or maybe my Google-Fu is just weak) to find out that you can — very easily, I might add — change this behavior by doing:

Carousel.setDefaultGlobalSnapHelperFactory(
object : Carousel.SnapHelperFactory() {
override fun buildSnapHelper(context: Context?
): SnapHelper {
// Return anything!
// Anything you could ever possibly dream of!
})

We’re going with PagerSnapHelper, because, as you can tell by the name, it replicates the exact snapping experience the ViewPager offers.

If you’re feeling adventurous, check out this library by Android developer Rúben Sousa, which implements a bunch of snapping behaviors. Or go ahead and implement your own!

TL;DR: here’s the full source code. Happy coding!

--

--