Flexbox for Android – Creating a CenterFlowAdapter with FlexboxLayoutManager

When working with RecyclerView, our choices are limited to linear, grid, and staggered grid (unless you’re creating a custom layout manager). But what if we want something that works like a grid layout, but allows us to center rows or justify them as we like?

That’s where a flexbox would work well. And it just so happens that Google has made one for Android; it just requires a dependency. (I think this should be available by default, but it’s not.)

Include this in your app build.gradle file:

implementation 'com.google.android:flexbox:1.1.1'

FlexboxLayout and FlexboxLayoutManager are included; both are very useful. We’ll be creating a CenterFlowAdapter using the FlexboxLayoutManager. The code can be found in the GithubBrowserSample for reference.

What I’m looking to create here isn’t just a grid layout that will center the last row of items. I want this behavior, but I’d also like it to equalize the number of items in each row. This will attempt to maximize the horizontal space and make the rows uniform.

Center Flow Layout using Flexbox Layout

Initial Layouts

First, we need a RecyclerView. Note the button that’s calling a method in the viewModel to add numbers.


<layout 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">

    <data>
        <variable
            name="viewModel"
            type="com.example.myapplication.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        tools:context=".MainActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/number_ball_collection"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="8dp"
            android:layout_marginTop="8dp"
            android:focusable="false"
            app:layout_constraintVertical_bias="0.0"
            app:layout_constraintBottom_toTopOf="@+id/add_numbers_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:listitem="@layout/number_ball_item" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/add_numbers_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="8dp"
            android:text="@string/add_number"
            android:onClick="@{() -> viewModel.addNumber()}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

For displaying our contents in the “grid,” I made a simple layout that takes in a number as data and displays it in a circle. The difference in sizes here is to account for the slight bit of translation for some shadow. This acts as padding for the shadow so it won’t get cut off when displayed.


<layout 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">

    <data>
        <variable
            name="number"
            type="String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="32dp"
        android:layout_height="32dp">
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="30dp"
            android:layout_height="30dp"
            android:background="@drawable/number_ball"
            android:translationZ="2dp"
            android:focusable="false"
            app:layout_constraintVertical_bias="0"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintTop_toTopOf="parent">
            <androidx.appcompat.widget.AppCompatTextView
                android:id="@+id/number_ball"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@{number}"
                android:textColor="@color/black"
                android:textSize="16sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                tools:text="36" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Here’s the simple drawable: a white oval.


<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/white" />
</shape>

The CenterFlowAdapter

This uses the DataBoundListAdapter from the GithubBrowserSample (mentioned above), so don’t worry much about the diffCallback, createBinding, and bind functions. Those bind the data, telling the RecyclerView when to rebind and what layout is being bound.

Note that we need to pass in the RecyclerView, the cell size (assuming square cells), the spacing between items, and the spacing between rows.


class CenterFlowAdapter(
    private val dataBindingComponent: DataBindingComponent,
    appExecutors: AppExecutors,
    private val recyclerView: RecyclerView,
    private val cellSize: Int,
    private val interItemSpacing: Int,
    private val lineSpacing: Int
) : DataBoundListAdapter<Int>(
    appExecutors = appExecutors,
    diffCallback = object: DiffUtil.ItemCallback<Int>() {
        override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean {
            return oldItem == newItem
        }

        override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean {
            return oldItem == newItem
        }
    }
) {
    override fun createBinding(parent: ViewGroup, viewType: Int): ViewDataBinding {
        return DataBindingUtil.inflate(LayoutInflater.from(parent.context), R.layout.number_ball_item, parent, false, dataBindingComponent)
    }

    override fun bind(binding: ViewDataBinding, item: Int) {
        (binding as? NumberBallItemBinding)?.let {
            binding.number = item.toString()
        }
    }

    fun setPadding(numberOfItems: Int) {
        ...
    }

    private fun computAndUpdatePadding(numberOfItems: Int, width: Int) {
        ...
    }

    private fun findMaxColumns(numItems: Double, cellsPerRow: Double): Double {
        ...
    }
}

SetPadding is called after data has been submitted to the adapter. FlexboxLayoutManager has a very nifty ItemDecoration that can be used in RecyclerViews; it allows us to use a drawable that has some width and height. It then uses these as the values for spacing the items and the rows apart.

The orientation can be HORIZONTAL, VERTICAL, or BOTH for spacing items, rows, or both respectively (assuming ROW as the flexbox direction).

If the RecyclerView has width, we can then update the padding on it. Otherwise, we need to wait for this information to be available. This happens on the first load because the view hasn’t loaded yet.


