Custom UICollectionViewLayout and floating supplementary header views crash - ios

I am trying to create a UICollectionView which represents a grid and I would like to create a ruler (floating header view) at the top edge which floats along in the y-axis.
So, much like in UITableViews, this view should basically just scroll with the content at the top of the screen. I hope that description makes it clear enough. So much for theory, let's get to the practical part!
Here is what I am working with now:
I registered the class GridRulerView which is a subclass of
UICollectionReusableView with the collection view.
I return the correct Size for the supplementary view for the right kind and CGSizeZero where there is no header.
I implemented viewForSupplementaryElementOfKind::
func collectionView(collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, atIndexPath indexPath: NSIndexPath) -> UICollectionReusableView
{
var reusableHeaderView : UICollectionReusableView!
if kind == GridRulerView.Kind{
reusableHeaderView = collectionView.dequeueReusableSupplementaryViewOfKind(GridRulerView.Kind, withReuseIdentifier: GridRulerView.Kind, forIndexPath: indexPath) as! UICollectionReusableView
}
return reusableHeaderView
}
I have a custom UICollectionViewLayout with the following
layoutAttributesForElementsInRect: method:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
var attribs = [UICollectionViewLayoutAttributes]()
for (indexPath, attrs) in self.attributes{
if CGRectIntersectsRect(rect, attrs.frame) {
attribs.append(attrs)
}
}
var headerAttributes = GridViewRulerAttributes(forSupplementaryViewOfKind: GridRulerView.Kind, withIndexPath: NSIndexPath(forItem: 0, inSection: 0))
headerAttributes.zIndex = 500
headerAttributes.frame = CGRectMake(0, self.collectionView!.contentOffset.y, self.preCalculatedContentSize.width, 80)
attribs.append(headerAttributes)
return attribs
}
When I start the app, everything looks great, exactly how I want it.
BUT as soon as I scroll the collectionView beyond it's bounds (collectionView's contentOffset = (-0.5,-0.5) <- any negative values for example) I get a crash:
*** Assertion failure in -[UICollectionViewData validateLayoutInRect:], /SourceCache/UIKit/UIKit-3318.16.25/UICollectionViewData.m:426
The interesting thing is that if I set the header view attribute's frame's y property to 0 instead of the variable contentOffset.y (which becomes negative), everything works fine.
Anybody any idea why this happens ?
EDIT
Here is a more informative error message:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'layout attributes for supplementary item at index path ( {length = 2, path = 0 - 0}) changed from index path: ( {length = 2, path = 0 - 0}); element kind: (GridRulerViewKind); frame = (0 0; 425.227 24); zIndex = 500; to index path: ( {length = 2, path = 0 - 0}); element kind: (GridRulerViewKind); frame = (0 -5; 425.227 24); zIndex = 500; without invalidating the layout'

Wow, actually disabled "All Exceptions" breakpoints and got a more informative error to work with:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'layout attributes for supplementary item at index path ( {length = 2, path = 0 - 0}) changed from index path: ( {length = 2, path = 0 - 0}); element kind: (GridRulerViewKind); frame = (0 0; 425.227 24); zIndex = 500; to index path: ( {length = 2, path = 0 - 0}); element kind: (GridRulerViewKind); frame = (0 -5; 425.227 24); zIndex = 500; without invalidating the layout'
Now, I still had no idea what to do, or where to
"invalidate the layout"
Then while browsing similar questions, I came across a similar question here on SO where I stumbled upon this simple function:
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool{
return true
}
And sure enough, that seemed to work! No crash anymore!
EDIT
Actually, this is causing the collection view layout to invalidate on every scroll, so this does not seem to be the solution I am looking for...
Edit #2
After a bit of research, I don't think it is possible to create a supplementary view that changes by itself without invalidating the layout (which kind of seems perfectly logical - how would the layout know to just update the ruler view without explicitly saying so ?). And updating the layout on each scroll is just way too expensive.
So I figured I will just create a separate view which is entirely independent of the collection view and simply listening to the collection view's scroll delegate and adjusting its frame according to the scroll offset. I think this approach is much more efficient in terms of performance and is equally easy to implement.

