RecyclerView GridLayoutManager: how to auto-detect span count?
Asked Answered
H

14

128

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?

Hoxsie answered 31/10, 2014 at 1:22 Comment(0)
S
148

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.

Sheedy answered 15/5, 2015 at 10:12 Comment(17)
This should be the accepted answer, it's LayoutManager's job to lay the children out and not RecyclerView'sPhelan
What if ColumnWidth is not fixed.Offprint
@Offprint do you mean situation when column width should differ depending on orientation for example?Sheedy
@s.maks: I mean columnWidth would differ depending on the data passed into the adapter. Say if four letters words are passed then it should accommodate four items in a row if 10 letters words are passed then it should be able to accommodate only 2 items in a row.Offprint
@Shubham, oh. I see. Well, I think you should not add this functionality to LayoutManager, because basically LayoutManager should not deal with actual data. It only knows "I must layout something depending on my parameters" and it knows nothing about this "something". So I think in this case you should write this logic outside of LayoutManager, in Activity.onCreate() eg. How to do so? It might be complicated depending of data passed to your adapter and actual requirenments. I suggest you to create separate question here for that, so anybody could easily find an answer later.Sheedy
@Sheedy I've got an exception inside setSpanCount: java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrollingAboral
@OleksandrAlbul do you manually calling setSpanCount(int) on layout manager? Also from where do you call setLayoutManager(LayoutManager) on your RecyclerView?Sheedy
The issues in this code is the following: if columnWidth = 300, and totalSpace = 736, then spanCount =2, which results in laying out items not in proportion. 2 * 300 = 600, the rest 136 pixels are not counted, which results not equal paddingSunnysunproof
@azizbekian, I'm not sure problem occures because of this code, but I can't tell for sure until I see how you setup your RecyclerView and it's items. Please, create question on SO and post some code so I can help you somehow.Sheedy
For me, when rotating the device, it's changing a little bit the size of the grid, shrinking 20dp. Got any info about this? Thanks.Academicism
@Academicism same thing, this code works perfectly well im my projects, so I can't tell what exactly causing problems without watching your code.Sheedy
@Sheedy I updated the support library to 23.3.0 and this stopped happening. Guessing it was a bug from 23.0.0. Thanks for the manager, nice snippet.Academicism
@Academicism Thank you, glad to hear your problem solved.Sheedy
Sometimes getWidth() or getHeight() is 0 before the view is created, which will get a wrong spanCount (1 since totalSpace will be <=0). What I added is ignoring setSpanCount in this case. (onLayoutChildren will be called again later)Bligh
This code is fixing width for each element and once it gets span count it gets fixed forever. I was looking for something like WRAP_CONTENT for each element and different span count for different row.Triarchy
I am getting OOM error at super.onLayoutChildren(). Any help is appreciatedCoccus
There's an edge condition that isn't covered. If you set the configChanges such that you handle the rotation rather than let it rebuild the entire activity, you have the odd case that the width of the recyclerview changes, while nothing else does. With changed width and height the spancount is dirty, but mColumnWidth hasn't changed, so onLayoutChildren() aborts and doesn't recalculate the now dirty values. Save the previous height and width, and trigger if it changes in a non-zero way.Micaelamicah
H
33

I accomplished this using a ViewTreeObserver to get the width of the RecylcerView once rendered and then getting the fixed dimensions of my CardView 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();
    }
});
Hermitage answered 18/11, 2014 at 17:53 Comment(4)
I used this and got ArrayIndexOutOfBoundsException (at android.support.v7.widget.GridLayoutManager.layoutChunk(GridLayoutManager.java:361)) when scrolling the RecyclerView.Cyndie
Just add mLayoutManager.requestLayout() after setSpanCount() and it workSquamous
Note: removeGlobalOnLayoutListener() is deprecated in API level 16. use removeOnGlobalLayoutListener() instead. Documentation.Sidsida
typo removeOnGLobalLayoutListener should be removeOnGlobalLayoutListenerBacchius
E
19

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);
Endoblast answered 21/1, 2015 at 21:55 Comment(2)
Why use screen width instead of the RecyclerView's width? And hardcoding 300 is bad practice (it needs to be kept in sync with your xml layout)Hoxsie
@Hoxsie in the xml you can just set match_parent on the item. But yes, its still ugly ;)Lute
R
15

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);
        }
    }
}
Revels answered 10/12, 2014 at 22:37 Comment(1)
+1 Chiu-ki Chan has a blog post outlining this approach and a sample project for it as well.Singletary
D
13

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

Duroc answered 26/9, 2020 at 19:47 Comment(1)
To me this is the best solution. The accepted answer is nice but as @Sunnysunproof said it, the items are not equally distributed horizontally leading to a greater right padding than left padding. And I couldn't find a way to distribute columns equally in the horizontal axis with the accepted answer.Dixie
T
8

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's answer due to my low reputation. I applied his solution solution 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.

Turbosupercharger answered 28/6, 2016 at 17:31 Comment(0)
C
3

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))
Calfskin answered 5/5, 2017 at 12:39 Comment(1)
yeah, that did throw me for a loop. really it would be just accept the resource itself. I mean that's like always what we'll be doing.Micaelamicah
H
3

I conclusion above answers here

Help answered 10/11, 2017 at 19:23 Comment(0)
P
2

To accommodate orientation change on 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);
}
Petropavlovsk answered 15/2, 2017 at 5:58 Comment(0)
S
2
  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

Samaveda answered 28/5, 2017 at 10:27 Comment(0)
M
2

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;
        }
    }
}
Micaelamicah answered 15/12, 2018 at 12:37 Comment(0)
L
0

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);
}
Lightness answered 17/7, 2021 at 9:38 Comment(0)
S
-1

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

Squamous answered 12/11, 2014 at 12:58 Comment(1)
The space on the right is not a bug. if the span count is 5 and getSpanSize returns 3 there will be a space because you are not filling the span.Radiotelegraphy
H
-6

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();
        }
    }
}
Hoxsie answered 27/3, 2015 at 6:23 Comment(1)
Why downvote to accepted answer. should explain why downvoteFathead

© 2022 - 2024 — McMap. All rights reserved.