Reload collectionview cells and not the section header - ios

When trying to create collapsible UICollectionView sections, I update the number of items in the section dependent on its state. However, doing it this way, I reload the section which also reloads the section header aswell, and I get a very weird behavior when animating my image in the section header.
Essentially, reloading the section header when changing section items enables the UICollectionView to update the items but the section animate looks and behaves strange.
Without calling reloadSection, it allows for the proper animation but the items do not load.
self?.collectionView?.performBatchUpdates({
let indexSet = IndexSet(integer: section)
self?.collectionView?.reloadSections(indexSet)
}, completion: nil)
What is the fix for this?

You may try to extract the sequence of IndexPath in a specific section, then call reloadItems on such sequence, doing so:
extension UICollectionView {
func reloadItems(inSection section:Int) {
reloadItems(at: (0..<numberOfItems(inSection: section)).map {
IndexPath(item: $0, section: section)
})
}
}
so your code might be something like:
var updateSection = 0 // whatever
collectionView.performBatchUpdates({
// modify here the collection view
// eg. with: collectionView.insertItems
// or: collectionView.deleteItems
}) { (success) in
collectionView.reloadItems(inSection: updateSection)
}

Related

UIViewRepresentable: Reloading data in UICollectionView from updateUIView fails, app crashes

I'm working with a UICollectionView as UIViewRepresentable. It should be updated when either current item is changed (to scroll to the current item and highlight it), or the source data was updated (it can add/remove one item). The first case works perfectly, but the second one can cause lags and even crash the app with this exception: "Attempted to scroll the collection view to an out-of-bounds item (9) when there are only 9 items in section 0". It's caused when scrolling after updating the source data (adding/removing item).
Here is the code for updateUIView function:
func updateUIView(_ uiView: UICollectionView, context: Context) {
uiView.reloadData()
if !sections.isEmpty, currentSectionId != context.coordinator.currentSectionIndex && uiView.numberOfItems(inSection: 0) == sections.count {
context.coordinator.currentSectionIndex = currentSectionId
guard let currentSection = sections.firstIndex(where: { $0.id == currentSectionId }) else { return }
let indexPath = IndexPath(row: currentSection, section: 0)
uiView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
}
And here are the variables of my UIViewRepresentable:
struct SectionScrollView: UIViewRepresentable {
#Binding var sections: [MenuSection]
#Binding var currentSectionId: Int
It's a menu that can be navigated through categories so scrolling main menu (also UIViewRepresentable UICollectionView) triggers currentSectionId that scrolls section collection view to the current section. And pressing current section in section collection view triggers main collection view to scroll to the beginning current section.
Also, making an item favorite adds additional section (if there is no favorites) and removes it (if user removes the last item from favorites). And here is when the exception appear. If the user scrolls main collection view after making item favorite, the app could crash (or could not).
It seems that reloadData() doesn't always work or work as expected.
So, what could be wrong here?
P.S. When I add the code below to updateUIView, the scrolling sometimes stops until another favorite dish isn't added/removed:
uiView.reloadData() // This was already there
if sections.count != uiView.numberOfItems(inSection: 0) {
uiView.reloadData()
}

Delete multiple cells from tableView without conflict

I have a tableView where the user can tap on a button inside the cell to delete it. That button is connected with this delegate-function:
extension WishlistViewController: DeleteWishDelegate {
func deleteWish(_ idx: IndexPath){
// remove the wish from the user's currently selected wishlist
wishList.wishes.remove(at: idx.row)
// set the updated data as the data for the table view
theTableView.wishData.remove(at: idx.row)
self.theTableView.tableView.deleteRows(at: [idx], with: .right)
print("deleted")
}
}
Here is how I call the callback (after an animation is finished):
#objc func checkButtonTapped(){
self.successAnimation.isHidden = false
self.successAnimation.play { (completion) in
if completion {
self.deleteWishCallback?()
}
}
}
And this callback is handled in cellForRowAt and passes the indexPath:
cell.deleteWishCallback = {
self.deleteWishDelegate?.deleteWish(indexPath)
}
It works fine until the user clicks multiple buttons right after another as I get a IndexOutOfBounds-Error. What I was thinking of is to store all the incoming indexes in some sort of list and delete them one after another but each index changes as soon as another cell below itself is deleted. What is the best way to get this done?
How are you sending the indexPath to delete in the delegate, can you show code?
You might have retain cycle on your deleted cell, and the index is not valid.
Edit: Solution
You must pass the cell in your closure self.deleteWishCallback?(cell) and then get the actual index path like this
cell.deleteWishCallback = { deletedCell in
let indexPath = tableView.indexPath(for: deletedCell)
wishList.wishes.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .right)
self.deleteWishDelegate?.deleteWish(indexPath)
}
Instead of self.theTableView.tableView.deleteRows(at: [idx], with: .right) just simply reloadData. (self.theTableView.reloadData())
If you set the new datasource for your tableView, you should reload cells.