Solution
Just check representedElementKind
class CustomFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributesForElementsInRect = super.layoutAttributesForElements(in: rect)
var newAttributesForElementsInRect = [UICollectionViewLayoutAttributes]()
for attributes in attributesForElementsInRect! {
if !(attributes.representedElementKind == UICollectionElementKindSectionHeader
|| attributes.representedElementKind == UICollectionElementKindSectionFooter) {
// cells will be customise here, but Header and Footer will have layout without changes.
}
newAttributesForElementsInRect.append(attributes)
}
return newAttributesForElementsInRect
}
}
class YourViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let headerNib = UINib.init(nibName: "HeaderCell", bundle: nil)
collectionView.register(headerNib, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "HeaderCell")
let footerNib = UINib.init(nibName: "FooterCell", bundle: nil)
collectionView.register(footerNib, forSupplementaryViewOfKind: UICollectionElementKindSectionFooter, withReuseIdentifier: "FooterCell")
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionElementKindSectionHeader:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "HeaderCell", for: indexPath) as! HeaderCell
return headerView
case UICollectionElementKindSectionFooter:
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "FooterCell", for: indexPath) as! FooterCell
return footerView
default:
return UICollectionReusableView()
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: collectionView.frame.width, height: 45)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
return CGSize(width: collectionView.frame.width, height: 25)
}
}

Why you are passing GridRulerView.Kind for ReuseIdentifier?
reusableHeaderView = collectionView.dequeueReusableSupplementaryViewOfKind(GridRulerView.Kind, withReuseIdentifier: **GridRulerView.Kind**, forIndexPath: indexPath) as! UICollectionReusableView
It should be as below.
var supplementaryView =
collectionView.dequeueReusableSupplementaryViewOfKind(kind,
withReuseIdentifier:Identifiers.HeaderIdentifier.rawValue,
forIndexPath: indexPath) as UICollectionReusableView

Related

UICollectionViewCell created from XIB will cause flickering during drag and drop

