Especificación de una dimensión de celdas en UICollectionView mediante diseño automático

113

En iOS 8, el UICollectionViewFlowLayoutsoporte cambia automáticamente el tamaño de las celdas según el tamaño de su propio contenido. Esto cambia el tamaño de las celdas tanto en ancho como en alto según su contenido.

¿Es posible especificar un valor fijo para el ancho (o alto) de todas las celdas y permitir que las otras dimensiones cambien de tamaño?

Para un ejemplo simple, considere una etiqueta de varias líneas en una celda con restricciones que la colocan a los lados de la celda. La etiqueta de varias líneas se puede cambiar de tamaño de diferentes maneras para acomodar el texto. La celda debe llenar el ancho de la vista de colección y ajustar su altura en consecuencia. En cambio, las celdas tienen un tamaño desordenado e incluso causa un bloqueo cuando el tamaño de la celda es mayor que la dimensión no desplazable de la vista de colección.


iOS 8 introduce el método systemLayoutSizeFittingSize: withHorizontalFittingPriority: verticalFittingPriority:Para cada celda en la vista de colección, el diseño llama a este método en la celda, pasando el tamaño estimado. Lo que tendría sentido para mí sería anular este método en la celda, pasar el tamaño dado y establecer la restricción horizontal como requerida y una prioridad baja para la restricción vertical. De esta manera, el tamaño horizontal se fija al valor establecido en el diseño y el tamaño vertical puede ser flexible.

Algo como esto:

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes {
    UICollectionViewLayoutAttributes *attributes = [super preferredLayoutAttributesFittingAttributes:layoutAttributes];
    attributes.size = [self systemLayoutSizeFittingSize:layoutAttributes.size withHorizontalFittingPriority:UILayoutPriorityRequired verticalFittingPriority:UILayoutPriorityFittingSizeLevel];
    return attributes;
}

Los tamaños devueltos por este método, sin embargo, son completamente extraños. La documentación sobre este método no es muy clara para mí y menciona el uso de constantes UILayoutFittingCompressedSize UILayoutFittingExpandedSizeque solo representan un tamaño cero y uno bastante grande.

¿Es el sizeparámetro de este método realmente solo una forma de pasar dos constantes? ¿No hay forma de lograr el comportamiento que espero de obtener la altura adecuada para un tamaño dado?


Soluciones alternativas

1) Agregar restricciones que especificarán un ancho específico para la celda logra el diseño correcto. Esta es una mala solución porque esa restricción debe establecerse en el tamaño de la vista de colección de la celda a la que no tiene una referencia segura. El valor de esa restricción podría pasarse cuando se configura la celda, pero eso también parece completamente contrario a la intuición. Esto también es incómodo porque agregar restricciones directamente a una celda o su vista de contenido está causando muchos problemas.

2) Utilice una vista de tabla. Las vistas de tabla funcionan de esta manera, ya que las celdas tienen un ancho fijo, pero esto no se adaptaría a otras situaciones, como un diseño de iPad con celdas de ancho fijo en varias columnas.

Anthony Mattox
fuente

Respuestas:

135

Parece que lo que está pidiendo es una forma de usar UICollectionView para producir un diseño como UITableView. Si eso es realmente lo que desea, la forma correcta de hacerlo es con una subclase UICollectionViewLayout personalizada (tal vez algo como SBTableLayout ).

Por otro lado, si realmente está preguntando si hay una forma limpia de hacer esto con el UICollectionViewFlowLayout predeterminado, entonces creo que no hay forma. Incluso con las celdas de tamaño propio de iOS8, no es sencillo. El problema fundamental, como usted dice, es que la maquinaria del diseño de flujo no proporciona forma de arreglar una dimensión y dejar que otra responda. (Además, incluso si pudiera, habría una complejidad adicional en torno a la necesidad de dos pasadas de diseño para dimensionar las etiquetas de varias líneas. Es posible que esto no se ajuste a la forma en que las celdas de tamaño propio desean calcular todo el tamaño a través de una llamada a systemLayoutSizeFittingSize).

