RecyclerView GridLayoutManager: how to auto-detect span count?

103,258

Solution 1

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

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

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

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

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.

Share:
103,258
foo64
Author by

foo64

Updated on July 17, 2021

Comments

  • foo64
    foo64 almost 3 years

    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?

  • fikr4n
    fikr4n over 9 years
    I used this and got ArrayIndexOutOfBoundsException (at android.support.v7.widget.GridLayoutManager.layoutChunk(Grid‌​LayoutManager.java:3‌​61)) when scrolling the RecyclerView.
  • alvinmeimoun
    alvinmeimoun over 9 years
    Just add mLayoutManager.requestLayout() after setSpanCount() and it work
  • pez
    pez over 9 years
    Note: removeGlobalOnLayoutListener() is deprecated in API level 16. use removeOnGlobalLayoutListener() instead. Documentation.
  • CommonsWare
    CommonsWare over 9 years
    +1 Chiu-ki Chan has a blog post outlining this approach and a sample project for it as well.
  • string.Empty
    string.Empty over 9 years
    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.
  • foo64
    foo64 about 9 years
    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)
  • mewa
    mewa almost 9 years
    This should be the accepted answer, it's LayoutManager's job to lay the children out and not RecyclerView's
  • Shubham
    Shubham over 8 years
    What if ColumnWidth is not fixed.
  • s.maks
    s.maks over 8 years
    @Shubham do you mean situation when column width should differ depending on orientation for example?
  • Shubham
    Shubham over 8 years
    @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.
  • s.maks
    s.maks over 8 years
    @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.
  • Philip Giuliani
    Philip Giuliani over 8 years
    @foo64 in the xml you can just set match_parent on the item. But yes, its still ugly ;)
  • Oleksandr Albul
    Oleksandr Albul over 8 years
    @s.maks I've got an exception inside setSpanCount: java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling
  • s.maks
    s.maks over 8 years
    @OleksandrAlbul do you manually calling setSpanCount(int) on layout manager? Also from where do you call setLayoutManager(LayoutManager) on your RecyclerView?
  • azizbekian
    azizbekian about 8 years
    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 padding
  • s.maks
    s.maks about 8 years
    @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.
  • Alexandre
    Alexandre about 8 years
    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.
  • s.maks
    s.maks about 8 years
    @Alexandre same thing, this code works perfectly well im my projects, so I can't tell what exactly causing problems without watching your code.
  • Alexandre
    Alexandre about 8 years
    @s.maks 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.
  • s.maks
    s.maks about 8 years
    @Alexandre Thank you, glad to hear your problem solved.
  • Elyess Abouda
    Elyess Abouda about 8 years
    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)
  • Jimit Patel
    Jimit Patel almost 8 years
    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.
  • Rushi M Thakker
    Rushi M Thakker almost 7 years
    I am getting OOM error at super.onLayoutChildren(). Any help is appreciated
  • Theo
    Theo over 6 years
    typo removeOnGLobalLayoutListener should be removeOnGlobalLayoutListener
  • androidXP
    androidXP over 6 years
    Why downvote to accepted answer. should explain why downvote
  • Tatarize
    Tatarize over 5 years
    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.
  • Tatarize
    Tatarize over 5 years
    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.
  • Charly Lafon
    Charly Lafon almost 3 years
    To me this is the best solution. The accepted answer is nice but as @azizbekian 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.