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.

Did you update the number of rows ?


You should do something like this :


    private var howManyItems = 20     // You know the number at start ; it may an array.count.

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return howManyItems
    }

     // When you delete item
      howManyItems -= 1
      items.remove(at: 0)
      cv.deleteItems(at: [IndexPath(row: 0, section: 0)])
      cv.reloadData()

Hi Claude Thanks, but I’ve got that covered already. I’m using the source data arrays count for number of items. I’m also removing items from the array. I’ve tried first removing the items and then reloading the CV as well as using deleteItemat etc. Cheers

So, when you say:


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


What do you mean ? It works or it does not work ?


I've created a small test that works perfectly (until there is no more item, then at:0 crashes of course.

I call the delete function on a button action:

class Item1ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {

    @IBOutlet weak var collectionView: UICollectionView!
    private var items: [Int] = Array(repeating: 0, count: 20)


    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
       
        return items.count 
    }


    @IBAction func deleteIt(_ sender: UIButton) {
        items.remove(at: 0)
        let theIndex = IndexPath(row: 0, section: 0)
        collectionView.deleteItems(at: [theIndex])
        //collectionView.reloadData()     // This is not needed in my case
    }


Did you comment out reloadData() ?


Could you show the complete code of the viewController ? And notably all that deal with layout functions

Hi Claude


Sorry, I wasn't very clear, I was rushing to get the post in. The code sample I flagged with "this works" works just fine. The error comes when I use a custom layout. My actual process is a bit convoluted as I'm storing image filepaths in core data for both main image and thumbnail, and I'm using custom layout to size the cells nicely etc. I did the test code (in my orignal post) simply to prove to myself I could get it working. Problem is, I can't seem to get it to work with a custom layout.


Note that the assertion failure is just a warning, the crash happens when I try and delete the cell(s). Thats when I get "UICollectionView received layout attributes for an index ath that does not exist".


Thanks again 🙂

Ps. Is it me or has this forum become pretty much impossible to use in iOS?

It is not you, this new version of the forum is a mess, even on OSX. I've posted a message in Developers section of the forum and filed a bug report, should do the same.


So, in your custome layout, you have a problem in some layout routine.

Could you post bthem here, so that we can have a look ?

Cool, thought all my devices were going crazy... Been a bad couple of weeks for car, computers etc., even my central heating 😟


I'm just knocking up some code now, starting from scratch on a test view controller with no other functionality. Will post the code shortly.


Cheers

Ok... Heres the view controller code, and below it the layout code (poached from StackOverflow if I remember right):


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]

}

}


Thanks agin...

Accepted Answer

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.

Ha! Fingers crossed that may well be it. I poached the layout code off the net and haven't looked at it that closesly.


I'm guessing then that I'd have to clear the cache at some point? Would that be done prior to removing items from the data source array?


Thanks again

If that works, great. And don't forget to close the thread by marking the correct answer.


Good luck.

I'm a bit confused as to where I would clear the cache?

Also, the cache is for layout attributes and, correct me if I'm wrong, line 97 is there simply because it's not necessary to recalculate for every item.

Well, sort of winning... I modified the code as follows (just as a fishing exercise):


// 1. Only calculate once

// guard cache.isEmpty == true, let collectionView = collectionView else {

// return

// }

cache.removeAll()

guard let collectionView = collectionView else {return} // NEW BIT


My only concern is that I've potentially impacted performance.


Does this look OK?


Thanks again

So, does it work with cache.removeAll() ?


I'm not much familiar with collectionViews, but the performance will depend on how large the collection is. If it is a few tens of items, should not be a problem. If there are thousand, need to think twice.

Yep... It's working. However I have other issues now, seems my code isn't deleting the correct items. I.e. the source images array is out of sync with the remaining items. I'm gonna have a beer and another look.


Thanks again for all the help. On to the next 🙂

Good luck.


Of course, you have to look at how you update the dataSource (images array) according to the change in collection view.


CollectionView is just presenting the elements you provide it in dataSource, and displays according to what you've defined in layOut.

Seem simple, but it is tricky to get perfectly working.


Those tutorials may help to understand it all :

h ttps://www.raywenderlich.com/136159/uicollectionview-tutorial-getting-started

h ttps://www.raywenderlich.com/136161/uicollectionview-tutorial-reusable-views-selection-reordering


Start another thread if you need.

Delete items in collection view with custom layout
 
 
Q