RecyclerView GridLayoutManager:如何自动检测跨度计数?

Posted

技术标签:

【中文标题】RecyclerView GridLayoutManager:如何自动检测跨度计数?【英文标题】:RecyclerView GridLayoutManager: how to auto-detect span count? 【发布时间】:2014-12-27 06:28:57 【问题描述】:

使用新的 GridLayoutManager:https://developer.android.com/reference/android/support/v7/widget/GridLayoutManager.html

它需要一个明确的跨度计数,所以现在问题变成了:你怎么知道每行有多少个“跨度”?毕竟,这是一个网格。根据测量的宽度,RecyclerView 应该有尽可能多的跨度。

使用旧的GridView,您只需设置“columnWidth”属性,它就会自动检测有多少列适合。这基本上就是我想为 RecyclerView 复制的内容:

RecyclerView 上添加 OnLayoutChangeListener 在此回调中,膨胀单个“网格项”并对其进行测量 spanCount = recyclerViewWidth / singleItemWidth;

这似乎是很常见的行为,所以有没有我没有看到的更简单的方法?

【问题讨论】:

【参考方案1】:

我个人不喜欢为此继承 RecyclerView,因为对我来说,似乎 GridLayoutManager 有责任检测跨度计数。因此,在为 RecyclerView 和 GridLayoutManager 挖掘了一些 android 源代码之后,我编写了自己的类扩展 GridLayoutManager 来完成这项工作:

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);
    

我实际上不记得为什么我选择在 onLayoutChildren 中设置跨度计数,我前段时间写了这个类。但关键是我们需要在视图被测量后这样做。所以我们可以得到它的高度和宽度。

编辑 1: 修复因错误设置跨度计数而导致的代码错误。感谢用户@Elyees Abouda 报告和建议solution。

编辑 2: 一些小的重构和修复边缘情况,手动方向更改处理。感谢用户@tatarize 报告和建议solution。

【讨论】:

这应该是公认的答案,这是LayoutManager 的工作,而不是RecyclerView 的工作 @s.maks:我的意思是 columnWidth 会根据传递到适配器的数据而有所不同。假设如果通过了四个字母单词,那么它应该连续容纳四个项目,如果通过了 10 个字母单词,那么它应该只能连续容纳 2 个项目。 这段代码的问题如下:如果columnWidth = 300,totalSpace = 736,那么spanCount =2,导致item的布局不成比例。 2 * 300 = 600,其余136个像素不计算,结果not equal padding 有时 getWidth()getHeight() 在创建视图之前为 0,这将得到错误的 spanCount(1 因为 totalSpace 将 onLayoutChildren 稍后会再次调用) 存在未涵盖的边缘条件。如果您设置 configChanges 以便您处理旋转而不是让它重建整个活动,那么您会遇到奇怪的情况,即 recyclerview 的宽度会发生变化,而其他任何事情都不会发生。随着宽度和高度的改变,跨度计数是脏的,但 mColumnWidth 没有改变,所以 onLayoutChildren() 中止并且不重新计算现在的脏值。保存之前的高宽,如果非零变化就触发。【参考方案2】:

我使用视图树观察器来完成此操作,以获取渲染后的 recyclerview 的宽度,然后从资源中获取我的卡片视图的固定尺寸,然后在进行计算后设置跨度计数。只有当您显示的项目具有固定宽度时,它才真正适用。这帮助我自动填充网格,无论屏幕大小或方向如何。

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();
                
            );

【讨论】:

我使用这个并在滚动RecyclerView 时得到了ArrayIndexOutOfBoundsExceptionat android.support.v7.widget.GridLayoutManager.layoutChunk(GridLayoutManager.java:361)) . 只需在 setSpanCount() 之后添加 mLayoutManager.requestLayout() 就可以了 注意:removeGlobalOnLayoutListener() 在 API 级别 16 中已弃用。请改用 removeOnGlobalLayoutListener()。 Documentation. 错字 removeOnGLobalLayoutListener 应该是 removeOnGlobalLayoutListener【参考方案3】:

嗯,这是我使用的,相当基本的,但可以为我完成工作。这段代码基本上以下降的形式获取屏幕宽度,然后除以 300(或您用于适配器布局的任何宽度)。因此,具有 300-500 dip 宽度的小型手机仅显示一列,平板电脑显示 2-3 列等。据我所知,简单、无忧无虑且没有缺点。

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);

【讨论】:

为什么使用屏幕宽度而不是 RecyclerView 的宽度?硬编码 300 是不好的做法(它需要与您的 xml 布局保持同步) @foo64 在 xml 中,您可以在项目上设置 match_parent。但是,是的,它仍然很丑;)【参考方案4】:

