Is applying NSDiffableDataSourceSnapshot broken? - swift

I have a problem with applying NSDiffableDataSourceSnapshot to UICollectionViewDiffableDataSource. Imagine this situation: I have two items and I want to remove the second one and also I want to reload all other items in that section.
I do it like this:
var snapshot = oldSnapshot
if let item = getterForItemToDelete() {
snapshot.deleteItems([item])
}
snapshot.reloadItems(otherItems)
But then in cell provider of data source app crashes since it tries to get cell for item identifier for which I no longer have data so I have to return nil:
let dataSource = MyDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, identifier in
guard let viewModel = self?.viewModel.item(for: identifier) else { return nil }
...
}
What is strange is that when I try to debug it and print my items when I apply snapshot, it prints one item:
(lldb) print snapshot.itemIdentifiers(inSection: .mySection)
([App.MyItemIdentifier]) $R0 = 1 value {
[0] = item (item = "id")
}
But, immediately after this, in cell provider I get that I have two items, which I don't
(lldb) print self?.dataSource.snapshot().itemIdentifiers(inSection: .mySection)
([App.MyItemIdentifier]) $R1 = 2 values {
[0] = item (item = "ckufpa58100041ps6gmiu5zl6")
[1] = item (item = "ckufpa58100051ps69yrgaspv")
}
What is even more strange is that this doesn't happen when I have 3 items and I want to delete one and reload the others.
One workaround which solves my problem is to return empty cell instead of nil in cell provider:
let dataSource = MyDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, identifier in
guard let viewModel = self?.viewModel.item(for: identifier) else {
return collectionView.dequeueReusableCell(withReuseIdentifier: "UICollectionViewCell", for: indexPath)
}
...
}
And the last strange thing, when I do this and then I look to View Hierarchy debugger, there is just one cell, so it seems that the empty cell gets removed.
Does anybody know what could I do wrong or is this just expected behavior? Since I didn't find any mention of providing cells for some sort of optimalizations, animations or something in the documentation.

You shouldn't be removing items from the snapshot. Remove them from your array. Create the dataSource again with the updated array, and call the snapshot with this newly created dataSource. CollectionView will automatically update with the new data. To put it more simply, change the array, then applySnapshot() again.

Related

How to add an extra static cell to a Diffable Data Source?

