TableView scrolling to top after applying UITableViewDiffableDataSource snapshot - ios

I'm doing pagination using UITableViewDataSourcePrefetching.
The values will be taken from the Realm local storage.
I will get an array of objects. These values will be applied to the existing UITableViewDiffableDataSource datasource.
After applying snapshot the tableview scrolling to the top.
I have verified that all my ChatMessage objects has unique hashValues.
How can I prevent the scrolling?
Link to the video TableView_scroll_issue_video
Given my code snippet
private func appendLocal(chats chatMessages: [ChatMessage]) {
var sections: [String] = chatMessages.map({ $0.chatDateTime.toString() })
sections.removeDuplicates()
guard !sections.isEmpty else { return }
var snapshot = dataSource.snapshot()
let chatSections = snapshot.sectionIdentifiers
sections.forEach { section in
let messages = chatMessages.filter({ $0.chatDateTime.toString() == section })
/// Checking the section is already exists in the dataSource
if let index = chatSections.firstIndex(of: section) {
let indexPath = IndexPath(row: 0, section: index)
/// Checking dataSource already have some messages inside the same section
/// If messages available then add the recieved messages to the top of existing messages
/// else only section is available so append all the messages to the section
if let item = dataSource.itemIdentifier(for: indexPath) {
snapshot.insertItems(messages, beforeItem: item)
} else {
snapshot.appendItems(messages, toSection: section)
}
} else if let firstSection = chatSections.first {
/// Newly receieved message's section not available in the dataSource
/// Add the section before existing section
/// Add the messages to the newly created section
snapshot.insertSections([section], beforeSection: firstSection)
snapshot.appendItems(messages, toSection: section)
} else {
/// There is no messages available append the new section and messages
snapshot.appendSections([section])
snapshot.appendItems(messages, toSection: section)
}
}
dataSource.apply(snapshot, animatingDifferences: false)
}

I think that everything works correctly. You scroll to the "past" and eventually you want to add more "old" messages. So you insert new messages into the indexPath (0-0). Your contentOffset stays the same so you see the "old" messages.
Calculating and preserving the visually correct contentOffset is possible but hard, and it's also pretty fragile. Whatever goes wrong your scroll position will "jump". You don't want to touch your contentOffset.
What you might actually want is to reuse the commonly famous trick that all popular messengers use. You want your "newest" messages to actually be on the bottom of the screen, and in the same time you'd like the newest message to be the first one in your dataSource.
So you need to flip the tableView vertically and then flip every cell vertically as well. Doing so you'll get more "natural" data source when the newest messages get inserted into the indexPath (0-0), and "older" messages get appended to the end of your message array.
let t = CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 0, ty: 0)
tableView.transform = t
cell.transform = t
That might sound crazy, but give it a try and you'll be impressed.

You can try the following:
dataSource.applySnapshotUsingReloadData(snapshot, completion: nil)
It's only available from iOS 15.0
Also common mistake is using UUID().uuidString.
Make sure that the id of each cell is fixed. UITableViewDiffableDataSource will not be able to properly calculate changes and it will reload the whole table if ids are updated.

You can try creating a new snapshot instead of referencing & mutating the current snapshot of your dataSource.

After looking a bit into it, I realized that you apply the snapshot without animation.
That means that on ios versions 13-14, you are not performing a diff, but actually reloading the data, which means the whole state of the dataSource is lost. Hence the scrolling position is also reset.
You need to apply the snapshot with animation on versions 13, 14.
There are a few work arounds if you insist on not animating the changes while setting ‘animated’ as true, but I don’t see, from a UX perspective, why you’d want that.