I implement a simple drag and drop sample.
import UIKit
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
private var collectionView: UICollectionView?
var colors: [UIColor] = [
.link,
.systemGreen,
.systemBlue,
.red,
.systemOrange,
.black,
.systemPurple,
.systemYellow,
.systemPink,
.link,
.systemGreen,
.systemBlue,
.red,
.systemOrange,
.black,
.systemPurple,
.systemYellow,
.systemPink
]
override func viewDidLoad() {
super.viewDidLoad()
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.itemSize = CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
//collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")
collectionView?.delegate = self
collectionView?.dataSource = self
collectionView?.backgroundColor = .white
view.addSubview(collectionView!)
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture))
collectionView?.addGestureRecognizer(gesture)
}
#objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
guard let collectionView = collectionView else {
return
}
switch gesture.state {
case .began:
guard let targetIndexPath = collectionView.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
return
}
collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
case .changed:
collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: collectionView))
case .ended:
collectionView.endInteractiveMovement()
default:
collectionView.cancelInteractiveMovement()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView?.frame = view.bounds
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.backgroundColor = colors[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
}
func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let item = colors.remove(at: sourceIndexPath.row)
colors.insert(item, at: destinationIndexPath.row)
}
}
However, I notice that, if my UICollectionViewCell is created with XIB, it will randomly exhibit flickering behaviour, during drag and drop.
The CustomCollectionViewCell is a pretty straightforward code.
CustomCollectionViewCell.swift
import UIKit
extension UIView {
static func instanceFromNib() -> Self {
return getUINib().instantiate(withOwner: self, options: nil)[0] as! Self
}
static func getUINib() -> UINib {
return UINib(nibName: String(describing: self), bundle: nil)
}
}
class CustomCollectionViewCell: UICollectionViewCell {
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
}
Flickering
By using the following code
let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")
It will have the following random flickering behaviour - https://youtu.be/CbcUAHlRJKI
No flickering
However, if the following code is used instead
collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
Things work fine. There are no flickering behaviour - https://youtu.be/QkV2HlIrXK8
May I know why it is so? How can I avoid the flickering behaviour, when my custom UICollectionView is created from XIB?
Please note that, the flickering behaviour doesn't happen all the time. It happens randomly. It is easier to reproduce the problem using real iPhone device, than simulator.
Here's the complete sample code - https://github.com/yccheok/xib-view-cell-cause-flickering
While we are rearranging cells in UICollectionView (gesture is active), it handles all of the cell movements for us (without having us to worry about changing dataSource while the rearrange is in flight).
At the end of this rearrange gesture, UICollectionView rightfully expects that we will reflect the change in our dataSource as well which you are doing correctly here.
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let item = colors.remove(at: sourceIndexPath.row)
colors.insert(item, at: destinationIndexPath.row)
}
Since UICollectionView expects a dataSource update from our side, it performs following steps -
Call our collectionView(_:, moveItemAt:, to:) implementation to provide us a chance to reflect the changes in dataSource.
Call our collectionView(_:, cellForItemAt:) implementation for the destinationIndexPath value from call #1, to re-create a new cell at that indexPath from scratch.
Okay, but why would it perform step 2 even if this is the correct cell to be at that indexPath?
It's because UICollectionView doesn't know for sure whether you actually made those dataSource changes or not. What happens if you don't make those changes? - now your dataSource & UI are out of sync.
In order to make sure that your dataSource changes are correctly reflected in the UI, it has to do this step.
Now when the cell is being re-created, you sometimes see the flicker. Let the UI reload the first time, put a breakpoint in the cellForItemAt: implementation at the first line and rearrange a cell. Right after rearrange completes, your program will pause at that breakpoint and you can see following on the screen.
Why does it not happen with UICollectionViewCell class (not XIB)?
It does (as noted by others) - it's less frequent. Using the above steps by putting a breakpoint, you can catch it in that state.
How to solve this?
Get a reference to the cell that's currently being dragged.
Return this instance from cellForItemAt: implementation.
var currentlyBeingDraggedCell: UICollectionViewCell?
var willRecreateCellAtDraggedIndexPath: Bool = false
#objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
guard let cv = collectionView else { return }
let location = gesture.location(in: cv)
switch gesture.state {
case .began:
guard let targetIndexPath = cv.indexPathForItem(at: location) else { return }
currentlyBeingDraggedCell = cv.cellForItem(at: targetIndexPath)
cv.beginInteractiveMovementForItem(at: targetIndexPath)
case .changed:
cv.updateInteractiveMovementTargetPosition(location)
case .ended:
willRecreateCellAtDraggedIndexPath = true
cv.endInteractiveMovement()
default:
cv.cancelInteractiveMovement()
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if willRecreateCellAtDraggedIndexPath,
let currentlyBeingDraggedCell = currentlyBeingDraggedCell {
self.willRecreateCellAtDraggedIndexPath = false
self.currentlyBeingDraggedCell = nil
return currentlyBeingDraggedCell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentView.backgroundColor = colors[indexPath.item]
return cell
}
Will this solve the problem 100%?
NO. UICollectionView will still remove the cell from it's view hierarchy and ask us for a new cell - we are just providing it with an existing cell instance (that we know is going to be correct according to our own implementation).
You can still catch it in the state where it disappears from UI before appearing again. However this time there's almost no work to be done, so it will be significantly faster and you will see the flickering less often.
BONUS
iOS 15 seems to be working on similar problems via UICollectionView.reconfigureItems APIs. See an explanation in following Twitter thread.
Whether these improvements will land in rearrange or not, we will have to see.
Other Observations
Your UICollectionViewCell subclass' XIB looks like following
However it should look like following (1st one is missing contentView wrapper, you get this by default when you drag a Collection View Cell to the XIB from the View library OR create a UICollectionViewCell subclass with XIB).
And your implementation uses -
cell.backgroundColor = colors[indexPath.row]
You should use contentView to do all the UI customization, also note the indexPath.item(vs row) that better fits with cellForItemAt: terminology (There are no differences in these values though). cellForRowAt: & indexPath.row are more suited for UITableView instances.
cell.contentView.backgroundColor = colors[indexPath.item]
UPDATE
Should I use this workaround for my app in production?
NO.
As noted by OP in the comments below -
The proposed workaround has 2 shortcomings.
(1) Missing cell
(2) Wrong content cell.
This is clearly visible in https://www.youtube.com/watch?v=uDRgo0Jczuw Even if you perform explicit currentlyBeingDraggedCell.backgroundColor = colors[indexPath.item] within if block, wrong content cell issue is still there.
The flickering is caused by the cell being recreated at its new position. You can try holding to the cell.
(only the relevant code is shown)
// keeps a reference to the cell being dragged
private weak var draggedCell: UICollectionViewCell?
// the flag is set when the dragging completes
private var didInteractiveMovementEnd = false
#objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
// keep cell reference
draggedCell = collectionView.cellForItem(at: targetIndexPath)
collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
case .ended:
// reuse the cell in `cellForItem`
didInteractiveMovementEnd = true
collectionView.performBatchUpdates {
collectionView.endInteractiveMovement()
} completion: { completed in
self.draggedCell = nil
self.didInteractiveMovementEnd = false
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// reuse the dragged cell
if didInteractiveMovementEnd, let draggedCell = draggedCell {
return draggedCell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
...
}

Could not dequeue a view of kind: UICollectionElementKindCell

I need a little help. I'm trying to create UICollectionView with header section, using custom layout for UICollectionViewCell(item) and UICollectionReusableView(header). Before header, everything was working perfectly, but now for some reason I'm always getting this error:
Terminating app due to uncaught exception 'NSInternalInconsistencyException',
reason:
'could not dequeue a view of
kind: UICollectionElementKindCell with identifier ProductCellView - must register
a nib or a class for the identifier or connect a prototype cell in a storyboard'
I have registered both files in CollectionView. Here is my code:
override func viewDidLoad() {
super.viewDidLoad()
productsCollectionView.delegate = self
productsCollectionView.dataSource = self
productsCollectionView.register(UINib(nibName: "SectionHeader", bundle: nil), forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "CollectionReusableView");
productsCollectionView.register(UINib(nibName: "HomeProductViewCell", bundle: nil), forCellWithReuseIdentifier: "ProductCellView")
}
And I will post now one block of code which is used for handling view of header section and which is causing crash. If I comment that part of code, collection view will show regular items without header of course. Here it is:
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
//1
switch kind {
//2
case UICollectionElementKindSectionHeader:
//3
if let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "CollectionReusableView", for: indexPath) as? SectionHeader{
sectionHeader.categoriesCollectionView.register(UINib(nibName: "CategoryViewCell", bundle: nil), forCellWithReuseIdentifier: "CategoryViewCell")
sectionHeader.homeImagesCollectionView.register(UINib(nibName: "HomeImageViewCell", bundle: nil), forCellWithReuseIdentifier: "HomeImageViewCell")
sectionHeader.homeImagesCollectionView.delegate = self
sectionHeader.homeImagesCollectionView.dataSource = self
sectionHeader.categoriesCollectionView.delegate = self
sectionHeader.categoriesCollectionView.dataSource = self
// Add icon to button
let icon = UIImage(named: "magnifying-glass")!
sectionHeader.btnSearch.setImage(icon, for: .normal)
sectionHeader.btnSearch.imageEdgeInsets = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 20)
sectionHeader.btnSearch.titleEdgeInsets = UIEdgeInsets(top: 0, left: 30, bottom: 0, right: 0)
sectionHeader.btnSearch.imageView?.contentMode = .scaleAspectFit
sectionHeader.btnSearch.layer.cornerRadius = 4
sectionHeader.prepareCategories()
sectionHeader.prepareHomeImages()
return sectionHeader
}
default:
//4
assert(false, "Unexpected element kind")
}
return UICollectionReusableView()
}
So I'm really desperate here because I have lost almost two days finding bug why this is happening. I would really appreciate if someone point me in the right direction where to look for bug, because I have tried almost everything I knew.
The problem is in the inner collections since you set the delegate & dataSource here
sectionHeader.homeImagesCollectionView.delegate = self
sectionHeader.homeImagesCollectionView.dataSource = self
sectionHeader.categoriesCollectionView.delegate = self
sectionHeader.categoriesCollectionView.dataSource = self
it asks for
if let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "CollectionReusableView", for: indexPath) as? SectionHeader{
so you have to wrap the whole part inside the type of the collection
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
if collectionView == mainCollection {
}
else
if collectionView == homeImagesCollectionView {
}
else { // categoriesCollectionView
}
}
BTW register should be inside viewDidLoad for a collection inside a VC , and in init/awakeFromnib inside a collectionCell custom class
I experienced a similar problem when I was fetching my data and reloading the tableview before registering the nibs. This is my best answer given the info you supplied.

