UICollectionView cells resizing when deleting items with estimatedItemSize - ios

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.

Related

Nest UICollectionView into UITableViewCell

I'm trying to build something
I'm trying to build a tag list view using UICollectionView and nest it into my custom UITableViewCell.
What do I have now
After searching the internet, I find the key to the problem:
Subclass UICollectionView and implement it's intrinsic content size property.
However, when I nest my custom UICollectionView into a self-sizing UITableViewCell, the whole thing doesn't work well. The layout is broken.
No matter how do I change the code, I get one of the following 3 buggy UIs.
The height of the collection view is always wrong, either too small or too large, it can not hug it's content just right.
When I use Debug View Hierarchy to check the views, I find that although the UI is broken, the contentSize property of the collection view has a correct value. It seems that the content size property can not be reflected to the UI in time.
class IntrinsicCollectionView: UICollectionView {
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
isScrollEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
There are many solution about how to create a custom UICollectionView with intrinsic content size. Some of them can work correctly. But when nesting them into a UITableViewCell, none of them works well.
There are also some answer about just nest one UICollectionView into UITableViewCell without other views. But if there are also some UILabel in UITableViewCell, it won't work.
I upload all the code to github. https://github.com/yunhao/nest-collectionview-in-tableviewcell
Thank you!
I'll try to explain what's going on....
To make it easy to understand, in your ListViewController let's work with just one row to begin with:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1 // items.count
}
In your ListViewCell class, add these lines at the end of prepareViews():
// so we can see the element frames
titleLabel.backgroundColor = .green
subtitleLabel.backgroundColor = .cyan
collectionView.backgroundColor = .yellow
In your IntrinsicCollectionView class, let's add a print() statement to give us some information:
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
// add this line
print("collView Width:", frame.width, "intrinsic height:", collectionViewLayout.collectionViewContentSize.height)
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
When I then run the app on an iPhone 8, I get this result:
and I see this in the debug console:
collView Width: 66.0 intrinsic height: 350.0
collView Width: 343.0 intrinsic height: 30.0
What that tells me is that the collection view is asked for its intrinsicContentSize before it has a complete frame.
At that point, it fills in its cells, and its layout ends up with a .collectionViewContentSize.height of 350 (this row has six "tags" cells).
Auto-layout then performs another pass... the collection view now has a valid frame width (based on the cell width)... and the cells are re-laid-out.
Unfortunately, the table view has already set the row height(s), based on the initial collection view intrinsicContentSize.height.
So, two steps that may (should) fix this:
In ListViewCell, invalidate the content size of the collection view when you get the tags:
func setTags(_ tags: [String]) {
self.tags = tags
collectionView.reloadData()
// add this line
collectionView.invalidateIntrinsicContentSize()
}
Then, in ListViewController, we need to reload the table after its frame has changed:
// add this var
var currentWidth: CGFloat = 0
// implement viewDidLayoutSubviews()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if view.frame.width != currentWidth {
currentWidth = view.frame.width
tableView.reloadData()
}
}
That seems (with very quick testing) to give me reliable results:
and on device rotation:

How to adjust height of UICollectionView to be the height of the content size of the UICollectionView?

