Settings UICollectionViewFlowLayout's properties not working when using Auto Layout - ios

I'm trying to set up a UICollectionView with a UICollectionViewFlowLayout with the following requirement: the minimumLineSpacing should always be exactly one-third of the height of the UICollectionView. My initial thought was to override viewDidLayoutSubviews like this:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionViewFlowLayout.minimumLineSpacing = collectionView.frame.height / 3
collectionViewFlowLayout.invalidateLayout()
}
Note that I use viewDidLayoutSubviews because I'm planning to use Auto Layout and the frame may depend on some complex constraints. So I can't calculate the frame myself but have to wait until Auto Layout calculated it for me to use in viewDidLayoutSubviews.
I tested this a bit by creating a UICollectionView programmatically (and rotating the simulator to see if the minimumLineSpacing is always correct). It seemed to work just fine.
Then, I switched to Auto Layout. I simply constrained the collection view's top, bottom, leading and trailing space to its superview. After doing so, setting the minimumLineSpacing didn't have the intended effect anymore, it simply didn't change anything about the appearance of the collection view.
The following code nicely demonstrates the issue. As soon as I set useAutoLayout to true, setting the minimumLineSpacing doesn't work anymore.
class DemoViewController: UIViewController, UICollectionViewDataSource {
var collectionView: UICollectionView!
var collectionViewFlowLayout: UICollectionViewFlowLayout!
// MARK: - UIViewController
override func viewDidLoad() {
super.viewDidLoad()
collectionViewFlowLayout = UICollectionViewFlowLayout()
collectionViewFlowLayout.itemSize = CGSizeMake(100, 100)
collectionView = UICollectionView(frame: view.frame, collectionViewLayout: collectionViewFlowLayout)
collectionView.registerClass(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
collectionView.dataSource = self
view.addSubview(collectionView)
let useAutoLayout = false // Change this to true to test!
if useAutoLayout {
collectionView.setTranslatesAutoresizingMaskIntoConstraints(false)
NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-[collectionView]-|", options: nil, metrics: nil, views: ["collectionView" : collectionView]))
NSLayoutConstraint.activateConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-[collectionView]-|", options: nil, metrics: nil, views: ["collectionView" : collectionView]))
} else {
collectionView.autoresizingMask = .FlexibleHeight | .FlexibleWidth
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionViewFlowLayout.minimumLineSpacing = collectionView.frame.height / 3
collectionViewFlowLayout.invalidateLayout()
}
// MARK: - <UICollectionViewDataSource>
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 100
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("cell", forIndexPath: indexPath) as! UICollectionViewCell
cell.backgroundColor = UIColor.greenColor()
return cell
}
}
Test this code in the Simulator, rotate it and see how setting the minimumLineSpacing doesn't do anything when useAutoLayout is set to true. So my question is: How can I use Auto Layout and still provide a minimumLineSpacing?
Notes
Base SDK is set to iOS 8.4 SDK. Setting other properties like itemSize or minimumInteritemSpacing doesn't work either.

I've reproduced what you describe. It's very strange.
I think there is something about rotation specifically that causes invalidLayout() to be ignored in this context. Perhaps the problem is that your call to invalidateLayout() occurs at a point where the layout object thinks it has already responded, or is in the process of responding, to the layout invalidation automatically produced by the rotation and consequent bounds change of the collection view. Then your invalidation is ignored, because it comes too late to be coalesced into the automatic one, and too soon to count as a separate invalidation. I'm guessing. I notice that you can even keep incrementing the minimumLineSpacing there, and it will happily march up to infinity without the collection view ever having the wit to do layout again.
But if you set up the view controller so that a shake event triggers the invalidation, then it notices the value.
So you can solve the problem by forcing the invalidation to happen at the next turn of the run loop, thus escaping whatever weird special logic is blocking it during rotation. For instance, if you replace your viewDidLayoutSubviews() with the following:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let newValue = collectionView.bounds.height / 3
dispatch_async(dispatch_get_main_queue()) {
[weak collectionViewFlowLayout] in
collectionViewFlowLayout?.minimumLineSpacing = newValue
}
then it works.
Why should this be necessary? I don't know. I don't think it should be necessary. This feels like a bug in UICollectionView to me, or at least a very unintuitive piece of API.

Related

In iOS 12, when does the UICollectionView layout cells, use autolayout in nib

Same code like this
collectionLayout.estimatedItemSize = CGSize(width: 50, height: 25)
collectionLayout.itemSize = UICollectionViewFlowLayoutAutomaticSize
collectionLayout.minimumInteritemSpacing = 10
for _ in 0 ..< 1000 {
let length = Int(arc4random() % 8)
let string = randomKeyByBitLength(length)
array.append(string!)
}
collectionView.reloadData()
cell constraints:
when I run it on iOS 12, it's different.
left simulator is iOS 11, and right is iOS 12:
But, when I scroll it, cells's frames will be normal.
Sample project to reproduce the issue: https://github.com/Coeur/StackOverflow51375566
For all solutions, note that there is no need to explicitly call reloadData in viewDidLoad: it will happen automatically.
Solution 1
Inspired by Samantha idea: invalidateLayout asynchronously in viewDidLoad.
override func viewDidLoad() {
super.viewDidLoad()
//[...]
for _ in 0 ..< 1000 {
array.append(randomKeyByBitLength(Int(arc4random_uniform(8)))!)
}
DispatchQueue.main.async {
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
Solution 2
(imperfect, see DHennessy13 improvement on it)
Based on Peter Lapisu answer. invalidateLayout in viewWillLayoutSubviews.
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
collectionView.collectionViewLayout.invalidateLayout()
}
As noted by DHennessy13, this current solution with viewWillLayoutSubviews is imperfect as it will invalidateLayout when rotating the screen.
You may follow DHennessy13 improvement regarding this solution.
Solution 3
Based on a combination of Tyler Sheaffer answer, Shawn Aukstak port to Swift and Samantha idea. Subclass your CollectionView to perform invalidateLayout on layoutSubviews.
class AutoLayoutCollectionView: UICollectionView {
private var shouldInvalidateLayout = false
override func layoutSubviews() {
super.layoutSubviews()
if shouldInvalidateLayout {
collectionViewLayout.invalidateLayout()
shouldInvalidateLayout = false
}
}
override func reloadData() {
shouldInvalidateLayout = true
super.reloadData()
}
}
This solution is elegant as it doesn't require to change your ViewController code. I've implemented it on branch AutoLayoutCollectionView of this sample project https://github.com/Coeur/StackOverflow51375566/tree/AutoLayoutCollectionView.
Solution 4
Rewrite UICollectionViewCell default constraints. See Larry answer.
Solution 5
Implement collectionView(_:layout:sizeForItemAt:) and return cell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize). See matt answer.
The problem is that the feature being posited here — collection view cells that size themselves based on their internal constraints in a UICollectionViewFlowLayout — does not exist. It has never existed. Apple claims that it does, but it doesn't. I have filed a bug on this every year since collection views were introduced and this claim was first made; and my bug reports have never been closed, because the bug is real. There is no such thing as self-sizing collection view cells.
See also my answer here: https://stackoverflow.com/a/51585910/341994
In some years, trying to use self-sizing cells has crashed. In other years, it doesn't crash but it gets the layout wrong. But it doesn't work.
The only way to do this sort of thing is to implement the delegate method sizeForItemAt and supply the size yourself. You can easily do that by calling
cell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
on a model cell that you have configured in advance. That is what the runtime should be doing for you — but it doesn't.
So here's my solution to the original question. Instead of a simple array of strings, we have generated an array of string-size pairs (as a tuple). Then:
override func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! MyCell
cell.label.text = self.array[indexPath.row].0
return cell
}
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
return self.array[indexPath.row].1
}
Here's another solution that works on Cœur's code sample, and also worked for my particular case, where the other answers didn't. The code below replaces the previous implementation of the CollectionViewCell subclass in ViewController.swift:
class CollectionViewCell: UICollectionViewCell {
#IBOutlet weak var label: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
let leftConstraint = contentView.leftAnchor.constraint(equalTo: leftAnchor)
let rightConstraint = contentView.rightAnchor.constraint(equalTo: rightAnchor)
let topConstraint = contentView.topAnchor.constraint(equalTo: topAnchor)
let bottomConstraint = contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
NSLayoutConstraint.activate([leftConstraint, rightConstraint, topConstraint, bottomConstraint])
}
}
This is inspired by the answer by ale84 from UICollectionViewFlowLayout estimatedItemSize does not work properly with iOS12 though it works fine with iOS 11.*
I have the same problem, cells use the estimated size (instead of automatic size) until scrolled. The same code built with Xcode 9.x runs perfectly fine on iOS 11 and 12, and built in Xcode 10 it runs correctly on iOS 11 but not iOS 12.
The only way I’ve found so far to fix this is to invalidate the collection view’s layout either in viewDidAppear, which can cause some jumpiness, or in an async block inside viewWillAppear (not sure how reliable that solution is).
override func viewDidLoad() {
super.viewDidLoad()
let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout
layout?.estimatedItemSize = CGSize(width: 50, height: 50)
layout?.itemSize = UICollectionViewFlowLayout.automaticSize
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// The following block also "fixes" the problem without jumpiness after the view has already appeared on screen.
DispatchQueue.main.async {
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// The following line makes cells size properly in iOS 12.
collectionView.collectionViewLayout.invalidateLayout()
}
Solution 2 of Cœur's prevents the layout from flashing or updating in front of the user. But it can create problems when you rotate the device. I'm using a variable "shouldInvalidateLayout" in viewWillLayoutSubviews and setting it to false in viewDidAppear.
private var shouldInvalidateLayout = true
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
shouldInvalidateLayout = false
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if shouldInvalidateLayout {
collectionView.collectionViewLayout.invalidateLayout()
}
}
Try this
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
DispatchQueue.main.async {
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
Adding to viewDidAppear and viewWillAppear will of course work. But viewDidAppear will cause a glitch for the user.
We had the same problem on our project. We also noticed differences between the multiple devices in iOS 12, requiring a call to layoutIfNeeded & invalidateLayout. The solution is based on #DHennessy13 's approach but doesn't require a boolean to manage states that seemed slightly hacky.
Here it is based on an Rx code, basically the first line is when the data is changing, inside the subscribe is what needs to be done to fix the nasty iOS 12 UI glitch:
viewModel.cellModels.asObservable()
.subscribe(onNext: { [weak self] _ in
// iOS 12 bug in UICollectionView for cell size
self?.collectionView.layoutIfNeeded()
// required for iPhone 8 iOS 12 bug cell size
self?.collectionView.collectionViewLayout.invalidateLayout()
})
.disposed(by: rx.disposeBag)
Edit:
By the way, it seems to be a known issue in iOS 12:
https://developer.apple.com/documentation/ios_release_notes/ios_12_release_notes (in UIKit section).
class CollectionViewCell: UICollectionViewCell {
#IBOutlet weak var label: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
let leftConstraint = contentView.leftAnchor.constraint(equalTo: leftAnchor)
let rightConstraint = contentView.rightAnchor.constraint(equalTo: rightAnchor)
let topConstraint = contentView.topAnchor.constraint(equalTo: topAnchor)
let bottomConstraint = contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
NSLayoutConstraint.activate([leftConstraint, rightConstraint, topConstraint, bottomConstraint])
}}
I've tried this and it's worked for me. Try this and let me know if you need any help.

