Spécification d'une dimension de cellule dans UICollectionView à l'aide de la disposition automatique

113

Dans iOS 8, il UICollectionViewFlowLayoutprend en charge le redimensionnement automatique des cellules en fonction de leur propre taille de contenu. Cela redimensionne les cellules en largeur et en hauteur en fonction de leur contenu.

Est-il possible de spécifier une valeur fixe pour la largeur (ou la hauteur) de toutes les cellules et permettre aux autres dimensions de se redimensionner?

Pour un exemple simple, considérons une étiquette multiligne dans une cellule avec des contraintes le positionnant sur les côtés de la cellule. L'étiquette multiligne peut être redimensionnée de différentes manières pour accueillir le texte. La cellule doit remplir la largeur de la vue de collection et ajuster sa hauteur en conséquence. Au lieu de cela, les cellules sont dimensionnées au hasard et cela provoque même un plantage lorsque la taille de la cellule est plus grande que la dimension non défilable de la vue de collection.


iOS 8 introduit la méthode systemLayoutSizeFittingSize: withHorizontalFittingPriority: verticalFittingPriority:Pour chaque cellule de la vue de collection, la mise en page appelle cette méthode sur la cellule, en transmettant la taille estimée. Ce qui aurait du sens pour moi serait de remplacer cette méthode sur la cellule, de passer la taille qui est donnée et de définir la contrainte horizontale sur obligatoire et une priorité faible sur la contrainte verticale. De cette façon, la taille horizontale est fixée à la valeur définie dans la mise en page et la taille verticale peut être flexible.

Quelque chose comme ça:

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

Les tailles rendues par cette méthode, cependant, sont complètement étranges. La documentation sur cette méthode est très peu claire pour moi et mentionne l'utilisation des constantes UILayoutFittingCompressedSize UILayoutFittingExpandedSizequi représentent juste une taille zéro et une taille assez grande.

Le sizeparamètre de cette méthode est-il vraiment juste un moyen de passer deux constantes? N'y a-t-il aucun moyen d'obtenir le comportement que j'attends d'obtenir la hauteur appropriée pour une taille donnée?


Solutions alternatives

1) L'ajout de contraintes qui spécifieront une largeur spécifique pour la cellule permet d'obtenir la disposition correcte. C'est une mauvaise solution car cette contrainte doit être définie sur la taille de la vue de collection de la cellule à laquelle elle n'a aucune référence sûre. La valeur de cette contrainte peut être transmise lorsque la cellule est configurée, mais cela semble également complètement contre-intuitif. Ceci est également gênant car l'ajout de contraintes directement à une cellule ou à sa vue de contenu pose de nombreux problèmes.

2) Utilisez une vue de table. Les vues de tableau fonctionnent de cette façon dès la sortie de la boîte car les cellules ont une largeur fixe, mais cela ne conviendrait pas à d'autres situations comme une disposition iPad avec des cellules de largeur fixe dans plusieurs colonnes.

Anthony Mattox
la source

Réponses:

135

Il semble que ce que vous demandez est un moyen d'utiliser UICollectionView pour produire une mise en page comme UITableView. Si c'est vraiment ce que vous voulez, la bonne façon de le faire est d'utiliser une sous-classe UICollectionViewLayout personnalisée (peut-être quelque chose comme SBTableLayout ).

D'un autre côté, si vous demandez vraiment s'il existe un moyen propre de le faire avec le UICollectionViewFlowLayout par défaut, alors je pense qu'il n'y a aucun moyen. Même avec les cellules auto-dimensionnantes d'iOS8, ce n'est pas simple. Le problème fondamental, comme vous le dites, est que la machinerie de la disposition des flux ne fournit aucun moyen de fixer une dimension et de laisser une autre répondre. (En outre, même si vous le pouviez, il y aurait une complexité supplémentaire liée à la nécessité de deux passes de mise en page pour dimensionner les étiquettes multilignes. Cela pourrait ne pas correspondre à la façon dont les cellules auto-dimensionnantes veulent calculer tout le dimensionnement via un appel à systemLayoutSizeFittingSize.)

Cependant, si vous souhaitez toujours créer une disposition de type tableau avec une disposition de flux, avec des cellules qui déterminent leur propre taille et répondent naturellement à la largeur de la vue de la collection, bien sûr, c'est possible. Il y a toujours la manière désordonnée. Je l'ai fait avec une "cellule de dimensionnement", c'est-à-dire une UICollectionViewCell non affichée que le contrôleur ne conserve que pour calculer la taille des cellules.