我扩展了 RecyclerView 并覆盖了 onMeasure 方法。

我尽可能早地设置了一个项目宽度(成员变量),默认值为 1。这也会在配置更改时更新。现在,这将有尽可能多的行以适合纵向、横向、手机/平板电脑等。

@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);
        
    

【讨论】:

+1 Chiu-ki Chan 有a blog post 概述了这种方法,a sample project 也有它。【参考方案5】:

更好的方法 (imo) 是在(许多)不同的 values 目录中定义不同的跨度计数,并让设备自动选择要使用的跨度计数。例如,

values/integers.xml -> span_count=3

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

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

【讨论】:

对我来说这是最好的解决方案。公认的答案很好,但正如@azizbekian 所说,这些项目水平分布不均,导致右填充比左填充更大。而且我找不到一种方法在水平轴上平均分配列和接受的答案。【参考方案6】:

我发布这个只是为了以防有人像我一样得到奇怪的列宽。

由于我的声誉低下,我无法对 @s-marks 的回答发表评论。我应用了他的解决方案solution,但我得到了一些奇怪的列宽,所以我修改了 checkedColumnWidth 函数如下:

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;

通过将给定的列宽转换为 DP 解决了这个问题。

【讨论】:

【参考方案7】:

为了适应s-marks 答案的方向变化,我添加了对宽度变化的检查(来自getWidth() 的宽度,而不是列宽)。

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);

【讨论】:

【参考方案8】:

赞成的解决方案很好,但将传入的值处理为像素,如果您为测试和假设 dp 对值进行硬编码,这可能会让您感到困惑。最简单的方法大概是把列宽放在一个维度里,在配置GridAutofitLayoutManager的时候读取,它会自动将dp转换为正确的像素值:

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

【讨论】:

是的,这确实让我陷入了困境。真的只是接受资源本身。我的意思是这就像我们将要做的一样。【参考方案9】:
    设置 imageView 的最小固定宽度(例如 144dp x 144dp)

    当您创建 GridLayoutManager 时,您需要知道最小尺寸的 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);
    

    之后,如果列中有空间,则需要在适配器中调整 imageView 的大小。您可以发送 newImageViewSize 然后从活动中删除适配器,然后计算屏幕和列数:

    @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); //Установка параметров
       ...
    
    

它适用于两个方向。在垂直我有 2 列和水平 - 4 列。结果:https://i.stack.imgur.com/WHvyD.jpg

【讨论】:

【参考方案10】:

我总结以上答案here

【讨论】:

【参考方案11】:

这是 s.maks 的类,对 recyclerview 本身更改大小时进行了小修复。例如,当您自己处理方向更改时(在清单 android:configChanges="orientation|screenSize|keyboardHidden" 中),或者由于其他原因,recyclerview 可能会更改大小而 mColumnWidth 不会更改。我还更改了作为大小资源所需的 int 值,并允许没有资源的构造函数然后 setColumnWidth 自己执行此操作。

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;
        
    

【讨论】:

【参考方案12】:

我喜欢 s.maks 的回答,但我发现了另一个极端情况:如果您将 RecyclerView 的高度设置为 WRAP_CONTENT,则可能会根据过时的 spanCount 值错误地计算 recyclerview 的高度。我找到的解决方案是对建议的 onLayoutChildren() 方法的一个小修改:

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);

【讨论】:

【参考方案13】:

将 spanCount 设置为一个较大的数字(即最大列数)并将自定义 SpanSizeLookup 设置为 GridLayoutManager。

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

这有点难看,但它可以工作。

我认为像 AutoSpanGridLayoutManager 这样的管理器会是最好的解决方案,但我没有找到类似的东西。

编辑:有一个错误,在某些设备上它会在右侧添加空格

【讨论】:

右边的空格不是bug。如果跨度计数为 5 并且getSpanSize 返回 3,则会有一个空格,因为您没有填充跨度。【参考方案14】:

这是我用来自动检测跨度计数的包装器的相关部分。您可以通过使用 R.layout.my_grid_item 引用调用 setGridLayoutManager 来初始化它,它会计算出每行可以容纳多少个。

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();
        
    

【讨论】:

为什么不赞成接受的答案。应该解释为什么投反对票

以上是关于RecyclerView GridLayoutManager:如何自动检测跨度计数?的主要内容,如果未能解决你的问题,请参考以下文章

关于RecyclerView

recyclerview 内的 RecyclerView 只显示父 recyclerview 的最后一项

RecyclerView详解

RecyclerView

RecyclerView嵌套RecyclerView问题

RecyclerView小结