RecyclerView GridLayoutManager: как автоматически определить количество промежутков?

Использование нового GridLayoutManager: https://developer.android.com/reference/android/support/v7/widget/GridLayoutManager.html

Требуется явное количество интервалов, поэтому теперь возникает проблема: как узнать, сколько «промежутков» подходит для каждой строки? Ведь это сетка. Должно быть столько промежутков, сколько RecyclerView может поместиться на основе измеренной ширины.

Используя старый GridView, вы просто установите свойство "columnWidth", и оно автоматически определит, сколько столбцов подходит. Это в основном то, что я хочу воспроизвести для RecyclerView:

  • добавить OnLayoutChangeListener на RecyclerView
  • в этом обратном вызове раздуйте один «элемент сетки» и измерьте его
  • spanCount = recyclerViewWidth / singleItemWidth;

Это кажется довольно распространенным поведением, так что есть ли более простой способ, которого я не вижу?


person foo64    schedule 31.10.2014    source источник


Ответы (14)


Лично мне не нравится подкласс RecyclerView для этого, потому что мне кажется, что GridLayoutManager несет ответственность за определение количества интервалов. Итак, после некоторого копания исходного кода Android для RecyclerView и GridLayoutManager я написал свой собственный расширенный класс 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 за сообщение и предложение решение.

EDIT 2: Небольшой рефакторинг и исправление крайних случаев с ручной обработкой изменений ориентации. Спасибо пользователю @tatarize за сообщение и предложение решение.

person s.maks    schedule 15.05.2015
comment
Это должен быть принятый ответ, это работа LayoutManager выкладывать детей, а не RecyclerView - person mewa; 27.08.2015
comment
Что делать, если ColumnWidth не исправлено. - person Shubham; 09.12.2015
comment
@Shubham Вы имеете в виду ситуацию, когда ширина столбца должна отличаться, например, в зависимости от ориентации? - person s.maks; 10.12.2015
comment
@s.maks: я имею в виду, что ширина столбца будет отличаться в зависимости от данных, переданных в адаптер. Скажем, если переданы слова из четырех букв, то он должен вместить четыре элемента подряд, если переданы слова из 10 букв, то он должен вместить только 2 элемента в ряд. - person Shubham; 11.12.2015
comment
@ Шубхам, о. Понимаю. Ну я думаю не стоит добавлять этот функционал в LayoutManager, потому что в принципе LayoutManager не должен иметь дело с реальными данными. Он знает только, что я должен что-то раскладывать в зависимости от моих параметров, и ничего об этом не знает. Поэтому я думаю, что в этом случае вы должны написать эту логику вне LayoutManager, например, в Activity.onCreate(). Как это сделать? Это может быть сложно в зависимости от данных, переданных вашему адаптеру, и фактических требований. Я предлагаю вам создать для этого отдельный вопрос здесь, чтобы любой мог легко найти ответ позже. - person s.maks; 11.12.2015
comment
@s.maks У меня есть исключение внутри setSpanCount: java.lang.IllegalStateException: невозможно вызвать этот метод, пока RecyclerView вычисляет макет или прокручивает - person Oleksandr Albul; 16.03.2016
comment
@OleksandrAlbul Вы вручную вызываете setSpanCount (int) в менеджере компоновки? Также откуда вы вызываете setLayoutManager (LayoutManager) в своем RecyclerView? - person s.maks; 22.03.2016
comment
Проблемы в этом коде следующие: если columnWidth = 300, а totalSpace = 736, то spanCount = 2, что приводит к непропорциональному размещению элементов. 2 * 300 = 600, остальные 136 пикселей не учитываются, что приводит к не равным отступам - person azizbekian; 28.03.2016
comment
@azizbekian, я не уверен, что проблема возникает из-за этого кода, но я не могу сказать наверняка, пока не увижу, как вы настраиваете свой RecyclerView и его элементы. Пожалуйста, создайте вопрос на SO и опубликуйте код, чтобы я мог вам как-то помочь. - person s.maks; 04.04.2016
comment
Для меня при вращении устройства размер сетки немного меняется, уменьшаясь на 20 dp. Есть какая-нибудь информация об этом? Спасибо. - person Alexandre; 07.04.2016
comment
@ Александр То же самое, этот код отлично работает в моих проектах, поэтому я не могу сказать, что именно вызывает проблемы, не глядя на ваш код. - person s.maks; 08.04.2016
comment
@s.maks Я обновил библиотеку поддержки до 23.3.0, и это перестало происходить. Предполагаю, что это была ошибка 23.0.0. Спасибо за менеджер, хороший фрагмент. - person Alexandre; 08.04.2016
comment
@Alexandre Спасибо, рад слышать, что ваша проблема решена. - person s.maks; 08.04.2016
comment
Иногда перед созданием представления getWidth() или getHeight() равно 0, что приведет к неправильному значению spanCount (1, так как totalSpace будет ‹=0). В этом случае я добавил игнорирование setSpanCount. (onLayoutChildren будет вызван позже) - person Elyess Abouda; 20.05.2016
comment
Этот код фиксирует ширину для каждого элемента, и как только он получает количество промежутков, он фиксируется навсегда. Я искал что-то вроде WRAP_CONTENT для каждого элемента и разного количества диапазонов для разных строк. - person Jimit Patel; 29.08.2016
comment
Я получаю ошибку OOM в super.onLayoutChildren(). Любая помощь приветствуется - person Rushi M Thakker; 17.08.2017
comment
Есть граничное условие, которое не покрыто. Если вы установите configChanges так, чтобы вы обрабатывали ротацию, а не позволяли ей перестраивать всю активность, у вас есть странный случай, когда изменяется ширина recyclerview, а больше ничего не меняется. С измененной шириной и высотой spancount является грязным, но mColumnWidth не изменился, поэтому onLayoutChildren() прерывает работу и не пересчитывает теперь грязные значения. Сохраните предыдущую высоту и ширину и сработайте, если они изменятся ненулевым способом. - person Tatarize; 15.12.2018

