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?
Related
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)
}
}
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 {
}
I am new in Swift. The sample codes about UITableView show that new item is placed on the bottom of the list. How may we reverse this? I searched internet but could not find an answer.
Thanks.
Guven
UITableviews show data based on the order of an array such as an array of characters like
var data = ["B","C","D","E"]
Typically, you add data into array by using append which adds data at the end of the array, hence why it adds it at the bottom of the list.
data.append("A")
If you want your table view to add data on top of the list, then you can add your data at the beginning of the array like this.
data.insert("A", at: 0)
now reload your tableView, and new data would be added at the top of the list
yourTableViewName.reloadData()
To put new item on top, insert it at desired position (index 0) and reload corresponding indexPath (row: 0, section: 0).
let indexPathOfFirstRow = NSIndexPath.init(forRow: 0, inSection: 0)
yourArray.insert("some element", atIndex: 0)
tableView.beginUpdates()
tableView.insertRowsAtIndexPaths([indexPathOfFirstRow], withRowAnimation: .Automatic)
tableView.endUpdates()
Reloading whole table is a costly task and not recommended.
Add item to the array last index .
Then reload tableView
All you really need to do is to .insert your object at index 0 rather than append. Appending your latest value at the first index 0 ensures your last data is shown at the top. You can continue using tableview.reloaddata()as usual. Hope the screenshot helps
Inserting new items on data array at 0 index is easy but keeping the state of tableview as it is difficult especially when you are dealing with headers too...
I am using this to append old messages when paginating the chat...
you can iterate on data and insert new items and header with data.insert("new message", at: 0) but tableview automatically going to jump on zero index. to keep that state you should need a unique id in every new item in my case it was time and messageId
I am saving the last message id before appending new items ... and using that id to calculate the indexPath of the previous top message cell and scroll to it with no animation...
func getIndexPath(WithMessageId id: Double) -> IndexPath? {
for (section, sectionObjects) in messagesArray.enumerated() {
if let index = sectionObjects.Messages?.firstIndex(where: { $0.MessageID == id }) {
return IndexPath(row: index, section: section)
}
}
return nil
}
this method returns the indexpath use that as follows
self.tableView.reloadData()
self.tableView.scrollToRow(at: getIndexPath(WithMessageId: lastMessageID) ?? IndexPath(row: 0, section: 0),
at: .top, animated: false)
its gonna append new items without even any glitches like Twitter appending new news feeds on top...
This question already has answers here:
How to detect the end of loading of UITableView
(22 answers)
Closed 6 years ago.
I need to call a function after the UITableView has been loaded completely. I know that in most cases not every row is displayed when you load the view for the first time, but in my case, it does as I only have 8 rows in total.
The annoying part is that the called function needs to access some of the tableView data, therefore, I cannot call it before the table has been loaded completely otherwise I'll get a bunch of errors.
Calling my function in the viewDidAppear hurts the user Experience as this function changes the UI. Putting it in the viewWillAppear screws up the execution (and I have no idea why) and putting it in the viewDidLayoutSubviews works really well but as it's getting called every time the layout changes I'm afraid of some bugs that could occur while it reloads the tableView.
I've found very little help about this topic. Tried few things I found here but it didn't work unfortunately as it seems a little bit outdated. The possible duplicate post's solution doesn't work and I tried it before posting here.
Any help is appreciated! Thanks.
Edit: I'm populating my tableView with some data and I have no problems with that. I got 2 sections and in each 4 rows. By default the user only sees 5 rows (4 in the first section, and only one in the second the rest is hidden). When the user clicks on the first row of the first section it displays the first row of the second section. When he clicks on the second row of the first section it displays two rows of the second section, and so on. If the user then clicks on the first row of the first section again, only one cell in the second section is displayed. He can then save his choice.
At the same time, the system changes the color of the selected row in the first section so the users know what to do.
Part of my issue here is that I want to update the Model in my database. If the users want to modify the record then I need to associate the value stored in my database with the ViewController. So for example, if he picked up the option 2 back then, I need to make sure the second row in the first section has a different color, and that two rows in the second sections are displayed when he tries to access the view.
Here's some code :
func setNonSelectedCellColor(indexPath: NSIndexPath) {
let currentCell = tableView.cellForRowAtIndexPath(indexPath)
currentCell?.textLabel?.textColor = UIColor.tintColor()
for var nbr = 0; nbr <= 3; nbr++ {
let aCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: nbr, inSection: 0))
let aCellIndexPath = tableView.indexPathForCell(aCell!)
if aCellIndexPath?.row != indexPath.row {
aCell?.textLabel?.textColor = UIColor.blackColor()
}
}
}
func hideAndDisplayPriseCell(numberToDisplay: Int, hideStartIndex: Int) {
for var x = 1; x < numberToDisplay; x++ {
let priseCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: x, inSection: 1))
priseCell?.hidden = false
}
if hideStartIndex != 0 {
for var y = hideStartIndex; y <= 3; y++ {
let yCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: y, inSection: 1))
yCell?.hidden = true
}
}
}
These two functions are getting called every time the user touches a row :
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
let path = (indexPath.section, indexPath.row)
switch path {
case(0,0):
setNonSelectedCellColor(indexPath)
hideAndDisplayPriseCell(1, hideStartIndex: 1)
data["frequencyType"] = Medecine.Frequency.OneTime.rawValue
case(0,1):
setNonSelectedCellColor(indexPath)
hideAndDisplayPriseCell(2, hideStartIndex: 2)
data["frequencyType"] = Medecine.Frequency.TwoTime.rawValue
case(0,2):
setNonSelectedCellColor(indexPath)
hideAndDisplayPriseCell(3, hideStartIndex: 3)
data["frequencyType"] = Medecine.Frequency.ThreeTime.rawValue
case(0,3):
setNonSelectedCellColor(indexPath)
hideAndDisplayPriseCell(4, hideStartIndex: 0)
data["frequencyType"] = Medecine.Frequency.FourTime.rawValue
default:break
}
}
I store the values in a dictionary so I can tackle validation when he saves.
I'd like the first two functions to be called right after my tableView has finished loading. For example, I can't ask the data source to show/hide 1 or more rows when I initialize the first row because those are not created yet.
As I said this works almost as intended if those functions are called in the viewDidAppear because it doesn't select the row immediately nor does it show the appropriate number of rows in the second sections as soon as possible. I have to wait for 1-2s before it does.
If you have the data already that is used to populate the tableView then can't you use that data itself in the function? I am presuming that the data is in the form of an array of objects which you are displaying in the table view. So you already have access to that data and could use it in the function.
But if that's not the case then and if your table view has only 8 rows then you can try implementing this function and inside that check the indexPath.row == 7 (8th row which is the last one).
tableView(tableView: UITableView, didEndDisplayingCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath)
Since all your rows are visible in one screen itself without scrolling you could use this function to determine that all the cells have been loaded and then call your function.
I have a UITableView which contains a message thread conversation. I'm trying to allow the user to scroll up to view previous messages, and seamlessly load previous messages. I'm able to append the previous messages just fine, but the UITableView jumps when I append the messages. How can I append messages without the table view jumping. Here is the code I have when I append the messages:
// Create the index paths for the updated messages
let indexPaths = (0..<newMessages.count).map { NSIndexPath(forItem: $0, inSection: 0) }
// Append new items
self.messageThreadTableView.insertRowsAtIndexPaths(indexPaths, withRowAnimation: UITableViewRowAnimation.None)
The newMessages array contains the messages to be appended to the UITableView, which were appended correctly to the UITableView's data source array. Is there another method to append to a table view which won't make the scrolling jump?