I need to create an UICollectionView with variable cell height but keeping a fixed vertical gap between cells. Something like this
I found a lot of examples, tutorials and libraries out there but all these need to implement a delegate method providing the height for each item.
My cells have a number of labels which can have one or more lines so I don't know in advance the final height of an element after the cell layouts its views.
Is there any way to achieve what I need?
You can calculate your cell height with systemLayoutSizeFitting function.
https://developer.apple.com/documentation/uikit/uiview/1622623-systemlayoutsizefitting
For example: (UICollectionViewCell is a subclass of UICollectionReusableView so you can use this for cell and header/footer too)
public extension UICollectionReusableView {
static func autoResizingView<T: UICollectionReusableView>(type: T.Type) -> T {
let nibViews = Bundle.main.loadNibNamed(T.identifier, owner: nil, options: nil)
return nibViews?.first as? T ?? T()
}
static func autoLayoutSize<T: UICollectionReusableView>(type: T.Type, targetWidth: CGFloat, configure: ((T) -> Void)?) -> CGSize {
let resizingView = UICollectionReusableView.autoResizingView(type: type)
resizingView.prepareForReuse()
configure?(resizingView)
resizingView.setNeedsLayout()
resizingView.layoutIfNeeded()
let targetSize = CGSize(width: targetWidth, height: 0)
let calculateView: UIView
if let contentView = (resizingView as? UICollectionViewCell)?.contentView {
calculateView = contentView
} else {
calculateView = resizingView
}
// Calculate the size (height) using Auto Layout
let autoLayoutSize = calculateView.systemLayoutSizeFitting(
targetSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .defaultLow)
return autoLayoutSize
}
}
Related
I have a UICollectionView with self-sizing cells. All works fine. I have a like button inside the cell that when it is tapped, the global number of likes updates and change it's height accordingly.
Here I provide a video: https://vimeo.com/732687527
When I tap like and the height changes, it's not updated at runtime but if I fetch again the post, the cell has the correct height.
I did a little bit of debug and when the collectionView cells are fetched for the first time, they call the function inside the cell:
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let autoLayoutAttributes = super.preferredLayoutAttributesFitting(layoutAttributes)
let targetSize = CGSize(width: layoutAttributes.frame.width, height: 0)
let autoLayoutSize = cellContentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriority.required, verticalFittingPriority: UILayoutPriority.defaultLow)
let autoLayoutFrame = CGRect(origin: autoLayoutAttributes.frame.origin, size: CGSize(width: autoLayoutSize.width, height: autoLayoutSize.height + 40))
autoLayoutAttributes.frame = autoLayoutFrame
return autoLayoutAttributes
}
to calculate it's height automatically.
When I tap the like button, I set the cell viewModel with the new value and the cell configure function is called, updating the height and reflecting it
var viewModel: PostViewModel? {
didSet {
configure()
}
}
func configure() {
guard let viewModel = viewModel else { return }
//Bunch of cell configuration with the viewModel info
//Here I update the constraint of the element according to the like
if viewModel.postHasInfo {
postInfoView.constrainHeight(constant: 20)
} else {
postInfoView.constrainHeight(constant: 0)
}
}
ConstrainHeight function:
extension UIView {
func constrainHeight(constant: CGFloat) {
constraints.forEach {
if $0.firstAttribute == .height {
self.removeConstraint($0)
}
}
heightAnchor.constraint(equalToConstant: constant).isActive = true
layoutIfNeeded()
}
But even tho the configure method is called and it updates the cell view content as shown in the video, the cell height is not being updated as the preferredLayoutAttributesFitting method, which defines the cell height, is not getting called.
Is there any way to force this method getting called? I don't want to reload the cell/cells if it's not mandatory. I tried:
setNeedsLayout()
invalidateIntrinsicContentSize()
layoutIfNeeded()
layoutSubviews()
when the viewModel is set to see if preferredLayoutAttributesFitting is getting called again but without any luck.
Thanks in advance :D
I'm using AutoSizing cells with Autolayout and UICollectionView.
I can specify constraints in code on cell initialization:
func configureCell() {
snp.makeConstraints { (make) in
make.width.equalToSuperview()
}
}
However, the app crashes as the cell hasn't been yet added to the collectionView.
Questions
At which stage of the cell's lifecycle it is possible to add a
constraint with cell's width?
Is there any default way of making a cell'swidthequal to the
widthof thecollectionViewwithout accessing an instance of
UIScreenorUIWindow`?
Edit
The question is not duplicate, as it is not about how to use the AutoSizing cells feature, but at which stage of the cell lifecycle to apply constraints to achieve the desired result when working with AutoLayout.
To implement self-sizing collection view cells you need to do two things:
Specify estimatedItemSize on UICollectionViewFlowLayout
Implement preferredLayoutAttributesFitting(_:) on your cell
1. Specifying estimatedItemSize on UICollectionViewFlowLayout
The default value of this property is CGSizeZero. Setting it to any other value causes the collection view to query each cell for its actual size using the cell’s preferredLayoutAttributesFitting(_:) method. If all of your cells are the same height, use the itemSize property, instead of this property, to specify the cell size instead.
This is just an estimate which is used to calculate the content size of the scroll view, set it to something sensible.
let collectionViewFlowLayout = UICollectionViewFlowLayout()
collectionViewFlowLayout.estimatedItemSize = CGSize(width: collectionView.frame.width, height: 100)
2. Implement preferredLayoutAttributesFitting(_:) on your UICollectionViewCell subclass
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let autoLayoutAttributes = super.preferredLayoutAttributesFitting(layoutAttributes)
// Specify you want _full width_
let targetSize = CGSize(width: layoutAttributes.frame.width, height: 0)
// Calculate the size (height) using Auto Layout
let autoLayoutSize = contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: UILayoutPriority.required, verticalFittingPriority: UILayoutPriority.defaultLow)
let autoLayoutFrame = CGRect(origin: autoLayoutAttributes.frame.origin, size: autoLayoutSize)
// Assign the new size to the layout attributes
autoLayoutAttributes.frame = autoLayoutFrame
return autoLayoutAttributes
}
You'll need to implement sizeForItemAt: to calculate the size.
We've also used a "sizing cell" if your cells have variable height. Eg:
class MyFancyCell: UICollectionViewCell {
class func cellSize(_ content: SomeContent, withWidth width: CGFloat) -> CGSize {
sizingCell.content = content
sizingCell.updateCellLayout(width)
return sizingCell.systemLayoutSizeFitting(UILayoutFittingExpandedSize)
}
fileprivate static let sizingCell = Bundle.main.loadNibNamed("ContentCell", owner: nil, options: nil)!.first as! ContentCell
func updateCellLayout(width: CGFloat) {
//Set constraints and calculate size
}
}
Environment:
UICollectionView that looks like UITableView
Custom UICollectionViewFlowLayout subclass to define the frame of the DecorationView
Self-Sizing cells enabled
Expected behavior:
A DecorationView that should be placed as a background for every section of the UICollectionView
Observed Behavior:
The DecorationView collapses to an arbitrary size:
Seems that UICollectionView tries to calculate an automatic size for the DecorationView. If I disable Self-Sizing cells, the decoration view is being placed exactly at the expected place.
Is there any way to disable Self-Sizing for DecorationView ?
In my UICollectionViewFlowLayout subclass I simply take the first and last cells in the section and stretch the background to fill the space underneath them. The problem is that UICollectionView does not respect the size calculated there:
override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let collectionView = collectionView else {
return nil
}
let section = indexPath.section
let attrs = UICollectionViewLayoutAttributes(forDecorationViewOfKind: backgroundViewClass.reuseIdentifier(),
with: indexPath)
let numberOfItems = collectionView.numberOfItems(inSection: section)
let lastIndex = numberOfItems - 1
guard let firstItemAttributes = layoutAttributesForItem(at: IndexPath(indexes: [section, 0])),
let lastItemAttributes = layoutAttributesForItem(at: IndexPath(indexes: [section, lastIndex])) else {
return nil
}
let startFrame = firstItemAttributes.frame
let endFrame = lastItemAttributes.frame
let origin = startFrame.origin
let size = CGSize(width: startFrame.width,
height: -startFrame.minY + endFrame.maxY)
let frame = CGRect(origin: origin, size: size)
attrs.frame = frame
attrs.zIndex = -1
return attrs
}
It's possible that the frames of your decoration views are not being updated (i.e. invalidated) after the frames of your cells have been self-sized. The result is that the width of each decoration view remains at its default size.
Try implementing this function, which should invalidate the layout of the decoration view for each section every time the layout of an item in that section is invalidated:
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
let invalidatedSections = context.invalidatedItemIndexPaths?.map { $0.section } ?? []
let decorationIndexPaths = invalidatedSections.map { IndexPath(item: 0, section: $0) }
context.invalidateDecorationElements(ofKind: backgroundViewClass.reuseIdentifier(), at: decorationIndexPaths)
super.invalidateLayout(with: context)
}
In my app I have UICollectionView with FlowLayout (vertical). and in collection cell I have label for header and textView for content. Content length may be vary, short or long. I need to implement cell's autoresizing. in my CustomCell class I overrided this method:
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
let desiredHeight = systemLayoutSizeFitting(UILayoutFittingCompressedSize).height
attributes.frame.size.height = desiredHeight
return attributes
}
in ViewController class in viewDidLoad() I wrote this:
if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
layout.estimatedItemSize = CGSize(width: view.frame.width, height: 120)
}
app runs w/o any errors, but cell resizing only after scrolling, not when view appears on screen. is there any issue to fix that?
before scrolling
after scrolling
I believe that you have an update constraint issue, this is why when you scroll the content, you have the constraints already updated and get the right height. Before calling systemLayoutSizeFitting(UILayoutFittingCompressedSize).height you should call layoutIfNeeded()
I feel like this should be a lot easier than it is. All I want to do is have a different height for each of my collection view's cells (depending on the size of the label inside each cell). I'm using sizeForItemAtIndexPath, but the trouble is figuring out the height before the cell is created.
What I have now:
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
// target width of each cell - widht of the collectionView
let targetWidth: CGFloat = collectionView.frame.width - 20.0
// setup a prototype cell
var cell = collectionView.dequeueReusableCellWithReuseIdentifier("MyCustomCellIdentifier", forIndexPath: indexPath) as! MyCustomCell
// for the sake of simplicity, let's just assume data is coming from somewhere else
cell.nameLabel.text = data.name
cell.notesLabel.text = data.notes
// resize - layoutSubviews in LocationCell controller
cell.layoutIfNeeded()
// get the size based on constraints
var size = cell.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
// force width
size.width = targetWidth
return size
}
What's not working is dequeueReusableCellWithReuseIdentifier. I'm guessing it's because the UICollectionViewCell is not yet available? I also tried registerClass to get that, but that doesn't seem to work either. :(
Is there an easier way to do this entirely? All I need to do is figure out what the height is for the cell before it's created. I need an instance of the UICollectionViewCell subclass in order to even be able to start (so I can actually access the label and try to determine a height). Been stuck on this for hours. :/
Use this method
func heightForComment(comment:NSString,font: UIFont, width: CGFloat) -> CGFloat {
let rect = NSString(string: comment).boundingRectWithSize(CGSize(width: width, height: CGFloat(MAXFLOAT)), options: .UsesLineFragmentOrigin, attributes: [NSFontAttributeName: font], context: nil)
return ceil(rect.height)
}
This is not the proper way to do this, but works for me
use this function to obtain the size of the text
func labelSize(texto: NSString) -> CGRect {
var atributos = [NSFontAttributeName: UIFont.systemFontOfSize(17)]
var labelSize = texto.boundingRectWithSize(CGSizeMake(280, CGFloat(MAXFLOAT)), options: NSStringDrawingOptions.UsesLineFragmentOrigin, attributes: atributos, context: nil)
return labelSize
}