android-recyclerviewandroid-gridviewandroid-gridlayoutgridlayoutmanagerstaggeredgridlayoutmanager

StaggeredGridLayoutManager with auto-fit span count


I need a StaggeredGridLayoutManager that follows these two requirements:

Until now, I haven't had the need for the grid items to be staggered, so I used an extension of GridLayoutManager to provide this functionality:

import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Recycler
import kotlin.math.max
import kotlin.math.min

class GridAutoFitLayoutManager : GridLayoutManager {
    private var mColumnWidth = 0
    private var mMaximumColumns: Int
    private var mLastCalculatedWidth = -1

    @JvmOverloads
    constructor(
        context: Context?,
        columnWidthDp: Int,
        maxColumns: Int = 99
    ) : super(context, 1) //Initially set spanCount to 1, will be changed automatically later.
    {
        mMaximumColumns = maxColumns
        setColumnWidth(columnWidthDp)
    }

    private fun setColumnWidth(newColumnWidth: Int) {
        if (newColumnWidth > 0 && newColumnWidth != mColumnWidth) {
            mColumnWidth = newColumnWidth
        }
    }

    override fun onLayoutChildren(
        recycler: Recycler,
        state: RecyclerView.State
    ) {
        val width = width
        val height = height
        if (width != mLastCalculatedWidth && mColumnWidth > 0 && width > 0 && height > 0) {
            val totalSpace: Int = if (orientation == RecyclerView.VERTICAL) {
                width - paddingRight - paddingLeft
            } else {
                height - paddingTop - paddingBottom
            }
            val spanCount = min(
                mMaximumColumns,
                max(1, totalSpace / mColumnWidth)
            )
            setSpanCount(spanCount)
            mLastCalculatedWidth = width
        }
        super.onLayoutChildren(recycler, state)
    }
}

Using the layout manager in the recycler view:

recyclerView.layoutManager = GridAutoFitLayoutManager(context, resources.getDimension(R.dimen.grid_column_width).toInt())

Lastly, the item view layouts are set to match_parent on width, so they'll occupy as much space as the layout manager will let them.

However, I'm having trouble getting this to work with StaggeredGridLayoutManager. Here's my code:

import android.content.Context
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Recycler
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import kotlin.math.max
import kotlin.math.min

class GridAutoFitStaggeredLayoutManager : StaggeredGridLayoutManager {
    private var mColumnWidth = 0
    private var mMaximumColumns: Int
    private var mLastCalculatedWidth = -1

    @JvmOverloads
    constructor(
        context: Context?,
        columnWidthDp: Int,
        maxColumns: Int = 99
    ) : super(1, LinearLayout.VERTICAL) //Initially set spanCount to 1, will be changed automatically later.
    {
        mMaximumColumns = maxColumns
        setColumnWidth(columnWidthDp)
    }

    private fun setColumnWidth(newColumnWidth: Int) {
        if (newColumnWidth > 0 && newColumnWidth != mColumnWidth) {
            mColumnWidth = newColumnWidth
        }
    }

    override fun onLayoutChildren(
        recycler: Recycler,
        state: RecyclerView.State
    ) {
        val width = width
        val height = height
        if (width != mLastCalculatedWidth && mColumnWidth > 0 && width > 0 && height > 0) {
            val totalSpace: Int = if (orientation == RecyclerView.VERTICAL) {
                width - paddingRight - paddingLeft
            } else {
                height - paddingTop - paddingBottom
            }
            val spanCount = min(
                mMaximumColumns,
                max(1, totalSpace / mColumnWidth)
            )
            setSpanCount(spanCount)
            mLastCalculatedWidth = width
        }
        super.onLayoutChildren(recycler, state)
    }
}

As soon as the recycler view is loaded, I get the following exception:

java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling

The issue is because I'm calling setSpanCount() while the layout is resolving, which wasn't a problem with GridLayoutManager, but not allowed in StaggeredGridLayoutManager. My question is, if I can't set the span count during onLayoutChildren(), when can I set it?

I'm aware I can just calculate how many columns will fit prior to initializing the layout manager, and pass the span count to the StaggeredGridLayoutManager constructor. However, that won't react to orientation changes, unless I add additional logic to the activity that re-creates the layout manager, which I'd prefer to avoid. Is there a way to make my GridAutoFitStaggeredLayoutManager calculate span count and handle orientation changes on its own?


Solution

  • Had a closer look at the cause of the exception, and this should prevent the error:

    import android.content.Context
    import android.os.Handler
    import android.util.DisplayMetrics
    import androidx.recyclerview.widget.RecyclerView
    import androidx.recyclerview.widget.RecyclerView.Recycler
    import androidx.recyclerview.widget.StaggeredGridLayoutManager
    import kotlin.math.max
    import kotlin.math.min
    
    class GridAutoFitStaggeredLayoutManager : StaggeredGridLayoutManager {
    
        companion object {
            private fun setInitialSpanCount(context: Context?, columnWidthDp: Int) : Int {
                val displayMetrics: DisplayMetrics? = context?.resources?.displayMetrics
                return ((displayMetrics?.widthPixels ?: columnWidthDp) / columnWidthDp)
            }
        }
    
        private var mContext: Context?
        private var mColumnWidth = 0
        private var mMaximumColumns: Int
        private var mLastCalculatedWidth = -1
    
        @JvmOverloads
        constructor(
            context: Context?,
            columnWidthDp: Int,
            maxColumns: Int = 99
        ) : super(setInitialSpanCount(context, columnWidthDp), VERTICAL)
        {
            mContext = context
            mMaximumColumns = maxColumns
            mColumnWidth = columnWidthDp
        }
    
        override fun onLayoutChildren(
            recycler: Recycler,
            state: RecyclerView.State
        ) {
            if (width != mLastCalculatedWidth && width > 0) {
                recalculateSpanCount()
            }
            super.onLayoutChildren(recycler, state)
        }
    
        private fun recalculateSpanCount() {
            val totalSpace: Int = if (orientation == RecyclerView.VERTICAL) {
                width - paddingRight - paddingLeft
            } else {
                height - paddingTop - paddingBottom
            }
            val newSpanCount = min(
                mMaximumColumns,
                max(1, totalSpace / mColumnWidth)
            )
            queueSetSpanCountUpdate(newSpanCount)
            mLastCalculatedWidth = width
        }
    
        private fun queueSetSpanCountUpdate(newSpanCount: Int) {
            if(mContext != null) {
                Handler(mContext!!.mainLooper).post { spanCount = newSpanCount }
            }
        }
    }