UICollectionView and AutoLayout - ios

I'm trying to create a UICollectionView whose width/height and coordinates are defined using AutoLayout (using SnapKit). When using the default UICollectionView constructor it fails with the following reason:
reason: 'UICollectionView must be initialized with a non-nil layout parameter'
The only constructor that allows a layout to be passed in, also requires a frame, so I tried using CGRectZero as the value for the frame like so:
collectionView = UICollectionView(frame: CGRectZero, collectionViewLayout: layout)
I then used SnapKit to setup the constraints like so:
collectionView?.snp_makeConstraints { make -> Void in
make.width.equalTo(view)
make.height.equalTo(300)
make.top.equalTo(someOtherView)
}
However, when doing so the UICollectionView is never rendered. In fact, I do not see the data source being called at all.
Any thoughts on how to use AutoLayout with a UICollectionView or what I might be doing wrong?

This following code works just fine for me and I got a red and empty collection view.
Xcode 7 beta 4 - Swift 2.0 - AutoLayout syntax from iOS 9
override func viewDidLoad() {
super.viewDidLoad()
let collectionView = UICollectionView(frame: CGRectZero, collectionViewLayout: UICollectionViewLayout())
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = UIColor.redColor()
self.view.addSubview(collectionView)
collectionView.widthAnchor.constraintEqualToAnchor(self.view.widthAnchor).active = true
collectionView.heightAnchor.constraintEqualToConstant(300).active = true
collectionView.topAnchor.constraintEqualToAnchor(self.view.topAnchor).active = true
}
I never used SnapKit but I do know it. The syntax provided in my code is almost the same as in your example. So it is only a clue that something is wrong with SnapKit or how you use it.
I hope this can help you somehow.

Related

iOS - How to make an UICollectionViewCell adapt its height according to its content ? (containing an UITableView)