How to correctly invalidate layout for supplementary views in UICollectionView

I am having a dataset displayed in a UICollectionView. The dataset is split into sections and each section has a header. Further, each cell has a detail view underneath it that is expanded when the cell is clicked.
For reference:
For simplicity, I have implemented the details cells as standard cells that are hidden (height: 0) by default and when the non-detail cell is clicked, the height is set to non-zero value. The cells are updates using invalidateItems(at indexPaths: [IndexPath]) instead of reloading cells in performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) as the animations seems glitchy otherwise.
Now to the problem, the invalidateItems function obviously updates only cells, not supplementary views like the section header and therefore calling only this function will result in overflowing the section header:
After some time Googling, I found out that in order to update also the supplementary views, one has to call invalidateSupplementaryElements(ofKind elementKind: String, at indexPaths: [IndexPath]). This might recalculate the section header's bounds correctly, however results in the content not appearing:
This is most likely caused due to the fact that the func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView does not seem to be called.
I would be extremely grateful if somebody could tell me how to correctly invalidate supplementary views to the issues above do not happen.
Code:
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return dataManager.getSectionCount()
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let count = dataManager.getSectionItemCount(section: section)
reminder = count % itemsPerWidth
return count * 2
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if isDetailCell(indexPath: indexPath) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Reusable.CELL_SERVICE, for: indexPath) as! ServiceCollectionViewCell
cell.lblName.text = "Americano detail"
cell.layer.borderWidth = 0.5
cell.layer.borderColor = UIColor(hexString: "#999999").cgColor
return cell
} else {
let item = indexPath.item > itemsPerWidth ? indexPath.item - (((indexPath.item / itemsPerWidth) / 2) * itemsPerWidth) : indexPath.item
let product = dataManager.getItem(index: item, section: indexPath.section)
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Reusable.CELL_SERVICE, for: indexPath) as! ServiceCollectionViewCell
cell.lblName.text = product.name
cell.layer.borderWidth = 0.5
cell.layer.borderColor = UIColor(hexString: "#999999").cgColor
return cell
}
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionElementKindSectionHeader:
if indexPath.section == 0 {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: Reusable.CELL_SERVICE_HEADER_ROOT, for: indexPath) as! ServiceCollectionViewHeaderRoot
header.lblCategoryName.text = "Section Header"
header.imgCategoryBackground.af_imageDownloader = imageDownloader
header.imgCategoryBackground.af_setImage(withURLRequest: ImageHelper.getURL(file: category.backgroundFile!))
return header
} else {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: Reusable.CELL_SERVICE_HEADER, for: indexPath) as! ServiceCollectionViewHeader
header.lblCategoryName.text = "Section Header"
return header
}
default:
assert(false, "Unexpected element kind")
}
}
// MARK: UICollectionViewDelegate
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = collectionView.frame.size.width / CGFloat(itemsPerWidth)
if isDetailCell(indexPath: indexPath) {
if expandedCell == indexPath {
return CGSize(width: collectionView.frame.size.width, height: width)
} else {
return CGSize(width: collectionView.frame.size.width, height: 0)
}
} else {
return CGSize(width: width, height: width)
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
if section == 0 {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height / 3)
} else {
return CGSize(width: collectionView.frame.width, height: heightHeader)
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if isDetailCell(indexPath: indexPath) {
return
}
var offset = itemsPerWidth
if isLastRow(indexPath: indexPath) {
offset = reminder
}
let detailPath = IndexPath(item: indexPath.item + offset, section: indexPath.section)
let context = UICollectionViewFlowLayoutInvalidationContext()
let maxItem = collectionView.numberOfItems(inSection: 0) - 1
var minItem = detailPath.item
if let expandedCell = expandedCell {
minItem = min(minItem, expandedCell.item)
}
// TODO: optimize this
var cellIndexPaths = (0 ... maxItem).map { IndexPath(item: $0, section: 0) }
var supplementaryIndexPaths = (0..<collectionView.numberOfSections).map { IndexPath(item: 0, section: $0)}
for i in indexPath.section..<collectionView.numberOfSections {
cellIndexPaths.append(contentsOf: (0 ... collectionView.numberOfItems(inSection: i) - 1).map { IndexPath(item: $0, section: i) })
//supplementaryIndexPaths.append(IndexPath(item: 0, section: i))
}
context.invalidateSupplementaryElements(ofKind: UICollectionElementKindSectionHeader, at: supplementaryIndexPaths)
context.invalidateItems(at: cellIndexPaths)
if detailPath == expandedCell {
expandedCell = nil
} else {
expandedCell = detailPath
}
UIView.animate(withDuration: 0.25) {
collectionView.collectionViewLayout.invalidateLayout(with: context)
collectionView.layoutIfNeeded()
}
}
EDIT:
Minimalistic project demonstrating this issue: https://github.com/vongrad/so-expandable-collectionview
You should use an Invalidation Context. It's a bit complex, but here's a rundown:
First, you need to create a custom subclass of UICollectionViewLayoutInvalidationContext since the default one used by most collection views will just refresh everything. There may be situations where you DO want to refresh everything though; in my instance, if the width of the collection view changes it has to layout all the cells again, so my solution looks like this:
class CustomInvalidationContext: UICollectionViewLayoutInvalidationContext {
var justHeaders: Bool = false
override var invalidateEverything: Bool { return !justHeaders }
override var invalidateDataSourceCounts: Bool { return false }
}
Now you need to tell the layout to use this context instead of the default:
override class var invalidationContextClass: AnyClass {
return CustomInvalidationContext.self
}
This won't trigger if we don't tell the layout it needs to update upon scrolling, so:
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
I'm passing true here because there will always be something to update when the user scrolls the collection view, even if it's only the header frames. We'll determine exactly what gets changed when in the next section.
Now that it is always updating when the bounds change, we need to provide it with information about which parts should be invalidated and which should not. To make this easier, I have a function called getVisibleSections(in: CGRect) that returns an optional array of integers representing which sections overlap the given bounds rectangle. I won't detail this here as yours will be different. I'm also caching the content size of the collection view as _contentSize since this only changes when a full layout occurs.
With a small number of sections you could probably just invalidate all of them. Be that as it may, we now need to tell the layout how to set up its invalidation context when the bounds changes.
Note: make sure you're calling super to get the context rather than just creating one yourself; this is the proper way to do things.
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! CustomInvalidationContext
// If we can't determine visible sections or the width has changed,
// we need to do a full layout - just return the default.
guard newBounds.width == _contentSize.width,
let visibleSections = getVisibleSections(in: newBounds)
else { return context }
// Determine which headers need a frame change.
context.justHeaders = true
let sectionIndices = visibleSections.map { IndexPath(item: 0, section: $0) }
context.invalidateSupplementaryElements(ofKind: "Header", at: sectionIndices)
return context
}
Note that I'm assuming your supplementary view kind is "Header"; change that if you need to. Now, provided that you've properly implemented layoutAttributesForSupplementaryView to return a suitable frame, your headers (and only your headers) should update as you scroll vertically.
Keep in mind that prepare() will NOT be called unless you do a full invalidation, so if you need to do any recalculations, override invalidateLayout(with:) as well, calling super at some point. Personally I do the calculations for shifting the header frames in layoutAttributesForSupplementaryView as it's simpler and just as performant.
Oh, and one last small tip: on the layout attributes for your headers, don't forget to set zIndex to a higher value than the one in your cells so that they definitely appear in front. The default is 0, I use 1 for my headers.
What I suggest is to create a separate subclass of a UICollectionFlowView
and set it up respectivel look at this example:
import UIKit
class StickyHeadersCollectionViewFlowLayout: UICollectionViewFlowLayout {
// MARK: - Collection View Flow Layout Methods
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return nil }
// Helpers
let sectionsToAdd = NSMutableIndexSet()
var newLayoutAttributes = [UICollectionViewLayoutAttributes]()
for layoutAttributesSet in layoutAttributes {
if layoutAttributesSet.representedElementCategory == .cell {
// Add Layout Attributes
newLayoutAttributes.append(layoutAttributesSet)
// Update Sections to Add
sectionsToAdd.add(layoutAttributesSet.indexPath.section)
} else if layoutAttributesSet.representedElementCategory == .supplementaryView {
// Update Sections to Add
sectionsToAdd.add(layoutAttributesSet.indexPath.section)
}
}
for section in sectionsToAdd {
let indexPath = IndexPath(item: 0, section: section)
if let sectionAttributes = self.layoutAttributesForSupplementaryView(ofKind: UICollectionElementKindSectionHeader, at: indexPath) {
newLayoutAttributes.append(sectionAttributes)
}
}
return newLayoutAttributes
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let layoutAttributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) else { return nil }
guard let boundaries = boundaries(forSection: indexPath.section) else { return layoutAttributes }
guard let collectionView = collectionView else { return layoutAttributes }
// Helpers
let contentOffsetY = collectionView.contentOffset.y
var frameForSupplementaryView = layoutAttributes.frame
let minimum = boundaries.minimum - frameForSupplementaryView.height
let maximum = boundaries.maximum - frameForSupplementaryView.height
if contentOffsetY < minimum {
frameForSupplementaryView.origin.y = minimum
} else if contentOffsetY > maximum {
frameForSupplementaryView.origin.y = maximum
} else {
frameForSupplementaryView.origin.y = contentOffsetY
}
layoutAttributes.frame = frameForSupplementaryView
return layoutAttributes
}
// MARK: - Helper Methods
func boundaries(forSection section: Int) -> (minimum: CGFloat, maximum: CGFloat)? {
// Helpers
var result = (minimum: CGFloat(0.0), maximum: CGFloat(0.0))
// Exit Early
guard let collectionView = collectionView else { return result }
// Fetch Number of Items for Section
let numberOfItems = collectionView.numberOfItems(inSection: section)
// Exit Early
guard numberOfItems > 0 else { return result }
if let firstItem = layoutAttributesForItem(at: IndexPath(item: 0, section: section)),
let lastItem = layoutAttributesForItem(at: IndexPath(item: (numberOfItems - 1), section: section)) {
result.minimum = firstItem.frame.minY
result.maximum = lastItem.frame.maxY
// Take Header Size Into Account
result.minimum -= headerReferenceSize.height
result.maximum -= headerReferenceSize.height
// Take Section Inset Into Account
result.minimum -= sectionInset.top
result.maximum += (sectionInset.top + sectionInset.bottom)
}
return result
}
}
then add your collection view to your view controller and this way you will implement the invalidation methods which currently are not getting triggered.
source here
Do reloadLoad cells in performBatchUpdates(_:) make it seems glitchy.
Just pass nil like below to update your cell's height.
collectionView.performBatchUpdates(nil, completion: nil)
EDIT:
I have recently found that performBatchUpdates(_:) only shift the header along with cell new height returned from the sizeForItemAt function. If using collection view cell sizing, your supplementary view may overlaps the cells. Then collectionViewLayout.invalidateLayout will fix without showing the animation.
If you want to go with sizing animation after calling performBatchUpdates(_:), try to calculate (then cache) and return cell's size in sizeForItemAt. It works for me.

