En iOS, lo habitual a la hora de implementar pantallas en las que se muestran colecciones de elementos, es que baste con utilizar UITableView o UICollectionView con UICollectionViewFlowLayout. Sin embargo, nos encontramos con numerosos casos en los que los diseños son más complejos y estas herramientas resultan insuficientes. En estos casos podemos optar por crear nuestro propio UICollectionViewLayout.

En este post, veremos cómo implementar un layout personalizado de UICollectionView.

 

 

MÉTODOS DE UICollectionViewLayout

Los layouts de UICollectionView son subclases de UICollectionViewLayout que definen los atributos visuales de todos los elementos del UICollectionView. Los atributos son instancias de UICollectionViewLayoutAttributes que contienen propiedades como el frame del elemento.

Aunque hay muchos más, es imprescindible que la subclase de UICollectionViewLayout implemente los siguientes métodos:

  • prepareLayout. Este método se encarga de calcular los atributos de todas las celdas que se van a mostrar en el UICollectionView y de determinar el tamaño del contenido.
  • layoutAttributesForElementsInRect:. Este método lo utiliza el UICollectionView para pedir los atributos de los elementos que se encuentren dentro del rectángulo especificado.
  • layoutAttributesForItemAtIndexPath:. Este método lo utiliza el UICollectionView para pedir los atributos de un elemento concreto.
  • collectionViewContentSize. Este método se limita a devolver el valor que guardamos en prepareLayout para que el UICollectionView conozca el tamaño del contenido.

Vamos a empezar por implementar el método prepareLayout de un layout básico en el que las celdas se colocarán en filas y columnas con un tamaño fijo. Utilizaremos el número de secciones del UICollectionView como el número de columnas y el número de elementos en cada sección como el número de filas.

- (void)prepareLayout {
	[super prepareLayout];
    
	self.itemAttributes = [NSMutableArray array];
    
	CGFloat xOffset = self.collectionView.contentInset.left;
	CGFloat yOffset = self.collectionView.contentInset.top;
    
	NSUInteger numberOfColumns = [self.collectionView numberOfSections];
	NSUInteger numberOfRows = [self.collectionView numberOfItemsInSection:0];
	for (int row = 0; row < numberOfRows; row++) {
   	 
    	NSMutableArray *sectionAttributes = [NSMutableArray array];
    	for (NSUInteger column = 0; column < numberOfColumns; column++) {
        	NSIndexPath *indexPath = [NSIndexPath indexPathForItem:row inSection:column];
        	UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
        	attributes.frame = CGRectIntegral(CGRectMake(xOffset, yOffset, ITEM_SIZE, ITEM_SIZE));
        	[sectionAttributes addObject:attributes];
       	 
        	xOffset = xOffset + ITEM_SIZE;
    	}
   	 
    	xOffset = 0;
    	yOffset = yOffset + ITEM_SIZE;
    	[self.itemAttributes addObject:sectionAttributes];
	}
    
	UICollectionViewLayoutAttributes *attributes = [[self.itemAttributes lastObject] lastObject];
	CGFloat contentWidth = attributes.frame.origin.x + attributes.frame.size.width;
	CGFloat contentHeight = attributes.frame.origin.y + attributes.frame.size.height;
	self.contentSize = CGSizeMake(contentWidth, contentHeight);
}

 

Los atributos calculados los guardamos en una propiedad de tipo array llamada itemAttributes. Obtenemos así el tamaño del contenido a partir de la posición y el tamaño del último elemento. Una vez disponemos de esa información, la implementación de los demás métodos es sencilla.

- (CGSize)collectionViewContentSize {
	return self.contentSize;
}
 
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
	NSMutableArray *attributes = [@[] mutableCopy];
	for (NSArray *section in self.itemAttributes) {
    	[attributes addObjectsFromArray:
); }]]]; } return attributes; } - (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath { return self.itemAttributes[indexPath.row][indexPath.section]; }

 

Con esta implementación básica ya podríamos utilizar nuestro layout obteniendo el siguiente resultado:

Métodos UICollectionViewLayout - SDOS

 

 

ANCLADO DE ELEMENTOS

Si queremos dejar algunos elementos en una posición fija necesitamos invalidar el layout cuando se haga scroll y recalcular sus atributos. Para ello debemos implementar el método shouldInvalidateLayoutForBoundsChange:

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
	[self updatePinnedItemsAttributesForBoundsChange:newBounds];
	return YES;
}
 