With reference to this question, I would like to do something similar. There were two ways I tried to replicate the answer for my diffable data source for my collection view.
The first way was to create another diffable data source but for the same collection view. The actual item cells would be configured when the indexpath.row is less than the count of my array while when it is equals to the count, then it will display my static cell.
//configure cells: categoryCollectionViewDataSource is the actual datasource for the items that I want to display.
categoryCollectionViewDataSource = UICollectionViewDiffableDataSource<Int, MistakeCategory>(collectionView: categoryCollectionView){
(collectionView, indexPath, mistakeCategory) in
if indexPath.row < (subjectSelected?.MistakeCategories.count)! {
let cell = self.categoryCollectionView.dequeueReusableCell(withReuseIdentifier: "CategoryCollectionViewCell", for: indexPath) as! CategoryCollectionViewCell
cell.setProperty(SubjectCategory: nil, MistakeCategory: self.subjectSelected?.MistakeCategories[indexPath.row])
return cell
} else {
return nil
}
}
//configure cells: staticCollectionViewDataSource is the data source for the cell that I want to display no matter what. it is displayed at the last row of the indexpath.
staticCellCollectionViewDataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: categoryCollectionView){
(collectionView, indexPath, mistakeCategory) in
if indexPath.row == subjectSelected?.MistakeCategories.count {
let cell = self.categoryCollectionView.dequeueReusableCell(withReuseIdentifier: "StaticCreateNewCategoryCollectionViewCell", for: indexPath) as! StaticCreateNewCategoryCollectionViewCell
return cell
} else {
return nil
}
}
And here is where I update my diffable data source which I run on my viewDidLoad after configuring my cells:
internal func updateCategoryCollectionView() {
var snapshot = NSDiffableDataSourceSnapshot<Int, MistakeCategory>()
snapshot.appendSections([0])
snapshot.appendItems(Array(subjectSelected!.MistakeCategories))
categoryCollectionViewDataSource.apply(snapshot) //error: 'attempt to insert section 0 but there are only 0 sections after the update'
var staticSnapshot = NSDiffableDataSourceSnapshot<Int,Int>()
staticSnapshot.appendSections([0])
staticSnapshot.appendItems([0])
staticCellCollectionViewDataSource.apply(staticSnapshot)
}
This results in the error 'attempt to insert section 0 but there are only 0 sections after the update'. I have tried to implement the functions of UICollectionViewDataSource but ran into the problem of how I can merge these two type of data sources.
As such, I am at a lost to what I can do to create a static custom cell at the end of my row.
I had the same problem with diffable data sources. Instead of creating a different data source and snapshot, I solved it by appending a default [AnyHashable] data to my snapshot where the static cell was wanted. In your case it would be after you append your usual MistakeCategories data.
To do this you need to initialize your data source as
categoryCollectionViewDataSource = UICollectionViewDiffableDataSource<Int, AnyHashable>(collectionView: categoryCollectionView){
(collectionView, indexPath, dataItem) in
and when using the dataItem object you need to cast
if let mistakeCategory = dataItem as? MistakeCategory {
// return your default cell here
}
and after the MistakeCategory cells
if indexPath.row == subjectSelected?.MistakeCategories.count {
let cell = self.categoryCollectionView.dequeueReusableCell(withReuseIdentifier: "StaticCreateNewCategoryCollectionViewCell", for: indexPath) as! StaticCreateNewCategoryCollectionViewCell
return cell
}
And finally when you apply your snapshot, you also need to use it as
var snapshot = NSDiffableDataSourceSnapshot<Int, AnyHashable>()
snapshot.appendSections([0])
snapshot.appendItems(Array(subjectSelected!.MistakeCategories))
//append your static value here
snapshot.appendItems([AnyHashable(0)])
categoryCollectionViewDataSource.apply(snapshot)
Disclaimer: This is an extremely hard-coded solution and I am looking for more elegant methods to do this, but I couldn't find any yet.
DiffableDataSource accepts enums for item and/or section. This allows you to easily have as many cell and section types as you need - just define them in an enum, register cells for them, and then point datasource to the right cell based on the enum value passed in.
The example below only has two types of items and one section, but you can use the same idea for different section types as well.
//Define as many types of items as you need
enum CollectionViewItem: Hashable {
case actualItem(ActualItem.ID) //You must to have a unique associated value if you want the datasource to be able to differentiate items. In this case I'm just using the item ID.
case staticButtonItem
}
//Register cells for each type of item
actualItemCellRegistration = UICollectionView.CellRegistration<YourDesiredCellTypeHere, CollectionViewItem> { (cell, indexPath, item) in
//Register cell
}
staticButtonCellRegistration = UICollectionView.CellRegistration<YourDesiredCellTypeHere, CollectionViewItem> { (cell, indexPath, item) in
//Register cell
}
//Point the datasource at the right registration based on the enum case
dataSource = UICollectionViewDiffableDataSource<Int, CollectionViewItem>(collectionView: collectionView){ (collectionView, indexPath, item) in
switch item {
case .actualItem:
return collectionView.dequeueConfiguredReusableCell(using: actualItemCellRegistration, for: indexPath, item: item)
case .staticButtonItem:
return collectionView.dequeueConfiguredReusableCell(using: staticButtonCellRegistration, for: indexPath, item: item)
}
}
//Make your snapshot with your defined items
func makeNewSnapshot(with data: [YourDataType]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, CollectionViewItem>()
//Add your single section
snapshot.appendSections([0])
//Add your static cell
snapshot.appendItems(CollectionViewItem.staticButtonItem, toSection: 0)
//Add your actual items
let itemsForView = data.map {CollectionViewItem.actualItem($0.ID)}
snapshot.appendItems(itemsForView, toSection: 0)
dataSource.apply(snapshot)
}

Get trouble of updating tableview rows when deleting Firebase data

something goes wrong when trying to update rows of tableview after delete of Firebase data.
Below is method I use.
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let delete = UITableViewRowAction(style: .destructive, title: "Delete") { (action, indexPath) in
let cell = self.messages[indexPath.row]
let b = cell.msgNo
let action = MyGlobalVariables.refMessages.child(MyGlobalVariables.uidUser!)
action.queryOrdered(byChild: "msgNo").queryEqual(toValue: b).observe(.childAdded, with: { snapshot in
if snapshot.exists() { let a = snapshot.value as? [String: AnyObject]
let autoId = a?["autoID"]
action.child(autoId as! String).removeValue()
self.messages.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
} else {
print("snapshot empty")
}}) }
...
return [delete, edit, preview]
}
Initially I checked whole logic without including line /*action.child(autoId as! String).removeValue()*/ then it works normally and removes rows as should be. But once I add this line it removes data from Firebase but tableview is updated in strange way by adding new rows below existing
My guess is that somewhere else in your application you have code like action .observe(.value, which shows the data in the table view. When you delete a node from the database, the code that populates the database gets triggered again, and it adds the same data (minus the node that you removed) to the table view again.
When working with Firebase it's best to follow the command query responsibility segregation principle, meaning that you keep the code that modifies the data completely separate from the flow that displays the data. That means that your code that deletes the data, should not try to update the table view. So something more like:
let action = MyGlobalVariables.refMessages.child(MyGlobalVariables.uidUser!)
action.queryOrdered(byChild: "msgNo").queryEqual(toValue: b).observe(.childAdded, with: { snapshot in
if snapshot.exists() { let a = snapshot.value as? [String: AnyObject]
let autoId = a?["autoID"]
action.child(autoId as! String).removeValue()
} else {
print("snapshot empty")
}}) }
All the above does is remove the selected message from the database.
Now you can focus on your observer, and ensuring it only shows the messages once. There are two options for this:
Always clear self.messages when your .value completion handler gets called before you add the messages from the database. This is by far the simplest method, but may cause some flicker if you're showing a lot of data.
Listen to the more granular messages like .childAdded and .childRemoved and update self.messages based on those. This is more work in your code, but will result in a smoother UI when there are many messages.

