How does Airbnb do it? Epoxy!

Adam Croutworst
AndroidPub
Published in
7 min readApr 9, 2017

--

Original Post by Airbnb’s Eli Hart: Epoxy

There are a few well known applications for their designs of beauty. Look at companies changing the design game such as Airbnb and Robinhood. Both provide simple, easy-to-use and aesthetically-pleasing user interfaces with a powerful user experience. It got me thinking how I can start building applications like them. Then comes along an open source Android library by Airbnb for simplifying complex views on Android. Perfect! This open source library is called Epoxy and can be used when designing recycler views in Android.

Building with Epoxy

Epoxy removes boilerplate code such as creating view holders and span counts, and it allows for the creation of complex views. For my project, Community, and for the purposes of this demo, I am going to need grid support and saving view states. Luckily, Epoxy handles this. To start using Epoxy, add it to your gradle file:

dependencies {
compile 'com.airbnb.android:epoxy:X.X.X'
}

For the current gradle version, go to Epoxy’s GitHub page. If you want to utilize annotations for generating subclasses of your model, you must additionally incorporate the annotation processor in your gradle. We will dive into annotations in a little bit, but they are great for binding layouts.

dependencies {
compile 'com.airbnb.android:epoxy:X.X.X'
annotationProcessor 'com.airbnb.android:epoxy-processor:X.X.X'
}

Great, now you are ready for all of Epoxy’s features!

Getting Started

In my project, I am using Butterknife for view injection and view binding; it works great with Epoxy especially if you plan on using annotations. If you are unfamiliar with Butterknife, go check out their GitHub page for details of setup and use. When using Butterknife and view holders within our application, we need to include a base class — BaseEpoxyHolder — that extends off of the EpoxyHolder class. This acts as our base Holder class for our ViewHolder.

public abstract class BaseEpoxyHolder extends EpoxyHolder {
@CallSuper
@Override
protected void bindView(View itemView) {
ButterKnife.bind(this, itemView);
}
}

Now we can move onto designing our view. For this demo, I made two simple sections and concatenated them vertically to create a more complex view. The top view is a HeaderView that displays the header text.

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<TextView
android:id="@+id/title_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="0.8"
android:textColor="#313131"
android:textSize="38sp"
android:textAlignment="center"
tools:text="Title" />
</merge>
HeaderView

Also we will need to define the ViewHeader layout for our upcoming section. Use the package structure name plus the name of your desired view.

<?xml version="1.0" encoding="utf-8"?>
<com.croutworst.community.views.HeaderView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="75dp" />

The bottom section is a grid view with each grid cell as a LinearLayout with a single TextView wrapped in a CardView.

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/card_view_group"
android:layout_width="match_parent"
android:layout_height="90dp"
android:layout_marginTop="10dp"
android:layout_gravity="center">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<TextView
android:id="@+id/text_view_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/colorWhite"
android:textSize="44px"
android:shadowColor="#0A0A0A"
android:shadowRadius="7"
android:textStyle="bold"
android:letterSpacing="0.05"/>

</LinearLayout>
</android.support.v7.widget.CardView>
GridView for Epoxy

Together they look like:

Now that we have designed our complex — in my case, not so complex — view, let's bind our XML layouts with our ViewHolder and add all this to a RecyclerView adapter.

Adapter and Model

Almost done…remember when I had you create the ViewHeader layout. This is the time to use it! We need to create the ViewHeader class to bind the text of the top header view.

public class HeaderView extends LinearLayout {

@BindView(R.id.title_text) TextView title;

public HeaderView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

private void init() {
setOrientation(VERTICAL);
inflate(getContext(), R.layout.view_header, this);
ButterKnife.bind(this);
}

public void setTitle(@StringRes int title) {
this.title.setText(title);
}
}

Let’s now create our Epoxy models. This will include the use of Epoxy annotations and attributes. The annotations function as an abstraction of your boilerplate code and generate a subclass for your model, so you can now bind attributes from the layout we just created in the section above. Annotations are one of the most powerful features to this library. I highly recommend using them but Epoxy should work fine if you decide not to. The annotation follows this syntax: @EpoxyModelClass(layout = R.layout.layout_name). I created a HeaderModel to showcase annotations binding to the HeaderView.

@EpoxyModelClass(layout = R.layout.model_header)
public abstract class HeaderModel extends EpoxyModel<HeaderView> {
@EpoxyAttribute @StringRes int title;

@Override
public void bind(HeaderView view) {
view.setTitle(title);
}

@Override
public int getSpanSize(int totalSpanCount, int position, int itemCount) {
return totalSpanCount;
}
}

Note: getSpanSize(…); returns the total span count so the grid cells can be properly placed evenly on the view.

We can add another model class to perform the same actions for our bottom grid view.

@EpoxyModelClass(layout = R.layout.model_group)
public abstract class GroupModel extends EpoxyModelWithHolder<GroupHolder> {
@EpoxyAttribute @ColorInt int color;
@EpoxyAttribute String text;
@EpoxyAttribute @DrawableRes int image;

@Override
public void bind(GroupHolder holder) {
holder.cardView.setCardBackgroundColor(color);
holder.cardView.setBackgroundResource(image);
holder.textView.setText(text);
}

static class GroupHolder extends BaseEpoxyHolder {
@BindView(R.id.card_view_group) CardView cardView;
@BindView(R.id.text_view_group) TextView textView;
}
}