If anyone facing the issue, I have resolved the issue by adding the section and items one at time with animation instead of in batches. The batching appears to be causing the jumping issue more than if done individually in the loop. The code is given below,
func appendLocal(chats chatMessages: [ChatMessage]) {
var sections: [String] = chatMessages.map({ $0.chatDateTime.toString() })
sections.removeDuplicates()
guard !sections.isEmpty else { return }
var snapshot = dataSource.snapshot()
let chatSections = snapshot.sectionIdentifiers
for section in sections {
let messages = chatMessages.filter({ $0.chatDateTime.toString() == section })
if messages.isEmpty {
continue
}
/// Checking the section is already exists in the dataSource
if let index = chatSections.firstIndex(of: section) {
let indexPath = IndexPath(row: 0, section: index)
/// Checking dataSource already have some messages inside the same section
/// If messages available then add the recieved messages to the top of existing messages
/// else only section is available so append all the messages to the section
if let item = dataSource.itemIdentifier(for: indexPath) {
snapshot.insertItems(messages, beforeItem: item)
} else {
snapshot.appendItems(messages, toSection: section)
}
} else if let firstSection = chatSections.first {
/// Newly receieved message's section not available in the dataSource
/// Add the section before existing section
/// Add the messages to the newly created section
viewModel.chatSections.insert(section, at: 0)
snapshot.insertSections([section], beforeSection: firstSection)
snapshot.appendItems(messages, toSection: section)
} else {
/// There is no messages available append the new section and messages
viewModel.chatSections.append(section)
snapshot.appendSections([section])
snapshot.appendItems(messages, toSection: section)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}

Related

Stop Diffable Data Source scrolling to top after refresh

How can I stop a diffable data source scrolling the view to the top after applying the snapshot. I currently have this...
fileprivate func configureDataSource() {
self.datasource = UICollectionViewDiffableDataSource<Section, PostDetail>(collectionView: self.collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, userComment: PostDetail) -> UICollectionViewCell? in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PostDetailCell.reuseIdentifier, for: indexPath) as? PostDetailCell else { fatalError("Cannot create cell")}
cell.user = self.user
cell.postDetail = userComment
cell.likeCommentDelegate = self
return cell
}
var snapshot = NSDiffableDataSourceSnapshot<Section, PostDetail>()
snapshot.appendSections([.main])
snapshot.appendItems(self.userComments)
self.datasource.apply(snapshot, animatingDifferences: true)
}
fileprivate func applySnapshot() {
//let contentOffset = self.collectionView.contentOffset
var snapshot = NSDiffableDataSourceSnapshot<Section, PostDetail>()
snapshot.appendSections([.main])
snapshot.appendItems(self.userComments)
self.datasource.apply(snapshot, animatingDifferences: false)
//self.collectionView.contentOffset = contentOffset
}
store the offset, then reapply it. Sometimes it works perfectly and sometimes the view jumps. Is there a better way of doing this?
The source of this problem is probably your Item identifier type - the UserComment.
Diffable data source uses the hash of your item identifier type to detect if it is a new instance or an old one which is represented currently.
If you implement Hashable protocol manually, and you use a UUID which is generated whenever a new instance of the type is initialized, this misguides the Diffable data source and tells it this is a new instance of item identifier. So the previous ones must be deleted and the new ones should be represented. This causes the table or collection view to scroll after applying snapshot.
To solve that replace the uuid with one of the properties of the type that you know is unique or more generally use a technique to generate the same hash value for identical instances.
So to summarize, the general idea is to pass instances of the item identifiers with the same hash values to the snapshot to tell the Diffable data source that these items are not new and there is no need to delete previous ones and insert these ones. In this case you will not encounter unnecessary scrolls.
Starting from iOS 15
dataSource.applySnapshotUsingReloadData(snapshot, completion: nil)
Resets the UI to reflect the state of the data in the snapshot without computing a diff or animating the changes
First up: in most cases #Amirrezas answer will be the correct reason for the problem. In my case it was not the item, but the section identifier that caused the problem. That was Hashable and Identifiable with correct values, but it was a class, and therefore the hash functions were never called. Took me a while to spot that problem. Changing to a struct (and therefore adopting some things ;) ) helped in my case.
For reference here's a link to the topic on the Apple-Dev forums: https://developer.apple.com/forums/thread/657499
Hope my answer helps somebody :)
You'd think that any of these methods would work:
https://developer.apple.com/documentation/uikit/uicollectionviewdelegate/1618007-collectionview
https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617724-targetcontentoffset
But (in my case) they did not. You might get more mileage out of them, I am doing some crazy stuff with a custom UICollectionViewCompositionalLayout
What I did get to work is manually setting the offset in my custom layout class:
override func finalizeCollectionViewUpdates() {
if let offset = collectionView?.contentOffset {
collectionView?.contentOffset = targetContentOffset(forProposedContentOffset: offset)
}
super.finalizeCollectionViewUpdates()
}
where I have targetContentOffset also overridden and defined (I tried that first, didn't work, figured it was cleanest to just use that here. I suspect if you define targetContentOffset on the delegate without overriding it in the layout the above will also work, but you already need a custom layout to get this far so it's all the same.)

