RecyclerView GridLayoutManager: how to auto-detect span count?

AndroidAndroid RecyclerviewAndroid GridlayoutGridlayoutmanager

Android Problem Overview


Using the new GridLayoutManager: https://developer.android.com/reference/android/support/v7/widget/GridLayoutManager.html

It takes an explicit span count, so the problem now becomes: how do you know how many "spans" fit per row? This is a grid, after all. There should be as many spans as the RecyclerView can fit, based on measured width.

Using the old GridView, you would just set the "columnWidth" property and it would automatically detect how many columns fit. This is basically what I want to replicate for the RecyclerView:

  • add OnLayoutChangeListener on the RecyclerView
  • in this callback, inflate a single 'grid item' and measure it
  • spanCount = recyclerViewWidth / singleItemWidth;

This seems like pretty common behavior, so is there a simpler way that I'm not seeing?

Android Solutions


Solution 1 - Android

Personaly I don't like to subclass RecyclerView for this, because for me it seems that there is GridLayoutManager's responsibility to detect span count. So after some android source code digging for RecyclerView and GridLayoutManager I wrote my own class extended GridLayoutManager that do the job:

public class GridAutofitLayoutManager extends GridLayoutManager
{
    private int columnWidth;
    private boolean isColumnWidthChanged = true;
    private int lastWidth;
    private int lastHeight;

    public GridAutofitLayoutManager(@NonNull final Context context, final int columnWidth) {
        /* Initially set spanCount to 1, will be changed automatically later. */
        super(context, 1);
        setColumnWidth(checkedColumnWidth(context, columnWidth));
    }

    public GridAutofitLayoutManager(
        @NonNull final Context context,
        final int columnWidth,
        final int orientation,
        final boolean reverseLayout) {

        /* Initially set spanCount to 1, will be changed automatically later. */
        super(context, 1, orientation, reverseLayout);
        setColumnWidth(checkedColumnWidth(context, columnWidth));
    }

    private int checkedColumnWidth(@NonNull final Context context, final int columnWidth) {
        if (columnWidth <= 0) {
            /* Set default columnWidth value (48dp here). It is better to move this constant
            to static constant on top, but we need context to convert it to dp, so can't really
            do so. */
            columnWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48,
                    context.getResources().getDisplayMetrics());
        }
        return columnWidth;
    }

    public void setColumnWidth(final int newColumnWidth) {
        if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
            columnWidth = newColumnWidth;
            isColumnWidthChanged = true;
        }
    }

    @Override
    public void onLayoutChildren(@NonNull final RecyclerView.Recycler recycler, @NonNull final RecyclerView.State state) {
        final int width = getWidth();
        final int height = getHeight();
        if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
            final int totalSpace;
            if (getOrientation() == VERTICAL) {
                totalSpace = width - getPaddingRight() - getPaddingLeft();
            } else {
                totalSpace = height - getPaddingTop() - getPaddingBottom();
            }
            final int spanCount = Math.max(1, totalSpace / columnWidth);
            setSpanCount(spanCount);
            isColumnWidthChanged = false;
        }
        lastWidth = width;
        lastHeight = height;
        super.onLayoutChildren(recycler, state);
    }
}

I don't actually remember why I choosed to set span count in onLayoutChildren, I wrote this class some time ago. But the point is we need to do so after view get measured. so we can get it's height and width.

EDIT 1: Fix error in code caused to incorrectly setting span count. Thanks user @Elyees Abouda for reporting and suggesting solution.

EDIT 2: Some small refactoring and fix edge case with manual orientation changes handling. Thanks user @tatarize for reporting and suggesting solution.

Solution 2 - Android

I accomplished this using a view tree observer to get the width of the recylcerview once rendered and then getting the fixed dimensions of my card view from resources and then setting the span count after doing my calculations. It is only really applicable if the items you are displaying are of a fixed width. This helped me automatically populate the grid regardless of screen size or orientation.

mRecyclerView.getViewTreeObserver().addOnGlobalLayoutListener(
			new ViewTreeObserver.OnGlobalLayoutListener() {
				@Override
				public void onGlobalLayout() {
					mRecyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
					int viewWidth = mRecyclerView.getMeasuredWidth();
					float cardViewWidth = getActivity().getResources().getDimension(R.dimen.cardview_layout_width);
					int newSpanCount = (int) Math.floor(viewWidth / cardViewWidth);
					mLayoutManager.setSpanCount(newSpanCount);
                    mLayoutManager.requestLayout();
				}
			});