I would like the UICollectionView (The red one) to shrink to the height of the content size in this case UICollectionViewCells(the yellow ones) because there is a lot of empty space. What I tried is to use:
override func layoutSubviews() {
super.layoutSubviews()
if !__CGSizeEqualToSize(bounds.size, self.intrinsicContentSize) {
self.invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
return self.collection.contentSize
}
but return self.collection.contentSize always return (width, 0)
and for this reason it shrinks too much to value of height 30 (The value which I set in the XIB file for the height, although I have constaint >= 30).
I would suggest the following:
Add a height constraint to your collection view.
Set its priority to 999.
Set its constant to any value that makes it reasonably visible on the storyboard.
Change the bottom equal constraint of the collection view to greater or equal.
Connect the height constraint to an outlet.
Every time you reload the data on the collection view do the following:
You may also want to consider the Inset of the collection view by adding it to the content size.
Code Sample:
CGFloat height = myCollectionView.collectionViewLayout.collectionViewContentSize.height
heightConstraint.constant = height
self.view.setNeedsLayout() Or self.view.layoutIfNeeded()
Explanation: Extra, You don't have to read if you understand it. obviously!!
The UI will try to reflect all the constraints no matter what are their priorities. Since there is a height constraint with lower priority of (999), and a bottom constraint of type greater or equal. whenever, the height constraint constant is set to a value less than the parent view height the collection view will be equal to the given height, achieving both constraints.
But, when the height constraint constant set to a value more than the parent view height both constraints can't be achieved. Therefore, only the constraint with the higher priority will be achieved which is the greater or equal bottom constraint.
The following is just a guess from an experience. So, it achieves one constrant. But, it also tries to make the error in the resulted UI for the other un-achieved lower priority constraint as lowest as possible. Therefore, the collection view height will be equal to the parent view size.
In Swift 5 and Xcode 10.2.1
My CollectionView name is myCollectionView
Fix height for your CollectionView
Create Outlet for your CollectionViewHeight
IBOutlet weak var myCollectionViewHeight: NSLayoutConstraint!
Use below code
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let height = myCollectionView.collectionViewLayout.collectionViewContentSize.height
myCollectionViewHeight.constant = height
self.view.layoutIfNeeded()
}
Dynamic width for cell based on text content...
Dynamic cell width of UICollectionView depending on label width
1) Set Fix Height of your CollectionView.
2) Create Outlet of this CollectionView Height Constant.
Like :
IBOutlet NSLayoutConstraint *constHeight;
3) Add below method in your .m file:
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGFloat height = collectionMenu.collectionViewLayout.collectionViewContentSize.height;
constHeight.constant = height;
}
I ended up, by subclassing the UICollectionView and overriding some methods as follows.
Returning self.collectionViewLayout.collectionViewContentSize for intrinsicContentSize makes sure, to always have the correct size
Then just call it whenever it might change (like on reloadData)
Code:
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
return self.collectionViewLayout.collectionViewContentSize
}
But be aware, that you lose "cell re-using", if you display large sets of data, eventhough they don't fit on the screen.
This seemed like the simplest solution for me.
class SelfSizingCollectionView: UICollectionView {
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
isScrollEnabled = false
}
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
return contentSize
}
}
You may not need to override reloadData
You have to set height constraint as equal to content size
HeightConstraint.constant = collection.contentSize.height
Took the solution by d4Rk which is great, except in my case it would keep cutting off the bottom of my collection view (too short). I figured out this was because intrinsic content size was sometimes 0 and this would throw off the calculations. IDK. All I know is this fixed it.
import UIKit
class SelfSizedCollectionView: UICollectionView {
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
let s = self.collectionViewLayout.collectionViewContentSize
return CGSize(width: max(s.width, 1), height: max(s.height,1))
}
}
Subclass UICollectionView as follows
Delete height constraint if any
Turn on Intrinsic Size
-
class ContentSizedCollectionView: UICollectionView {
override var contentSize:CGSize {
didSet {
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
layoutIfNeeded()
return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewLayout.collectionViewContentSize.height)
}
}
If you set the height constraint of the collection view. Just observe the contentSize change in the viewDidLoad and update the constraint.
self.contentSizeObservation = collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, change in
guard let `self` = self else { return }
guard self.collectionView.contentSize != .zero else { return }
self.collectionViewHeightLayoutConstraint.constant = self.collectionView.contentSize.height
}
I have a multi-line, multi-selection UICollectionView subclass where the cells are of fixed height and left-aligned flowing from left to right. It's embedded in a vertical stack view that's inside a vertical scroll view. See the UI component below the label "Property Types".
In order for the collection view to fit the height of its contentSize, here's what I had to do (note that this is all within the UICollectionView subclass):
Give the collection view a non-zero minimum height constraint of priority 999. Auto-sizing the collection view to its content height simply won't work with zero height.
let minimumHeight = heightAnchor.constraint(greaterThanOrEqualToConstant: 1)
minimumHeight.priority = UILayoutPriority(999)
minimumHeight.isActive = true
Set the collection view's content hugging priority to .required for the vertical axis.
setContentHuggingPriority(.required, for: .vertical)
Calling reloadData() is followed by the following calls:
invalidateIntrinsicContentSize()
setNeedsLayout()
layoutIfNeeded()
For example, I have a setItems() function in my subclass:
func setItems(_ items: [Item]) {
self.items = items
selectedIndices = []
reloadData()
invalidateIntrinsicContentSize()
setNeedsLayout()
layoutIfNeeded()
}
Override contentSize and intrinsicContentSize as follows:
override var intrinsicContentSize: CGSize {
return contentSize
}
override var contentSize: CGSize {
didSet {
invalidateIntrinsicContentSize()
setNeedsLayout()
layoutIfNeeded()
}
}
Do following.
first set height constrain for UICollectionView
here calendarBaseViewHeight is UICollectionView height Variable
call the function after reload the collection view
func resizeCollectionViewSize(){
calendarBaseViewHeight.constant = collectionView.contentSize.height
}
first of all calculate number of cells than multiply it with height of cell and then return height in this method
collectionView.frame = CGRectMake (x,y,w,collectionView.collectionViewLayout.collectionViewContentSize.height); //objective c
//[collectionView reloadData];
collectionView.frame = CGRect(x: 0, y: 0, width: width, height: collectionView.collectionViewLayout.collectionViewContentSize.height) // swift
On your UICollectionView set your constraints such as Trailing, Leading, and Bottom:
If you look at my height constraint in more detail, as it is purely for storyboard look so I don't get errors, I have it to Remove at build time. The real height constraint is set in my code down below.
My code for DrawerCollectionView, which is set as the collection view Custom Class:
import UIKit
class DrawerCollectionView: UICollectionView {
override func didMoveToSuperview() {
super.didMoveToSuperview()
heightAnchor.constraint(equalToConstant: contentSize.height).isActive = true
}
}
Adjusting height of UICollectionView to the height of it's content size 🙌🏻
SWIFT 5
final class MyViewController: UIViewController {
// it's important to declare layout as separate constant due to late update in viewDidLayoutSubviews()
private let layout = UICollectionViewFlowLayout()
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
override func viewDidLoad() {
super.viewDidLoad()
setupCollectionView()
setupCollectionViewConstraints()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateFlowLayout()
}
private func setupCollectionView() {
view.addSubview(collectionView)
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "UICollectionViewCell")
collectionView.dataSource = self
}
private func setupCollectionViewConstraints() {
// your collectionView constraints setup
}
private func updateFlowLayout() {
let height = collectionView.collectionViewLayout.collectionViewContentSize.height
layout.itemSize = CGSize(width: view.frame.width, height: height)
layout.scrollDirection = .horizontal
layout.minimumInteritemSpacing = .zero
layout.minimumLineSpacing = .zero
layout.sectionInset = UIEdgeInsets.zero
}
}
extension MyViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {...}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {...}
}
work for me
let heightRes = resCollectionView.collectionViewLayout.collectionViewContentSize.height
foodHeightConstrant.constant = height.advanced(by: 1 )
foodCollectionView.setNeedsLayout()
foodCollectionView.layoutIfNeeded()
I was using a UICollectionView in UITableView cell. For me, the following solution worked.
In parent view of collection view, I updated the height constraint in layoutSubviews method like this
override func layoutSubviews() {
heightConstraint.constant = myCollectionView.collectionViewLayout.collectionViewContentSize.height
}
and then in cellForRowAtIndexpath, just before returning the cell, call this
cell.layoutIfNeeded()
The only solution worked for me when CollectionView is inside TableView custom cell is to
Subclass from ContentSizedCollectionView:
final class ContentSizedCollectionView: UICollectionView {
override var contentSize: CGSize{
didSet {
if oldValue.height != self.contentSize.height {
invalidateIntrinsicContentSize()
}
}
}
override var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric,
height: contentSize.height)
}
}
private let collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.sectionInset = UIEdgeInsets(top: 20, left: 17, bottom: 20, right: 17)
let collectionView = ContentSizedCollectionView(frame: .zero, collectionViewLayout: layout).prepareForAutoLayout()
return collectionView
}()
In UITableViewDelegate for TableView cell:
if let reusableCell = cell as? YourTableViewCell {
reusableCell.frame = tableView.bounds
reusableCell.layoutIfNeeded()
}
Remove height constraints of UICollectionView if any.
This article helped me a lot: https://medium.com/#ar.sarris/self-sizing-collectionview-inside-a-tableview-f1fd4f42c44d
Get the height of the cell. Something like this
let cellHeight = cell.frame.height
Get the origin of the collection view
let cvOrigin = collectionView.frame.origin
Get the width of the collection view
let cvWidth = collectionView.bounds.width
Set the frame of the content view
collection.frame = CGRect(x: cvOrigin.x, y: cvOrigin.y, width: cvWidth, height: cellHeight )