swift iOS filter on array containing uitableviews

func convertPointToIndexPath(_ point: CGPoint) -> (UITableView, IndexPath)? {
if let tableView = [tableView1, tableView2, tableView3].filter({ $0.frame.contains(point) }).first {
let localPoint = scrollView.convert(point, to: tableView)
let lastRowIndex = focus?.0 === tableView ? tableView.numberOfRows(inSection: 0) - 1 : tableView.numberOfRows(inSection: 0)
let indexPath = tableView.indexPathForRow(at: localPoint) ?? IndexPath(row: lastRowIndex, section: 0)
return (tableView, indexPath)
}
return nil
}
So i got this method, which converts a CGPoint into the indexPath of the given uitableView. I struggle with the filter-Method on the array which contains uitableViews.
I got an array outside of this method which contains any number of uitableViews. For example:
public var littleKanbanColumnsAsTableViews: [UITableView] = []
So i got to make a change inside of the method. Like this:
if let tableView = littleKanbanColumnsAsTableViews.filter({ $0.frame.contains(point)}).first { ... }
Now when i click on any tableView on the gui, i track the coordinates of the point and transform it on the belonging tableview with the frame.contains(point) method.
My problem is that the filter is not working, it always gives me the first tableView back, no matter which tableview is clicked. Why it doesn't work with my littleKanbanColumnsAsTableViews-Array?
One hint:
let tableView = littleKanbanView.littleKanbanColumnsAsTableViews[3]
When i indexing its direct then it works. But i want it depending on which tableView is containing the clicked point.
Here is my array with the tableViews, in this case the array contains 5 tableviews.
array containing tableviews
Now i want to filter the tableView out of them, which includes the point from tapping on this tableView. How can i achieve this?
For more understanding, i add the ui, here is it:
UI of my app
When i click on this tableView it works, because it is the first element in my array of tableViews. So for this case the convertPointToIndexPath-Method is working.
But when i scroll horizontally to the second tableView for example and click on that, it doesn't work. Because I think the method gives me always the first element back, but i thought it filters it with the given condition.
What is the problem, why doesn't work the condition{ $0.frame.contains(point)}? It have to localize the tableView when the coordinates of the point are tracked.
Preferred Solution:
In this case the moment the first satisfying condition is met, the rest of the elements are not traversed.
if let tableView = littleKanbanColumnsAsTableViews.first(where: { $0.frame.contains(point) }) {
}
Not so efficient solution:
In this case all the elements in the array are traversed to build an array of table views that satisfy the condition. Then the first element of that filtered array is chosen.
if let tableView = (littleKanbanColumnsAsTableViews.filter{ $0.frame.contains(point)}).first {
}

How to deal with 'Assertion Failure' Collection View crashes in Firebase DatabaseUI in Swift?