Sin embargo, si aún desea crear un diseño similar a una vista de tabla con un diseño de flujo, con celdas que determinan su propio tamaño y responden naturalmente al ancho de la vista de la colección, por supuesto que es posible. Todavía queda el camino desordenado. Lo he hecho con una "celda de tamaño", es decir, una UICollectionViewCell no mostrada que el controlador guarda solo para calcular el tamaño de las celdas.

Este enfoque consta de dos partes. La primera parte es que el delegado de la vista de colección calcule el tamaño de celda correcto, tomando el ancho de la vista de colección y usando la celda de tamaño para calcular la altura de la celda.

En su UICollectionViewDelegateFlowLayout, implementa un método como este:

func collectionView(collectionView: UICollectionView,
  layout collectionViewLayout: UICollectionViewLayout,
  sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
  // NOTE: here is where we say we want cells to use the width of the collection view
   let requiredWidth = collectionView.bounds.size.width

   // NOTE: here is where we ask our sizing cell to compute what height it needs
  let targetSize = CGSize(width: requiredWidth, height: 0)
  /// NOTE: populate the sizing cell's contents so it can compute accurately
  self.sizingCell.label.text = items[indexPath.row]
  let adequateSize = self.sizingCell.preferredLayoutSizeFittingSize(targetSize)
  return adequateSize
}

Esto hará que la vista de la colección establezca el ancho de la celda en función de la vista de la colección adjunta, pero luego le pedirá a la celda de tamaño que calcule la altura.

La segunda parte es hacer que la celda de tamaño use sus propias restricciones AL para calcular la altura. Esto puede ser más difícil de lo que debería ser, debido a la forma en que los UILabel de múltiples líneas requieren efectivamente un proceso de diseño de dos etapas. El trabajo se realiza en el método preferredLayoutSizeFittingSize, que es así:

 /*
 Computes the size the cell will need to be to fit within targetSize.

 targetSize should be used to pass in a width.

 the returned size will have the same width, and the height which is
 calculated by Auto Layout so that the contents of the cell (i.e., text in the label)
 can fit within that width.

 */
 func preferredLayoutSizeFittingSize(targetSize:CGSize) -> CGSize {

   // save original frame and preferredMaxLayoutWidth
   let originalFrame = self.frame
   let originalPreferredMaxLayoutWidth = self.label.preferredMaxLayoutWidth

   // assert: targetSize.width has the required width of the cell

   // step1: set the cell.frame to use that width
   var frame = self.frame
   frame.size = targetSize
   self.frame = frame

   // step2: layout the cell
   self.setNeedsLayout()
   self.layoutIfNeeded()
   self.label.preferredMaxLayoutWidth = self.label.bounds.size.width

   // assert: the label's bounds and preferredMaxLayoutWidth are set to the width required by the cell's width

   // step3: compute how tall the cell needs to be

   // this causes the cell to compute the height it needs, which it does by asking the 
   // label what height it needs to wrap within its current bounds (which we just set).
   let computedSize = self.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)

   // assert: computedSize has the needed height for the cell

   // Apple: "Only consider the height for cells, because the contentView isn't anchored correctly sometimes."
   let newSize = CGSize(width:targetSize.width,height:computedSize.height)

   // restore old frame and preferredMaxLayoutWidth
   self.frame = originalFrame
   self.label.preferredMaxLayoutWidth = originalPreferredMaxLayoutWidth

   return newSize
 }

(Este código está adaptado del código de muestra de Apple del código de muestra de la sesión WWDC2014 en "Vista de colección avanzada").

Un par de puntos para notar. Está usando layoutIfNeeded () para forzar el diseño de toda la celda, con el fin de calcular y establecer el ancho de la etiqueta. Pero eso no es suficiente. Creo que también debe configurar preferredMaxLayoutWidthpara que la etiqueta use ese ancho con Diseño automático. Y solo entonces se puede usar systemLayoutSizeFittingSizepara que la celda calcule su altura teniendo en cuenta la etiqueta.

¿Me gusta este enfoque? ¡¡No!! Se siente demasiado complejo y hace el diseño dos veces. Pero siempre que el rendimiento no se convierta en un problema, prefiero realizar el diseño dos veces en tiempo de ejecución que tener que definirlo dos veces en el código, que parece ser la única otra alternativa.