displaying two different types of cells in cellforitem swift

I have two arrays, one with Strings and one with a custom object.
I've created two different cells respectively. I've added the contents of the two arrays into a third generic array Any. I use the third array (combinedArray) in cellForItem.
var customObjectArray: [customObject] = []
var stringArray = [String]()
var combinedArray = [Any]()
if combinedArray[indexPath.row] is CustomObject {
cell1.LabelName.text = customObjectArray[indexPath.row].caption
cell1.iconView.image = customObjectArray[indexPath.row].icon
return cell1
} else {
let stringName = stringArray[indexPath.row]
cell2.LabelName.setTitle(stringName for: UIControlState())
return cell2
}
let's say customObjectArray has 13 objects and stringObjectArray has 17 objects. I want a separate counter for each array so that it populates them correctly. The way it's working now:
the combinedArray populates all of one type first (i.e. 13 customObjectArray first), then the next type second (i.e 17 stringObjects). The order the combined array isn't necessarily important as I will probably shuffle things around at some point before getting to cellforitem. So when cellforItem goes through the first 13 objects, indexpath.row = 14, and when it gets to the second type of object, it's skipping the first 13 objects and displaying stringObject's 14th element (obviously).
I can't figure out how to start at the beginning of the second array instead of indexPath.row's current position.
I might be totally off base here and likely should be using two sections or something to that nature, but I'm relatively new to iOS dev, so any guidance is appreciated.
Another option would be to enclose the different data types in an enum and add all the data to your combined array in whatever order you would like.
enum DataHolder {
case stringType(String)
case customType(CustomObject)
}
var combinedArray: [DataHolder]
This gives you a single type for the data and a way to distingue between the cell types.
Inside of the cellForItem perform a switch on the combinedArray
switch combinedArray[indexPath.row] {
case .stringType(let stringValue):
let cell = tableView.dequeueReusableCell(withIdentifier: "StringCell", for: indexPath) as! StringObjectCell
cell.labelName.text = stringValue
case .customType(let customData):
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomObjectCell
cell.labelName.text = customData.caption
cell.iconView.image = customData.icon
Use always only one array with a more specific type than Any
Create a protocol including all properties and functions both types have in common for example
protocol CommonType {
var labelName : String { get }
// var ...
// func
}
and make the types adopt the protocol.
Then declare the single array with that type to be able to add both static types
var commonArray = [CommonType]()
In cellForItem determine the cell type by conditional downcasting the type
let common = commonArray[indexPath.row]
if common is CustomObject {
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomObject", for: indexPath) as! CustomObjectCell
cell.LabelName.text = common.labelName
return cell
} else {
let cell = tableView.dequeueReusableCell(withIdentifier: "Other", for: indexPath) as! OtherCell
cell.LabelName.text = common.labelName
return cell
}
indexPath math is cumbersome and error-prone.
If you want to display 2 different types of data in your UITableView you can simply create a wrapping enum whose cases will represent the type. By the case you will always know how to configure the cell.
Example:
Let's say you need to show Facebook posts in your UITableView and there are 3 types of posts: text, image and video. Your enum would like this:
enum Post {
case text(String)
case image(URL)
case video(URL)
}
Simple enough, right?
You need to have your UITableViewCell subclasses for each type of data (or single and configure it properly, but I recommend to have separate for many reasons).
Now all you need to do is keep array of Posts and in your UITableViewDataSource construct the cell you need.
let post = self.posts[indexPath].row
switch post {
case .text(let text):
let cell = // dequeue proper cell for text
// configure the cell
cell.someLabel.text = text
return cell
case .image(let url):
let cell = // dequeue proper cell for image
// configure the cell
cell.loadImage(url)
return cell
case .video(let url):
let cell = // dequeue proper cell for video
// configure the cell
cell.videoView.contentURL = url
return cell
}

Selecting Multiple Table View Cells At Once in Swift

I am trying to make an add friends list where the user selects multiple table view cells and a custom check appears for each selection. I originally used didSelectRowAtIndexPath, but this did not give me the results I am looking for since you can highlight multiple cells, but unless you unhighlight the original selected row you cannot select anymore. I then tried using didHighlighRowAtIndexPath, but this doesn't seem to work because now I am getting a nil value for my indexPath. Here is my code:
override func tableView(tableView: UITableView, didHighlightRowAtIndexPath indexPath: NSIndexPath) {
let indexPath = tableView.indexPathForSelectedRow
let currentCell = tableView.cellForRowAtIndexPath(indexPath!) as! AddedYouCell
let currentUser = PFUser.currentUser()?.username
let username = currentCell.Username.text
print(currentCell.Username.text)
let Friends = PFObject(className: "Friends");
Friends.setObject(username!, forKey: "To");
Friends.setObject(currentUser!, forKey: "From");
Friends.saveInBackgroundWithBlock { (success: Bool,error: NSError?) -> Void in
print("Friend has been added.");
currentCell.Added.image = UIImage(named: "checked.png")
}
}
How can I solve this? Thanks
I'm not going to write the code for you, but this should help you on your way:
To achieve your goal, you should separate the data from your views (cells).
Use an Array (i.e. friendList) to store your friend list and selected state of each of them, and use that Array to populate your tableView.
numberOfCellsForRow equals friendList.count
In didSelectRowAtIndexPath, use indexPath.row to change the state of your view (cell) and set the state for the same index in your Array
In cellForRowAtIndexpath, use indexPath.row to retrieve from the Array what the initial state of the cell should be.

NSInternalInconsistencyException: Invalid Update using tableview CoreData

I am using a tableView to display a list of people. I am trying to add an alert to confirm that the user actually wants to delete the person and to prevent mistakes. However, when I try to delete the person that is stored with CoreData, there seems to be a problem reloading the view. I get this exception:
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (2) must be equal to the number of rows contained in that section before the update (2), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).'
Editing and Delete Function:
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
// Delete the row from the data source
var deleteRow = indexPath.row
indexPathforDelete = indexPath
let entityDescription = NSEntityDescription.entityForName("People", inManagedObjectContext: managedObjectContext!)
let request = NSFetchRequest()
request.entity = entityDescription
var error: NSError?
var objects = managedObjectContext?.executeFetchRequest(request, error: &error)
if let results = objects {
let personToDelete = results[deleteRow] as! NSManagedObject
let firstName = personToDelete.valueForKey("firstName") as! String
let lastName = personToDelete.valueForKey("lastName") as! String
var message = "Are you sure you would like to delete \(firstName) \(lastName)?\nThis will permanentaly remove all records of "
if(personToDelete.valueForKey("gender") as! String == "Male"){
message = "\(message)him."
}
else{
println(personToDelete.valueForKey("gender") as! String)
message = "\(message)her."
}
var deleteAlert : UIAlertView = UIAlertView(title: "Delete \(firstName) \(lastName)", message: message, delegate: self, cancelButtonTitle: "Cancel")
deleteAlert.addButtonWithTitle("Delete")
deleteAlert.show()
}
save()
} else if editingStyle == .Insert {
// Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
}
}
AlertView Response Function:
func alertView(alertView: UIAlertView, clickedButtonAtIndex buttonIndex: Int){
if(buttonIndex == 1){
managedObjectContext?.deleteObject(personToDelete)
tableView.deleteRowsAtIndexPaths([indexPathforDelete], withRowAnimation: .Fade)
save()
}
setEditing(false, animated: true)
self.navigationItem.leftBarButtonItem = nil
}
tableView number of rows function:
var personToDelete = NSManagedObject()
var indexPathforDelete = NSIndexPath()
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// #warning Incomplete method implementation.
// Return the number of rows in the section.
let entityDescription = NSEntityDescription.entityForName("People", inManagedObjectContext: managedObjectContext!)
let request = NSFetchRequest()
request.entity = entityDescription
var error: NSError?
var objects = managedObjectContext?.executeFetchRequest(request, error: &error)
let results = objects
println("Results Count: \(results!.count)")
return results!.count
}
I think the problem is that you have two variables with the name propertyToDelete: a property that you declare and initialise with a blank NSManagedObject:
var personToDelete = NSManagedObject()
and a local variable that you declare within your commitEditingStyle function:
let personToDelete = results[deleteRow] as! NSManagedObject
It is this local variable to which you assign the object from your results array. But this local variable is destroyed when the function completes, and the AlertView action is deleting the object to which the property points. (The reason I hesitate is that I would expect your context to throw an error when it tries to delete an object that has never been registered with it). Note that by contrast you have only the one variable named indexPathforDelete. This holds the correct value when the AlertView action runs, and consequently the tableView deletes the correct row. That's why you get the error: it has deleted a row, but then finds (because no object has been deleted) it still has the same number of rows as before.
The immediate solution is to use the property within your function, rather than a local variable: just delete let:
personToDelete = results[deleteRow] as! NSManagedObject
But I would also recommend rethinking your approach: you are repeating the same fetch. If all the datasource methods do the same, it will be repeated numerous times when the table view is first built, whenever a cell is scrolled into view, whenever a cell is tapped, etc. This will be costly in terms of performance. You should instead undertake the fetch once (perhaps in viewDidLoad), store the results in an array property, and use that for the table view datasource methods. Alternatively, and perhaps preferably, use an NSFetchedResultsController: it is very efficient and there is boilerplate code for updating the table view when objects are added or deleted.
The documentations of tableView:commitEditingStyle:forRowAtIndexPath: says: "You should not call setEditing:animated: within an implementation of this method. If for some reason you must, invoke it after a delay by using the performSelector:withObject:afterDelay: method."