- (void)updatePinnedItemsAttributesForBoundsChange:(CGRect)newBounds {
	NSUInteger numberOfRows = [self.collectionView numberOfItemsInSection:0];
	NSUInteger numberOfColumns = [self.collectionView numberOfSections];
	for (int row = 0; row < numberOfRows; row++) {
    	for (NSUInteger column = 0; column < numberOfColumns; column++) {
        	if (row != 0 && column != 0) {
            	continue;
        	}
       	 
        	UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:row inSection:column]];
        	if (column == 0) { // Fijamos los elementos de la primera columna
            	CGRect frame = attributes.frame;
            	frame.origin.x = newBounds.origin.x;
            	attributes.frame = frame;
        	}
    	}
	}
}

 

Devolvemos YES indicando al UICollectionView que el layout se debe invalidar siempre que cambie su área visible. Utilizamos el valor del CGRect para actualizar los atributos de todos los elementos de la primera columna. De esta forma siempre se mantienen pegados al borde izquierdo de la pantalla.

Al hacer esto, los elementos de la primera columna se superponen a los de las demás columnas. Por ello necesitamos indicar la profundidad a la que se encuentran los distintos elementos, para que el UICollectionView sepa cuales deben verse por encima. La profundidad se especifica como una propiedad más de los atributos de cada elemento, el zIndex. Modificamos la implementación del método prepareLayout para que los elementos de la primera columna tengan un zIndex mayor que los demás.

- (void)prepareLayout {
	[super prepareLayout];
    
	if (self.itemAttributes) {
    	return;
	}
    
	self.itemAttributes = [NSMutableArray array];
    
	CGFloat xOffset = 0;
	CGFloat yOffset = 0;
    
	NSUInteger numberOfColumns = [self.collectionView numberOfSections];
	NSUInteger numberOfRows = [self.collectionView numberOfItemsInSection:0];
	for (int row = 0; row < numberOfRows; row++) {
   	 
    	NSMutableArray *sectionAttributes = [NSMutableArray array];
    	for (NSUInteger column = 0; column < numberOfColumns; column++) {
 
        	NSIndexPath *indexPath = [NSIndexPath indexPathForItem:row inSection:column];
        	UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
        	attributes.frame = CGRectIntegral(CGRectMake(xOffset, yOffset, ITEM_WIDTH, ITEM_HEIGHT));
       	 
        	if (column == 0) {
            	attributes.zIndex = ZINDEX_FIRST_COL_ITEMS;
        	} else {
            	attributes.zIndex = ZINDEX_ITEMS;
        	}
       	 
        	[sectionAttributes addObject:attributes];
       	 
        	xOffset = xOffset + ITEM_WIDTH;
    	}
   	 
    	xOffset = 0;
    	yOffset = yOffset + ITEM_HEIGHT;
    	[self.itemAttributes addObject:sectionAttributes];
	}
    
	UICollectionViewLayoutAttributes *attributes = [[self.itemAttributes lastObject] lastObject];
	CGFloat contentWidth = attributes.frame.origin.x + attributes.frame.size.width;
	CGFloat contentHeight = attributes.frame.origin.y + attributes.frame.size.height;
	self.contentSize = CGSizeMake(contentWidth, contentHeight);
}

 

Anclado de elementos UICollectionViewLayout - SDOS

 

 

ELEMENTOS ADICIONALES

Además de las celdas básicas (UICollectionViewCell) disponemos de otro tipo de vistas (UICollectionReusableView) que podemos utilizar en nuestro layout para complementar al contenido principal, añadiendo elementos decorativos o suplementarios.

Los elementos decorativos son aquellos que se pueden pintar sin necesidad de información adicional, como separadores.

Los elementos suplementarios son aquellos que necesitan información adicional. Un ejemplo son las cabeceras, a las que se le tiene que especificar el texto a mostrar implementando el método collectionView:viewForSupplementaryElementOfKind:atIndexPath: del delegado de UICollectionView.

 

SOMBRA EN SCROLL

