Some with experience can tell me what is the best way to execute a reloadData() from a UITableView on swift avoiding freeze?
I have a ViewController with a TableView, this shows a list of users in pairs of 10 rows. When the scroll show the last row - 1, in a background, the app request the next 10 users and then they are added to the rest of users for show now 20 users in the TableView.
When this is executed with a delegated method, the reload causes a freezing around 1~2 seconds and hasn't a comfortable navigation.
Any idea to solve this?
When new data coming, you don't need to reload the whole tableView. You just need to insert new rows accordingly. That won't cause any lag/freeze.
func didFinishLoadNewUsers(newUsers: [User]) {
tableView.beginUpdates()
//array of index paths for new rows at the bottom
var indexPaths = [NSIndexPath]()
for row in (currentUsers.count..<(currentUsers.count + newUsers.count)) {
indexPaths.append(NSIndexPath(forRow: row, inSection: 0))
}
//update old data
currentUsers.appendContentsOf(newUsers)
//insert new rows to tableView
tableView.insertRowsAtIndexPaths(indexPaths, withRowAnimation: .Automatic)
tableView.endUpdates()
}
If I understand your question correctly, your symptom is this:
The user scrolls to the bottom.
Scrolling is halted (because the bottom was reached).
Your controller notices that the user reached the bottom and starts downloading more rows.
There is an ugly delay while new rows finish downloading.
You insert the new rows, and now the user can resume scrolling.
The user is disappointed with your app now, because it stopped scrolling and delayed for no apparent reason. I believe this is what you meant by "freezing" and "uncomfortable navigation".
Solution:
Don't wait until the last row is displayed! For example: start with 40 rows and download 40 more rows when scrolling reaches a distance of about 15 rows from the bottom. That way, there's a good chance the download will finish quickly enough that it will look perfectly smooth to the user.
If you want to get really fancy, you can take scroll speed, row height, and server latency into account. But in my experience, none of that is really necessary for a smooth "infinite scrolling" experience.
With all due respect, you and the other responder are wrong to think that "reloading the whole table view" is responsible for this. UITableView.reloadData() is actually seamless (if the user hasn't reached the bottom yet).
Try this:
var shouldDownloadMoreRows : Bool {
get {
// This should return false if the server tells us there are no more rows.
// For example, if our last request for 40 got less than 40 rows, then we
// can probably assume there are no more.
// It should also return false if a request is currently in progress, or a
// request failed within the last 0.5 seconds or so, or if the controller
// is quitting (about to animate away).
return ...
}
}
func downloadMoreRows() {
...
// After the download finishes
didFinishDownloadingMoreRows()
}
func didFinishDownloadingMoreRows() {
// This will be smooth. It will not disrupt scrolling or cause any freezing or lag.
self.tableView.reloadData()
}
func tableView(tableView: UITableView,
willDisplayCell cell: UITableViewCell,
forRowAtIndexPath indexPath: NSIndexPath) {
let numRowsInSection = tableView.numberOfRowsInSection(indexPath.section)
if self.shouldDownloadMoreRows && indexPath.row + 15 >= numRowsInSection {
self.downloadMoreRows()
}
}
Related
I have a UIViewController that has a UITableView which presents comments fetched form a live Firebase database.
Every time a new comment arrives, I call
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: self.liveComments.count-1, section: 0)], with: .fade)
tableView.endUpdates()
to insert the latest comment with a fade animation. This works fine.
However, each cell has a label that shows when it was posted, in the form of "seconds, minutes or hours ago". The problem is that when many comments arrive, the age label does not get updated, since the existing cells are not updated, and it looks to the user like the comment ages are wrong.
I've tried calling
tableView.reloadRows(at: self.tableView.indexPathsForVisibleRows ?? [], with: .none)
inside my tableView updated block, but the animation is all messed up, since all of the visible cells seem to get animated in a weird, "jumpy" way.
I've also tried getting all of the visible cells, and calling a method on them to update their timestamp labels manually, but I get a crash when I do this, so I guess it's not recommended:
if let visibleCells = self.tableView.visibleCells as? [LiveCommentTableViewCell] {
visibleCells.forEach { cell in
cell.updateCommentAgeLabel()
}
How can I approach this? I just need to reload all visible cells without an animation, and the last cell with a fade in animation. Thank you!
I would just reload all the data, as long the cellForRowAt sets the timestamp label correctly it should work fine:
// still do your nice animation
tableView.beginUpdates()
tableView.insertRows(at: [IndexPath(row: self.liveComments.count-1, section: 0)], with: .fade)
tableView.endUpdates()
// now just refresh the entire table
tableView.reloadData()
of course you're going to want to make sure that whatever collection feeds the numberOfItemsInSection is also updated before calling reloadData() im assuming you're already doing this as well or you'd be running into a lot of bugs and crashes
make sure that code that edits UI is on the main thread too, obviously.
That being said what does your cell.updateCommentAgeLabel() function look like bc that would work in theory as well unless potentially its not being called on the main thread again or the cast isn't working.
Perhaps try telling the system you want it to do a layout pass:
if let visibleCells = self.tableView.visibleCells as? [LiveCommentTableViewCell] {
visibleCells.forEach { cell in
cell.updateCommentAgeLabel()
cell.layoutIfNeeded() // either this
}
tableView.layoutIfNeeded() // OR this at the end, I dont expect you'll need to do both but not sure if both work
I'm trying to implement a tableView that has 4 different possible prototype cells. They all inherit from base UITableViewCell class and implement its protocol.
For two of the cells there's asynchronous data fetching but one in particular has been giving me fits. The flow is as follows:
1) Dequeue reusable cell
2) Call configure
func configure(someArguments: ) {
//some checks
process(withArguments: ) { [weak self in] in
if let weakSelf = self {
weakSelf.reloadDelegate.reload(forID: id)
}
}
}
3) If the async data is in the cache, configure the cell using the image/data/stuff available and be happy
4) If the async data is NOT in the cache, fetch it, cache it, and call the completion
func process(withArguments: completion:) {
if let async_data = cache.exists(forID: async_data.id) {
//set labels, add views, etc
} else {
fetch_async_data() {
//add to cache
//call completion
}
}
}
5) If the completion is called, reload the row in question by passing the index path up to the UITableViewController and calling reloadRows(at:with:)
func reload(forID: ) {
tableView.beginUpdates()
tableView.reloadRows(at: indexPath_matching_forID with: .automatic)
tableView.endUpdates()
}
Now, my understanding is that reloadRows(at:with:) will trigger another dataSource/delegate cycle and thus result in a fresh resuable cell being dequeued, and the configure method being called again, thereby making step #3 happy (the async data will now be in the cache since we just fetched it).
Except...that's not always happening. If there are cells in my initial fetch that require reloading, it works - they get the data and display it. Sometimes, though, scrolling down to another cell that requires fetching DOES NOT get the right data...or more specifically, it doesn't trigger a reload that populates the cell with the right data. I CAN see the cache being updated with the fresh data, but it's not...showing up.
If, however, I scroll completely past the bad cell, and then scroll back up, the correct data is used. So, what the hell reloadRows?!
I've tried wrapping various things in DispatchQueue.main.async to no avail.
reloadData works, ish, but is expensive because of potentially many async requests firing on a full reload (plus it causes some excessive flickering as cells come back)
Any help would be appreciated!
Reused cells are not "fresh". Clear the cell while waiting for content.
func process(withArguments: completion:) {
if let async_data = cache.exists(forID: async_data.id) {
//set labels, add views, etc
} else {
fetch_async_data() {
// ** reset the content of the cell, clear labels etc **
//add to cache
//call completion
}
}
}
I'm currently using CloudKit as my backend and so far I've been enjoying it pretty much.
I've a query happening, that retrieves data to populate a table view.
Since I don't know the items count that it may have, I'm filtering the query to X number of items (let's say 15).
My goal is that when the user scrolls down to the bottom (last queried item) of the table view ill query the backend to continue filling the table view.
I've searched but couldn't find CloudKit code that does this.
Can somebody give me a hint on how to do it?
Thank you all for the given help.
Best, Ivan.
On IOS I think you have to use UITableViewController or just UITableView.
Add a UIActivityIndicatorView (i.e. spinner) to your UITableViewController. Connect the outlet to the code:
#IBOutlet weak var spinner: UIActivityIndicatorView!
Add a property to your UITableViewController to keep track that you're currently loading more data so that you don't try to do it twice:
var loadingData = false
Start the spinner animating and then call refreshRes():
func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
if !loadingData && indexPath.row == refreshPage - 1 {
spinner.startAnimating()
loadingData = true
refreshRes()
}
Have refreshRes() run on a background thread. This will allow your table to still move freely. The animated spinner will tell the user that more data is coming. Once your query returns, update the table data on the main thread.
func refreshRes() {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0)) {
// this runs on the background queue
// here the query starts to add new 15 rows of data to arrays
dispatch_async(dispatch_get_main_queue()) {
// this runs on the main queue
self.refreshPage += 15
self.tableView.reloadData()
self.spinner.stopAnimating()
self.loadingData = false
}
}
After that it's depend of the server result you have to make a request to GET the others 15 datas
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've got a table view showing the output of a search. When I update it to show the output of a totally different search if the old set of results was longer then old cells remain below my new ones.
For examples, if my first results are:
[Sam,
Joe,
Sally,
Betty,
Bob]
then I have five cells, one per result, as expected. If my second set of results is short, say just
[Smith]
then I now have five cells (Smith, Joe, Sally, Betty and Bob), when only one (Smith) is expected.
Here's how I'm reloading:
results = getResults()
tableView.reloadData()
And here's how I'm getting the number of cells:
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if results != nil {
println("Table has \(results!.count) rows.")
return results!.count
}
println("Table is empty.")
return 0
}
which is printing out "Table has 1 rows." as expected, but the four old rows are still there.
Now, I could delete them before reloading, or delete the whole section, but is there a better way of achieving this? I thought reloadData would reload everything.
Additional Info
Here's cellForRowAtIndexPath as requested:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("SearchEventsCell", forIndexPath: indexPath) as SearchEventsCell
if results != nil && eventStore != nil && results!.count >= indexPath.row {
let event = results![indexPath.row] as EKEvent
cell.configureCellWithEvent(event)
}
else {
println("Couldn't dequeue the cell")
}
return cell
}
And just to prove we have the right number of rows I put a println in before reloadData():
println("We're about to reload the table view, we have \(numberOfSectionsInTableView(tableView)) sections and \(tableView(tableView, numberOfRowsInSection:0)) rows in section 0")
tableView.reloadData()
Which outputs
Table has 1 rows.
We're about to reload the table view, we have 1 sections and 1 rows in sections 0
Table has 1 rows.
as it should.
Something else I've noticed, which surely has to be related - the table doesn't update at all until I try scrolling. What am I missing? I know reloadData has been called as println is being called within numberOfRowsInSection.
Update
The textFieldShouldReturn method that triggers the update includes this code:
eventStore.requestAccessToEntityType(EKEntityTypeEvent,
{ accessGranted, error in
if accessGranted {
if let searchEventsController = self.searchEventsController {
searchEventsController.search(self.searchTextField.text)
}
}
else {
self.accessDenied()
}
}
)
which seems very likely to be the culprit. Is there a better way of checking for permission? I included it there so that if the user ever disallowed it it would ask again next time they try to use it, rather than just failing.
The problem was indeed the fact that reloadData was taking place in another thread due to the eventStore.requestAccessToEntityType call.
There are two solutions:
1) Perform the permissions check once, when the app loads, instead of every time you access the the EventStore, as suggested by Paulw11. This means for the majority of the application there's only one thread.
2) Use the following code to execute reloadData on the main thread:
dispatch_async(dispatch_get_main_queue()) {
self.tableView.reloadData()
}
as suggested by almas.
Update: I've just checked and if you revoke the permission for the app to access the Calendar then it doesn't ask the user again anyway, it just denies access, so there's no reason to keep the eventStore.requestAccessToEntityType where it is.