Android RecyclerView con GridLayoutManager hace que el artículo abarque varias filas

Tengo una colección de fotos, y estoy usando un RecyclerView para mostrarlos. Quiero tener el primer elemento en mi RecyclerView span 2 columnas Y 2 filas: Introduzca aquí la descripción de la imagen

Sé que puedo abarcar 2 columnas con setSpanSizeLookup :

 GridLayoutManager gridLayoutManager = new GridLayoutManager(this, 3); gridLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { @Override public int getSpanSize(int position) { if (position == 0) { return 2; } else { return 1; } } }); 

Pero ¿cómo puedo hacer que el primer elemento abarque también 2 filas?

He intentado fijar la altura del primer artículo para ser diferente inflando un diverso formato con el doble la altura de los otros, pero ésa dio lugar a cada artículo en la misma fila que el primer artículo que también se estiró a esa altura:

 @Override public ProfilePicViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View itemView; if (viewType == TYPE_MAIN_PHOTO) { itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.card_view_main_profile_photo, parent, false); } else { itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.card_view_profile_photo, parent, false); } return new ProfilePicViewHolder(itemView); } 

No se puede lograr este comportamiento con GridLayoutManager , ya que sólo soporta múltiples columnas.

Nick Butcher está implementando un SpannedGridLayoutManager personalizado que hace exactamente lo que desea. Le permite abarcar varias filas y columnas al mismo tiempo. La implementación sigue siendo WIP, pero ya funciona bastante bien.