Vamos a añadir como elemento decorativo una sombra que aparecerá a la derecha de la primera columna cuando se haga scroll horizontal para resaltar la jerarquía del contenido.

Para ello especificamos un identificador que nos permita distinguirlo de otros elementos a la hora de generar sus atributos. Este identificador se debe utilizar a la hora de registrar la vista que se vaya a usar para que el UICollectionView pueda localizarla.

NSString *const CollectionElementKindGridVerticalShadow = @"CollectionElementKindGridVerticalShadow";

 

Igual que con los elementos del contenido, tenemos que calcular los atributos de este elemento en prepareLayout para tenerlos listos cuando nos los pida el UICollectionView.

- (void)generateShadowAttributes {
	NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];
	UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForDecorationViewOfKind:CollectionElementKindGridVerticalShadow withIndexPath:indexPath];
	attributes.frame = CGRectIntegral(CGRectMake(0, 0, ITEM_WIDTH, self.contentSize.height));
	attributes.zIndex = ZINDEX_SHADOW;
	self.shadowAttributes = attributes;
}

 

Además, tenemos que actualizar su posición igual que hacemos con los elementos de la primera columna.

- (void)updatePinnedItemsAttributesForBoundsChange:(CGRect)newBounds {
	NSUInteger numberOfRows = [self.collectionView numberOfItemsInSection:0];
	NSUInteger numberOfColumns = [self.collectionView numberOfSections];
	for (int row = 0; row < numberOfRows; row++) {
    	for (NSUInteger column = 0; column < numberOfColumns; column++) {
        	if (column != 0) {
            	continue;
        	}
       	 
        	UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:row inSection:column]];
        	if (column == 0) { // Fijamos los elementos de la primera columna
            	CGRect frame = attributes.frame;
            	frame.origin.x = newBounds.origin.x;
            	attributes.frame = frame;
        	}
    	}
	}
    
	CGRect shadowFrame = self.shadowAttributes.frame;
	if (newBounds.origin.x > 0) {
    	shadowFrame.origin.x = newBounds.origin.x + ITEM_WIDTH - 1;
    	self.shadowAttributes.zIndex = ZINDEX_SHADOW;
	} else {
    	shadowFrame.origin.x = 0;
    	self.shadowAttributes.zIndex = 0;
	}
	self.shadowAttributes.frame = shadowFrame;
}

 

UICollectionViewLayout - SDOS

 

CABECERA

Como elemento suplementario añadiremos también una cabecera. Al igual que con los elementos decorativos, es necesario especificar un identificador para utilizarlo a la hora de generar sus atributos y registrar la vista que queremos utilizar, así como para ser capaces de distinguir los distintos tipos de elementos en el método collectionView:viewForSupplementaryElementOfKind:atIndexPath:.

NSString *const CollectionElementKindGridHeader = @"CollectionElementKindGridHeader";

 

La implementación por parte del layout es similar a la de los demás elementos. Debemos calcular sus atributos en prepareLayout para devolverlos cuando sea necesario en layoutAttributesForElementsInRect: y actualizar su posición para evitar que el título desaparezca al hacer scroll horizontal.

- (void)generateHeaderAttributes {
	NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];
	UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForSupplementaryViewOfKind:CollectionElementKindGridHeader withIndexPath:indexPath];
	attributes.frame = CGRectIntegral(CGRectMake(0, 0, self.contentSize.width, [self headerHeight]));
	self.headerAttributes = attributes;
}
 
- (void)updatePinnedItemsAttributesForBoundsChange:(CGRect)newBounds {
	NSUInteger numberOfRows = [self.collectionView numberOfItemsInSection:0];
	NSUInteger numberOfColumns = [self.collectionView numberOfSections];
	for (int row = 0; row < numberOfRows; row++) {
    	for (NSUInteger column = 0; column < numberOfColumns; column++) {
        	if (column != 0) {
            	continue;
        	}
       	 
        	UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:row inSection:column]];
        	if (column == 0) { // Fijamos los elementos de la primera columna
            	CGRect frame = attributes.frame;
            	frame.origin.x = newBounds.origin.x;
            	attributes.frame = frame;
        	}
    	}
	}
    
	CGRect shadowFrame = self.shadowAttributes.frame;
	if (newBounds.origin.x > 0) {
    	shadowFrame.origin.x = newBounds.origin.x + ITEM_WIDTH - 1;
    	self.shadowAttributes.zIndex = ZINDEX_SHADOW;
	} else {
    	shadowFrame.origin.x = 0;
    	self.shadowAttributes.zIndex = 0;
	}
	self.shadowAttributes.frame = shadowFrame;
    
	CGRect headerFrame = self.headerAttributes.frame;
	headerFrame.origin.x = newBounds.origin.x;
	self.headerAttributes.frame = headerFrame;
}

 

