Especificando uma dimensão de células em UICollectionView usando Auto Layout

113

No iOS 8, o UICollectionViewFlowLayoutsuporta o redimensionamento automático de células com base em seu próprio tamanho de conteúdo. Isso redimensiona as células em largura e altura de acordo com seu conteúdo.

É possível especificar um valor fixo para a largura (ou altura) de todas as células e permitir que as outras dimensões sejam redimensionadas?

Para um exemplo simples, considere um rótulo de várias linhas em uma célula com restrições posicionando-o nas laterais da célula. A etiqueta de várias linhas pode ser redimensionada de maneiras diferentes para acomodar o texto. A célula deve preencher a largura da visualização da coleção e ajustar sua altura de acordo. Em vez disso, as células são dimensionadas ao acaso e até mesmo causa uma falha quando o tamanho da célula é maior do que a dimensão não rolável da exibição da coleção.


iOS 8 apresenta o método systemLayoutSizeFittingSize: withHorizontalFittingPriority: verticalFittingPriority:Para cada célula na visualização da coleção, o layout chama esse método na célula, passando o tamanho estimado. O que faria sentido para mim seria substituir esse método na célula, passar o tamanho que é fornecido e definir a restrição horizontal como necessária e uma baixa prioridade para a restrição vertical. Desta forma, o tamanho horizontal é fixado ao valor definido no layout e o tamanho vertical pode ser flexível.

Algo assim:

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

Os tamanhos devolvidos por este método, entretanto, são completamente estranhos. A documentação sobre este método não é muito clara para mim e menciona o uso de constantes UILayoutFittingCompressedSize UILayoutFittingExpandedSizeque representam apenas um tamanho zero e um muito grande.

O sizeparâmetro deste método é realmente apenas uma maneira de passar duas constantes? Não há como atingir o comportamento que espero de obter a altura apropriada para um determinado tamanho?


Soluções Alternativas

1) Adicionar restrições que irão especificar uma largura específica para a célula atinge o layout correto. Esta é uma solução pobre porque essa restrição deve ser definida para o tamanho da visualização de coleção da célula para a qual não há referência segura. O valor dessa restrição pode ser passado quando a célula é configurada, mas isso também parece totalmente contra-intuitivo. Isso também é estranho porque adicionar restrições diretamente a uma célula ou a sua visualização de conteúdo está causando muitos problemas.

2) Use uma visão de tabela. As visualizações de tabela funcionam assim fora da caixa, pois as células têm uma largura fixa, mas isso não acomodaria outras situações como um layout de iPad com células de largura fixa em várias colunas.

Anthony Mattox
fonte

Respostas:

135

Parece que o que você está pedindo é uma maneira de usar UICollectionView para produzir um layout como UITableView. Se isso é realmente o que você deseja, a maneira certa de fazer isso é com uma subclasse UICollectionViewLayout personalizada (talvez algo como SBTableLayout ).

Por outro lado, se você está realmente perguntando se há uma maneira limpa de fazer isso com o UICollectionViewFlowLayout padrão, então acredito que não há maneira. Mesmo com as células autodimensionáveis ​​do iOS8, não é simples. O problema fundamental, como você disse, é que a maquinaria do layout de fluxo não fornece nenhuma maneira de fixar uma dimensão e deixar outra responder. (Além disso, mesmo se você pudesse, haveria complexidade adicional em torno da necessidade de duas passagens de layout para dimensionar os rótulos de várias linhas. Isso pode não se encaixar com a forma como as células de autodimensionamento desejam calcular todo o dimensionamento por meio de uma chamada para systemLayoutSizeFittingSize.)

No entanto, se você ainda deseja criar um layout do tipo tableview com um layout de fluxo, com células que determinam seu próprio tamanho e respondem naturalmente à largura da visualização da coleção, é claro que é possível. Ainda existe a forma complicada. Eu fiz isso com uma "célula de dimensionamento", ou seja, um UICollectionViewCell não exibido que o controlador mantém apenas para calcular o tamanho das células.