UICollectionView custom layout cell-type-dependent attribute

My collection view has a dynamic layout and multiple cell/header types, and I would like to apply background color to only certain types of headers. The way I tried, unsuccessfully, to determine the IndexPath of those headers during run time is:
class MyCustomLayout: UICollectionViewFlowLayout {
private var backgroundAttrs = [UICollectionViewLayoutAttributes]()
override func prepare() {
super.prepare()
guard let numberOfSections = collectionView?.numberOfSections else { return }
backgroundAttrs.removeAll()
for section in 0 ..< numberOfSections {
if let header = collectionView?
.supplementaryView(forElementKind: UICollectionElementKindSectionHeader,
at: IndexPath(item: 0, section: section)) as? HeaderThatNeedsBackground,
let attr = getBackgroundAttribute(forSection: section) {
backgroundAttrs.append(attr)
}
}
}
...
}
supplementaryView(forElementKind:, at:) keeps giving me nil, so the loop is never executed. My guess is that the cells are not yet instantiated at this point? Any advice on how to selectively apply layout attribute based on cell/header type would be appreciated
First of all, you shouldn't call collection view methods that retrieve cell or supplementary views, since the collection view uses the layout (the one you are implementing) to retrieve information for those elements. You can read how to properly subclass a UICollectionViewLayout in this link.
In your particular case you need to override the function:
layoutAttributesForSupplementaryView(ofKind:at:)

Is it unsafe to call reloadData() after getting an indexPath but before removing a cell at that indexPath?

