Delete items in collection view with custom layout

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...

Answered by Claude31 in 311326022

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.

Excellent, thanks again.


I must have messed up somewhere. I modified my code to clear the items from the array and then do a reload as opposed to "deleteItems(at", thought it'd be simple if the source was modified first. Fingers crossed it'll just be a simple mistake.


Have a good weekend and thanks again!

Delete items in collection view with custom layout
 
 
Q