Mi esperanza es que eventualmente las células de tamaño automático funcionen de manera diferente y todo esto se volverá mucho más simple.

Proyecto de ejemplo que lo muestra en funcionamiento.

Pero, ¿por qué no usar simplemente células de tamaño propio?

En teoría, las nuevas instalaciones de iOS8 para "celdas de tamaño propio" deberían hacer esto innecesario. Si ha definido una celda con Auto Layout (AL), entonces la vista de colección debería ser lo suficientemente inteligente como para permitir que se dimensione y se distribuya correctamente. En la práctica, no he visto ningún ejemplo que haya logrado que esto funcione con etiquetas de varias líneas. Creo que esto se debe en parte a que el mecanismo de la celda de tamaño automático todavía tiene errores.

Pero apuesto a que se debe principalmente a la complicación habitual del diseño automático y las etiquetas, que es que UILabels requiere un proceso de diseño básicamente de dos pasos. No tengo claro cómo se pueden realizar ambos pasos con células de tamaño propio.

Y como dije, este es realmente un trabajo para un diseño diferente. Es parte de la esencia del diseño de flujo que coloca las cosas que tienen un tamaño, en lugar de fijar un ancho y les permite elegir su altura.

¿Y qué hay de favoriteLayoutAttributesFittingAttributes:?

El preferredLayoutAttributesFittingAttributes:método es una pista falsa, creo. Eso solo está ahí para ser utilizado con el nuevo mecanismo de celda de tamaño propio. Entonces, esta no es la respuesta mientras ese mecanismo no sea confiable.

¿Y qué pasa con systemlayoutSizeFittingSize :?

Tienes razón, los documentos son confusos.

Los documentos en systemLayoutSizeFittingSize:y systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:ambos sugieren que solo debe pasar UILayoutFittingCompressedSizey UILayoutFittingExpandedSizecomo targetSize. Sin embargo, la propia firma del método, los comentarios del encabezado y el comportamiento de las funciones indican que están respondiendo al valor exacto del targetSizeparámetro.

De hecho, si configura el UICollectionViewFlowLayoutDelegate.estimatedItemSize, para habilitar el nuevo mecanismo de celda de auto-tamaño, ese valor parece pasar como targetSize. Y UILabel.systemLayoutSizeFittingSizeparece devolver exactamente los mismos valores que UILabel.sizeThatFits. Esto es sospechoso, dado que systemLayoutSizeFittingSizese supone que el argumento to es un objetivo aproximado y sizeThatFits:se supone que el argumento to tiene un tamaño de circunscripción máximo.

Más recursos

Si bien es triste pensar que un requisito tan rutinario debería requerir "recursos de investigación", creo que sí. Buenos ejemplos y discusiones son:

alga
fuente
¿Por qué vuelve a colocar el marco en el marco anterior?
vrwim
Hago eso para que la función se pueda usar solo para determinar el tamaño de diseño preferido de la vista, sin afectar el estado de diseño de la vista a la que la llama. Prácticamente, esto es irrelevante si está sosteniendo esta vista solo con el propósito de haciendo cálculos de diseño. Además, lo que hago aquí es insuficiente. Restaurar el marco por sí solo no lo restaura realmente a su estado anterior, ya que la realización del diseño podría haber modificado los diseños de las subvistas.
alga
1
Esto es un desastre para algo que la gente ha estado haciendo desde ios6. Apple siempre sale con alguna forma, pero nunca es del todo correcta.
GregP
1
Puede crear una instancia manual de la celda de tamaño en viewDidLoad y mantener en ella ver una propiedad personalizada. Es solo una celda, por lo que es barato. Dado que solo lo está usando para dimensionar y está administrando toda la interacción con él, no necesita participar en el mecanismo de reutilización de celdas de la vista de colección, por lo que no necesita obtenerlo desde la vista de colección en sí.
alga
1
¿Cómo diablos se pueden romper las células de tamaño propio?
Reuben Scratton
50

Hay una forma más limpia de hacer esto que algunas de las otras respuestas aquí, y funciona bien. Debe ser eficaz (las vistas de la colección se cargan rápido, sin pases de diseño automático innecesarios, etc.) y no tiene ningún "número mágico" como un ancho de vista de colección fijo. Cambiar el tamaño de la vista de la colección, por ejemplo, en rotación, y luego invalidar el diseño también debería funcionar muy bien.

