I have a CollectionView with sections (2 sections). When I delete a cell from section 1 its deleting very good. But when I delete a cell from section 0 my app is crashing with error like this:
invalid number of items in section 0. The number of items contained in an existing section after the update (5) must be equal to the number of items contained in that section before the update (5), plus or minus the number of items inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out). with userInfo (null)
Before delete item from collectionView, I delete it item from my data source in performBatchUpdates:
extension MainCollectionViewController: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
collectionView?.performBatchUpdates({ [weak self] in
guard let self = self else { return }
items = controller.fetchedObjects as! [Item]
items2 = items.chunked(into: 5)
self.collectionView?.deleteItems(at: [self.deletedItemIndex!])
})
}
}
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0 ..< Swift.min($0 + size, count)])
}
}
}
func chunked - is a function that slice array like this (5 items in section):
Before chunked:
[1,2,3,4,5,6,7,8,9,10]
After chunked:
[
[1, 2, 3, 4, 5], // first section in collectionView
[6, 7, 8, 9, 10], // second section in collectionView
]
I populate my items from Core Data to collectionView with this functions:
override func numberOfSections(in collectionView: UICollectionView) -> Int {
print("call numberOfSections")
//3
return items2.count
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
print("call numberOfItemsInSection, current section is \(section)")
//4
return items2[section].count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! CollectionViewCell
let item = items2[indexPath.section][indexPath.row]
cell.itemNameTextLabel.text = item.name
cell.itemImageView.image = UIImage(data: item.image! as Data)
return cell
}
}
Item deleting from collectionView and CoreData when user long press it item (cell). Deleting process is here:
#objc func handleLongPress(gesture: UILongPressGestureRecognizer!) {
if gesture.state != .ended {
return
}
let p = gesture.location(in: self.collectionView)
if let indexPath = self.collectionView?.indexPathForItem(at: p) {
let fetchRequest: NSFetchRequest<Item> = Item.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %#", self.items2[indexPath.section][indexPath.row].name!)
do {
if let context = (UIApplication.shared.delegate as? AppDelegate)?.persistentContainer.viewContext {
let selectedItem = try context.fetch(fetchRequest)[0]
//save deleted item index in var that use it index in performBatchUpdatesBlock
deletedItemIndex = IndexPath(row: indexPath.row, section: indexPath.section)
context.delete(selectedItem)
do {
try context.save()
print("Save!")
} catch let error as NSError {
print("Oh, error! \(error), \(error.userInfo)")
}
}
} catch {
print(error.localizedDescription)
}
}
}
Image of this process:
enter image description here
My project like Apple Books app. I want to repeat deleting book process...
My full code is here (GitHub). Plis, use iPhone SE simulator. My data in items.plist file and automatic saving to CoreData when run app in first time.
Where is mistake in my code?
When you delete from the CollectionView, you should also delete from the underlying data array (items2).
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
collectionView?.performBatchUpdates({ [weak self] in
guard let self = self else { return }
items = controller.fetchedObjects as! [Item]
items2 = items.chunked(into: 5)
for indexPath in self.deletedItemIndex! {
items2[indexPath.section].remove(at: indexPath.row)
}
self.collectionView?.deleteItems(at: [self.deletedItemIndex!])
})
}
Maybe, the problem is that you delete one item of section 0, and after deletion the section 0 still with the same number of itens before deletion, because you "redistribute" the itens after each deletion.
Related
In https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views , Apple has shown a simple example on how to perform reordering using DiffableDataSource
ReorderableListViewController.swift
dataSource.reorderingHandlers.canReorderItem = { item in return true }
dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
guard let self = self else { return }
// method 1: enumerate through the section transactions and update
// each section's backing store via the Swift stdlib CollectionDifference API
if self.reorderingMethod == .collectionDifference {
for sectionTransaction in transaction.sectionTransactions {
let sectionIdentifier = sectionTransaction.sectionIdentifier
if let previousSectionItems = self.backingStore[sectionIdentifier],
let updatedSectionItems = previousSectionItems.applying(sectionTransaction.difference) {
self.backingStore[sectionIdentifier] = updatedSectionItems
}
}
// method 2: use the section transaction's finalSnapshot items as the new updated ordering
} else if self.reorderingMethod == .finalSnapshot {
for sectionTransaction in transaction.sectionTransactions {
let sectionIdentifier = sectionTransaction.sectionIdentifier
self.backingStore[sectionIdentifier] = sectionTransaction.finalSnapshot.items
}
}
}
Is there any way, to limit the reordering can only be performed within the same section?
There is not much we can do in reorderingHandlers.canReorderItem, because the closure parameter item is referring to the current source item we are dragging. There is no information on destination item, which we can compare with to decide whether to return true or false.
This behaviour isn't a question for your data source. It is a question for your delegate
You can use
collectionView(_:targetIndexPathForMoveFromItemAt:toProposedIndexPath:) to determine whether a move is permitted
func collectionView(_ collectionView: UICollectionView,
targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath,
toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
let sourceSection = sourceIndexPath.section
let destSection = proposedDestinationIndexPath.section
var destination = proposedDestinationIndexPath
if destSection < sourceSection {
destination = IndexPath(item: 0, section: sourceSection)
} else if destSection > sourceSection {
destination = IndexPath(item: self.backingStore[sourceSection].count-1, section: sourceSection)
}
return destination
}
This constrains an item's movement to its own section.
If your Items had titles, here a different approach:
dataSource.reorderingHandlers.canReorderItem = {item in
let exclude = ["Library","Favorites","Recents","Search","All"]
if(exclude.contains(item.title!)){return false}
return true
}
You can also use such record in delegate method collectionView(_:targetIndexPathForMoveFromItemAt:toProposedIndexPath:)
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
if originalIndexPath.section != proposedIndexPath.section {
return originalIndexPath
}
return proposedIndexPath
}
I read this in Matt Neuburg «Programming iOS 14» book and it works fine:)
I'm using a UICollectionViewDiffableDataSource and a NSFetchedResultsController to populate my UICollectionView inside my UIViewController.
To add the ability of reordering cells I added a UILongPressGestureRecognizer and subclassed UICollectionViewDiffableDataSource in order to use it's canMoveItemAt: and moveItemAt: methods.
When reordering a cell the following things happen:
moveItemAt: is called and I update the objects position property and save the MOC
controllerDidChangeContent: of the NSFetchedResultsControllerDelegate is called and I create a new snapshot from the current fetchedObjects and apply it.
When I apply dataSource?.apply(snapshot, animatingDifferences: true) the cells switch positions back immediately. If I set animatingDifferences: false it works, but all cells are reloaded visibly.
Is there any best practice here, how to implement cell reordering on a UICollectionViewDiffableDataSource and a NSFetchedResultsController?
Here are my mentioned methods:
// ViewController
func createSnapshot(animated: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<Int, Favorite>()
snapshot.appendSections([0])
snapshot.appendItems(provider.fetchedResultsController.fetchedObjects ?? [])
dataSource?.apply(snapshot, animatingDifferences: animated)
}
// NSFetchedResultsControllerDelegate
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
createSnapshot(animated: false)
}
// Subclassed UICollectionViewDiffableDataSource
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
provider.moveFavorite(from: sourceIndexPath.row, to: destinationIndexPath.row)
}
// Actual cell moving in a provider class
public func moveFavorite(from source: Int, to destination: Int) {
guard let favorites = fetchedResultsController.fetchedObjects else { return }
if source < destination {
let partialObjects = favorites.filter({ $0.position <= destination && $0.position >= source })
for object in partialObjects {
object.position -= 1
}
let movedFavorite = partialObjects.first
movedFavorite?.position = Int64(destination)
}
else {
let partialObjects = favorites.filter({ $0.position >= destination && $0.position <= source })
for object in partialObjects {
object.position += 1
}
let movedFavorite = partialObjects.last
movedFavorite?.position = Int64(destination)
}
do {
try coreDataHandler.mainContext.save()
} catch let error as NSError {
print(error.localizedDescription)
}
}
My solution to the same issue is to subclass the UICollectionViewDiffableDataSource and implement the canMoveItemAt method in the subclass to answer true.
The animation seems to work fine for me if the longPressAction case of .ended does three things:
update the model
call dateSource.collectionView(..moveItemAt:..)
run your dataSource.apply
The other usual methods for drag behavior have to be also implemented which it looks like you have done. FYI for others- These methods are well documented in the section for 'Reordering Items Interactively' of UICollectionView. https://developer.apple.com/documentation/uikit/uicollectionview
class PGLDiffableDataSource: UICollectionViewDiffableDataSource<Int, Int> {
override func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
}
No need to subclass. Starting in iOS 14.0, UICollectionViewDiffableDataSource supports reordering handlers you can implement.
let data_source = UICollectionViewDiffableDataSource<MySection, MyModelObject>( collectionView: collection_view, cellProvider:
{
[weak self] (collection_view, index_path, video) -> UICollectionViewCell? in
let cell = collection_view.dequeueReusableCell( withReuseIdentifier: "cell", for: index_path ) as! MyCollectionViewCell
if let self = self
{
//setModel() is my own method to update the view in MyCollectionViewCell
cell.setModel( self.my_model_objects[index_path.item] )
}
return cell
})
// Allow every item to be reordered as long as there's 2 or more
diffable_data_source.reorderingHandlers.canReorderItem =
{
item in
my_model_objects.count >= 2 return true
}
//Update your model objects before the reorder occurs.
//You can also use didReorder, but it might be useful to have your
//model objects in the correct order before dequeueReusableCell() is
//called so you can update the cell's view with the correct model object.
diffable_data_source.reorderingHandlers.willReorder =
{
[weak self] transaction in
guard let self = self else { return }
self.my_model_objects = transaction.finalSnapshot.itemIdentifiers
}
I have table view that delete some rows with following:
func deleteRows(_ indecies: [Int]) {
guard !indecies.isEmpty else { return }
let indexPathesToDelete: [IndexPath] = indecies.map{ IndexPath(row: $0, section: 0)}
let previousIndex = IndexPath(row: indecies.first! - 1, section: 0)
tableView.deleteRows(at: indexPathesToDelete, with: .none)
tableView.reloadRows(at: [previousIndex], with: .none)
}
In cellForRow i have cell that have "tap" closure like this:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard indexPath.row < presenter.fields.count else { return EmptyCell() }
let field = presenter.fields[indexPath.row]
switch field.cellType {
case .simple:
guard let model = field as? SimpleTextItem else { return EmptyCell() }
let cell = SimpleTextCell()
cell.setup(label: LabelSL.regularSolidGray(), text: model.text, color: Theme.Color.bleakGray)
return cell
case .organization:
guard let model = field as? OrganizationFilterItem else { return EmptyCell() }
let cell = OrganizationFilterCell()
cell.setup(titleText: model.title,
holdingNumberText: model.holdingNumberText,
isChosed: model.isChosed,
isHolding: model.isHolding,
isChild: model.isChild,
bottomLineVisible: model.shouldDrawBottomLine)
cell.toggleControlTapped = {[weak self] in
self?.presenter.tappedItem(indexPath.row)
}
return cell
}
}
When
cell.toggleControlTapped = {[weak self] in
self?.presenter.tappedItem(indexPath.row)
}
Tapped after rows deletion, index is pass is wrong (it's old). For example, i have 10 rows, i delete 2-3-4-5 row, and then i tap on 2 row (it was 6 before deletion). That method pass "6" instead of "2".
Problem actually was solved by adding tableView.reloadData() in deleteRows function, but, as you may assume smoothly animation gone and it look rough and not nice. Why is table still pass old index and how to fix it?
A quite easy solution is to pass the cell in the closure to be able to get the actual index path
Delare it
var toggleControlTapped : ((UITableViewCell) -> Void)?
Call it
toggleControlTapped?(self)
Handle it
cell.toggleControlTapped = {[weak self] cell in
guard let actualIndexPath = self?.tableView.indexPath(for: cell) else { return }
self?.presenter.tappedItem(actualIndexPath.row)
}
Side note: Reuse cells. Creating cells with the default initializer is pretty bad practice.
Be sure to update your data too, indexes are appearing from numberofrows function of table view
I have a collectionView that has two sections, each filling up cells with separate arrays.
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if section == 0 && GlobalList.priorityArray.count != 0 {
return GlobalList.priorityArray.count
} else if section == 1 && GlobalList.listItemArray.count != 0 {
return GlobalList.listItemArray.count
} else {
return 0
}
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return sections
}
I can move items between sections without any issues. I also can delete items. My problem occurs when I move an item between sections, reload data, then try to delete all the items in that section, I get the following error.
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (3) must be equal to the number of items contained in that section before the update (3), plus or minus the number of items inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).'
Here are the methods I have created to deal with deleting and moving items
Moving Items:
override func collectionView(_ collectionView: UICollectionView,
moveItemAt sourceIndexPath: IndexPath,
to destinationIndexPath: IndexPath) {
if (sourceIndexPath as NSIndexPath).section == 0 {
listItem = GlobalList.priorityArray.remove(at: sourceIndexPath.item)
} else if (sourceIndexPath as NSIndexPath).section == 1 {
listItem = GlobalList.listItemArray.remove(at: sourceIndexPath.item)
}
if (destinationIndexPath as NSIndexPath).section == 0 {
GlobalList.priorityArray.insert(listItem, at: destinationIndexPath.item)
} else if (destinationIndexPath as NSIndexPath).section == 1 {
GlobalList.listItemArray.insert(listItem, at: destinationIndexPath.item)
}
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(reloadData), userInfo: nil, repeats: false)
}
Deleting Items:
func deleteItem(sender: UIButton) {
let point : CGPoint = sender.convert(.zero, to: collectionView)
let i = collectionView!.indexPathForItem(at: point)
print("deleting.. \(String(describing: i))")
let index = i
GlobalList.listItemArray.remove(at: (index?.row)!)
deleteItemCell(indexPath: i!)
}
func deleteItemCell(indexPath: IndexPath) {
collectionView?.performBatchUpdates({
self.collectionView?.deleteItems(at: [indexPath])
}, completion: nil)
}
Let me know if anything else is needed to figure this out.
Thanks in advance!
I also had a similar issue with the collectionView when calling reloadData and then directly after trying to insert cells into the collectionView. I solved this issue by instead manually deleting and inserting sections in the collectionView inside a perform batch updates, like so:
collectionView.performBatchUpdates({ [weak self] () in
self?.collectionView.deleteSections([0])
self?.collectionView.insertSections([0])
}, completion: nil)
My app crashed due to 'attempt to delete item 1 from section 1, but there are only 1 sections before the update'
I found other articles said that the data source must keep in sync with the collectionView or tableView, so I remove the object from my array: alarms before I delete the item in my collectionView, this works on didSelectItemAtIndexPath. But I don't know why this doesn't work.
I also tried to print the array.count, it showed the object did remove form the array.
func disableAlarmAfterReceiving(notification: UILocalNotification) {
for alarm in alarms {
if alarm == notification.fireDate {
if let index = alarms.index(of: alarm) {
let indexPath = NSIndexPath(item: index, section: 1)
alarms.remove(at: index)
collectionView.deleteItems(at: [indexPath as IndexPath])
reloadCell()
}
}
}
}
func reloadCell() {
userDefaults.set(alarms, forKey: "alarms")
DispatchQueue.main.async {
self.collectionView.reloadData()
print("Cell reloaded")
print(self.alarms.count)
}
}
You are having single section so you need to delete it from the 0 section. Also use Swift 3 native IndexPath directly instead of NSIndexPath.
let indexPath = IndexPath(item: index, section: 0)
alarms.remove(at: index)
DispatchQueue.main.async {
collectionView.deleteItems(at: [indexPath])
}
Edit: Try to break the loop after deleting items from collectionView.
if let index = alarms.index(of: alarm) {
let indexPath = IndexPath(item: index, section: 0)
alarms.remove(at: index)
DispatchQueue.main.async {
collectionView.deleteItems(at: [indexPath])
}
reloadCell()
break
}