Expand UITableView to show all cells in Stack View?

I am having trouble getting my UITableView to appear full height in my Stack View.
My view tree looks as follows:
- View
- Scroll View
- Stack View
- Table View
- Image View
- Map View
The table view is dynamically populated with data, which works fine. The issue is that only one row is visible at a time and I have to scroll through the list. What I would like to see happen is for the table view to take as much vertical room as it needs to display all the cells.
I did try adjusting table height as follows, but that just ends up with table that no longer scrolls, though even if it did work I would rather have something more dynamic:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
self.detailsTableView.frame.size.height = 200
}
I am suspecting that it is probably an aspect of the 'stack view' that needs adjusting, but I am not sure at this point. Can anyone suggest an appropriate way?
I had been encountering the same issue and realized you need a self sizing table view. I stumbled on this answer and created a subclass like #MuHAOS suggested. I did not encounter any issues.
final class IntrinsicTableView: UITableView {
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height)
}
}
A UIStackView will compress views wherever it can, to counteract this set a height anchor and width anchor to the UITableView or a priority for its height and width. Here is a working example of how we can be in charge of the dimensions of a table within a stack view.
An extension to instantiate and centrally position the UIStackView
First of all I've written a UIStackView extension so that I don't need to include all the code inside the view controller. Your positioning and setup will be different because you are placing your stack view inside a scroll view, but separating this code out means you can make your own adjustments.
extension UIStackView {
convenience init(axis:UILayoutConstraintAxis, spacing:CGFloat) {
self.init()
self.axis = axis
self.spacing = spacing
self.translatesAutoresizingMaskIntoConstraints = false
}
func anchorStackView(toView view:UIView, anchorX:NSLayoutXAxisAnchor, equalAnchorX:NSLayoutXAxisAnchor, anchorY:NSLayoutYAxisAnchor, equalAnchorY:NSLayoutYAxisAnchor) {
view.addSubview(self)
anchorX.constraintEqualToAnchor(equalAnchorX).active = true
anchorY.constraintEqualToAnchor(equalAnchorY).active = true
}
}
We don't set a size for the UIStackView only a position, it is the things contained within it that determine its size. Also note the setting of translatesAutoresizingMaskIntoConstraints to false in the UIStackView extension. (It is only required that we set this property for the stack view, its subviews simply inherit the behaviour.)
UITableView class with data source code
Next I've created a basic table class for demo purposes.
class MyTable: UITableView, UITableViewDataSource {
let data = ["January","February","March","April","May","June","July","August","September","October","November","December"]
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("SauceCell", forIndexPath: indexPath)
cell.textLabel?.text = data[indexPath.row]
return cell
}
}
Setup of stack view and table in view controller
Finally, the important stuff. As soon as we add our table to the stack view all the frame information is disregarded. So we need the final two lines of code to set the width and height for the table in terms that Auto Layout can understand.
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
let table = MyTable(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: view.frame.height))
table.registerClass(UITableViewCell.self, forCellReuseIdentifier: "SauceCell")
table.dataSource = table
let stack = UIStackView(axis: .Vertical, spacing: 10)
stack.anchorStackView(toView: view, anchorX: stack.centerXAnchor, equalAnchorX: view.centerXAnchor, anchorY: stack.centerYAnchor, equalAnchorY: view.centerYAnchor)
stack.addArrangedSubview(table)
table.widthAnchor.constraintEqualToAnchor(view.widthAnchor, multiplier: 1).active = true
table.heightAnchor.constraintEqualToAnchor(view.heightAnchor, multiplier: 0.5).active = true
}
}
Note that we use addArrangedSubview: not addSubview: when adding views to the stack view.
(I've written blogposts about UIStackView as well as others about Auto Layout in general that might help too.)

UICollectionView cells resizing when deleting items with estimatedItemSize

I have a simple project with a storyboard containing only a single a UICollectionViewController, built with Xcode 7.1.1 for iOS 9.1
class ViewController: UICollectionViewController {
var values = ["tortile", "jetty", "tisane", "glaucia", "formic", "agile", "eider", "rooter", "nowhence", "hydrus", "outdo", "godsend", "tinkler", "lipscomb", "hamlet", "unbreeched", "fischer", "beastings", "bravely", "bosky", "ridgefield", "sunfast", "karol", "loudmouth", "liam", "zunyite", "kneepad", "ashburn", "lowness", "wencher", "bedwards", "guaira", "afeared", "hermon", "dormered", "uhde", "rusher", "allyou", "potluck", "campshed", "reeda", "bayonne", "preclose", "luncheon", "untombed", "northern", "gjukung", "bratticed", "zeugma", "raker"]
#IBOutlet weak var flowLayout: UICollectionViewFlowLayout!
override func viewDidLoad() {
super.viewDidLoad()
flowLayout.estimatedItemSize = CGSize(width: 10, height: 10)
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return values.count
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("MyCell", forIndexPath: indexPath) as! MyCell
cell.name = values[indexPath.row]
return cell
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
values.removeAtIndex(indexPath.row)
collectionView.deleteItemsAtIndexPaths([indexPath])
}
}
class MyCell: UICollectionViewCell {
#IBOutlet weak var label: UILabel!
var name: String? {
didSet {
label.text = name
}
}
}
When deleting the cells from the collection view, all remaining cells animate to their estimatedItemSize, and then swap back to the correct size.
Interestingly, this produces auto layout constraint warnings for each cell when the animation occurs:
2015-12-02 14:30:45.236 CollectionTest[1631:427853] Unable to simultaneously satisfy constraints.
Probably at least one of the constraints in the following list is one you don't want.
Try this:
(1) look at each constraint and try to figure out which you don't expect;
(2) find the code that added the unwanted constraint or constraints and fix it.
(Note: If you're seeing NSAutoresizingMaskLayoutConstraints that you don't understand, refer to the documentation for the UIView property translatesAutoresizingMaskIntoConstraints)
(
"<NSAutoresizingMaskLayoutConstraint:0x14556f780 h=--& v=--& H:[UIView:0x1456ac6c0(10)]>",
"<NSLayoutConstraint:0x1456acfd0 UIView:0x1456ac6c0.trailingMargin == UILabel:0x1456ac830'raker'.trailing>",
"<NSLayoutConstraint:0x1456ad020 UILabel:0x1456ac830'raker'.leading == UIView:0x1456ac6c0.leadingMargin>"
)
Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x1456acfd0 UIView:0x1456ac6c0.trailingMargin == UILabel:0x1456ac830'raker'.trailing>
My initial thought was that breaking these constraints was what was causing the resizing problem.
Updating the cell's awakeFromNib method:
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
}
fixes the warnings, but the problem still occurs.
I tried re-adding my own constraints between the cell and its contentView, but this didn't resolve the issue:
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
for constraint in [
contentView.leadingAnchor.constraintEqualToAnchor(leadingAnchor),
contentView.trailingAnchor.constraintEqualToAnchor(trailingAnchor),
contentView.topAnchor.constraintEqualToAnchor(topAnchor),
contentView.bottomAnchor.constraintEqualToAnchor(bottomAnchor)]
{
constraint.priority = 999
constraint.active = true
}
}
Thoughts?
flow layout calculates actual sizes of cells after doing layout by estimated sizes to define which ones are visible. After that it adjusts the layout based on real sizes.
However, when it animates, when it calculates initial position for animation, it doesn't reach the stage of dequeueing cells and running auto layout there, so it uses only estimated sizes.
The easiest way is to try to give the closest estimated sizes, or if you could provide the size in the delegate in sizeForItemAt call.
In my case, I was trying to animate layoutAttributes without inserting or deleting cells and for that specific case I subclassed UICollectionViewFlowLayout and then overridden this method:
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
if !context.invalidateEverything && context.invalidatedItemIndexPaths == nil && context.contentOffsetAdjustment == .zero && context.contentSizeAdjustment == .zero {
return
}
super.invalidateLayout(with: context)
}
This prevents recalculating layout attributes using estimated sizes when nothing has been changed.
TL;DR: I could only get a collection view to properly behave with the delegate sizeForItem method. Working sample here: https://github.com/chrisco314/CollectionView-AutoLayout
In the controller:
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
var cell = Cell.prototype
let contents = data[indexPath.section][indexPath.item]
cell.text = contents
cell.expand = selected.contains(indexPath)
let width = collectionView.bounds
.inset(collectionView.contentInset)
.inset(layout.sectionInset)
.width
let finalSize = cell.systemLayoutSizeFitting(
.init(width: width, height: 0),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
.withWidth(width)
print("sizeForItemAt: \(finalSize)")
return finalSize
}
In the cell:
override func systemLayoutSizeFitting(
_ targetSize: CGSize,
withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
verticalFittingPriority: UILayoutPriority) -> CGSize {
let contentSize = contentView.systemLayoutSizeFitting(
targetSize,
withHorizontalFittingPriority: horizontalFittingPriority,
verticalFittingPriority: verticalFittingPriority)
return contentSize
}
Constraints for an expanding panel:
lazy var panel: UIView = {
let view = Panel()
view.pin(body, to: .left, .top, .right)
view.clipsToBounds = true
panelHeight = view.heightAnchor.constraint(equalTo: body.heightAnchor)
return view
}()
var panelHeight: NSLayoutConstraint!
lazy var height:CGFloat = 60
lazy var body: UIView = {
let view = Body()
view.backgroundColor = .blue
view.pin(contents, inset: 9)
let bodyHeight = view.heightAnchor.constraint(equalToConstant: height)
bodyHeight.isActive = true
return view
}()
lazy var contents: UILabel = {
let label = UILabel()
label.backgroundColor = .white
label.numberOfLines = 0
label.text = "Body with height constraint of \(height)"
return label
}()
I had a host of problems like this and many others, spent a stupid amount of time trying to find a path through that worked for all cases - rendering with autolayout, rational animations for insertion and deletion, handling rotations, etc. In my experience, the only way that worked was to use the sizeForItem delegate method. You can use estimatedSize and auto layout, but for me, the animations would always collapse to the top, and everything then spring down again - perhaps what you are seeing.
I have a sample that is basically my playground for testing. I tried different approaches across the different tabs of the tab view controller here, using estimated sizes, constraints on the cells themselves, custom systemSizeFitting that returns the desired size, and the delegate based sizeThatFits
The sample is a bit hacked up, but the third tab demonstrates a delegate based method that works for expanding cells, and insertion and deletion animations. Note that tab2? demonstrates inconsistent animations that the collection view uses, based on the ratio of expanding cells. If the ratio is greater than 2:1, it fades and snaps, if it is less then 2:1, it animates up and down smoothly.
All the non delegate approaches that tried failed when it came to animations, per above. Maybe there is an approach that works without the delegate method (and I would love to see if it it did), but I could not find it.