I'm building a screen on my app where profiles will be displayed on a collection view. My data is being retrieved from Firebase and for the first time I'm using Firebase DatabaseUI.
I'm building out a query, then creating a FUICollectionViewDataSource, dequeuing my cells, and then plugging that dataSource into the collectionView.
The functionality of the dataSource works fine as long as I'm toggling between online and offline users (which checks the value of a child key on the query), but when I change the query to be from male to women, or women to men, (a new query itself) I'm crashing with an NSInternalInconsistenencyException.
*** 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 (1) must be equal to the number of items contained in that section before the update (1), plus or minus the number of items inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).'
*** First throw call stack:
(0x18672d1b8 0x18516455c 0x18672d08c 0x1871e502c 0x18cedc14c 0x18c7b1d4c 0x10020f594 0x1002102c8 0x1001eb3e0 0x101609258 0x101609218 0x10160e280 0x1866da810 0x1866d83fc 0x1866062b8 0x1880ba198 0x18c64d7fc 0x18c648534 0x1000c4200 0x1855e95b8)
libc++abi.dylib: terminating with uncaught exception of type NSException
When I'm building out collection views, I'm used to just running commands like this:
collectionView.dataSource = collectionViewDataSource
self.collectionView.reloadData()
But now that calls like above don't work, and I'm not using UICollectionViewDataSource protocol functions, I'm unsure how to go about creating, running, and displaying new queries which will always have differing number of items per section.
What are the functions that I have to run before processing a new query to not deal with these kinds of crashes?
I've tried deleting all the cells from the collectionView's indexPathsForVisibleItems, and then reloading data... I've thrown around collectionView.reloadData() all over the place and still crashes. Not all the time, but the app will definitely crash sooner or later.
How do I go about starting a brand new query on a collectionView and not have to deal with NSInternalInconsistencyExceptions?
Thanks everyone. I love Firebase and would love to figure this out!
-Reezy
When the VC loads, the getUserData() function is called.
When the query is changed, the resetQuery() function is run from a posted notification.
extension BrowseVC {
func getUserData() {
let isOnline = (segmentedControl.selectedSegmentIndex == 0) ? true : false
generateQuery(isOnline: isOnline)
populateDataSource()
}
func resetQuery() {
removeOldData()
getUserData()
}
func removeOldData() {
let indexPaths = collectionView.indexPathsForVisibleItems
collectionView.deleteItems(at: indexPaths)
// collectionView.reloadData()
}
func generateQuery(isOnline: Bool) {
if let selectedShowMe = UserDefaults.sharedInstance.string(forKey: Constants.ProfileKeys.ShowMe) {
let forMen = (selectedShowMe == Constants.ProfileValues.Men) ? true : false
query = FirebaseDB.sharedInstance.buildQuery(forMen: forMen, isOnline: isOnline)
} else {
query = FirebaseDB.sharedInstance.buildQuery(forMen: false, isOnline: isOnline)
}
}
func populateDataSource() {
if let query = query {
collectionViewDataSource = FUICollectionViewDataSource(query: query, view: collectionView, populateCell: { (view, indexPath, snapshot) -> ProfileCVCell in
if let cell = view.dequeueReusableCell(withReuseIdentifier: Constants.CollectionViewCellIdentifiers.ProfileCVCell, for: indexPath) as? ProfileCVCell {
if let userDictionary = snapshot.value as? [String: AnyObject] {
if let user = User(uid: snapshot.key, userDictionary: userDictionary) {
cell.populate(withUser: user)
}
}
return cell
}
return ProfileCVCell()
})
collectionView.dataSource = collectionViewDataSource
collectionView.reloadData()
// collectionView.performBatchUpdates({
// self.collectionView.dataSource = self.collectionViewDataSource
// }, completion: nil)
// collectionView.reloadData()
}
}
}
The reason you're running into this is the FirebaseUI Database data sources assume responsibility for pushing updates to the collection view itself. This way you get animated updates for free and shouldn't ever have to call reloadData.
When you swap out the old data source for a new one, you need to set the data source's collection view to nil so it stops pushing events to the collection view.

Swift, adding a class array into a variable does not update array content in original class

