Epoxy—Build Declarative & Reusable UI Components
Tags: Design, Android, Android Design, Airbnb, Epoxy

Epoxy—Build Declarative & Reusable UI Components

Complex & Reusable UI Components Made Easy

Intro

Epoxy is an Android library for building complex screens in a RecyclerView. Working directly with RecyclerView.Adapter<T> can be challenging when we have multiple view types. However, Epoxy solved that by using Code Generation (Annotation Processor) to generate builder classes & Kotlin extension functions. It uses RecyclerView.Adapter & ViewHolder under the hood!

Let’s say we have these screens below, now imagine how you would achieve it with a traditional RecyclerView Adapter & ViewHolder.

We will start by creating a RecyclerView adapter and then several ViewHolders. After that, we will have to use a nested RecyclerView to create the horizontal carousel. It’s quite difficult to work with nested RecyclerView in my opinion since we have to manually bind the RecycledPool to increase Performance and you might end up using a lot of data classes & sealed classes. Binding an adapter inside a ViewHolder is not fun!

Installation & Configuration

Since Epoxy uses code generation we have to add a Gradle plugin for Kotlin KAPT.

According to the official docs, we have to add correctErrorTypes = true to the KAPT configuration block. Read more on the Kotlin website.

Now let’s add the dependencies to our build.gradle

$epoxy_version variable is the latest version of Epoxy which you can check inside on GitHub.

Thinking in Epoxy

When we use Epoxy, we are likely to think of every view as an individual component and called EpoxyModel. An EpoxyModel can be used inside any EpoxyController. A controller is where we combine models together. It’s similar to the RecyclerView.Adapter<T>.

This is how it should look like inside EpoxyController.

One of the best things that Epoxy provides is that we are allowed to write for-loop and if-else inside buildModels method. The logic that we write reflects what we will get.

Reusability

ViewHolder is independent, but whenever you want to use a ViewHolder inside any adapters you will have to register it manually inside those adapters by yourself. This is what Epoxy has solved by isolating all those components into each and individual. So every model in Epoxy is independent so it can be used in every controller. For example, a simple loading indicator can be used throughout the application.

Epoxy Models

Epoxy Model is an individual component that we can use inside any controller. It’s quite similar to a RecyclerView ViewHolder however, it gives some advanced features to work with such as view visibility spans and much more. There are two approaches to create Epoxy Models.

The most common one is to use create an abstract class and annotate it with @EpoxyModelClass(..) to let it generate the implementation class for us. The generated classes will be suffixed with _ (underscore). For example, we have an abstract model class call CardModel so the generated class is CardModel_ . One thing to note is that if you are using Kotlin, it will generate DSL extension functions that you can easily use inside any controllers.

The second method is to create a non-abstract class that extends EpoxyModel and overrides all those abstract methods by ourselves. This approach can be difficult since we have to implement methods such as getDefaultLayout() and createNewHolder() manually.

Create an EpoxyHolder

It’s basically the same as RecyclerView ViewHolder but this one is for Epoxy.

The method bindView(itemView: View) is called when the layout has been inflated for the first time. The method will be called once, so we can use something like findViewById(...) to create a reference to our view, or in this example, I bound the ViewBinding inside that method.

Create a Model from EpoxyHolder

Start by creating an abstract class that is annotated with @EpoxyModelClass(layout = R.layout.<layout_name>) also extended with EpoxyModelWithHolder<YourHolder>()

Props

Props are any types of objects that we went to send to models and those values will be available inside those models. We use @EpoxyAttribute Annotation to indicates if it’s a prop field. We can also use those attributes for Callback & Listeners as well, however, we have to add an option for DoNotHash inside the argument of the annotation so that Epoxy won’t incorrectly hash the values.

Lifecycle Methods

There are two important methods inside EpoxyModels: bind() and unbind()

bind() bind data or listeners that we get from Props to the Holder.

bind data or listeners that we get from to the unbind() cancel or remove unused references after the holder is destroyed. We could use it to cancel Image Requests or Dereference Listeners.

Epoxy Controllers

A high-level adapter for RecyclerView. Models can only be used inside controllers, so let’s start building a controller for our models.

EpoxyAsyncUtil.getAsyncBackgroundHandler() is used to tell Epoxy that we want any diffing and building process to be done in a dedicated background thread instead of the UI thread. It significantly improves the rendering performance of our application.

We then just put our generated DSL models inside buildModel() method accordingly. We can use Loop, If-Else inside this method.

Notify Data Changes

In a regular RecyclerView adapter, we use notifyDataSetChanged() or notifyItemChanged(..) to tell the adapter to invalidate views and data. However, in Epoxy we just have to call requestModelBuilds() and Epoxy will take of diffing and invalidate only what has changed. This saves us a lot of time!

Diffs

Epoxy provides an easy way to work with DiffUtil so that we will get good performance out of the box. Epoxy requires every model to have its own unique ID so that I can track which item is which and what has changed.

RecyclerView.ListAdapter uses this approach too. You may need to check that out.

Attach Controller to RecyclerView

If you are using a regular RecyclerView, you need to use EpoxyController#getAdapter() or EpoxyController.adapter in Kotlin.

val controller = MenuController()

binding.recyclerView.adapter = controller.adapter

Attach Controller to EpoxyRecyclerView

Epoxy also provides us a class that extends RecyclerView, there are some useful methods such as setController() and setControllerAndBuildModels() .

val controller = MenuController()

binding.epoxyRecyclerView.setController(controller) ... // observe data from a ViewModel

viewModel.items.observe(viewLifecycleOwner) { items ->

controller.submit(items)

}

setControllerAndBuildModels() is a utility method for setting controller and then build models.

val controller = MenuController()

binding.epoxyRecyclerView.setControllerAndBuildModels(controller)

Thoughts on Epoxy & Jetpack Compose

Both Compose & Epoxy have the same goal is to design UIs declaratively with less boilerplate & reusability. Compose made it even easier to design complex layouts. Compose has its own system while Epoxy is just a library that extends the behavior of the RecyclerView<T> & DiffUtils under the hood. Thinking that Epoxy is a replacement for the regular RecyclerView adapter.

Conclusion

Epoxy helps us to deal with complex layout design from our designers in a declarative way. I have been using it for a while now and I found it useful and wanted to share this experience with all of you and hope you find it helpful.

Recent Article

Check out my next article about how to use Epoxy without Annotation Processing for improving build performance.

Sample Project Source

If you want to learn more about it, please check out my sample project on GitHub.