Setting tableView header's height in Swift

I am trying to set the height of a view that is on top of my prototype cell in a table view controller. I use IB to set it's height (size inspector) and set it to 61 like so (the green view is the 'header' view):
But whenever I run the app, its' height ends up being 568.0. I have an IBOutlet called testUIView for the view in my table view controller, and I do: println("testUIView Height->\(testUIView.frame.height)") and indeed ends up being 568.0 at runtime.
Here is a screenshot showing its' height at runtime:
So my question is: How can I set the view's height so it is 61 at runtime so it indeed looks like my first screenshot (size-wise)?
I tried to set its' height property inside override func viewWillLayoutSubviews() but it did not let me assign a value to the height testUIView.frame.height = CGFloat(61.0).
Any help is appreciated! Thanks in advance!
Cheers!
Here is a solution which uses section header views rather than the actual table header view:
If you'd like to use a header for you UITableView instead you can design another prototype cell in Interface Builder, make a custom class based on a UITableViewCell and assign it to the prototype cell in interface builder on the class inspector.
Then in your controller you're going to use
func tableView(tableView: UITableView, viewForHeaderInSection section: Int) -> UIView?
In that function you're actually going to create a reusable cell from your table view but cast as the custom cell you made for the header. You will have access to all of it's properties like a regular UITableViewCell, then you're just going to return the cell's view
return cell.contentView
Another method you're going to use is
func tableView(tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 61.0
}
That one is pretty self explanatory.
Swift 3.0.1
public override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 61.0
}
Swift 3/Xcode 8:
Add this in viewDidLoad():
let HEADER_HEIGHT = 100
tableView.tableHeaderView?.frame.size = CGSize(width: tableView.frame.width, height: CGFloat(HEADER_HEIGHT))
Enjoy!
The accepted answer doesn't actually answer the question. It instead offers an alternative by using the SECTION header. This question has been answered by others but I will duplicate the answer here with a few more instructions.
Loading the view
Table views are as old as iPhones and therefore you sometimes have to force it to do what you want.
First we need to load the header and manually set its height. Otherwise the view will take more height than it needs. We do this on the viewDidLayoutSubviews callback:
lazy var profileHeaderView: ProfileHeaderView = {
let headerView = ProfileHeaderView()
return headerView
}()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
sizeHeaderToFit()
}
private func sizeHeaderToFit() {
profileHeaderView.setNeedsLayout()
profileHeaderView.layoutIfNeeded()
var frame = profileHeaderView.frame
frame.size.height = profileHeaderView.calculateHeight()
profileHeaderView.frame = frame
tableView.tableHeaderView = profileHeaderView
}
As you can see, I like to put my views inside lazy vars. This ensures that they are always created but only when I start using them.
You can also see that I'm calculating the height. In some cases, your height is fixed and therefore you can just set the frame height to a hardcoded value.
Set some priorities
We will likely see some constraint warnings appear in our debugger. This happens because the table view first forces a 0x0 size before using the size we specified above At this moment, your constraints and the height of the view are in conflict with each other.
To clear these, we simply set the constraint priorities. First you should wrap your header view components inside another view (I generally always do this for header views). This will make managing constraints much easier on your header view.
We then need to set the bottom constraint priorities to high:
containerView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
containerView.setContentHuggingPriority(.defaultHigh, for: .vertical)
Here is a more complete example:
WARNING: Thought it is still useful as a guide for laying out your views, do not use this code if you're creating your views using nibs or storyboards.
class ProfileHeaderView: UIView {
lazy var containerView: UIView = {
let view = UIView()
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
setupLayout()
}
required init?(coder aDecoder: NSCoder) {
// We do this because the view is not created using storyboards or nibs.
fatalError("init(coder:) has not been implemented")
}
private func setupLayout() {
self.addSubview(containerView)
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
containerView.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
containerView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
containerView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
containerView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
containerView.setContentHuggingPriority(.defaultHigh, for: .vertical)
// Set the rest of your constraints against your containerView not self and add your subviews to your containerView not self
}
}
Here is the example of the constraints set using snap-kit:
containerView.snp.makeConstraints() { make in
make.top.equalTo(self.snp.top)
make.leading.equalTo(self.snp.leading)
make.trailing.equalTo(self.snp.trailing)
make.bottom.equalTo(self.snp.bottom).priority(.high)
}
Make sure you add your constraints to the containerView not self and use containerView to add your subviews and rest of your constraints.
It has to be one of the strangest issues in iOS.
If you do just want a fixed height, as of 2019 you can:
public override func viewDidLayoutSubviews() {
var frame = tableView.tableHeaderView!.frame
frame.size.height = 68
tableView.tableHeaderView!.frame = frame
}
Strange stuff.
In swift 4.1 and Xcode 9.4.1
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
if UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiom.pad {
return 75.0
} else {
return 50.0
}
}
If you use .xib file with UIVIew for your HeaderView, you can use self-sizing header like this
override func layoutSubviews() {
super.layoutSubviews()
// Manually set the view's frame based on layout constraints.
// The parent UITableView uses the header view's frame height when laying out it's subviews.
// Only the header view's height is respected.
// The UITableView ignores the view frame's width.
// Documentation: https://developer.apple.com/documentation/uikit/uitableview/1614904-tableheaderview
frame.size = systemLayoutSizeFitting(
.init(
width: frame.size.width,
height: 0
),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
}

UICollectionView inside a UITableViewCell -- dynamic height?

One of our application screens requires us to place a UICollectionView inside of a UITableViewCell. This UICollectionView will have a dynamic number of items, resulting in a height which must be calculated dynamically as well. However, I am running into problems trying to calculate the height of the embedded UICollectionView.
Our overarching UIViewController was created in Storyboards and does make use of auto layout. But, I don't know how to dynamically increase the height of the UITableViewCell based on the height of the UICollectionView.
Can anyone give some tips or advice on how to accomplish this?
The right answer is YES, you CAN do this.
I came across this problem some weeks ago. It is actually easier than you may think. Put your cells into NIBs (or storyboards) and pin them to let auto layout do all the work
Given the following structure:
TableView
TableViewCell
CollectionView
CollectionViewCell
CollectionViewCell
CollectionViewCell
[...variable number of cells or different cell sizes]
The solution is to tell auto layout to compute first the collectionViewCell sizes, then the collection view contentSize, and use it as the size of your cell. This is the UIView method that "does the magic":
-(void)systemLayoutSizeFittingSize:(CGSize)targetSize
withHorizontalFittingPriority:(UILayoutPriority)horizontalFittingPriority
verticalFittingPriority:(UILayoutPriority)verticalFittingPriority
You have to set here the size of the TableViewCell, which in your case is the CollectionView's contentSize.
CollectionViewCell
At the CollectionViewCell you have to tell the cell to layout each time you change the model (e.g.: you set a UILabel with a text, then the cell has to be layout again).
- (void)bindWithModel:(id)model {
// Do whatever you may need to bind with your data and
// tell the collection view cell's contentView to resize
[self.contentView setNeedsLayout];
}
// Other stuff here...
TableViewCell
The TableViewCell does the magic. It has an outlet to your collectionView, enables the auto layout for collectionView cells using estimatedItemSize of the UICollectionViewFlowLayout.
Then, the trick is to set your tableView cell's size at the systemLayoutSizeFittingSize... method. (NOTE: iOS8 or later)
NOTE: I tried to use the delegate cell's height method of the tableView -(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath.but it's too late for the auto layout system to compute the CollectionView contentSize and sometimes you may find wrong resized cells.
#implementation TableCell
- (void)awakeFromNib {
[super awakeFromNib];
UICollectionViewFlowLayout *flow = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
// Configure the collectionView
flow.minimumInteritemSpacing = ...;
// This enables the magic of auto layout.
// Setting estimatedItemSize different to CGSizeZero
// on flow Layout enables auto layout for collectionView cells.
// https://developer.apple.com/videos/play/wwdc2014-226/
flow.estimatedItemSize = CGSizeMake(1, 1);
// Disable the scroll on your collection view
// to avoid running into multiple scroll issues.
[self.collectionView setScrollEnabled:NO];
}
- (void)bindWithModel:(id)model {
// Do your stuff here to configure the tableViewCell
// Tell the cell to redraw its contentView
[self.contentView layoutIfNeeded];
}
// THIS IS THE MOST IMPORTANT METHOD
//
// This method tells the auto layout
// You cannot calculate the collectionView content size in any other place,
// because you run into race condition issues.
// NOTE: Works for iOS 8 or later
- (CGSize)systemLayoutSizeFittingSize:(CGSize)targetSize withHorizontalFittingPriority:(UILayoutPriority)horizontalFittingPriority verticalFittingPriority:(UILayoutPriority)verticalFittingPriority {
// With autolayout enabled on collection view's cells we need to force a collection view relayout with the shown size (width)
self.collectionView.frame = CGRectMake(0, 0, targetSize.width, MAXFLOAT);
[self.collectionView layoutIfNeeded];
// If the cell's size has to be exactly the content
// Size of the collection View, just return the
// collectionViewLayout's collectionViewContentSize.
return [self.collectionView.collectionViewLayout collectionViewContentSize];
}
// Other stuff here...
#end
TableViewController
Remember to enable the auto layout system for the tableView cells at your TableViewController:
- (void)viewDidLoad {
[super viewDidLoad];
// Enable automatic row auto layout calculations
self.tableView.rowHeight = UITableViewAutomaticDimension;
// Set the estimatedRowHeight to a non-0 value to enable auto layout.
self.tableView.estimatedRowHeight = 10;
}
CREDIT: #rbarbera helped to sort this out
I think my solution is much simpler than the one proposed by #PabloRomeu.
Step 1. Create outlet from UICollectionView to UITableViewCell subclass, where UICollectionView is placed. Let, it's name will be collectionView
Step 2. Add in IB for UICollectionView height constraint and create outlet to UITableViewCell subclass too. Let, it's name will be collectionViewHeight.
Step 3. In tableView:cellForRowAtIndexPath: add code:
// deque a cell
cell.frame = tableView.bounds;
[cell layoutIfNeeded];
[cell.collectionView reloadData];
cell.collectionViewHeight.constant = cell.collectionView.collectionViewLayout.collectionViewContentSize.height;
Both table views and collection views are UIScrollView subclasses and thus don't like to be embedded inside another scroll view as they try to calculate content sizes, reuse cells, etc.
I recommend you to use only a collection view for all your purposes.
You can divide it in sections and "treat" some sections' layout as a table view and others as a collection view. After all there's nothing you can't achieve with a collection view that you can with a table view.
If you have a basic grid layout for your collection view "parts" you can also use regular table cells to handle them. Still if you don't need iOS 5 support you should better use collection views.
I read through all the answers. This seems to serve all cases.
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
collectionView.layoutIfNeeded()
collectionView.frame = CGRect(x: 0, y: 0, width: targetSize.width , height: 1)
return collectionView.collectionViewLayout.collectionViewContentSize
}
Pablo Romeu's answer above (https://stackoverflow.com/a/33364092/2704206) helped me immensely with my issue. I had to do a few things differently, however, to get this working for my problem. First off, I didn't have to call layoutIfNeeded() as often. I only had to call it on the collectionView in the systemLayoutSizeFitting function.
Secondly, I had auto layout constraints on my collection view in the table view cell to give it some padding. So I had to subtract the leading and trailing margins from the targetSize.width when setting the collectionView.frame's width. I also had to add the top and bottom margins to the return value CGSize height.
To get these constraint constants, I had the option of either creating outlets to the constraints, hard-coding their constants, or looking them up by an identifier. I decided to go with the third option to make my custom table view cell class easily reusable. In the end, this was everything I needed to get it working:
class CollectionTableViewCell: UITableViewCell {
// MARK: -
// MARK: Properties
#IBOutlet weak var collectionView: UICollectionView! {
didSet {
collectionViewLayout?.estimatedItemSize = CGSize(width: 1, height: 1)
selectionStyle = .none
}
}
var collectionViewLayout: UICollectionViewFlowLayout? {
return collectionView.collectionViewLayout as? UICollectionViewFlowLayout
}
// MARK: -
// MARK: UIView functions
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
collectionView.layoutIfNeeded()
let topConstraintConstant = contentView.constraint(byIdentifier: "topAnchor")?.constant ?? 0
let bottomConstraintConstant = contentView.constraint(byIdentifier: "bottomAnchor")?.constant ?? 0
let trailingConstraintConstant = contentView.constraint(byIdentifier: "trailingAnchor")?.constant ?? 0
let leadingConstraintConstant = contentView.constraint(byIdentifier: "leadingAnchor")?.constant ?? 0
collectionView.frame = CGRect(x: 0, y: 0, width: targetSize.width - trailingConstraintConstant - leadingConstraintConstant, height: 1)
let size = collectionView.collectionViewLayout.collectionViewContentSize
let newSize = CGSize(width: size.width, height: size.height + topConstraintConstant + bottomConstraintConstant)
return newSize
}
}
As a helper function to retrieve a constraint by identifier, I add the following extension:
extension UIView {
func constraint(byIdentifier identifier: String) -> NSLayoutConstraint? {
return constraints.first(where: { $0.identifier == identifier })
}
}
NOTE: You will need to set the identifier on these constraints in your storyboard, or wherever they are being created. Unless they have a 0 constant, then it doesn't matter. Also, as in Pablo's response, you will need to use UICollectionViewFlowLayout as the layout for your collection view. Finally, make sure you link the collectionView IBOutlet to your storyboard.
With the custom table view cell above, I can now subclass it in any other table view cell that needs a collection view and have it implement the UICollectionViewDelegateFlowLayout and UICollectionViewDataSource protocols. Hope this is helpful to someone else!
An alternative to Pablo Romeu's solution is to customise UICollectionView itself, rather than doing the work in table view cell.
The underlying problem is that by default a collection view has no intrinsic size and so cannot inform auto layout of the dimensions to use. You can remedy that by creating a custom subclass which does return a useful intrinsic size.
Create a subclass of UICollectionView and override the following methods
override func intrinsicContentSize() -> CGSize {
self.layoutIfNeeded()
var size = super.contentSize
if size.width == 0 || size.height == 0 {
// return a default size
size = CGSize(width: 600, height:44)
}
return size
}
override func reloadData() {
super.reloadData()
self.layoutIfNeeded()
self.invalidateIntrinsicContentSize()
}
(You should also override the related methods: reloadSections, reloadItemsAtIndexPaths in a similar way to reloadData())
Calling layoutIfNeeded forces the collection view to recalculate the content size which can then be used as the new intrinsic size.
Also, you need to explicitly handle changes to the view size (e.g. on device rotation) in the table view controller
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)
{
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
dispatch_async(dispatch_get_main_queue()) {
self.tableView.reloadData()
}
}
Easiest approach I've came up with, so far, Credits to #igor answer above,
In your tableviewcell class just insert this
override func layoutSubviews() {
self.collectionViewOutlet.constant = self.postPoll.collectionViewLayout.collectionViewContentSize.height
}
and of course, change the collectionviewoutlet with your outlet in the cell's class
I was facing the same issue recently and I almost tried every solution in the answers, some of them worked and others didn't my main concern about #PabloRomeu approach is that if you have other contents in the cell (other than the collection view) you will have to calculate their heights and the heights of their constraints and return the result to get the auto layout right and I don't like to calculate things manually in my code. So here is the solution that worked fine for me without doing any manual calculations in my code.
in the cellForRow:atIndexPath of the table view I do the following:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//do dequeue stuff
//initialize the the collection view data source with the data
cell.frame = CGRect.zero
cell.layoutIfNeeded()
return cell
}
I think what happens here is that I force the tableview cell to adjust its height after the collection view height has been calculated. (after providing the collectionView date to the data source)
I would put a static method on the collection view class that will return a size based on the content it will have. Then use that method in the heightForRowAtIndexPath to return the proper size.
Also note that you can get some weird behavior when you embed these kinds of viewControllers. I did it once and had some weird memory issues I never worked out.
Maybe my variant will be useful; i've been deciding this task during last two hours. I don't pretend it's 100% correct or optimal, but my skill's very small yet and i'd like to hear comments from experts. Thank you.
One important note: this works for static table - it's specified by my current work.
So, all I use is viewWillLayoutSubviews of tableView. And a little bit more.
private var iconsCellHeight: CGFloat = 500
func updateTable(table: UITableView, withDuration duration: NSTimeInterval) {
UIView.animateWithDuration(duration, animations: { () -> Void in
table.beginUpdates()
table.endUpdates()
})
}
override func viewWillLayoutSubviews() {
if let iconsCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 1)) as? CategoryCardIconsCell {
let collectionViewContentHeight = iconsCell.iconsCollectionView.contentSize.height
if collectionViewContentHeight + 17 != iconsCellHeight {
iconsCellHeight = collectionViewContentHeight + 17
updateTable(tableView, withDuration: 0.2)
}
}
}
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch (indexPath.section, indexPath.row) {
case ...
case (1,0):
return iconsCellHeight
default:
return tableView.rowHeight
}
}
I know, that the collectionView is located in the first row of the second section;
Let the height of the row is 17 p. bigger, than its content height;
iconsCellHeight is a random number as the program starts (i know, that in the portrait form it has to be exactly 392, but it's not important). If the content of collectionView + 17 is not equal this number, so change its value. Next time in this situation the condition gives FALSE;
After all update the tableView. In my case its the combination of two operations (for nice updating of extending rows);
And of course, in the heightForRowAtIndexPath add one row to code.
I get idea from #Igor post and invest my time to this for my project with swift
Just past this in your
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//do dequeue stuff
cell.frame = tableView.bounds
cell.layoutIfNeeded()
cell.collectionView.reloadData()
cell.collectionView.heightAnchor.constraint(equalToConstant: cell.collectionView.collectionViewLayout.collectionViewContentSize.height)
cell.layoutIfNeeded()
return cell
}
Addition:
If you see your UICollectionView choppy when loading cells.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
//do dequeue stuff
cell.layer.shouldRasterize = true
cell.layer.rasterizationScale = UIScreen.main.scale
return cell
}
Pablo's solution did not work very well for me, I had strange visual effects (the collectionView not adjusting correctly).
What worked was to adjust the height constraint of the collectionView (as a NSLayoutConstraint) to the collectionView contentSize during layoutSubviews(). This is the method called when autolayout is applied to the cell.
// Constraint on the collectionView height in the storyboard. Priority set to 999.
#IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
// Method called by autolayout to layout the subviews (including the collectionView).
// This is triggered with 'layoutIfNeeded()', or by the viewController
// (happens between 'viewWillLayoutSubviews()' and 'viewDidLayoutSubviews()'.
override func layoutSubviews() {
collectionViewHeightConstraint.constant = collectionView.contentSize.height
super.layoutSubviews()
}
// Call `layoutIfNeeded()` when you update your UI from the model to trigger 'layoutSubviews()'
private func updateUI() {
layoutIfNeeded()
}
func configure(data: [Strings]) {
names = data
contentView.layoutIfNeeded()
collectionviewNames.reloadData()
}
Short and sweet. Consider the above method in your tableViewCell class. You would probably call it from func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell after dequeing your cell. Before calling reloadData on your collection view, in your tableCell, you need to tell the collection view to lay out its subviews, if layout updates are pending.
In your UITableViewDelegate:
-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
return ceil(itemCount/4.0f)*collectionViewCellHeight;
}
Substitute itemCount and CollectionViewCellHeight with the real values. If you have an array of arrays itemCount might be:
self.items[indexPath.row].count
Or whatever.
1.Create dummy cell.
2.Use collectionViewContentSize method on UICollectionViewLayout of UICollectionView using current data.
You can calculate the height of the collection based on its properties like itemSize, sectionInset, minimumLineSpacing, minimumInteritemSpacing, if your collectionViewCell has the border of a rule.

Resources