Esta abordagem tem duas partes. A primeira parte é para o delegado da visualização da coleção calcular o tamanho correto da célula, tomando a largura da visualização da coleção e usando o dimensionamento da célula para calcular a altura da célula.

Em seu UICollectionViewDelegateFlowLayout, você implementa um 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
}

Isso fará com que a visualização da coleção defina a largura da célula com base na visualização da coleção envolvente, mas, em seguida, peça à célula de dimensionamento para calcular a altura.

A segunda parte é fazer com que a célula de dimensionamento use suas próprias restrições AL para calcular a altura. Isso pode ser mais difícil do que deveria ser, devido à maneira como UILabel de várias linhas exige efetivamente um processo de layout de duas fases. O trabalho é feito no método preferredLayoutSizeFittingSize, que é assim:

 /*
 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 foi adaptado do código de amostra da Apple do código de amostra da sessão WWDC2014 em "Visualização de coleção avançada".)

Alguns pontos a serem observados. Ele está usando layoutIfNeeded () para forçar o layout de toda a célula, a fim de calcular e definir a largura do rótulo. Mas isso não é o suficiente. Acredito que você também precise definir preferredMaxLayoutWidthpara que a etiqueta use essa largura com o Layout automático. E só então você pode usar o systemLayoutSizeFittingSizepara fazer com que a célula calcule sua altura levando em consideração o rótulo.

Eu gosto dessa abordagem? Não!! Parece muito complexo e faz o layout duas vezes. Mas, contanto que o desempenho não se torne um problema, prefiro executar o layout duas vezes em tempo de execução do que defini-lo duas vezes no código, o que parece ser a única alternativa.

Minha esperança é que, eventualmente, as células autoadensáveis ​​funcionem de forma diferente e tudo fique muito mais simples.

Projeto de exemplo mostrando isso no trabalho.

Mas por que não usar células autodimensionáveis?

Em teoria, os novos recursos do iOS8 para "células autodimensionáveis" deveriam tornar isso desnecessário. Se você definiu uma célula com Auto Layout (AL), a visualização da coleção deve ser inteligente o suficiente para permitir que ela se dimensione e se disponha corretamente. Na prática, não vi nenhum exemplo que tenha feito isso funcionar com etiquetas de várias linhas. Acho que isso ocorre em parte porque o mecanismo de autodimensionamento das células ainda é problemático.

Mas eu aposto que é principalmente por causa da dificuldade usual do Auto Layout e rótulos, que é que UILabels requerem um processo de layout basicamente de duas etapas. Não está claro para mim como você pode executar as duas etapas com células autodimensionáveis.

E como eu disse, este é realmente um trabalho para um layout diferente. Faz parte da essência do layout de fluxo posicionar coisas que têm um tamanho, em vez de fixar uma largura e permitir que escolham sua altura.

E que tal preferredLayoutAttributesFittingAttributes:?

O preferredLayoutAttributesFittingAttributes:método é uma pista falsa, eu acho. Isso está lá apenas para ser usado com o novo mecanismo de célula autodimensionável. Portanto, essa não é a resposta, desde que esse mecanismo não seja confiável.

E o que está acontecendo com systemlayoutSizeFittingSize :?

Você está certo, os documentos são confusos.

Os documentos em systemLayoutSizeFittingSize:e systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:ambos sugerem que você só deve passar UILayoutFittingCompressedSizee UILayoutFittingExpandedSizecomo o targetSize. No entanto, a própria assinatura do método, os comentários do cabeçalho e o comportamento das funções indicam que elas estão respondendo ao valor exato do targetSizeparâmetro.

Na verdade, se você definir o UICollectionViewFlowLayoutDelegate.estimatedItemSize, a fim de ativar o novo mecanismo de célula de autodimensionamento, esse valor parece ser transmitido como targetSize. E UILabel.systemLayoutSizeFittingSizeparece retornar exatamente os mesmos valores que UILabel.sizeThatFits. Isso é suspeito, visto que o argumento para systemLayoutSizeFittingSizedeve ser um alvo aproximado e o argumento para sizeThatFits:deve ter um tamanho máximo circunscrito.

Mais recursos

Embora seja triste pensar que tal requisito de rotina deva exigir "recursos de pesquisa", acho que sim. Bons exemplos e discussões são:

algas
fonte
Por que você recolocou a moldura na moldura antiga?
vrwim
Eu faço isso para que a função possa ser usada apenas para determinar o tamanho de layout preferido da visualização, sem afetar o estado de layout da visualização em que você a chama. Praticamente, isso é irrelevante se você estiver mantendo esta visualização apenas com o propósito de fazendo cálculos de layout. Além disso, o que faço aqui é insuficiente. Restaurar apenas o quadro não o restaura ao estado anterior, pois a execução do layout pode ter modificado os layouts das subvisualizações.
algal
1
Isso é uma bagunça para algo que as pessoas têm feito desde iOS6. A Apple sempre apresenta alguma maneira, mas nunca é totalmente certa.
GregP,
1
Você pode instanciar manualmente a célula de dimensionamento em viewDidLoad e manter nela a visualização de uma propriedade customizada. É apenas uma célula, então é barato. Como você o está usando apenas para dimensionamento e gerenciando toda a interação com ele, ele não precisa participar do mecanismo de reutilização de células da visualização da coleção, portanto, não é necessário obtê-lo na própria visualização da coleção.
algal
1
Como diabos as células autodimensionadas ainda podem ser quebradas ?!
Reuben Scratton
50

Há uma maneira mais limpa de fazer isso do que algumas das outras respostas aqui, e funciona bem. Deve ter desempenho (visualizações de coleção carregam rápido, sem passes de layout automáticos desnecessários etc), e não tem nenhum 'número mágico' como uma largura de visualização de coleção fixa. Alterar o tamanho da visualização da coleção, por exemplo, na rotação, e então invalidar o layout deve funcionar muito bem.

1. Crie a seguinte subclasse de layout de fluxo

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 sua visualização de coleção para dimensionamento 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 a largura predefinida + altura personalizada em sua subclasse de célula

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    layoutAttributes.bounds.size.height = systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
    return layoutAttributes
}
Jordan Smith
fonte
8
Esta é a melhor resposta que encontrei no tópico de autodimensionamento. Obrigada Jordan!
haik.ampardjian
1
Não é possível fazer isso funcionar no iOS (9.3). No 10 funciona bem. O aplicativo falha em _updateVisibleCells (recursão infinita?). openradar.me/25303115
cristiano2lopes
@ cristiano2lopes funciona bem para mim no iOS 9.3. Certifique-se de fornecer uma estimativa razoável e certifique-se de que suas restrições de célula estão configuradas para resolver para um tamanho correto.
Jordan Smith
Isso funciona muito bem. Estou usando o Xcode 8 e testei no iOS 9.3.3 (dispositivo físico) e ele não trava! Funciona bem. Apenas certifique-se de que sua estimativa é boa!
hahmed
1
@JordanSmith Isso não está funcionando no iOS 10. Você tem algum exemplo de demonstração. Eu tentei muitas coisas para fazer a altura da célula de exibição de coleção com base em seu conteúdo, mas nada. Estou usando o layout automático no iOS 10 e usando o swift 3.
Ekta Padaliya
6

Uma maneira simples de fazer isso no iOS 9 em algumas linhas de códigos - a forma horizontal, por exemplo (fixando sua altura para a altura da visualização da coleção):

Inicie seu layout de fluxo de visualização de coleção com um estimatedItemSizepara habilitar a célula de autodimensionamento:

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

Implemente o Delegado de Layout de Visualização de Coleção (em seu Controlador de Visualização na maioria das vezes), collectionView:layout:sizeForItemAtIndexPath: ,. O objetivo aqui é definir a altura fixa (ou largura) para a dimensão da Visualização da coleção. O valor 10 pode ser qualquer coisa, mas você deve defini-lo como um valor que não quebre as restrições:

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

Substitua seu preferredLayoutAttributesFittingAttributes:método de célula personalizado , esta parte realmente calcula a largura da célula dinâmica com base nas restrições de layout automático e na altura que você acabou de definir:

- (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.
fonte
Você sabe se no iOS9 esse método mais simples funciona corretamente quando a célula inclui uma multilinha UILabel, cuja quebra de linha afeta a altura intrínseca da célula? Este é um caso comum que exigia todos os backflips que eu descrevo. Estou me perguntando se o iOS corrigiu isso desde minha resposta.
algal
2
@algal Infelizmente, a resposta é não.
Jamesk
4

Tente fixar sua largura nos atributos de layout 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, você também deseja garantir que configurou seu layout corretamente para:

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

Aqui está algo que coloquei no Github que usa células de largura constante e suporta tipo dinâmico, de modo que a altura das células é atualizada conforme o tamanho da fonte do sistema muda.

Daniel Galasko
fonte
Neste snippet, você chama systemLayoutSizeFittingSize :. Você conseguiu fazer isso funcionar sem também definir preferredMaxLayoutWidth em algum lugar? Na minha experiência, se você não estiver definindo preferredMaxLayoutWidth, precisa fazer um layout manual adicional, por exemplo, substituindo sizeThatFits: com cálculos que reproduzem a lógica das restrições de layout automático definidas em outro lugar.
algal
@algal preferredMaxLayoutWidth é uma propriedade em UILabel? Não tem certeza de como isso é relevante?
Daniel Galasko
Ops! Bom ponto, não é! Eu nunca lidei com isso com um UITextView, então não percebi que suas APIs eram diferentes aqui. Parece que UITextView.systemLayoutSizeFittingSize respeita seu quadro, pois não tem intrinsicContentSize. Mas UILabel.systemLayoutSizeFittingSize ignora o quadro, pois tem um intrinsicContentSize, portanto, preferredMaxLayoutWidth é necessário para obter intrinsicContentSize para expor sua altura encapsulada para AL. Ainda parece que UITextView vai precisar de duas passagens ou layout manual, mas eu não acho que entendo tudo isso completamente.
algal
@algal Posso concordar, também experimentei alguns empecilhos ao usar o UILabel. Configurar sua largura preferida resolve o problema, mas seria bom ter uma abordagem mais dinâmica, pois essa largura precisa ser dimensionada com sua visão geral. A maioria dos meus projetos não usa a propriedade preferida, geralmente eu uso isso como uma solução rápida, pois há sempre um problema mais profundo
Daniel Galasko
0

SIM, pode ser feito usando o layout automático de forma programática e definindo restrições no storyboard ou xib. Você precisa adicionar uma restrição para o tamanho da largura permanecer constante e definir a altura maior ou igual a.
http://www.thinkandbuild.it/learn-to-love-auto-layout-programmatically/

http://www.cocoanetics.com/2013/08/variable-sized-items-in-uicollectionview/
Espero que seja útil e resolver seu problema.

Dhaivat Vyas
fonte
3
Isso deve funcionar para um valor de largura absoluto, mas no caso de você querer que a célula sempre tenha a largura total da visualização da coleção, não funcionará.
Michael Waterfall de
@MichaelWaterfall Consegui fazê-lo funcionar com largura total usando AutoLayout. Eu adicionei uma restrição de largura na minha visualização de conteúdo dentro da célula. Então, em cellForItemAtIndexPathatualizar a restrição: cell.constraintItemWidth.constant = UIScreen.main.bounds.width. Meu aplicativo é apenas para retratos. Você provavelmente deseja, reloadDataapós uma mudança de orientação, atualizar a restrição.
Flitskikker de