Solution 3 - Android

Well, this is what I used, fairly basic, but gets the job done for me. This code basically gets the screen width in dips and then divides by 300 (or whatever width you're using for your adapter's layout). So smaller phones with 300-500 dip width only display one column, tablets 2-3 columns etc. Simple, fuss free and without downside, as far as I can see.

Display display = getActivity().getWindowManager().getDefaultDisplay();
DisplayMetrics outMetrics = new DisplayMetrics();
display.getMetrics(outMetrics);
    
float density  = getResources().getDisplayMetrics().density;
float dpWidth  = outMetrics.widthPixels / density;
int columns = Math.round(dpWidth/300);
mLayoutManager = new GridLayoutManager(getActivity(),columns);
mRecyclerView.setLayoutManager(mLayoutManager);

Solution 4 - Android

I extended the RecyclerView and overrode the onMeasure method.

I set an item width(member variable) as early as I can,with a default of 1. This also updates on configuration changed. This will now have as many rows as can fit in portrait,landscape,phone/tablet etc.

@Override
protected void onMeasure(int widthSpec, int heightSpec) {
	super.onMeasure(widthSpec, heightSpec);
	int width = MeasureSpec.getSize(widthSpec);
	if(width != 0){
		int spans = width / mItemWidth;
		if(spans > 0){
			mLayoutManager.setSpanCount(spans);
		}
	}
}

Solution 5 - Android

I'm posting this just in case someone gets weird column width as in my case.

I'm not able to comment on [@s-marks][1]'s answer due to my low reputation. I applied his solution [solution][2] but I got some weird column width, so I modified checkedColumnWidth function as follows:

private int checkedColumnWidth(Context context, int columnWidth)
{
    if (columnWidth <= 0)
    {
        /* Set default columnWidth value (48dp here). It is better to move this constant
        to static constant on top, but we need context to convert it to dp, so can't really
        do so. */
        columnWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48,
                context.getResources().getDisplayMetrics());
    }

    else
    {
        columnWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, columnWidth,
                context.getResources().getDisplayMetrics());
    }
    return columnWidth;
}

By converting the given column width into DP fixed the issue.

[1]: https://stackoverflow.com/users/1315647/s-maks "s-marks" [2]: https://stackoverflow.com/a/30256880/6484775

Solution 6 - Android

A better way (imo) would be to define different span counts in (many) different values directories and let the device automatically select which span count to use. For example,

values/integers.xml -> span_count=3

values-w480dp/integers.xml -> span_count=4

values-w600dp/integers.xml -> span_count=5

Solution 7 - Android

To accommodate orientation change on https://stackoverflow.com/users/1315647/s-maks" title="s-marks">s-marks's answer, I added a check on width change (width from getWidth(), not column width).

private boolean mWidthChanged = true;
private int mWidth;


@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)
{
	int width = getWidth();
	int height = getHeight();
	
	if (width != mWidth) {
		mWidthChanged = true;
		mWidth = width;
	}
	
	if (mColumnWidthChanged && mColumnWidth > 0 && width > 0 && height > 0
			|| mWidthChanged)
	{
		int totalSpace;
		if (getOrientation() == VERTICAL)
		{
			totalSpace = width - getPaddingRight() - getPaddingLeft();
		}
		else
		{
			totalSpace = height - getPaddingTop() - getPaddingBottom();
		}
		int spanCount = Math.max(1, totalSpace / mColumnWidth);
		setSpanCount(spanCount);
		mColumnWidthChanged = false;
		mWidthChanged = false;
	}
	super.onLayoutChildren(recycler, state);
}

Solution 8 - Android

The upvoted solution is fine, but handles the incoming values as pixels, which can trip you up if you're hardcoding values for testing and assuming dp. Easiest way is probably to put the column width in a dimension and read it when configuring the GridAutofitLayoutManager, which will automatically convert dp to correct pixel value:

new GridAutofitLayoutManager(getActivity(), (int)getActivity().getResources().getDimension(R.dimen.card_width))