1. Cree la siguiente subclase de diseño de flujo

class HorizontallyFlushCollectionViewFlowLayout: UICollectionViewFlowLayout {

    // Don't forget to use this class in your storyboard (or code, .xib etc)

    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = super.layoutAttributesForItemAtIndexPath(indexPath)?.copy() as? UICollectionViewLayoutAttributes
        guard let collectionView = collectionView else { return attributes }
        attributes?.bounds.size.width = collectionView.bounds.width - sectionInset.left - sectionInset.right
        return attributes
    }

    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let allAttributes = super.layoutAttributesForElementsInRect(rect)
        return allAttributes?.flatMap { attributes in
            switch attributes.representedElementCategory {
            case .Cell: return layoutAttributesForItemAtIndexPath(attributes.indexPath)
            default: return attributes
            }
        }
    }
}

2. Registre su vista de colección para el tamaño automático

// The provided size should be a plausible estimate of the actual
// size. You can set your item size in your storyboard
// to a good estimate and use the code below. Otherwise,
// you can provide it manually too, e.g. CGSize(width: 100, height: 100)

flowLayout.estimatedItemSize = flowLayout.itemSize

3. Use el ancho predefinido + alto personalizado en su subclase de celda

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    layoutAttributes.bounds.size.height = systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
    return layoutAttributes
}
Jordan Smith
fuente
8
Esta es la mejor respuesta que he encontrado sobre el tema del tamaño propio. ¡Gracias Jordan!
haik.ampardjian
1
No se puede hacer que esto funcione en iOS (9.3). En 10 funciona bien. La aplicación se bloquea en _updateVisibleCells (¿recursividad infinita?). openradar.me/25303115
cristiano2lopes
@ cristiano2lopes me funciona bien en iOS 9.3. Asegúrese de proporcionar una estimación razonable y asegúrese de que las restricciones de su celda estén configuradas para resolver a un tamaño correcto.
Jordan Smith
Esto funciona de maravilla. Estoy usando Xcode 8 y lo probé en iOS 9.3.3 (dispositivo físico) ¡y no falla! Funciona genial. ¡Solo asegúrese de que su estimación sea buena!
hahmed
1
@JordanSmith Esto no funciona en iOS 10. ¿Tiene alguna demostración de muestra? He intentado muchas cosas para hacer la altura de la celda de la vista de colección en función de su contenido, pero nada. Estoy usando el diseño automático en iOS 10 y swift 3.
Ekta Padaliya
6

Una forma sencilla de hacerlo en iOS 9 en unas pocas líneas de códigos: el ejemplo de la forma horizontal (fijando su altura a la altura de la Vista de colección):

Inicie su diseño de flujo de vista de colección con un estimatedItemSizepara habilitar la celda de tamaño propio:

self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
self.estimatedItemSize = CGSizeMake(1, 1);

Implemente el Delegado de diseño de vista de colección (en su Controlador de vista la mayor parte del tiempo) collectionView:layout:sizeForItemAtIndexPath:,. El objetivo aquí es establecer la altura (o ancho) fija en la dimensión Vista de colección. El valor 10 puede ser cualquier cosa, pero debe establecerlo en un valor que no rompa las restricciones:

- (CGSize)collectionView:(UICollectionView *)collectionView
                  layout:(UICollectionViewLayout *)collectionViewLayout
  sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return CGSizeMake(10, CGRectGetHeight(collectionView.bounds));
}

Anule su preferredLayoutAttributesFittingAttributes:método de celda personalizado , esta parte realmente calcula el ancho de su celda dinámica en función de sus restricciones de diseño automático y la altura que acaba de establecer:

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
    UICollectionViewLayoutAttributes *attributes = [layoutAttributes copy];
    float desiredWidth = [self.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].width;
    CGRect frame = attributes.frame;
    frame.size.width = desiredWidth;
    attributes.frame = frame;
    return attributes;
}
Tanguy G.
fuente
¿Sabes si en iOS9 este método más simple funciona correctamente cuando la celda incluye una multilínea UILabel, cuyo salto de línea afecta la altura intrínseca de la celda? Este es un caso común que solía requerir todos los backflips que describo. Me pregunto si iOS lo ha solucionado desde mi respuesta.
alga
2
@algal Lamentablemente, la respuesta es no.
jamesk
4