Collection view cellForItemAt indexPath not getting called (Swift)

I created a collection view controller from story board, and set its custom class to ItemCollectionVC, the custom class of its cell to ItemCell, and set its reuse identifier to Cell
Here's my ItemCollectionVC class:
import UIKit
private let reuseIdentifier = "Cell"
class ItemCollectionVC: UICollectionViewController {
var dataSourceItems: [Items] = []
var counterBuildItems: [Items] {
let weaponItemArray = WeaponItems.weaponItems as [Items]
let defenseItemArray = DefenseItems.defenseItems as [Items]
return weaponItemArray + defenseItemArray
}
var freeBuildItems = WeaponItems.weaponItems as [Items]
var captureKrakenItems: [Items] {
let weaponItemArray = WeaponItems.weaponItems as [Items]
let abilityItemArray = AbilityItems.abilityItems as [Items]
return weaponItemArray + abilityItemArray
}
override func viewDidAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSourceItems.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! ItemCell
cell.cellImage.image = dataSourceItems[indexPath.row].image
print(dataSourceItems.count)
return cell
}
}
When the collection view controller is presented, it's empty, what could cause the problem?
One of three things caused this problem, pretty much every time I have encountered it, in a TableView or CollectionView:
1) Your ViewController is not the dataSource of your UICollectionView
2) numberOfRows or numberOfSections method returns 0
3) The height of your cell is 0, either due to constraint problems, or a heightForCell method being not/improperly implemented.
It's impossible to say which of these is your problem, and it's always possible that you've encountered something strange. Make certain that none of these is your problems, before exploring less likely options.
If you are pretty sure that the dataSource of the collectionView is connected to the viewController (it should be by default), then you should reloadData() because the collectionView reading from dataSourceItems. To understand the case, add a break point in cellForItemAt and add another one in viewDidAppear and check which one is called first?
override func viewDidAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
collectionView.reloadData()
}
Hope that helped.
Some things that you could easily miss if you use storyboard
1) Don't forget that content in cell must have connected Top, Bottom constraint and
content view must have height, by this cell will know to set height for cell. If you don't have these, cell height will be 0, and function cellForItemAt will never get called.
2) You can use cell layout to set dynamic cell and height for cell if you use this function:
func collectionView(_ collectionView: UICollectionView,layout collectionViewLayout:
UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) ->
CGSize {return CGSize(width: 20.00, height: 20.00)}
I fixed my problem by initializing UICollectionView properly like the following:
fileprivate let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: UICollectionViewFlowLayout.init())
Check if you get the right number of sections in the numberOfItemsInSection method for the collection view.
If you are adding a flow layout to the collection view, remove the flow layout, and check if the collection view cells show now.
If they do, just adjust your collection view flow layout code it should look like this
let _flowLayout = UICollectionViewFlowLayout()
_flowLayout.sectionInset = UIEdgeInsets(top:0, left: 0, bottom: 0, right: 0)
_flowLayout.scrollDirection = .vertical
yourCollectionView.collectionViewLayout = _flowLayout
you can set the inset to fit your use.
You have to add your code in viewWillAppear, then it will work properly.
/*
override func viewDidAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
}
Like this :-
override func viewWillAppear(_ animated: Bool) {
switch self.presentingViewController!.title! {
case "CounterBuildVC":
dataSourceItems = counterBuildItems
case "FreeBuildVC":
dataSourceItems = freeBuildItems
case "CaptureKrakenVC":
dataSourceItems = captureKrakenItems
default:
break
}
}
*/
In my case there was a problem with the collection view contentInset, try adding below code in your collection view sub class.
override func layoutSubviews() {
super.layoutSubviews()
self.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)
}
override func reloadData() {
DispatchQueue.main.async {
super.reloadData()
}
}