Solution 9 - Android

  1. Set minimal fixed width of imageView (144dp x 144dp for example)

  2. When you create GridLayoutManager, you need to know how much columns will be with minimal size of imageView:

    WindowManager wm = (WindowManager) this.getSystemService(Context.WINDOW_SERVICE); //Получаем размер экрана
    Display display = wm.getDefaultDisplay();
    
    Point point = new Point();
    display.getSize(point);
    int screenWidth = point.x; //Ширина экрана
    
    int photoWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 144, this.getResources().getDisplayMetrics()); //Переводим в точки
    
    int columnsCount = screenWidth/photoWidth; //Число столбцов
    
    GridLayoutManager gridLayoutManager = new GridLayoutManager(this, columnsCount);
    recyclerView.setLayoutManager(gridLayoutManager);
    
  3. After that you need to resize imageView in adapter if you have space in column. You may send newImageViewSize then inisilize adapter from activity there you calculate screen and column count:

    @Override //Заполнение нашей плитки
    public void onBindViewHolder(PhotoHolder holder, int position) {
       ...
       ViewGroup.LayoutParams photoParams = holder.photo.getLayoutParams(); //Параметры нашей фотографии
    
       int newImageViewSize = screenWidth/columnsCount; //Новый размер фотографии
    
       photoParams.width = newImageViewSize; //Установка нового размера
       photoParams.height = newImageViewSize;
       holder.photo.setLayoutParams(photoParams); //Установка параметров
       ...
    }
    

It works in both orientations. In vertical I have 2 columns and in horizontal - 4 columns. The result: https://i.stack.imgur.com/WHvyD.jpg

Solution 10 - Android

I conclusion above answers here

Solution 11 - Android

This is s.maks' class with a minor fix for when the recyclerview itself changes size. Such as when you deal with the orientation changes yourself (in the manifest android:configChanges="orientation|screenSize|keyboardHidden"), or some other reason the recyclerview might change size without the mColumnWidth changing. I also changed the int value it takes to be the resource of the size and allowed a constructor of no resource then setColumnWidth to do that yourself.

public class GridAutofitLayoutManager extends GridLayoutManager {
    private Context context;
    private float mColumnWidth;

    private float currentColumnWidth = -1;
    private int currentWidth = -1;
    private int currentHeight = -1;


    public GridAutofitLayoutManager(Context context) {
        /* Initially set spanCount to 1, will be changed automatically later. */
        super(context, 1);
        this.context = context;
        setColumnWidthByResource(-1);
    }

    public GridAutofitLayoutManager(Context context, int resource) {
        this(context);
        this.context = context;
        setColumnWidthByResource(resource);
    }

    public GridAutofitLayoutManager(Context context, int resource, int orientation, boolean reverseLayout) {
        /* Initially set spanCount to 1, will be changed automatically later. */
        super(context, 1, orientation, reverseLayout);
        this.context = context;
        setColumnWidthByResource(resource);
    }

    public void setColumnWidthByResource(int resource) {
        if (resource >= 0) {
            mColumnWidth = context.getResources().getDimension(resource);
        } else {
            /* Set default columnWidth value (48dp here). It is better to move this constant
            to static constant on top, but we need context to convert it to dp, so can't really
            do so. */
            mColumnWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48,
                    context.getResources().getDisplayMetrics());
        }
    }

    public void setColumnWidth(float newColumnWidth) {
        mColumnWidth = newColumnWidth;
    }

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        recalculateSpanCount();
        super.onLayoutChildren(recycler, state);
    }

    public void recalculateSpanCount() {
        int width = getWidth();
        if (width <= 0) return;
        int height = getHeight();
        if (height <= 0) return;
        if (mColumnWidth <= 0) return;
        if ((width != currentWidth) || (height != currentHeight) || (mColumnWidth != currentColumnWidth)) {
            int totalSpace;
            if (getOrientation() == VERTICAL) {
                totalSpace = width - getPaddingRight() - getPaddingLeft();
            } else {
                totalSpace = height - getPaddingTop() - getPaddingBottom();
            }
            int spanCount = (int) Math.max(1, Math.floor(totalSpace / mColumnWidth));
            setSpanCount(spanCount);
            currentColumnWidth = mColumnWidth;
            currentWidth = width;
            currentHeight = height;
        }
    }
}

Solution 12 - Android

I like s.maks' answer but I found another edge case: If you set the height of the RecyclerView to WRAP_CONTENT it may happen that the height of the recyclerview is calculated incorrectly based on an outdated spanCount value. The solution I found is a small modification of the proposed onLayoutChildren() method:

public void onLayoutChildren(@NonNull final RecyclerView.Recycler recycler, @NonNull final RecyclerView.State state) {
    final int width = getWidth();
    final int height = getHeight();
    if (columnWidth > 0 && (width > 0 || getOrientation() == HORIZONTAL) && (height > 0 || getOrientation() == VERTICAL) && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
        final int totalSpace;
        if (getOrientation() == VERTICAL) {
            totalSpace = width - getPaddingRight() - getPaddingLeft();
        } else {
            totalSpace = height - getPaddingTop() - getPaddingBottom();
        }
        final int spanCount = Math.max(1, totalSpace / columnWidth);
        if (getSpanCount() != spanCount) {
            setSpanCount(spanCount);
        }
        isColumnWidthChanged = false;
    }
    lastWidth = width;
    lastHeight = height;
    super.onLayoutChildren(recycler, state);
}

Solution 13 - Android

Set spanCount to a large number (which is the max number of column) and set a custom SpanSizeLookup to the GridLayoutManager.

mLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
    @Override
    public int getSpanSize(int i) {
        return SPAN_COUNT / (int) (mRecyclerView.getMeasuredWidth()/ CELL_SIZE_IN_PX);
    }
});

It's a bit ugly, but it work.

I think a manager like AutoSpanGridLayoutManager would be the best solution, but i didn't find anything like that.

EDIT : There is a bug, on some device it add blank space to the right

Solution 14 - Android

Here's the relevant parts of a wrapper I've been using to auto-detect the span count. You initialize it by calling setGridLayoutManager with a R.layout.my_grid_item reference, and it figures out how many of those can fit on each row.

public class AutoSpanRecyclerView extends RecyclerView {
    private int     m_gridMinSpans;
    private int     m_gridItemLayoutId;
    private LayoutRequester m_layoutRequester = new LayoutRequester();

    public void setGridLayoutManager( int orientation, int itemLayoutId, int minSpans ) {
        GridLayoutManager layoutManager = new GridLayoutManager( getContext(), 2, orientation, false );
        m_gridItemLayoutId = itemLayoutId;
        m_gridMinSpans = minSpans;

        setLayoutManager( layoutManager );
    }

    @Override
    protected void onLayout( boolean changed, int left, int top, int right, int bottom ) {
        super.onLayout( changed, left, top, right, bottom );

        if( changed ) {
            LayoutManager layoutManager = getLayoutManager();
            if( layoutManager instanceof GridLayoutManager ) {
                final GridLayoutManager gridLayoutManager = (GridLayoutManager) layoutManager;
                LayoutInflater inflater = LayoutInflater.from( getContext() );
                View item = inflater.inflate( m_gridItemLayoutId, this, false );
                int measureSpec = View.MeasureSpec.makeMeasureSpec( 0, View.MeasureSpec.UNSPECIFIED );
                item.measure( measureSpec, measureSpec );
                int itemWidth = item.getMeasuredWidth();
                int recyclerViewWidth = getMeasuredWidth();
                int spanCount = Math.max( m_gridMinSpans, recyclerViewWidth / itemWidth );

                gridLayoutManager.setSpanCount( spanCount );

				// if you call requestLayout() right here, you'll get ArrayIndexOutOfBoundsException when scrolling
                post( m_layoutRequester );
            }
        }
    }

    private class LayoutRequester implements Runnable {
        @Override
        public void run() {
            requestLayout();
        }
    }
}

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
Questionfoo64View Question on Stackoverflow
Solution 1 - Androids.maksView Answer on Stackoverflow
Solution 2 - AndroidspeedynomadsView Answer on Stackoverflow
Solution 3 - AndroidRibsView Answer on Stackoverflow
Solution 4 - Androidandrew_sView Answer on Stackoverflow
Solution 5 - AndroidAriqView Answer on Stackoverflow
Solution 6 - Androiddenvercoder9View Answer on Stackoverflow
Solution 7 - AndroidAndreasView Answer on Stackoverflow
Solution 8 - AndroidtoncekView Answer on Stackoverflow
Solution 9 - AndroidSerjantArbuzView Answer on Stackoverflow
Solution 10 - AndroidOmid RahaView Answer on Stackoverflow
Solution 11 - AndroidTatarizeView Answer on Stackoverflow
Solution 12 - AndroidLars BrinkmannView Answer on Stackoverflow
Solution 13 - AndroidalvinmeimounView Answer on Stackoverflow
Solution 14 - Androidfoo64View Answer on Stackoverflow