I don't know why it is so complicated to design cells that can adapt to its content. It shouldn't need that much code, I still don't understand why UIKit can't handle this properly.
Anyway, here is my issue (I have edited the whole post):
I have an UICollectionViewCell that contains an UITableView.
Here is my sizeForItem method :
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
var cellWidth: CGFloat = collectionView.bounds.size.width
var cellHeight: CGFloat = 0
let cellConfigurator = items[indexPath.item].cellConfigurator
if type(of: cellConfigurator).reuseId == "MoonCollectionViewCell" {
if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: type(of: cellConfigurator).reuseId, for: indexPath) as? MoonCollectionViewCell {
cell.contentView.layoutIfNeeded()
let size = cell.selfSizedTableView.intrinsicContentSize
cellHeight = size.height
}
}
return CGSize.init(width: cellWidth, height: cellHeight)
}
sizeForItem is called before cellForItem, that's the reason of the layoutIfNeeded, because I couldn't get the correct intrinsic content size.
I have removed the XIB as suggested, and designed my UICollectionViewCell within the Storyboard.
Here is my UICollectionViewCell designed within a Storyboard (only the UITableViewCell is designed in a XIB file)
I only added an UITableView within the UICollectionViewCell.
I want the UICollectionViewCell to adapt its size according to the height of the tableView.
Now here is my tableView :
I have created a subclass of UITableView (from this post)
class SelfSizedTableView: UITableView {
var maxHeight: CGFloat = UIScreen.main.bounds.size.height
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
self.layoutIfNeeded()
}
override var intrinsicContentSize: CGSize {
let height = min(contentSize.height, maxHeight)
return CGSize(width: contentSize.width, height: height)
}
}
Please note that I have disabled scrolling, I have dynamic prototype for the tableView cells, the style is grouped.
EDIT : Check the configure method, it comes from a protocol I used to configure in a generic way all my UICollectionViewCell
func configure(data: [MoonImages]) {
selfSizedTableView.register(UINib.init(nibName: "MoonTableViewCell", bundle: nil), forCellReuseIdentifier: "MoonTableViewCell")
selfSizedTableView.delegate = self
selfSizedTableView.dataSource = moonDataSource
var frame = CGRect.zero
frame.size.height = .leastNormalMagnitude
selfSizedTableView.tableHeaderView = UIView(frame: frame)
selfSizedTableView.tableFooterView = UIView(frame: frame)
selfSizedTableView.maxHeight = 240.0
selfSizedTableView.estimatedRowHeight = 40.0
selfSizedTableView.rowHeight = UITableView.automaticDimension
moonDataSource.data.addAndNotify(observer: self) { [weak self] in
self?.selfSizedTableView.reloadData()
}
moonDataSource.data.value = data
}
FYI the dataSource is a custom dataSource, with dynamic value (Generics) and the observer pattern, to reload the collection/tableView when the data is set.
I also have this warning when I launch the App.
[CollectionView] An attempt to update layout information was detected
while already in the process of computing the layout (i.e. reentrant
call). This will result in unexpected behaviour or a crash. This may
happen if a layout pass is triggered while calling out to a delegate.
Any hints or advice on how I should handle this ?
Because I am facing a strange behavior, it's like my sizeForItem use random values. The UICollectionViewCell height is not the same than my UITableView intrinsic content size height.
If I have 2 rows within my UITableView, the UICollectionView is not always equal at this size. I really don't know how to achieve this...
Should I invalideLayout?
Maybe it's not the answer you wanted, but here're my two cents. For your particular requirements, the better solution is moving away from UITableView, and use UIStackView or your custom container view.
Here's why:
UITableView is a subclass of UIScrollView, but since you've disabled its scrolling feature, you don't need a UIScrollView.
UITableView is mainly used to reuse cells, to improve performance and make code more structured. But since you're making it as large as its content size, none of your cells are reused, so features of UITableView is not taken any advantage of.
Thus, actually you don't need and you should not use either UITableView or UIScrollView inside the UICollectionViewCell for your requirements.
If you agree with above part, here're some learnings from our practices:
We always move most of the underlying views and code logics, mainly data assembling, into a UIView based custom view, instead of putting in UITableViewCell or UICollectionViewCell directly. Then add it to UITableViewCell or UICollectionViewCell's contentView and setup constraints. With this structure, we can reuse our custom view in more scenarios.
For requirements similar to yours, we'll create a factory class to create "rows" similar to how you create "cells" for your UITableView, add them into a vertical UIStackView, create constraints deciding UIStackView's width. Auto layout will take care of the rest things.
In your usage with UICollectionViewCell, to calculate the wanted height, inside preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) func of your cell, you can use contentView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) to calculate the height, do some check and return. Also, remember to invalidate layout when the width of the UICollectionView changes.
It is indeed very tricky, but I found a working way to solve this problem. As far as i know i got this from a chat app, where message bubble sizes are dynamic.
Here we go:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
// Minimum size
let frame = CGRect(x: 0, y: 0, width: view.frame.width - 30, height: 0)
let cell = MoonCollectionViewCell()
// Fill it with the content it will have in the actual cell,
// cell.content is just an example
let cell.content = items[indexPath.item]
cell.layoutIfNeeded()
// Define the maximum size it can be
let targetSize = CGSize(width: view.frame.width - 30, height: 240)
let estimatedSize = cell.systemLayoutSizeFittingSize(tagetSize)
return CGSize(width: view.frame.width - 30, height: estimatedSize.height)
}
What it basically do is, to define a minimum frame and the size that is targeted. Then by calling systemLayoutSizeFittingSize, it resizes the cell to the optimal size, but not larger than the targetSize.
Adjust the code to your needs, but this should work.
I tried to find the culprit in the posted code, but it seems that there are many moving parts. So, I will try to give some hints, that hopefully could help.
In theory (there is caveat for iOS 12), self sizing UICollectionViewCells should not be difficult. You essentially could set the collectionViewLayout.estimedItemSize to any value (preferred is the constant below), like this:
(collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
Then you have to make sure the constraints in the cells are set in a way that it can self size; that is auto layout can calculate the width and the height of the cell. You are providing an intrinsicContentSize of the tableView and it is wrapped by its super view from all four ends, so this should be OK.
Once you set the estimatedItemSize as shown above, you should not implement the delegate method returning the size:
func collectionView(_: UICollectionView, layout: UICollectionViewLayout, sizeForItemAt: IndexPath) -> CGSize
A quick tutorial can be found here for further reference: https://medium.com/#wasinwiwongsak/uicollectionview-with-autosizing-cell-using-autolayout-in-ios-9-10-84ab5cdf35a2
As I said in theory it should not be difficult, but cell auto sizing seems broken on iOS 12 see here In iOS 12, when does the UICollectionView layout cells, use autolayout in nib
If I were in you position, I would start from afresh, adding complexity step by step:
try implement the self sizing cells, possibly with with a simple UIView and an override of intrinsicContentSize; possibly by using iOS 11.4 SDK to exclude issues relevant to iOS 12 (the easiest way is to download latest Xcode 9 and work from there); if not possible do the iOS 12 fixes at this step
replace the simple view with a table view (which may also have dynamic sizing per see)
do the tableview reload data flow, i.e. dynamic sizing feature
if everything OK, do the iOS 12 fixes and migrate to iOS 12
Hope this helps.
BTW, the warning in the console is probably due to call to layoutIfNeeded() in the delegate method. It triggers an immediate layout pass, whereas this is done for the UICollectionView once all sizes are collected.

layoutSubviews freezes the app when added to UICollectionView

I would like to be notified when my custom UICollectionView gets its size. It is created from xib.
I've added the following function:
override func layoutSubviews() {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.itemSize = CGSize(width: segmentWidth(), height: segmentHeight())
self.setCollectionViewLayout(layout, animated: false)
}
This function is called endlessly. What am I doing wrong?
You shouldn't need to update the layout like this when the collection view changes.
In fact, because you're using a Flow Layout there should be a much easier way of getting it to do what you want.
I presume that segmentHeight and segmentWidth are functions that depend on the size of the collection view which is why you need to recalculate them?
If so then you should be able to use the delegate functions of the UICollectionViewFlowLayout.
First update your view controller with a new protocol...
class MyViewController: UICollectionViewDelegateFlowLayout
This will then let your view controller act as a delegate for the flow layout.
And use the method...
func collectionView(UICollectionView, layout: UICollectionViewLayout, sizeForItemAt: IndexPath) -> CGSize {
return CGSize(width: segmentWidth(), height: segmentHeight())
}
That should be it.
If I've missed anything out let me know but you should get everything you want from that.
After that just delete the layoutSubviews method and it should be performing as you want.

How to use ScrollView with CollectionView

I need to have a view controller that has a scroll in view.
The problem is that I need that both scrolls (of scrollView and collectionView) work together.
But when I add ScrollView in all frame, nothing works...
That's an image that shows what I want:
And when I scroll viewController, all content scroll together..
Someone know a good way to implement this in swift 4?
Thanks in advance.
I've seen this done many ways. The most popular of which is to have these views embedded in a UITableView.
You can set the first cell height to a proportional amount to the superview, while keeping another cell that will contain your CollectionView. You would be able to take advantage of the UITableView's scrollView as well as it's memory efficiency and delegate.
A better way to do this is to use the scroll direction in UICollectionViewDelagateFlowLayout. Just add the UICollectionViewDelegateFlowLayout protocol or extends your ViewController to it. Then use .scrollDirection in the section of your UICollectionView.
And sample declaration like this
let collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
...
layout.scrollDirection = .vertical //or .horizontal depends on your liking
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
...//
return cv
}()
then add to your view view.addSubview(collectionView)
More info in the apple doc or jump to definition

Creating a swiping card stack with UICollectionView and UICollectionViewCells?

So far, I made my collection views which scroll horizontally in either left or right.I added UICollectionViewCells into one UICollectionView. My problem that I'm having is trying to find the right settings to make the cards stack on top of the first card, as demonstrated in the photo below.
Here are the settings for my collectionView and how it displays its cells.
lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .Horizontal
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 78
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.backgroundColor = UIColor.clearColor()
cv.showsHorizontalScrollIndicator = false
cv.translatesAutoresizingMaskIntoConstraints = false
return cv
}()
You're going to have to subclass UICollectionViewLayout, set the UICollectionViewLayoutAttributes for each cell, and make sure to set zIndex to the indexPath.row of the item in order to get overlapping. You can take a look at my sample project on GitHub that implements something very similar.
There are some bugs in UICollectionViewLayout related to animating insertions of new cells when the cells are overlapping (which is why I made the sample project in the first place).

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.

Resources