Change collection view cell width at runtime

I'm creating an EPG (Electronic Program Guide); to retrieve the program info I use JSON. For the design, I have a table view in the left half for the channel logo and name, and in the right half a collection view for all the TV programs for that channel. The thing is that for every show the collection view row must have a different width that depends on the duration of the show.
I'm building it based on this fantastic example called: EPG Grid
But in this example, all the channels are loaded previously in one array. This works fine if the channels list is short, but in my case the list is very large, so I need to load every channel in tableView:cellForRowAtIndexPath:, then create the layout with the specified width.
Any thoughts about this?
I figured out by my self, the thing is in my case not to use UICollectionViewLayout (CustomLayout) use layout Flow and add the collectionView inside tableView like this:
Then in every row of tableView delegate the collectionView.
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
{
let cell: CustomTableViewCell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath) as! CustomTableViewCell
cell.collectionView.delegate = self
cell.collectionView.dataSource = self
cell.collectionView.tag = indexPath.row
cell.tag = 100 + indexPath.row;
return cell;
}
After delegate, the method collectionView:sizeForItemAtIndexPath is called, on this method you must to calculate width or height retrieving data using indexPath
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
//Calculation of width based on indexPath
//...
return CGSizeMake(width, 89)
}
Finally for making the scroll uniform for all the cells at the same time use the method scrollViewDidScroll. Check this post: Horizontally scroll ALL rows of UICollectionView together
func scrollViewDidScroll(scrollView: UIScrollView) {
var ret: Bool = false;
if (self.lastContentOffset > scrollView.contentOffset.x)
{
ret = true;
}
else if (self.lastContentOffset < scrollView.contentOffset.x)
{
ret = true;
}
if(scrollView.isKindOfClass(UICollectionView) && ret)
{
self.lastContentOffset = scrollView.contentOffset.x;
for var i = 0; i < data.count; i++
{
let cell = self.view.viewWithTag(100 + i) as? ChannelTableViewCell;
if(cell != nil)
{
let collectionCell: UICollectionView? = cell?.collectionView;
if(collectionCell != nil)
{
collectionCell!.contentOffset = scrollView.contentOffset;
}
}
}
}
}
This is the only thing that worked for me with Swift 5, I hope it helps.
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
{
super.viewWillTransition(to: size, with: coordinator)
collectionView.collectionViewLayout.invalidateLayout()
collectionView.frame.size = size
}

Resources