Además vamos a permitir que el delegado del UICollectionView especifique la altura de la cabecera creando un protocolo que extiende UICollectionViewDelegate con el método collectionView:heightForHeaderInLayout:.

@protocol GridCollectionViewLayoutDelegate <UICollectionViewDelegate>
 
@optional
- (CGFloat)collectionView:(UICollectionView *)collectionView heightForHeaderInLayout:(GridCollectionViewLayout *)collectionViewLayout;
 
@end
- (CGFloat)headerHeight {
	if ([self.collectionView.delegate respondsToSelector:@selector(collectionView:heightForHeaderInLayout:)]) {
    	return [(id<GridCollectionViewLayoutDelegate>)self.collectionView.delegate collectionView:self.collectionView heightForHeaderInLayout:self];
	}
    
	return 0;
}

 

Cabecera UICollectionViewLayout - SDOS

 

USO DEL LAYOUT

Para utilizar nuestro layout en un UICollectionView debemos asignarle una instancia del layout. Esto se puede hacer tanto por código como desde Interface Builder en un Storyboard o XIB.

Por código se le asigna la instancia del layout a la propiedad collectionViewLayout.

self.collectionView.collectionViewLayout = [GridCollectionViewLayout new];

 

En Interface Builder seleccionamos Layout Custom y especificamos la clase de nuestro layout.

Clase layout UICollectionViewLayout - SDOS

 

Necesitamos registrar también en el UICollectionView las celdas y/o vistas a utilizar para los distintos elementos que hemos definido en el layout, excepto las vistas de elementos decorativos que debemos registrarlas en el layout.

UINib *itemNib = [UINib nibWithNibName:NSStringFromClass([ItemCollectionViewCell class]) bundle:nil];
[self.collectionView registerNib:itemNib forCellWithReuseIdentifier:NSStringFromClass([ItemCollectionViewCell class])];
    
UINib *shadowNib = [UINib nibWithNibName:NSStringFromClass([ShadowCollectionReusableView class]) bundle:nil];
[self.collectionView.collectionViewLayout registerNib:shadowNib forDecorationViewOfKind:CollectionElementKindGridVerticalShadow];
    
UINib *headerNib = [UINib nibWithNibName:NSStringFromClass([HeaderCollectionReusableView class]) bundle:nil];
[self.collectionView registerNib:headerNib forSupplementaryViewOfKind:CollectionElementKindGridHeader withReuseIdentifier:NSStringFromClass([HeaderCollectionReusableView class])];

 

Por último, implementamos los delegados del UICollectionView y de nuestro layout:

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
	return 50;
}
 
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
	return 50;
}
 
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
	ItemCollectionViewCell *itemCell = [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass([ItemCollectionViewCell class]) forIndexPath:indexPath];
	if (indexPath.section == 0) {
    	itemCell.text = [NSString stringWithFormat:@"Fila %ld", (long)indexPath.row + 1];
	} else {
    	itemCell.text = [NSString stringWithFormat:@"%ld-%ld", (long)indexPath.row + 1, (long)indexPath.section];
	}
	return itemCell;
}
 
- (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
	HeaderCollectionReusableView *headerView = [collectionView dequeueReusableSupplementaryViewOfKind:CollectionElementKindGridHeader withReuseIdentifier:NSStringFromClass([HeaderCollectionReusableView class]) forIndexPath:indexPath];
	headerView.title = @"Título";
	return headerView;
}
 
- (CGFloat)collectionView:(UICollectionView *)collectionView heightForHeaderInLayout:(GridCollectionViewLayout *)collectionViewLayout {
	return 40;
}

 

¡Y aquí habríamos acabado! Si quieres echarle un ojo al proyecto del ejemplo que hemos visto en este post, aquí tienes el enlace.