Cette approche comporte deux volets. La première partie consiste pour le délégué de vue de collection à calculer la taille de cellule correcte, en prenant la largeur de la vue de collection et en utilisant la cellule de dimensionnement pour calculer la hauteur de la cellule.

Dans votre UICollectionViewDelegateFlowLayout, vous implémentez une méthode comme celle-ci:

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
}

Cela obligera la vue de collection à définir la largeur de la cellule en fonction de la vue de collection englobante, mais demandera ensuite à la cellule de dimensionnement de calculer la hauteur.

La deuxième partie consiste à faire en sorte que la cellule de dimensionnement utilise ses propres contraintes AL pour calculer la hauteur. Cela peut être plus difficile qu'il ne devrait l'être, en raison de la manière dont les UILabel multilignes nécessitent effectivement un processus de mise en page en deux étapes. Le travail se fait dans la méthode preferredLayoutSizeFittingSize, qui est comme ceci:

 /*
 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
 }

(Ce code est adapté de l'exemple de code Apple de l'exemple de code de la session WWDC2014 sur «Advanced Collection View».)

Quelques points à remarquer. Il utilise layoutIfNeeded () pour forcer la disposition de la cellule entière, afin de calculer et de définir la largeur de l'étiquette. Mais cela ne suffit pas. Je pense que vous devez également définir preferredMaxLayoutWidthpour que l'étiquette utilise cette largeur avec la mise en page automatique. Et ce n'est qu'alors que vous pourrez utiliser systemLayoutSizeFittingSizepour que la cellule calcule sa hauteur tout en tenant compte de l'étiquette.

Est-ce que j'aime cette approche? Non!! Cela semble beaucoup trop complexe et fait deux fois la mise en page. Mais tant que les performances ne deviennent pas un problème, je préfère effectuer la mise en page deux fois au moment de l'exécution plutôt que de la définir deux fois dans le code, ce qui semble être la seule autre alternative.

J'espère que finalement les cellules auto-dimensionnantes fonctionneront différemment et que tout deviendra beaucoup plus simple.

Exemple de projet le montrant au travail.

Mais pourquoi ne pas utiliser uniquement des cellules auto-dimensionnantes?

En théorie, les nouvelles fonctionnalités d'iOS8 pour les «cellules auto-dimensionnantes» devraient rendre cela inutile. Si vous avez défini une cellule avec Mise en page automatique (AL), la vue de collection doit être suffisamment intelligente pour lui permettre de se dimensionner et de se disposer correctement. En pratique, je n'ai vu aucun exemple qui a permis à cela de fonctionner avec des étiquettes multilignes. Je pense que c'est en partie parce que le mécanisme cellulaire auto-dimensionnement est encore buggy.

Mais je parie que c'est principalement à cause de la complexité habituelle de la mise en page automatique et des étiquettes, à savoir que les UILabels nécessitent un processus de mise en page en deux étapes. Je ne vois pas comment vous pouvez effectuer les deux étapes avec des cellules auto-dimensionnantes.

Et comme je l'ai dit, c'est vraiment un travail pour une mise en page différente. Cela fait partie de l'essence de la disposition de flux que de positionner les choses qui ont une taille, plutôt que de fixer une largeur et de leur permettre de choisir leur hauteur.

Et qu'en est-il de PreferredLayoutAttributesFittingAttributes:?

La preferredLayoutAttributesFittingAttributes:méthode est un hareng rouge, je pense. C'est seulement là pour être utilisé avec le nouveau mécanisme de cellule auto-dimensionnable. Ce n'est donc pas la réponse tant que ce mécanisme n'est pas fiable.

Et quoi de neuf avec systemlayoutSizeFittingSize:?

Vous avez raison, les documents sont déroutants.

Les documents sur systemLayoutSizeFittingSize:et systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:tous les deux suggèrent que vous ne devriez passer UILayoutFittingCompressedSizeet UILayoutFittingExpandedSizeque le targetSize. Cependant, la signature de la méthode elle-même, les commentaires d'en-tête et le comportement des fonctions indiquent qu'elles répondent à la valeur exacte du targetSizeparamètre.

En fait, si vous définissez le UICollectionViewFlowLayoutDelegate.estimatedItemSize, afin d'activer le nouveau mécanisme de cellule auto-dimensionnable, cette valeur semble être passée en tant que targetSize. Et UILabel.systemLayoutSizeFittingSizesemble renvoyer exactement les mêmes valeurs que UILabel.sizeThatFits. Ceci est suspect, étant donné que l'argument to systemLayoutSizeFittingSizeest censé être une cible approximative et l'argument to sizeThatFits:est censé être une taille maximale circonscrite.

Plus de ressources

S'il est triste de penser qu'une telle exigence de routine devrait exiger des «ressources de recherche», je pense que c'est le cas. Les bons exemples et discussions sont:

algue
la source
Pourquoi remettez-vous le cadre à l'ancien cadre?
vrwim
Je fais cela pour que la fonction ne puisse être utilisée que pour déterminer la taille de mise en page préférée de la vue, sans affecter l'état de mise en page de la vue dans laquelle vous l'appelez. En pratique, cela n'a pas d'importance si vous maintenez cette vue uniquement dans le but de faire des calculs de mise en page. De plus, ce que je fais ici est insuffisant. La restauration du cadre seul ne le restaure pas vraiment à son état précédent, car l'exécution de la mise en page peut avoir modifié la mise en page des sous-vues.
algal
1
C'est un tel gâchis pour quelque chose que les gens font depuis ios6. Apple propose toujours un moyen, mais ce n'est jamais tout à fait correct.
GregP
1
Vous pouvez instancier manuellement la cellule de dimensionnement dans viewDidLoad et y maintenir une propriété personnalisée. Ce n'est qu'une cellule, donc c'est bon marché. Étant donné que vous ne l'utilisez que pour le dimensionnement et que vous gérez toutes les interactions avec elle, elle n'a pas besoin de participer au mécanisme de réutilisation des cellules de la vue de collection, vous n'avez donc pas besoin de l'obtenir à partir de la vue de collection elle-même.
algal
1
Comment diable les cellules auto-dimensionnantes peuvent-elles encore être brisées?!
Reuben Scratton
50

Il existe une façon plus propre de faire cela que certaines des autres réponses ici, et cela fonctionne bien. Il doit être performant (les vues de collection se chargent rapidement, pas de passes de mise en page automatique inutiles, etc.), et n'a pas de «nombres magiques» comme une largeur de vue de collection fixe. Changer la taille de la vue de la collection, par exemple lors de la rotation, puis invalider la mise en page devrait également fonctionner très bien.

1. Créez la sous-classe de disposition de flux suivante

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. Enregistrez votre vue de collection pour le dimensionnement automatique

// 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. Utilisez la largeur prédéfinie + la hauteur personnalisée dans votre sous-classe de cellules

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    layoutAttributes.bounds.size.height = systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
    return layoutAttributes
}
Jordan Smith
la source
8
C'est la meilleure réponse que j'ai trouvée sur le sujet de l'auto-dimensionnement. Merci Jordan!
haik.ampardjian
1
Impossible de faire fonctionner cela sur iOS (9.3). Sur 10, cela fonctionne très bien. L'application plante sur _updateVisibleCells (récursion infinie?). openradar.me/25303115
cristiano2lopes
@ cristiano2lopes cela fonctionne bien pour moi sur iOS 9.3. Assurez-vous de fournir une estimation raisonnable et assurez-vous que vos contraintes de cellule sont configurées pour être résolues à une taille correcte.
Jordan Smith
Cela fonctionne à merveille. J'utilise Xcode 8 et testé sous iOS 9.3.3 (appareil physique) et il ne plante pas! Fonctionne très bien. Assurez-vous simplement que votre estimation est bonne!
hahmed
1
@JordanSmith Cela ne fonctionne pas sous iOS 10. Avez-vous un exemple de démo. J'ai essayé beaucoup de choses pour faire la hauteur de la cellule de vue de la collection en fonction de son contenu, mais rien. J'utilise la mise en page automatique dans iOS 10 et
j'utilise
6

Un moyen simple de le faire dans iOS 9 en quelques lignes de codes - l'exemple de chemin horizontal (fixer sa hauteur à sa hauteur de vue de collection):

Lancez votre disposition de flux d'affichage de collection avec une estimatedItemSizepour activer la cellule d'auto-dimensionnement:

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

Implémentez le délégué de disposition de vue de collection (dans votre contrôleur de vue la plupart du temps) collectionView:layout:sizeForItemAtIndexPath:,. Le but ici est de définir la hauteur (ou largeur) fixe sur la dimension Vue de collection. La valeur 10 peut être n'importe quoi, mais vous devez la définir sur une valeur qui ne rompt pas les contraintes:

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

Remplacez votre preferredLayoutAttributesFittingAttributes:méthode de cellule personnalisée , cette partie calcule en fait la largeur de votre cellule dynamique en fonction de vos contraintes de disposition automatique et de la hauteur que vous venez de définir:

- (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.
la source
Savez-vous si sur iOS9 cette méthode plus simple fonctionne correctement lorsque la cellule comprend une multi-ligne UILabel, dont le saut de ligne affecte la hauteur intrinsèque de la cellule? C'est un cas courant qui nécessitait tous les backflips que je décris. Je me demande si iOS l'a corrigé depuis ma réponse.
algal
2
@algal Malheureusement, la réponse est non.
jamesk
4

Essayez de fixer votre largeur dans les attributs de mise en page préférés:

- (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;
}

Naturellement, vous voulez également vous assurer que vous configurez correctement votre mise en page pour:

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

Voici quelque chose que j'ai mis sur Github qui utilise des cellules à largeur constante et prend en charge le type dynamique afin que la hauteur des cellules se mette à jour à mesure que la taille de la police du système change.

Daniel Galasko
la source
Dans cet extrait de code, vous appelez systemLayoutSizeFittingSize:. Avez-vous réussi à faire fonctionner cela sans définir également favoriteMaxLayoutWidth quelque part? D'après mon expérience, si vous ne définissez pas preferMaxLayoutWidth, vous devez effectuer une mise en page manuelle supplémentaire, par exemple en remplaçant sizeThatFits: avec des calculs qui reproduisent la logique des contraintes de mise en page automatique définies ailleurs.
algal
@algal preferMaxLayoutWidth est une propriété sur UILabel? Vous ne savez pas exactement comment cela est pertinent?
Daniel Galasko
Oups! Bon point, ce n'est pas! Je n'ai jamais géré cela avec un UITextView, donc je n'ai pas réalisé que leurs API différaient là-bas. On dirait que UITextView.systemLayoutSizeFittingSize respecte son cadre car il n'a pas intrinsicContentSize. Mais UILabel.systemLayoutSizeFittingSize ignore le cadre, car il a un intrinsicContentSize, donc preferMaxLayoutWidth est nécessaire pour obtenir intrinsicContentSize pour exposer sa hauteur encapsulée à AL. Il semble toujours que UITextView aura besoin de deux passes ou d'une mise en page manuelle, mais je ne pense pas que je comprends tout cela complètement.
algal
@algal Je suis d'accord, j'ai également connu des accrocs poilus lors de l'utilisation d'UILabel. Le paramétrage de sa largeur préférée résout le problème, mais il serait bien d'avoir une approche plus dynamique car cette largeur doit être mise à l'échelle avec sa vue. La plupart de mes projets n'utilisent pas la propriété préférée, je l'utilise généralement comme solution rapide car il y a toujours un problème plus profond
Daniel Galasko
0

OUI, cela peut être fait en utilisant la mise en page automatique par programme et en définissant des contraintes dans le storyboard ou xib. Vous devez ajouter une contrainte pour que la taille de la largeur reste constante et définir une hauteur supérieure ou égale à.
http://www.thinkandbuild.it/learn-to-love-auto-layout-programmatically/

http://www.cocoanetics.com/2013/08/variable-sized-items-in-uicollectionview/
J'espère que ce sera utile et résolvez votre problème.

Dhaivat Vyas
la source
3
Cela devrait fonctionner pour une valeur de largeur absolue, mais dans le cas où vous voulez que la cellule soit toujours la pleine largeur de la vue de collection, cela ne fonctionnera pas.
Michael Waterfall
@MichaelWaterfall J'ai réussi à le faire fonctionner en pleine largeur en utilisant AutoLayout. J'ai ajouté une contrainte de largeur sur ma vue de contenu à l'intérieur de la cellule. Puis, cellForItemAtIndexPathmettre à jour la contrainte: cell.constraintItemWidth.constant = UIScreen.main.bounds.width. Mon application est uniquement en mode portrait. Vous voudrez probablement le faire reloadDataaprès un changement d'orientation pour mettre à jour la contrainte.
Flitskikker