Asymmetrical grid layout using RecyclerView

Back at Twitter, I worked with the search team to introduce image results in the Android app. The designer came up with a wonderful layout that at that time, was challenging, but we managed to launch it.

Twitter image search (courtesy Twitter)

During that time, there was no RecyclerView, no abstract layout managers that can be extended to easily build an asymmetrical grid view. Instead of trying to reinvent ListView’s recycling logic, that gallery was built on ListView and LinearLayout in addition to some math to handle the edge cases.

Today, however, the Android ecosystem has a lot of abstractions that can be leveraged to build a similar gallery. In fact, it can be just built with the basic features available in the RecyclerView library and as before some math to handle the edge cases.

3 column grid

Let’s start from the basics and set up a RecyclerView with the standard GridLayoutManager with 3 columns.

val columns = 3
val layoutManager = GridLayoutManager(context, columns)
recyclerView.layoutManager = layoutManager
val adapter = GalleryAdapter(context)
recyclerView.adapter = adapter

The source code for the standard grid gallery can be found here. Just to recap, the adapter is simply going to show the image and the accompanied title.

override fun onCreateViewHolder(parent: ViewGroup,
viewType: Int): ImageViewHolder {
return ImageViewHolder(inflater.inflate(R.layout.image_item,
parent, false))
}
override fun onBindViewHolder(holder: ImageViewHolder,
position: Int) {
val item = items[position]
holder.label.text = item.name
picasso.load(item.url)
.fit()
.centerCrop()
.into(holder.image)
}

Asymmetrical column grid

Starting with the standard grid layout, we need some small changes to allow support for items that span different column width. GridLayoutManager allows the user to provide a lookup callback to get the column widths for different positions.

For example, to use double width for all items in even position we can use the following code:

lookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (position and 0x1) {
1 -> 2
else -> 1
}
}
}
layoutManager.spanSizeLookup = lookup

We can extend this to build a more asymmetrical columns, by essentially performing a similar calculation to the weight distribution in LinearLayout. To ensure that the image calculations only happen once we can save the column width in the model objects.

Let’s look into the math around this layout. Since we are working to build an asymmetrical look, we need to find a grid that allows for that flexibility. In our case we are going to use a 15 column grid. Next these 15 columns will be distributed among the row items based on the image aspect ratio. Essentially we are working on a simpler version of the LinearLayout’s formula.

val row = ArrayList<Item>()
var rowRatios = 0f
items.forEach { it: Item ->
it.imageRatio = it.width / it.height.toFloat()
rowRatios += imageRatio
if (rowRatios > 2f) {
// We crossed our allotment, distribute columns in row
var used = 0
row.forEach { it2: Item ->
it2.columns = ((maxColumns * it2.imageRatio) / rowRatios).toInt()
used += it2.columns
}
it.columns = maxColumns - used
row.clear()
rowRatios = 0f
} else {
row.add(it)
}
}

Now that we have our column calculations, we can update maxColumns to be 15, and update the span lookup to read from the item.

lookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return items[position].columns
}
}

Ta-da!

Gallery Z — powered by RecylerView w/ GridLayoutManager

Fully working example mimicking the original Twitter gallery is available in this public repository.