Settings UICollectionViewFlowLayout's properties not working when using Auto Layout

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.

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
)
}

Subview frame is incorrect when creating UICollectionViewCell

The problem
I created a UICollectionViewController with a custom UICollectionViewCell.
The custom cell contains a large and rectangular UIView (named colorView) and a UILabel (named nameLabel).
When the collection is first populated with its cells and I print colorView.frame, the printed frames have incorrect values. I know they are incorrect, because the colorView frames are larger than the cell frame themselves, even though the colorView gets drawn correctly.
However, if I scroll the collectionView enough to trigger a reuse of a previously created cell, the colorView.frame now has correct values!
I need the correct frames because I want to apply rounded corners to the colorView layer and I need the correct coloView size in order to do this.
By the way, in case you are wondering, colorView.bounds also has the same wrong size value as the colorView.frame.
The question
Why are the frames incorrect when creating the cells?
And now some code
This is my UICollectionViewCell:
class BugCollectionViewCell: UICollectionViewCell {
#IBOutlet weak var colorView: UIView!
#IBOutlet weak var nameLabel: UILabel!
}
and this is the UICollectionViewController:
import UIKit
let reuseIdentifier = "Cell"
let colors = [UIColor.redColor(), UIColor.blueColor(),
UIColor.greenColor(), UIColor.purpleColor()]
let labels = ["red", "blue", "green", "purple"]
class BugCollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
override func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as BugCollectionViewCell
println("ColorView frame: \(cell.colorView.frame) Cell frame: \(cell.frame)")
cell.colorView.backgroundColor = colors[indexPath.row]
cell.nameLabel.text = labels[indexPath.row]
return cell
}
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
let width = self.collectionView?.frame.width
let height = self.collectionView?.frame.height
return CGSizeMake(width!, height!/2)
}
}
The collection view is setup in order to show two cells at a time, vertically, each cell containing a large rectangle painted with a color and a label with the color name.
When I just run the above code on the simulator, I get the following printed result:
ColorView frame: (0.0,0.0,320.0,568.0) Cell frame: (0.0,0.0,375.0,333.5)
ColorView frame: (0.0,0.0,320.0,568.0) Cell frame: (0.0,343.5,375.0,333.5)
It is a weird result - colorView.frame has a height of 568 points, while the cell frame is only 333.5 points tall.
If I drag the collectionView down and a cell gets reused, the following result is printed:
ColorView frame: (8.0,8.0,359.0,294.0) Cell frame: (0.0,1030.5,375.0,333.5)
ColorView frame: (8.0,8.0,359.0,294.0) Cell frame: (0.0,343.5,375.0,333.5)
Something, which I can’t understand, happened along the way that corrects the frame of colorView.
I think it has something to do with the fact that the cell is loaded from the Nib, so instead of using the init(frame: frame) initializer the controller uses the init(coder: aCoder) initializer, so as soon as the cell is created it probably comes with some default frame which I can't edit anyhow.
I’ll appreciate any help that allows me to understand what is happening!
I am using Xcode 6.1.1. with the iOS SDK 8.1.
You can get the final frames of your cell by overriding layoutIfNeeded() in your custom Cell class like this:
override func layoutIfNeeded() {
super.layoutIfNeeded()
self.subView.layer.cornerRadius = self.subView.bounds.width / 2
}
then in your UICollectionView data Source method cellForRowAtIndexPath: do this:
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! CustomCollectionViewCell
cell.setNeedsLayout()
cell.layoutIfNeeded()
I had the same issue with a UICollectionViewCell using auto layout constraints.
I had to call layoutIfNeeded before I was configuring my subview that relied on the views frame width.
Had this issue with Core Graphics drawing in iOS 10, Swift 3.0.1.
Add this method to UICollectionView subclass:
override func didMoveToSuperview() {
super.didMoveToSuperview()
setNeedsLayout()
layoutIfNeeded()
}
My problem was that Core Graphics shapes were not calculated properly, because a layoutSubviews() wasn't called.
Ok, I understand now that the cell is created before auto layout defines its frames. That is the reason why at the moment of creation the bounds are wrong. When the cells are reused the frames have been already corrected.
I was having this problem while creating a custom UIView that placed some layers and subviews in specific coordinates. When instances of this UIView were created, the placement of the subviews were all wrong (because auto layout hadn't kick off yet).
I found out that instead of configuring the view subviews on init(coder: aCoder) I had to override the method layoutSubviews(). This is called when auto layout asks each view to layout its own subviews, so at this point at least the parent view has the correct frame and I can use it for laying the subviews correctly.
Probably if I had used constraints on the custom view code instead of dealing myself with frame sizes and positioning then the layout would have been done properly and it wouldn't be necessary to override layoutSubviews().
I'd suggest making a subclass of whatever you're doing. I needed a gradient over an UIImageView in my cell and it was calculating it wrongly. I tried the suggestion with layoutSubviews but it was also causing issues where it seems like it would apply gradient twice.
I made a UIImageView subclass and it works as wanted.
class MyOwnImageView: UIImageView{
override func layoutSubviews() {
super.layoutSubviews()
let view = UIView(frame: frame)
let width = bounds.width
let height = bounds.height
let sHeight:CGFloat = 122.0
let shadow = UIColor.black.withAlphaComponent(0.9).cgColor
let topImageGradient = CAGradientLayer()
topImageGradient.frame = CGRect(x: 0, y: 0, width: width, height: sHeight)
topImageGradient.colors = [shadow, UIColor.clear.cgColor]
view.layer.insertSublayer(topImageGradient, at: 0)
let bottomImageGradient = CAGradientLayer()
bottomImageGradient.frame = CGRect(x: 0, y: height - sHeight, width: width, height: sHeight)
bottomImageGradient.colors = [UIColor.clear.cgColor, shadow]
view.layer.insertSublayer(bottomImageGradient, at: 0)
addSubview(view)
bringSubviewToFront(view)
}
}

Resources