I'm new to IOS so forgive me for my coding mistakes. I'm facing an issue where I have a tableView Controller with two sections. The first section has a button, when clicked, appends data into an array and deletes it's own row in the first section (i did this as there are extra non related rows in the first section). The number of rows in the second section is based upon array.count.
My issue is that I tried begin/end update, and it still doesn't work. Whenever I run the code below and run the startNewDay function (when the button is clicked), this error occurs:
'attempt to insert row 0 into section 1, but there are only 0 rows in section 1 after the update'
This doesn't make any sense, as I appended the array already before I inserted the new rows. The array was empty before I appended it. Shouldn't there be the same number of rows in the second section as array.count?
Table View Delegate code:
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 2
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
if dataModel.lists[0].dayHasStarted == false {
return 2
} else {
return 1
}
} else {
if itemDoneCount == dataModel.lists[0].item.count && dataModel.lists[0].doneButtonVisible {
return dataModel.lists[0].item.count + 1
} else {
return dataModel.lists[0].item.count
}
}
}
startNewDay button function when pressed:
#IBAction func startNewDayDidPress(sender: AnyObject) {
dataModel.lists[0].dayHasStarted = true
dataModel.lists[0].startDate = NSDate()
addItemButton.enabled = !addItemButton.enabled
// deleting start new day button
let indexPath = NSIndexPath(forRow: 1, inSection: 0)
let indexPaths = [indexPath]
tableView.beginUpdates()
tableView.deleteRowsAtIndexPaths(indexPaths, withRowAnimation: .Fade)
tableView.endUpdates()
// Inserting new array elements and rows into 2nd section
let ritualsArray = dataModel.lists[0].rituals
var itemsArray = dataModel.lists[0].item
itemsArray.appendContentsOf(ritualsArray)
tableView.beginUpdates()
var insertRitualsArray = [NSIndexPath]()
for item in itemsArray {
let itemIndex = itemsArray.indexOf(item)
let indexPath = NSIndexPath(forRow: itemIndex!, inSection: 1)
insertRitualsArray.append(indexPath)
}
tableView.insertRowsAtIndexPaths(insertRitualsArray, withRowAnimation: .Top)
tableView.endUpdates()
}
SOLVED
The problem of this code is not at all related to the previous title of this thread, which may be misleading to people having the same issue as mine. Hence, I will be changing it. The previous title (for the curious) was :
"tableView.begin/end update not updating number of rows in section"
Just for others who might come across this issue, the issue isn't in the tableView delegate, nor is it in reloading the tableview data. For readability, I placed both dataModel.list[0].item into itemsArray and dataModel.list[0].item into ritualsArray. This apparently updates the itemsArray when appended but not the initial dataModel.list[0].item instead, which caused the second section in the tableView not to load the new number of rows, causing the error when inserting rows into non-existant rows.
Hence instead of:
let ritualsArray = dataModel.lists[0].rituals
var itemsArray = dataModel.lists[0].item
itemsArray.appendContentsOf(ritualsArray)
this solved it:
dataModel.list[0].item += dataModel.list[0].rituals
Hope it helps any beginner like me out there that comes across this issue.
Latest update
I found out recently that an array is of value type, and not reference type. Hence placing an array into a variable makes a copy of that array instead of serving as a placeholder for the original array.
Beginner mistake opps.
The error you are receiving means that the datasource contains a different number of items to however many there would be after inserting or deleting rows. This probably means that the data are not being inserted into your datasource array, or that the data do not match the criteria in the if statements in your numberOfRowsInSection function. To troubleshoot this, you should log the contents of the datasource array after modifying it to check what its contents are. If they are what you are expecting (I.e. The data have been added correctly) then the issue is in the way you are evaluating its contents to establish the number of rows. If the contents are not what you are expecting, then the issue is in the way you are inserting the data into the datasource array.
I had a similar problem after deleting a row. It seems that if
numberOfRowsInSection is not coherent (equal to last value -1) this error appears.
I see that there's a condition in your numberOfRowsInSection, this is perhaps the culprit

Adding new data to the top of a UICollectionView without animation

I have a UICollectionView that is displaying data that has been saved locally to the device. Once this data is shown, I call to the server to retrieve any new data.
If the server has new data, I need to insert it at the beginning of the array datasource since it is the newest data. I then need to refresh the UICollectionView so that it knows new data has been inserted in the beginning / at the top.
This all works fine, but the problem is that an animation happens and it is noticeable to the user. If the user has scrolled down into some of the cached data, and then the new data is added to the top, the cells they are seeing on screen will reload/animate.
I need to keep all of the above just without the animation. The new cells should be added at the top without changing any of the current cells the user is viewing.
Here is the code I am using to insert and handle the new data:
self.collectionView.performBatchUpdates({
var index = 0
var indexPaths = [NSIndexPath]()
for newObject in objects {
// Update datasource
self.datasource.insert(newObject, atIndex: index)
// Create index paths for new objects
let indexPath = NSIndexPath(forItem: index, inSection: 0)
indexPaths.append(indexPath)
// Update index
index += 1
}
self.collectionView.insertItemsAtIndexPaths(indexPaths)
}, completion: { (completed) in
})
I have tried wrapping this code in a UIView animation block with a duration of 0, in a UIView.performWithoutAnimation function, and have also implemented the following in my UICollectionViewFlowLayout subclass:
public override func initialLayoutAttributesForAppearingItemAtIndexPath(itemIndexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return nil
}
None of this works. The animation always happens. The cells the user is viewing flash / animate / etc. when the new data is inserted and added.
How can I accomplish this without an animation?

Resources