Afternoon All
I have a collection view with a simple custom layout (each cell is a simple image) and am getting errors when tryig to delete items, i.e. "assertion failure in -[UICollectionViewData validateLayoutInRect]" and then also "UICollectionView received layout attributes for an index ath that does not exist". The second error crashes the process.
I've created a very simple test, without custom layout, as follows:
// This works
items.remove(at: 0)
cv.deleteItems(at: [IndexPath(row: 0, section: 0)])
cv.reloadData()
This works fine. I've tried at least a dozen solutions I found online but nothing works. I've tried batch updates, setting delegates to nil and then resetting after the updates, invalidating the layout etc.
The error is almost always on the line "cv.deleteItems(at: [IndexPath(row: 0, section: 0)])", sometimes in the app delegate.
Does anyone have a simple checklist of what I'd need to do to get this working with custom layout?
Many thanks...
I edited with <> to make it more readable
import UIKit
class testVCViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, ImagesCVLayoutDelegate {
@IBOutlet weak var cv: UICollectionView!
@IBOutlet weak var btn: UIButton!
@IBAction func btn(_ sender: UIButton) {
items.remove(at: 0)
cv.deleteItems(at: [IndexPath(row: 0, section: 0)]) // THIS IS WHERE THE WARNINGS AND ERROR OCCUR.
cv.reloadData()
}
var items = [1,2,3,4,5,6,7,8,9] // Data source.
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! zCollectionViewCell
cell.lbl.text = "\(items[indexPath.row])"
return cell
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let h = cv.frame.height
var w = cv.frame.width
if UIDevice.current.orientation.isLandscape == true {
w -= 56 // I.e. 7 gaps between 8 images.
w = w / 8
} else if UIDevice.current.orientation.isPortrait == true || UIDevice.current.orientation.isFlat == true {
w -= 24 // I.e. 3 gaps between 4 images.
w = w / 4
}
return CGSize(width: w, height: h)
}
func collectionView(_ collectionView:UICollectionView, heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat{
return 68
}
override func viewDidLoad() {
super.viewDidLoad()
cv.delegate = self
cv.dataSource = self
}
}
// LAYOUT CODE:
import UIKit
protocol ImagesCVLayoutDelegate: class {
// 1. Method to ask the delegate for the height of the image
func collectionView(_ collectionView:UICollectionView, heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat
}
class ImagesCVLayout: UICollectionViewLayout {
//1. Layout Delegate
weak var delegate: ImagesCVLayoutDelegate!
//2. Configurable properties
fileprivate var numberOfColumns = 4
fileprivate var cellPadding: CGFloat = 2 // 6
//3. Array to keep a cache of attributes.
fileprivate var cache = [UICollectionViewLayoutAttributes]()
//4. Content height and size
fileprivate var contentHeight: CGFloat = 0
fileprivate var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
override func prepare() {
// 1. Only calculate once
guard cache.isEmpty == true, let collectionView = collectionView else {
return
}
// 2. Pre-Calculates the X Offset for every column and adds an array to increment the currently max Y Offset for each column
let columnWidth = contentWidth / CGFloat(numberOfColumns)
var xOffset = [CGFloat]()
for column in 0 ..< numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
var column = 0
var yOffset = [CGFloat](repeating: 0, count: numberOfColumns)
// 3. Iterates through the list of items in the first section
for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: item, section: 0)
// 4. Asks the delegate for the height of the picture and the annotation and calculates the cell frame.
//let photoHeight = delegate.collectionView(collectionView, heightForPhotoAtIndexPath: indexPath)
let photoHeight = CGFloat(100) // BODGED THIS AS I'M JUST USING TEXT IN THIS EXAMPLE.
let height = cellPadding * 2 + photoHeight
let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)
let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
// 5. Creates an UICollectionViewLayoutItem with the frame and add it to the cache
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// 6. Updates the collection view content height
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
// Loop through the cache and look for items in the rect
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
How is cache updated when you remove an item ?
With line 97, if I understand well, as soon as cache is not empty, you exit.
It could be worth adding some log:
line 137,
print(#function, cache)
Before line 148
print(#function, indexPath.item, cache[indexPath.item])
Hence, you may have too many items in cache, or not consistent items, which could confuse layout somewhere.