Я выполнил это с помощью наблюдателя дерева представлений, чтобы получить ширину recylcerview после рендеринга, а затем получить фиксированные размеры моего представления карты из ресурсов, а затем установить количество интервалов после выполнения моих вычислений. Это действительно применимо только в том случае, если отображаемые элементы имеют фиксированную ширину. Это помогло мне автоматически заполнить сетку независимо от размера или ориентации экрана.

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();
                }
            });
person speedynomads    schedule 18.11.2014
comment
Я использовал это и получил ArrayIndexOutOfBoundsException (в android.support.v7.widget.GridLayoutManager.layoutChunk(GridLayoutManager.java:361)) при прокрутке RecyclerView. - person fikr4n; 01.12.2014
comment
Просто добавьте mLayoutManager.requestLayout() после setSpanCount() и все заработает. - person alvinmeimoun; 04.12.2014
comment
Примечание. removeGlobalOnLayoutListener() устарело на уровне API 16. Вместо этого используйте removeOnGlobalLayoutListener(). Документация. - person pez; 20.01.2015
comment
опечатка removeOnGLobalLayoutListener должна быть removeOnGlobalLayoutListener - person Theo; 06.01.2018

Ну, это то, что я использовал, довольно простое, но выполняет свою работу за меня. Этот код в основном получает ширину экрана в провалах, а затем делит на 300 (или любую ширину, которую вы используете для макета вашего адаптера). Таким образом, телефоны меньшего размера с шириной провала 300-500 отображают только один столбец, планшеты — 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);
person Ribs    schedule 21.01.2015
comment
Зачем использовать ширину экрана вместо ширины RecyclerView? И жесткое кодирование 300 - плохая практика (его нужно синхронизировать с макетом xml) - person foo64; 27.03.2015
comment
@foo64 в xml вы можете просто установить match_parent для элемента. Но да, все равно некрасиво ;) - person Philip Giuliani; 15.12.2015

Я расширил 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);
        }
    }
}
person andrew_s    schedule 10.12.2014
comment
+1 У Чиу-ки Чан есть сообщение в блоге с изложением этого подход, а также пример проекта для него. - person CommonsWare; 06.02.2015

Я публикую это на случай, если кто-то получит странную ширину столбца, как в моем случае.

Я не могу прокомментировать ответ @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 устранило проблему.

person Ariq    schedule 28.06.2016

Лучшим способом (imo) было бы определить разные счетчики диапазонов в (многих) разных каталогах values и позволить устройству автоматически выбирать, какой счетчик диапазонов использовать. Например,

values/integers.xml -> span_count=3

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

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

person denvercoder9    schedule 26.09.2020

Чтобы учесть изменение ориентации в ответе 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);
}
person Andreas    schedule 15.02.2017

Решение, за которое проголосовали, в порядке, но обрабатывает входящие значения как пиксели, что может сбить вас с толку, если вы жестко кодируете значения для тестирования и предполагаете dp. Самый простой способ, вероятно, поместить ширину столбца в измерение и прочитать его при настройке GridAutofitLayoutManager, который автоматически преобразует dp в правильное значение в пикселях:

new GridAutofitLayoutManager(getActivity(), (int)getActivity().getResources().getDimension(R.dimen.card_width))
person toncek    schedule 05.05.2017
comment
да, это поставило меня в тупик. на самом деле было бы просто принять сам ресурс. Я имею в виду, что это как всегда то, что мы будем делать. - person Tatarize; 15.12.2018

  1. Установите минимальную фиксированную ширину imageView (например, 144dp x 144dp)
  2. Когда вы создаете 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);
    
  3. После этого вам нужно изменить размер изображения в адаптере, если у вас есть место в столбце. Вы можете отправить 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

person SerjantArbuz    schedule 28.05.2017

Вывод выше отвечает здесь

person Omid Raha    schedule 10.11.2017

Это класс 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;
        }
    }
}
person Tatarize    schedule 15.12.2018

Мне нравится ответ s.maks, но я нашел еще один пограничный случай: если вы установите высоту RecyclerView в WRAP_CONTENT, может случиться так, что высота recyclerview будет рассчитана неправильно на основе устаревшего значения spanCount. Решение, которое я нашел, представляет собой небольшую модификацию предложенного метода 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);
}
person Lars Brinkmann    schedule 17.07.2021

Задайте для 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, был бы лучшим решением, но я не нашел ничего подобного.

РЕДАКТИРОВАТЬ: есть ошибка, на некоторых устройствах добавляется пробел справа

person alvinmeimoun    schedule 12.11.2014
comment
Пробел справа — это не ошибка. если количество диапазонов равно 5, а getSpanSize возвращает 3, будет пробел, потому что вы не заполняете диапазон. - person Nicolas Tyler; 27.02.2015

Вот соответствующие части оболочки, которые я использовал для автоматического определения количества интервалов. Вы инициализируете его, вызывая setGridLayoutManager со ссылкой R.layout.my_grid_item, и он вычисляет, сколько из них может поместиться в каждой строке.

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();
        }
    }
}
person foo64    schedule 27.03.2015
comment
Зачем понижать принятый ответ. должен объяснить, почему минусует - person androidXP; 03.02.2018