SpannedGridLayoutManager.java

 package io.plaidapp.ui.recyclerview; import android.content.Context; import android.content.res.TypedArray; import android.graphics.PointF; import android.graphics.Rect; import android.support.annotation.Keep; import android.support.annotation.NonNull; import android.support.v7.widget.LinearSmoothScroller; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.util.SparseArray; import android.view.View; import android.view.ViewGroup; import java.util.ArrayList; import java.util.List; import io.plaidapp.R; /** * A {@link RecyclerView.LayoutManager} which displays a regular grid (ie all cells are the same * size) and allows simultaneous row & column spanning. */ public class SpannedGridLayoutManager extends RecyclerView.LayoutManager { private GridSpanLookup spanLookup; private int columns = 1; private float cellAspectRatio = 1f; private int cellHeight; private int[] cellBorders; private int firstVisiblePosition; private int lastVisiblePosition; private int firstVisibleRow; private int lastVisibleRow; private boolean forceClearOffsets; private SparseArray<GridCell> cells; private List<Integer> firstChildPositionForRow; // key == row, val == first child position private int totalRows; private final Rect itemDecorationInsets = new Rect(); public SpannedGridLayoutManager(GridSpanLookup spanLookup, int columns, float cellAspectRatio) { this.spanLookup = spanLookup; this.columns = columns; this.cellAspectRatio = cellAspectRatio; setAutoMeasureEnabled(true); } @Keep /* XML constructor, see RecyclerView#createLayoutManager */ public SpannedGridLayoutManager( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { TypedArray a = context.obtainStyledAttributes( attrs, R.styleable.SpannedGridLayoutManager, defStyleAttr, defStyleRes); columns = a.getInt(R.styleable.SpannedGridLayoutManager_spanCount, 1); parseAspectRatio(a.getString(R.styleable.SpannedGridLayoutManager_aspectRatio)); // TODO use this! int orientation = a.getInt( R.styleable.SpannedGridLayoutManager_android_orientation, RecyclerView.VERTICAL); a.recycle(); setAutoMeasureEnabled(true); } public interface GridSpanLookup { SpanInfo getSpanInfo(int position); } public void setSpanLookup(@NonNull GridSpanLookup spanLookup) { this.spanLookup = spanLookup; } public static class SpanInfo { public int columnSpan; public int rowSpan; public SpanInfo(int columnSpan, int rowSpan) { this.columnSpan = columnSpan; this.rowSpan = rowSpan; } public static final SpanInfo SINGLE_CELL = new SpanInfo(1, 1); } public static class LayoutParams extends RecyclerView.LayoutParams { int columnSpan; int rowSpan; public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); } public LayoutParams(int width, int height) { super(width, height); } public LayoutParams(ViewGroup.MarginLayoutParams source) { super(source); } public LayoutParams(ViewGroup.LayoutParams source) { super(source); } public LayoutParams(RecyclerView.LayoutParams source) { super(source); } } @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { calculateWindowSize(); calculateCellPositions(recycler, state); if (state.getItemCount() == 0) { detachAndScrapAttachedViews(recycler); firstVisibleRow = 0; resetVisibleItemTracking(); return; } // TODO use orientationHelper int startTop = getPaddingTop(); int scrollOffset = 0; if (forceClearOffsets) { // see #scrollToPosition startTop = -(firstVisibleRow * cellHeight); forceClearOffsets = false; } else if (getChildCount() != 0) { scrollOffset = getDecoratedTop(getChildAt(0)); startTop = scrollOffset - (firstVisibleRow * cellHeight); resetVisibleItemTracking(); } detachAndScrapAttachedViews(recycler); int row = firstVisibleRow; int availableSpace = getHeight() - scrollOffset; int lastItemPosition = state.getItemCount() - 1; while (availableSpace > 0 && lastVisiblePosition < lastItemPosition) { availableSpace -= layoutRow(row, startTop, recycler, state); row = getNextSpannedRow(row); } layoutDisappearingViews(recycler, state, startTop); } @Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } @Override public RecyclerView.LayoutParams generateLayoutParams(Context c, AttributeSet attrs) { return new LayoutParams(c, attrs); } @Override public RecyclerView.LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { if (lp instanceof ViewGroup.MarginLayoutParams) { return new LayoutParams((ViewGroup.MarginLayoutParams) lp); } else { return new LayoutParams(lp); } } @Override public boolean checkLayoutParams(RecyclerView.LayoutParams lp) { return lp instanceof LayoutParams; } @Override public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) { removeAllViews(); reset(); } @Override public boolean supportsPredictiveItemAnimations() { return true; } @Override public boolean canScrollVertically() { return true; } @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state){ if (getChildCount() == 0 || dy == 0) return 0; int scrolled; int top = getDecoratedTop(getChildAt(0)); if (dy < 0) { // scrolling content down if (firstVisibleRow == 0) { // at top of content int scrollRange = -(getPaddingTop() - top); scrolled = Math.max(dy, scrollRange); } else { scrolled = dy; } if (top - scrolled >= 0) { // new top row came on screen int newRow = firstVisibleRow - 1; if (newRow >= 0) { int startOffset = top - (firstVisibleRow * cellHeight); layoutRow(newRow, startOffset, recycler, state); } } int firstPositionOfLastRow = getFirstPositionInSpannedRow(lastVisibleRow); int lastRowTop = getDecoratedTop( getChildAt(firstPositionOfLastRow - firstVisiblePosition)); if (lastRowTop - scrolled > getHeight()) { // last spanned row scrolled out recycleRow(lastVisibleRow, recycler, state); } } else { // scrolling content up int bottom = getDecoratedBottom(getChildAt(getChildCount() - 1)); if (lastVisiblePosition == getItemCount() - 1) { // is at end of content int scrollRange = Math.max(bottom - getHeight() + getPaddingBottom(), 0); scrolled = Math.min(dy, scrollRange); } else { scrolled = dy; } if ((bottom - scrolled) < getHeight()) { // new row scrolled in int nextRow = lastVisibleRow + 1; if (nextRow < getSpannedRowCount()) { int startOffset = top - (firstVisibleRow * cellHeight); layoutRow(nextRow, startOffset, recycler, state); } } int lastPositionInRow = getLastPositionInSpannedRow(firstVisibleRow, state); int bottomOfFirstRow = getDecoratedBottom(getChildAt(lastPositionInRow - firstVisiblePosition)); if (bottomOfFirstRow - scrolled < 0) { // first spanned row scrolled out recycleRow(firstVisibleRow, recycler, state); } } offsetChildrenVertical(-scrolled); return scrolled; } @Override public void scrollToPosition(int position) { if (position >= getItemCount()) position = getItemCount() - 1; firstVisibleRow = getRowIndex(position); resetVisibleItemTracking(); forceClearOffsets = true; removeAllViews(); requestLayout(); } @Override public void smoothScrollToPosition( RecyclerView recyclerView, RecyclerView.State state, int position) { if (position >= getItemCount()) position = getItemCount() - 1; LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) { @Override public PointF computeScrollVectorForPosition(int targetPosition) { final int rowOffset = getRowIndex(targetPosition) - firstVisibleRow; return new PointF(0, rowOffset * cellHeight); } }; scroller.setTargetPosition(position); startSmoothScroll(scroller); } @Override public int computeVerticalScrollRange(RecyclerView.State state) { // TODO update this to incrementally calculate if (firstChildPositionForRow == null) return 0; return getSpannedRowCount() * cellHeight + getPaddingTop() + getPaddingBottom(); } @Override public int computeVerticalScrollExtent(RecyclerView.State state) { return getHeight(); } @Override public int computeVerticalScrollOffset(RecyclerView.State state) { if (getChildCount() == 0) return 0; return getPaddingTop() + (firstVisibleRow * cellHeight) - getDecoratedTop(getChildAt(0)); } @Override public View findViewByPosition(int position) { if (position < firstVisiblePosition || position > lastVisiblePosition) return null; return getChildAt(position - firstVisiblePosition); } public int getFirstVisibleItemPosition() { return firstVisiblePosition; } private static class GridCell { final int row; final int rowSpan; final int column; final int columnSpan; GridCell(int row, int rowSpan, int column, int columnSpan) { this.row = row; this.rowSpan = rowSpan; this.column = column; this.columnSpan = columnSpan; } } /** * This is the main layout algorithm, iterates over all items and places them into [column, row] * cell positions. Stores this layout info for use later on. Also records the adapter position * that each row starts at. * <p> * Note that if a row is spanned, then the row start position is recorded as the first cell of * the row that the spanned cell starts in. This is to ensure that we have sufficient contiguous * views to layout/draw a spanned row. */ private void calculateCellPositions(RecyclerView.Recycler recycler, RecyclerView.State state) { final int itemCount = state.getItemCount(); cells = new SparseArray<>(itemCount); firstChildPositionForRow = new ArrayList<>(); int row = 0; int column = 0; recordSpannedRowStartPosition(row, column); int[] rowHWM = new int[columns]; // row high water mark (per column) for (int position = 0; position < itemCount; position++) { SpanInfo spanInfo; int adapterPosition = recycler.convertPreLayoutPositionToPostLayout(position); if (adapterPosition != RecyclerView.NO_POSITION) { spanInfo = spanLookup.getSpanInfo(adapterPosition); } else { // item removed from adapter, retrieve its previous span info // as we can't get from the lookup (adapter) spanInfo = getSpanInfoFromAttachedView(position); } if (spanInfo.columnSpan > columns) { spanInfo.columnSpan = columns; // or should we throw? } // check horizontal space at current position else start a new row // note that this may leave gaps in the grid; we don't backtrack to try and fit // subsequent cells into gaps. We place the responsibility on the adapter to provide // continuous data ie that would not span column boundaries to avoid gaps. if (column + spanInfo.columnSpan > columns) { row++; recordSpannedRowStartPosition(row, position); column = 0; } // check if this cell is already filled (by previous spanning cell) while (rowHWM[column] > row) { column++; if (column + spanInfo.columnSpan > columns) { row++; recordSpannedRowStartPosition(row, position); column = 0; } } // by this point, cell should fit at [column, row] cells.put(position, new GridCell(row, spanInfo.rowSpan, column, spanInfo.columnSpan)); // update the high water mark book-keeping for (int columnsSpanned = 0; columnsSpanned < spanInfo.columnSpan; columnsSpanned++) { rowHWM[column + columnsSpanned] = row + spanInfo.rowSpan; } // if we're spanning rows then record the 'first child position' as the first item // *in the row the spanned item starts*. ie the position might not actually sit // within the row but it is the earliest position we need to render in order to fill // the requested row. if (spanInfo.rowSpan > 1) { int rowStartPosition = getFirstPositionInSpannedRow(row); for (int rowsSpanned = 1; rowsSpanned < spanInfo.rowSpan; rowsSpanned++) { int spannedRow = row + rowsSpanned; recordSpannedRowStartPosition(spannedRow, rowStartPosition); } } // increment the current position column += spanInfo.columnSpan; } totalRows = rowHWM[0]; for (int i = 1; i < rowHWM.length; i++) { if (rowHWM[i] > totalRows) { totalRows = rowHWM[i]; } } } private SpanInfo getSpanInfoFromAttachedView(int position) { for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (position == getPosition(child)) { LayoutParams lp = (LayoutParams) child.getLayoutParams(); return new SpanInfo(lp.columnSpan, lp.rowSpan); } } // errrrr? return SpanInfo.SINGLE_CELL; } private void recordSpannedRowStartPosition(final int rowIndex, final int position) { if (getSpannedRowCount() < (rowIndex + 1)) { firstChildPositionForRow.add(position); } } private int getRowIndex(final int position) { return position < cells.size() ? cells.get(position).row : -1; } private int getSpannedRowCount() { return firstChildPositionForRow.size(); } private int getNextSpannedRow(int rowIndex) { int firstPositionInRow = getFirstPositionInSpannedRow(rowIndex); int nextRow = rowIndex + 1; while (nextRow < getSpannedRowCount() && getFirstPositionInSpannedRow(nextRow) == firstPositionInRow) { nextRow++; } return nextRow; } private int getFirstPositionInSpannedRow(int rowIndex) { return firstChildPositionForRow.get(rowIndex); } private int getLastPositionInSpannedRow(final int rowIndex, RecyclerView.State state) { int nextRow = getNextSpannedRow(rowIndex); return (nextRow != getSpannedRowCount()) ? // check if reached boundary getFirstPositionInSpannedRow(nextRow) - 1 : state.getItemCount() - 1; } /** * Lay out a given 'row'. We might actually add more that one row if the requested row contains * a row-spanning cell. Returns the pixel height of the rows laid out. * <p> * To simplify logic & book-keeping, views are attached in adapter order, that is child 0 will * always be the earliest position displayed etc. */ private int layoutRow( int rowIndex, int startTop, RecyclerView.Recycler recycler, RecyclerView.State state) { int firstPositionInRow = getFirstPositionInSpannedRow(rowIndex); int lastPositionInRow = getLastPositionInSpannedRow(rowIndex, state); boolean containsRemovedItems = false; int insertPosition = (rowIndex < firstVisibleRow) ? 0 : getChildCount(); for (int position = firstPositionInRow; position <= lastPositionInRow; position++, insertPosition++) { View view = recycler.getViewForPosition(position); LayoutParams lp = (LayoutParams) view.getLayoutParams(); containsRemovedItems |= lp.isItemRemoved(); GridCell cell = cells.get(position); addView(view, insertPosition); // TODO use orientation helper int wSpec = getChildMeasureSpec( cellBorders[cell.column + cell.columnSpan] - cellBorders[cell.column], View.MeasureSpec.EXACTLY, 0, lp.width, false); int hSpec = getChildMeasureSpec(cell.rowSpan * cellHeight, View.MeasureSpec.EXACTLY, 0, lp.height, true); measureChildWithDecorationsAndMargin(view, wSpec, hSpec); int left = cellBorders[cell.column] + lp.leftMargin; int top = startTop + (cell.row * cellHeight) + lp.topMargin; int right = left + getDecoratedMeasuredWidth(view); int bottom = top + getDecoratedMeasuredHeight(view); layoutDecorated(view, left, top, right, bottom); lp.columnSpan = cell.columnSpan; lp.rowSpan = cell.rowSpan; } if (firstPositionInRow < firstVisiblePosition) { firstVisiblePosition = firstPositionInRow; firstVisibleRow = getRowIndex(firstVisiblePosition); } if (lastPositionInRow > lastVisiblePosition) { lastVisiblePosition = lastPositionInRow; lastVisibleRow = getRowIndex(lastVisiblePosition); } if (containsRemovedItems) return 0; // don't consume space for rows with disappearing items GridCell first = cells.get(firstPositionInRow); GridCell last = cells.get(lastPositionInRow); return (last.row + last.rowSpan - first.row) * cellHeight; } /** * Remove and recycle all items in this 'row'. If the row includes a row-spanning cell then all * cells in the spanned rows will be removed. */ private void recycleRow( int rowIndex, RecyclerView.Recycler recycler, RecyclerView.State state) { int firstPositionInRow = getFirstPositionInSpannedRow(rowIndex); int lastPositionInRow = getLastPositionInSpannedRow(rowIndex, state); int toRemove = lastPositionInRow; while (toRemove >= firstPositionInRow) { int index = toRemove - firstVisiblePosition; removeAndRecycleViewAt(index, recycler); toRemove--; } if (rowIndex == firstVisibleRow) { firstVisiblePosition = lastPositionInRow + 1; firstVisibleRow = getRowIndex(firstVisiblePosition); } if (rowIndex == lastVisibleRow) { lastVisiblePosition = firstPositionInRow - 1; lastVisibleRow = getRowIndex(lastVisiblePosition); } } private void layoutDisappearingViews( RecyclerView.Recycler recycler, RecyclerView.State state, int startTop) { // TODO } private void calculateWindowSize() { // TODO use OrientationHelper#getTotalSpace int cellWidth = (int) Math.floor((getWidth() - getPaddingLeft() - getPaddingRight()) / columns); cellHeight = (int) Math.floor(cellWidth * (1f / cellAspectRatio)); calculateCellBorders(); } private void reset() { cells = null; firstChildPositionForRow = null; firstVisiblePosition = 0; firstVisibleRow = 0; lastVisiblePosition = 0; lastVisibleRow = 0; cellHeight = 0; forceClearOffsets = false; } private void resetVisibleItemTracking() { // maintain the firstVisibleRow but reset other state vars // TODO make orientation agnostic int minimumVisibleRow = getMinimumFirstVisibleRow(); if (firstVisibleRow > minimumVisibleRow) firstVisibleRow = minimumVisibleRow; firstVisiblePosition = getFirstPositionInSpannedRow(firstVisibleRow); lastVisibleRow = firstVisibleRow; lastVisiblePosition = firstVisiblePosition; } private int getMinimumFirstVisibleRow() { int maxDisplayedRows = (int) Math.ceil((float) getHeight() / cellHeight) + 1; if (totalRows < maxDisplayedRows) return 0; int minFirstRow = totalRows - maxDisplayedRows; // adjust to spanned rows return getRowIndex(getFirstPositionInSpannedRow(minFirstRow)); } /* Adapted from GridLayoutManager */ private void calculateCellBorders() { cellBorders = new int[columns + 1]; int totalSpace = getWidth() - getPaddingLeft() - getPaddingRight(); int consumedPixels = getPaddingLeft(); cellBorders[0] = consumedPixels; int sizePerSpan = totalSpace / columns; int sizePerSpanRemainder = totalSpace % columns; int additionalSize = 0; for (int i = 1; i <= columns; i++) { int itemSize = sizePerSpan; additionalSize += sizePerSpanRemainder; if (additionalSize > 0 && (columns - additionalSize) < sizePerSpanRemainder) { itemSize += 1; additionalSize -= columns; } consumedPixels += itemSize; cellBorders[i] = consumedPixels; } } private void measureChildWithDecorationsAndMargin(View child, int widthSpec, int heightSpec) { calculateItemDecorationsForChild(child, itemDecorationInsets); RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams(); widthSpec = updateSpecWithExtra(widthSpec, lp.leftMargin + itemDecorationInsets.left, lp.rightMargin + itemDecorationInsets.right); heightSpec = updateSpecWithExtra(heightSpec, lp.topMargin + itemDecorationInsets.top, lp.bottomMargin + itemDecorationInsets.bottom); child.measure(widthSpec, heightSpec); } private int updateSpecWithExtra(int spec, int startInset, int endInset) { if (startInset == 0 && endInset == 0) { return spec; } int mode = View.MeasureSpec.getMode(spec); if (mode == View.MeasureSpec.AT_MOST || mode == View.MeasureSpec.EXACTLY) { return View.MeasureSpec.makeMeasureSpec( View.MeasureSpec.getSize(spec) - startInset - endInset, mode); } return spec; } /* Adapted from ConstraintLayout */ private void parseAspectRatio(String aspect) { if (aspect != null) { int colonIndex = aspect.indexOf(':'); if (colonIndex >= 0 && colonIndex < aspect.length() - 1) { String nominator = aspect.substring(0, colonIndex); String denominator = aspect.substring(colonIndex + 1); if (nominator.length() > 0 && denominator.length() > 0) { try { float nominatorValue = Float.parseFloat(nominator); float denominatorValue = Float.parseFloat(denominator); if (nominatorValue > 0 && denominatorValue > 0) { cellAspectRatio = Math.abs(nominatorValue / denominatorValue); return; } } catch (NumberFormatException e) { // Ignore } } } } throw new IllegalArgumentException("Could not parse aspect ratio: '" + aspect + "'"); } } 

Attrs.xml

 <?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="SpannedGridLayoutManager"> <attr name="android:orientation" /> <attr name="spanCount" /> <attr name="aspectRatio" format="string" /> </declare-styleable> </resources> 

El código también está disponible aquí .

Ejemplo de uso

El código requiere RecyclerView 23.2.0 o superior. Así que agrega la siguiente línea a tu build.gradle, si aún no lo has hecho.

 dependencies { compile 'com.android.support:recyclerview-v7:24.2.1' } 

Para lograr el diseño mostrado en la publicación inicial, definimos el LayoutManager como sigue

 recyclerView.setLayoutManager(new SpannedGridLayoutManager( new SpannedGridLayoutManager.GridSpanLookup() { @Override public SpannedGridLayoutManager.SpanInfo getSpanInfo(int position) { if (position == 0) { return new SpannedGridLayoutManager.SpanInfo(2, 2); } else { return new SpannedGridLayoutManager.SpanInfo(1, 1); } } }, 3 /* Three columns */, 1f /* We want our items to be 1:1 ratio */)); 

Puede lograr este comportamiento utilizando RecycleView sólo para filas, con ViewHolder para cada fila. Así que tendrá RowViewHolder para filas simples y algo como DoubleRowViewHolder para el diseño personalizado que tendrá 3 elementos, de la manera que desee.

  • Android notifyItemRangeInserted deshabilitar autoscroll
  • RecycleView se bloquea en Fragmento porque LayoutManager es NULL
  • Android RecyclerView último elemento eliminar (tiempo de ejecución) error
  • Android 5.0 (Lollipop) CheckedTextView presione destacar problemas
  • RecyclerView enfrenta pequeños problemas
  • Cómo implementar correctamente un adaptador para un ListView
  • ViewHolder en RecyclerView.Adapter no es específico para la posición
  • Configurar RecyclerView para trabajar como chat
  • Android Cómo reciclar el mapa de bits correctamente cuando se utiliza RecyclerView?
  • Crear un RecyclerView con desplazamiento horizontal y vertical
  • Ocultar barra de desplazamiento de RecyclerView
  • FlipAndroid es un fan de Google para Android, Todo sobre Android Phones, Android Wear, Android Dev y Aplicaciones para Android Aplicaciones.