fun setPadding(numberOfItems: Int) {
    if (recyclerView.itemDecorationCount == 0) {
        val divider = GradientDrawable()
        divider.setSize(interItemSpacing.toPx(), lineSpacing.toPx())
        val decoration = FlexboxItemDecoration(recyclerView.context)
        decoration.setDrawable(divider)
        decoration.setOrientation(FlexboxItemDecoration.BOTH)
        recyclerView.addItemDecoration(decoration)
    }

    val width = recyclerView.width

    if (width > 0) {
        computAndUpdatePadding(numberOfItems, width)
    } else {
        val vto = recyclerView.viewTreeObserver
        vto.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
            override fun onPreDraw(): Boolean {
                recyclerView.viewTreeObserver.removeOnPreDrawListener(this)
                computAndUpdatePadding(numberOfItems, recyclerView.width)
                return true
            }
        })
    }
}

Because we are using what’s essentially a Flexbox, we know that these automatically adjust the items per row to fill the space available. Since we are looking to equalize the rows, though, we need to compute how many we can have per row, then adjust the recyclerView padding. This will force the flexbox to update accordingly.

First, we find the number of cells per row that it takes to fill the width. Then we find the max columns (or cellsPerRow); this is the max that allows for each row to be equal. If this ends up being one, then the item count was prime, and we can divide the initial by two.


private fun computAndUpdatePadding(numberOfItems:Int, width: Int) {
    var cellsPerRow = (width + interItemSpacing.toPx()).toDouble() / (cellSize.toPx() + interItemSpacing.toPx()).toDouble()
    val columns = findMaxColumns(numberOfItems.toDouble(), cellsPerRow)
    cellsPerRow = if (columns == 1.0) ceil(cellsPerRow / 2) else columns

    val remainingWidth = width - cellsPerRow * (cellSize.toPx() + interItemSpacing.toPx())
    val padding = (remainingWidth / 2).toInt()
    recyclerView.updatePadding(left = padding, right = padding)
    recyclerView.invalidateItemDecorations()
}

Finally, we can compute the remaining width that’s leftover. This we can use as padding, in effect restricting our recyclerView to a specific width so the flexbox will layout properly. Invalidating the item decoration forces the spacing to reevaluate.

The findMaxColumns function recursively operates until the number of rows has no decimal part, i.e., the columns and rows are factors of the number of items, or the number of items is prime.


private fun findMaxColumns(numItems: Double, cellsPerRow: Double): Double {
    val columns = floor(numItems / ceil(numItems / cellsPerRow))
    val rows = numItems / columns

    if (rows > rows.toInt()) {
        return findMaxColumns(numItems, columns)
    }

    return columns
}

This is the extension method toPx; it converts integers provided as dp to px. This is useful since we generally specify our xml dimensions in dp.


fun Int.toPx(): Int = (this * Resources.getSystem().displayMetrics.density).toInt()

The Fragment & ViewModel

The last remaining part is to set up the CenterFlowAdapter and FlexboxLayoutManager in the fragment. The manager works like the CSS flexbox: you set the direction, wrap, justifyContent, and alignItems properties. Then, with creating the CenterFlowAdapter, passing in the required dimensions as mentioned earlier, these are the dp values not px values (they get converted with the toPx function).

Set the manager and adapter, then observe the live data from the viewModel, where the list is submitted and padding is set. Note that the size is passed in because, at that point, itemCount won’t yet have updated.


binding.viewModel = viewModel

val manager = FlexboxLayoutManager(context, FlexDirection.ROW)
manager.justifyContent = JustifyContent.CENTER
manager.alignItems = AlignItems.CENTER

val adapter = CenterFlowAdapter(
    dataBindingComponent,
    appExecutors,
    recyclerView = binding.numberBallCollection,
    cellSize = 32,
    interItemSpacing = 4,
    lineSpacing = 6
)

binding.numberBallCollection.layoutManager = manager
binding.numberBallCollection.adapter = adapter

viewModel.numbers.observe(viewLifecycleOwner, Observer {
    adapter.submitList(it)
    adapter.setPadding(it.size)
})

The remaining bit is the viewModel. It contains a LiveData list of integers and the function called by the button, which adds the next sequential number to the list.


val numbers: MutableLiveData<List<Int>> = MutableLiveData(emptyList())

fun addNumber() {
    val oldNumbers = numbers.value!!.toMutableList()
    val lastNumber = oldNumbers.lastOrNull()

    if (lastNumber != null) {
        oldNumbers.add(lastNumber + 1)
    } else {
        oldNumbers.add(1)
    }

    numbers.value = oldNumbers
}

The code for this project can be found here.