Intente arreglar su ancho en los atributos de diseño preferidos:

- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes {
    UICollectionViewLayoutAttributes *attributes = [[super preferredLayoutAttributesFittingAttributes:layoutAttributes] copy];
    CGSize newSize = [self systemLayoutSizeFittingSize:CGSizeMake(FIXED_WIDTH,layoutAttributes.size) withHorizontalFittingPriority:UILayoutPriorityRequired verticalFittingPriority:UILayoutPriorityFittingSizeLevel];
    CGRect newFrame = attr.frame;
    newFrame.size.height = size.height;
    attr.frame = newFrame;
    return attr;
}

Naturalmente, también desea asegurarse de configurar su diseño correctamente para:

UICollectionViewFlowLayout *flowLayout = (UICollectionViewFlowLayout *) self.collectionView.collectionViewLayout;
flowLayout.estimatedItemSize = CGSizeMake(FIXED_WIDTH, estimatedHeight)];

Aquí hay algo que puse en Github que usa celdas de ancho constante y admite tipo dinámico para que la altura de las celdas se actualice a medida que cambia el tamaño de fuente del sistema.

Daniel Galasko
fuente
En este fragmento, llama systemLayoutSizeFittingSize :. ¿Ha logrado que esto funcione sin también configurar preferidoMaxLayoutWidth en alguna parte? En mi experiencia, si no está configurando favoriteMaxLayoutWidth, entonces necesita hacer un diseño manual adicional, por ejemplo, anulando sizeThatFits: con cálculos que reproducen la lógica de las restricciones de diseño automático definidas en otro lugar.
alga
@algal favoriteMaxLayoutWidth es una propiedad en UILabel? ¿No estás del todo seguro de qué importancia tiene eso?
Daniel Galasko
¡Ups! Buen punto, ¡no lo es! Nunca he manejado esto con un UITextView, así que no me di cuenta de que sus API diferían allí. Parece que UITextView.systemLayoutSizeFittingSize respeta su marco ya que no tiene intrinsicContentSize. Pero UILabel.systemLayoutSizeFittingSize ignora el marco, ya que tiene un intrinsicContentSize, por lo que es necesario favoriteMaxLayoutWidth para obtener intrinsicContentSize para exponer su altura envuelta a AL. Todavía parece que UITextView necesitará dos pases o diseño manual, pero no creo que entienda todo esto por completo.
alga
@algal Estoy de acuerdo, también he experimentado algunos inconvenientes peludos al usar UILabel. La configuración de su ancho preferido resuelve el problema, pero sería bueno tener un enfoque más dinámico ya que este ancho debe escalar con su supervista. La mayoría de mis proyectos no usan la propiedad preferida, generalmente la uso como una solución rápida, ya que siempre hay un problema más profundo
Daniel Galasko
0

SÍ, se puede hacer usando el diseño automático mediante programación y estableciendo restricciones en el guión gráfico o xib. Debe agregar una restricción para que el tamaño del ancho permanezca constante y establecer una altura mayor o igual a.
http://www.thinkandbuild.it/learn-to-love-auto-layout-programmatic/

http://www.cocoanetics.com/2013/08/variable-sized-items-in-uicollectionview/
Espero que esto sea útil y resuelve tu problema.

Dhaivat Vyas
fuente
3
Esto debería funcionar para un valor de ancho absoluto, pero en el caso de que desee que la celda tenga siempre el ancho completo de la vista de colección, no funcionará.
Michael Waterfall
@MichaelWaterfall Me las arreglé para que funcionara con todo el ancho usando AutoLayout. Agregué una restricción de ancho en mi vista de contenido dentro de la celda. Luego, en cellForItemAtIndexPathactualizar la restricción: cell.constraintItemWidth.constant = UIScreen.main.bounds.width. Mi aplicación es solo portátil. Probablemente desee hacerlo reloadDatadespués de un cambio de orientación para actualizar la restricción.
Flitskikker