I'm trying to track down a difficult crash in an app.
I have some code which effectively does this:
if let indexPath = self.tableView.indexPath(for: myTableViewCell) {
// .. update some state to show a different view in the cell ..
self.tableView.reloadData()
// show nice fade out of the cell
self.friendRequests.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .fade)
}
The concern is that calling reloadData() somehow makes the indexPath I just retrieved invalid so the app crashes when it tries to delete the cell at that indexPath. Is that possible?
Edit:
The user interaction is this:
User taps a button [Add Friend] inside of table view cell <-- indexPath retrieved here
Change the button to [Added] to show the tap was received. <-- reloadData called here
Fade the cell out after a short delay (0.5s). <-- delete called here with indexPath from #1
I can change my code to not call reloadData and instead just update the view of the cell. Is that advisable? What could happen if I don't do that?
Personally, I'd just reload the button in question with reloadRows(at:with:), rather than the whole table view. Not only is this more efficient, but it will avoid jarring scrolling of the list if you're not already scrolled to the top of the list.
I'd then defer the deleteRows(at:with:) animation by some small fraction of time. I personally think 0.5 seconds is too long because a user may proceed to tap on another row and they can easily get the a row other than what they intended if they're unlucky enough to tap during the wrong time during the animation. You want the delay just long enough so they get positive confirmation on what they tapped on, but not long enough to yield a confusing UX.
Anyway, you end up with something like:
func didTapAddButton(in cell: FriendCell) {
guard let indexPath = tableView.indexPath(for: cell), friendsToAdd[indexPath.row].state == .notAdded else {
return
}
// change the button
friendsToAdd[indexPath.row].state = .added
tableView.reloadRows(at: [indexPath], with: .none)
// save reference to friend that you added
let addedFriend = friendsToAdd[indexPath.row]
// later, animate the removal of that row
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if let row = self.friendsToAdd.index(where: { $0 === addedFriend }) {
self.friendsToAdd.remove(at: row)
self.tableView.deleteRows(at: [IndexPath(row: row, section: 0)], with: .fade)
}
}
}
(Note, I used === because I was using a reference type. I'd use == with a value type that conforms to Equatable if dealing with value types. But those are implementation details not relevant to your larger question.)
That yields:
Yes, probably what's happening is the table view is invalidating stored index path.
To test whether or not it is the issue try to change data that is represented in the table right before reloadData() is called.
If it is a problem, then you'll need to use an identifier of an object represented by the table cell instead of index path. Modified code will look like this:
func objectIdentifer(at: IndexPath) -> Identifier? {
...
}
func indexPathForObject(_ identifier: Identifier) -> IndexPath? {
...
}
if
let path = self.tableView.indexPath(for: myTableViewCell)
let identifier = objectIdentifier(at: path) {
...
self.tableView.reloadData()
...
if let newPath = indexPathForObject(identifier) {
self.friendRequests.removeObject(identifier)
self.tableView.deleteRows(at: [newPath], with: .fade)
}
}

UITableView deleterows scroll to top

I made a query to delete specified rows in UITableView but the problem when the deleteRows is called the UITableView scroll to top so how to prevent it scrolling to top? and why it scroll to top!
I try called a method to force it scroll to down but the tableview scroll to top first after return
the code of remove
FIRDatabase.database().reference().child("messages").child(self.roomId).observe(.childRemoved, with: {(snap) in
print("child Removed")
for msg in self.messageArray {
let message: NSDictionary = msg as! NSDictionary
let messageContent: [String: Any] = message as! [String: Any]
if messageContent["messageID"] as! String == snap.key {
let indexRemoved = self.messageArray.index(of: msg)
self.messageArray.remove(msg)
self.userArray.removeObject(at: indexRemoved)
let indexPath = IndexPath(row: indexRemoved, section: 0)
print(indexPath)
self.conversationTableView.deleteRows(at: [indexPath], with: UITableViewRowAnimation.none)
break;
}
}
})
Please be sure you are not using table view method like 'estimatedHeightFor...' to calculate hight to scroll table view after row delete. One more point to be sure is, reloadData method should not use when deleting row from table view if you don't want to table scrolling.
If all are good then on deleting a particular row, table view will move all rows up to fill deleted row empty space.
There are a few issues with the code that can be easily corrected.
First before doing anything, grab the current scroll position of the first visible item (given the cells are the same size)
let savedIndex = myTableView.indexPathsForVisibleRows?.first
then once the row is deleted from the array and you reload the tableview, you can return to where it was with
myTableView.scrollToRowAtIndexPath(savedIndex, atScrollPosition: .Top, animated: false)
If you need another option (variable heights), you can use
tableView.contentOffset.y;
Also, there's no need to iterate over the entire array to find the row to delete from it. Assuming the Firebase nodes(s) data are stored in the array as a class, in the array with the Firebase key as a property of each class, you can simply get the index of that element in the array and remove it. Here's an option
let key = snapshot.key
if let index = self.myArray.indexOf({$0.firebaseKey == key}) {
self.myArray.removeAtIndex(index)
self.myTableView.reloadData()
}
This object would look like:
class MyClass {
var firebaseKey: String?
var some_var: String?
var another_var: String?
}

Resources