As seen in the code just above, we see how the annotation will generate a subclass that has color, text, and image attributes and binds to ids specified in the grid view layout. You may be wondering at this point, “You say this will generate a subclass but where and how?” Well, good question! The RecyclerView adapter will use the NameOfModel_ notation to represent generated subclasses and the subclasses will be generated at runtime. So do not worry if it says that the subclasses do not exist before you compile. Also important but quick note; you will need to enable auto diffing if you update the view within the adapter. Read more about auto diffing here.

public class GridAdapter extends EpoxyAdapter {
private static final Random RANDOM = new Random();

public final HeaderModel headerModel;

GridAdapter() {
enableDiffing();

headerModel = new HeaderModel_()
.title(R.string.epoxy);

addModels(
headerModel
);
// This is to add a grid cell using my project's business logic update this with your project's click listener public void addGroup(Group group) {
insertModelAfter(new GroupModel_().text(group.getTitle()).image(randomPicture()), headerModel);
}

public void removeGroup() {
removeAllModels();
}

private int randomColor() {
int r = RANDOM.nextInt(256);
int g = RANDOM.nextInt(256);
int b = RANDOM.nextInt(256);

return Color.HSVToColor(new float[] { r, 18f, 86f});
}

private int randomTitle() {
int title = RANDOM.nextInt(8);

switch (title) {
case 0:
return R.string.group_1;
case 1:
return R.string.group_2;
case 2:
return R.string.group_3;
case 3:
return R.string.group_4;
case 4:
return R.string.group_5;
case 5:
return R.string.group_6;
case 6:
return R.string.group_7;
default:
return R.string.group_0;
}
}

private int randomPicture() {
int grid = RANDOM.nextInt(6);

switch(grid) {
case 0:
return R.drawable.mountain;
case 1:
return R.drawable.cloud;
case 2:
return R.drawable.water;
case 3:
return R.drawable.fire;
case 4:
return R.drawable.trail;
case 5:
return R.drawable.tent;
default:
return R.drawable.tent;
}
}
}

Off to our final step.

Final Step: Adding to Activity / Fragment

Finally, we need to add the adapter to the activity and/or fragment. In my case, I am actually using a fragment. Like you normally would, instantiate a RecyclerView and GridAdapter.

/* Initialize Views */
private RecyclerView recyclerView;
private GridAdapter adapter;
...private void initViews() {
recyclerView = (RecyclerView)view.findViewById(R.id.recycler_view);

int spanCount = getSpanCount();

adapter = new GridAdapter();

adapter.setSpanCount(spanCount);
GridLayoutManager gridLayoutManager = new GridLayoutManager(this.getContext(), spanCount);
gridLayoutManager.setSpanSizeLookup(adapter.getSpanSizeLookup());
gridLayoutManager.setRecycleChildrenOnDetach(true);

recyclerView.setLayoutManager(gridLayoutManager);
recyclerView.setHasFixedSize(true);
recyclerView.addItemDecoration(new VerticalGridCardSpacingDecoration());
recyclerView.setAdapter(adapter);

recyclerView.getRecycledViewPool().setMaxRecycledViews(R.layout.model_group, 50);
}
...private int getSpanCount() {
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
float dpWidth = displayMetrics.widthPixels / displayMetrics.density;
return (int) (dpWidth / 100);
}

I have a neat function that initializes the views (initViews). The adapter and recycler view are setup as normal with small modifications. Also I attached the VerticalGridCardSpacing decoration below.

public class VerticalGridCardSpacingDecoration extends ItemDecoration {
private static final int OUTER_PADDING_DP = 10;
private static final int INNER_PADDING_DP = 4;
private int outerPadding = -1;
private int innerPadding = -1;

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
if (outerPadding == -1 || innerPadding == -1) {
DisplayMetrics m = view.getResources().getDisplayMetrics();
outerPadding = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, OUTER_PADDING_DP, m);
innerPadding = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, INNER_PADDING_DP, m);
}

int position = parent.getChildAdapterPosition(view);
final GridLayoutManager layoutManager = (GridLayoutManager) parent.getLayoutManager();
final SpanSizeLookup spanSizeLookup = layoutManager.getSpanSizeLookup();

// Zero everything out for the common case
outRect.setEmpty();

int spanSize = spanSizeLookup.getSpanSize(position);
int spanCount = layoutManager.getSpanCount();
int spanIndex = spanSizeLookup.getSpanIndex(position, spanCount);

if (spanSize == spanCount) {
// Only item in row
outRect.left = outerPadding;
outRect.right = outerPadding;
} else if (spanIndex == 0) {
// First item in row
outRect.left = outerPadding;
outRect.right = innerPadding;
} else if (spanIndex == spanCount - 1) {
// Last item in row
outRect.left = innerPadding;
outRect.right = outerPadding;
} else {
// Inner item (not relevant for less than three columns)
outRect.left = innerPadding;
outRect.right = innerPadding;
}
}
}

Note: Be sure to add RecyclerView to your XML layout.

<android.support.v7.widget.RecyclerView android:id="@+id/recycler_view"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentTop="true"
android:paddingBottom="16dp" />

Recap

Thanks for the journey! Let’s go through a quick recap to review what we did.

Together, we learned what Epoxy is and how Airbnb’s Android view architecture is structured. With Epoxy, we were able to add it to our gradle, and we included butterknife for view injection in collaboration with the BaseEpoxyHolder. We designed views to help us build our Epoxy structure. Then we created models to reflect the views that were created. The models were then instantiated and added to our adapter to later be used in our activity or fragment. Lastly, we learned about annotations, attributes, and automatic diffing in relation to Epoxy.

--

--

Adam Croutworst
AndroidPub

Posts about mobile tech, operations, and growth