Improve perfomance when scrolling UITableView with custom cells - ios

I have a UITableView which cells are custom, and cell has a view with chart (Charts library). Data for chart is pretty big (it stored as a property of Controller), it appears during few seconds.
Also I do some Chart's View setup (only little UI changes) in setSelected method in custom TableViewCell class
When scrolling table view there some lags appear. I suppose it happens because of heavy content I want to display.
During solving this problem I've thought about four solutions:
Load all cells in ViewDidAppear, save them to array, in CellForRowAt method show cells from this array.
Put table view into scroll view, make content's height pretty big to fit all cells in table view.
Make chart loads only after cell's loading and its content view appearing. Show activity indicator while chart's loading is on.
Put chart's loading in background thread.
First two approaches seem to me a not so good in terms of memory managment. But I am not sure, maybe there is common solution which I have not known.
Some code example from project:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let graphCell = tableView.dequeueReusableCell(withIdentifier: Cells.graphViewCell) as! GraphViewCell
graphCell.lineChartView.data = track.chartDataForHeight()
return graphCell
}
Here's track is variable where I store data for chart.

If you have a very complex cell and not too many of them and you do not want to pay to create it every time, then its correct not to use dequeResuableCell and just lazily create the cells and cache them. You can mitigate the memory problem by responding to didReceiveMemoryWarning and dumping the off screen cells then lazily recreating them. UICollectionView now has prefetching in ios 10. You can also try using this instead of UITableView.

Related

Reusing cells with dynamic layout content at runtime

I have a type of UITableViewCell that lets the user add/remove as many UITextViews as they want at run time.
I'm running into issues when trying to reuse/dequeue cells of that type, as sometimes the tableview cells just start overlapping when you scroll up and down. When I dequeue/return the cell, I'm running a setup method (which initiates a teardown method internally first to remove all the previous views), and uses the model to setup/restore all the necessary views and layout constraints.
if let cell = tableView.dequeueReusableCell(withIdentifier: "MultipleContentCell", for: indexPath) as? MultipleChoiceTableViewCell {
cell.setupCellWithModel(model: model)
cell.setNeedsUpdateConstraints()
cell.updateConstraintsIfNeeded()
cell.delegate = self
return cell
}
I can't really figure out why that cells sometimes overlap in the tableview, but I'm guessing it has to do with the layout being recreated on the fly. I'm considering not reusing these types of cells and just storing them in a list.
My question is: are reusable cells always suppose to have the same general UIView layout, and only the content changes? Am I not supposed to use reuse these types of cells? Or has someone experienced this before?
Thanks
The UITextView are created each time you dequeue cell and never delete. To repair that use function prepareForReuse(). You have to define, what your cell should do before dequeue in MultipleChoiceTableViewCell. For example:
override func prepareForReuse() {
super.prepareForReuse()
for view in speciesName.subviews {
if view is UITableView {
view.removeFromSuperview()
}
}
}
I added similar question few days ago:
Cells in UITableView overlapping. Many cells in one place
If you have some question, I can try to help you more tomorrow.
Cheers!
In general, yes. You want the physical layout of your cells to be static, and only vary the contents when you recycle them. If you add views to your cells in cellForRow(at:) then the burden is on you to manage the extra fields to avoid duplicate views.
Your case where you add a variable number of views to a table view cell based on user interaction is an odd case where you might need to add and remove cells on the fly.
One way to handle this would be to put all of your text fields in a container view, add an outlet to that container view, and then simply use code like this in your prepareForReuse or cellForRowAt function:
containerView.subviews.forEach { $0.removeFromSuperview() }

Getting loaded vs visible cells on a UITableView or UICollectionView

With the introduction of iOS 10, it seems like we're going to have prefetching enabled by default on UITableView and UICollectionViews. This means that cells that aren't displayed on the screen are going to be fetched before the user actually sees them.
Here are some relevant methods:
UITableView:
cellForRowAtIndexPath:: returns "nil if the cell is not visible."
visibleCells: each item represents "a visible cell in the table view."
indexPathsForVisibleRows: each item represents "a visible row in the table view."
UICollectionView:
visibleCells: "returns the complete list of visible cells displayed by the collection view."
indexPathsForVisibleItems: each item represents "a visible cell in the collection view."
cellForItemAtIndexPath:: returns "nil if the cell is not visible."
All these specifically mention "visible" in their descriptions. With the introduction of pre-fetching in iOS 10, how would I distinguish between a cell that was pre-fetched vs. one that is currently visible?
In other words:
How do I get all visible cells?
How do I get all loaded cells?
It does not look like there are any new APIs on either UITableView or UICollectionView that can help with this.
TL;DR
Take the visible in function names literally.
UITableView behaves just as it did in iOS 9.
You'll need to do some bookkeeping if you want to treat loaded vs. visible cells differently in UICollectionView on iOS 10.
UITableView and UICollectionView appear to behave very differently when it comes to prefetching.
First thing to notice is that there is a difference between prefetching cells and prefetching data:
Prefetching cells refers to the cellForRowAtIndexPath being called before the cell is actually displayed on screen. This enables the scenario where you have cells that are off-screen but still loaded.
Prefetching data refers to the prefetchDataSource methods which inform you about indexPaths that are going to be displayed on screen. You do not have a reference to the cell when this method is called, and you do not return a cell when this method is called. Instead, this method should do things like fire off a network request to download an image that will be displayed in the cell.
Note: In all of these scenarios, imagine there are 8 cells that can be displayed at any given time.
UITableView: (options: no prefetching, or prefetch data)
Does not prefetch cells, ever. In other words, it will never call cellForRowAtIndexPath on an indexPath that isn't displayed.
As such, there is no isPrefetchingEnabled property on a UITableView.
You can opt-in to prefetching data by using the prefetchDataSource.
Note that although the table view does seem to be less aggressive with reusing cells, it still appears to call cellForItemAtIndexPath when the reused cell comes back on screen. (Although I may need to do some more investigation as to this, especially for collection views.)
UICollectionView: (options: no prefetching, prefetch cells, or prefetch cells and data)
Prefetches cells by default. In other words, it will call cellForItemAtIndexPath for cells that aren't going to be immediately displayed.
The prefetching of cells only begins when the user scrolls up or down on the collection view. In other words, you will get exactly 8 calls to cellForItemAtIndexPath when the view is loaded. Only once the user scrolls down will it start asking for non-visible cells (e.g. if you scrolled down to show 2-10, it might ask for 11-14).
When the prefetched, non-visible cell comes on screen, it's not going to call cellForItemAtIndexPath again. It's going to assume that instantiation you did the first time is still valid.
You can opt-in to prefetching data by using the prefetchDataSource.
The prefetchDataSource turns out to be only useful for the initial load. In the same scenario above, when the first 8 cells are displayed, it may fire off a prefetching of data for cells 9-14, for example. However, once this initial method is called, it's useless thereafter. This is because cellForItemAtIndexPath is going to be called immediately after each call to prefetchItemsAt. For example, you'll get prefetchItemsAt:[14, 15] immediately followed by cellForItemAt:14, cellForItemAt:15.
You can opt-out of all prefetching behavior by setting isPrefetchingEnabled = false. This means you can't make a UICollectionView behave similarly to a UITableView with a prefetchDataSource. Or, in other words, you can not have a UICollectionView prefetch data only.
For both:
visibleCells, indexPathsForVisibleRows, and cellForItemAtIndexPath do exactly as they say: they only deal with visible cells. In our same scenario, if we have 20 cells loaded, but only 8 are visible on screen. All 3 of these methods will only report about the 8 on-screen cells.
So what does this mean?
If you're using a UITableView, you can use it as is and never have to worry about a difference between loaded vs. visible cells. They are always equivalent.
For UICollectionView, on the other hand, you're gonna need to do some book-keeping to keep track of loaded, non-visible cells vs. visible cells if you care about this difference. You can do this by looking at some of the methods on the data source and delegate methods (e.g. willDisplayCell, didEndDisplayingCell).

Multiple cells got affected after calling cellForRowAtIndexPath

I have a UITableView, and I want to change text color of the selected row. But I see every other 15 row got affected along with the one I clicked. This is tested under the master-detail sample project.
if let indexPath = self.tableView.indexPathForSelectedRow(){
self.tableView.cellForRowAtIndexPath(indexPath)?.textLabel?.textColor = UIColor.redColor()
}
I checked cellForRowAtIndexPath value in debug session, it seems returning only one object, how come other cells got affected too?
Cells are reused - almost certainly you are not resetting your cells to a base state in prepareForReuse and configuring them correctly on each call to cellForRowAtIndexPath.
When you look at a table view that can display some number of cells at once, typically there will only exist one more cell than can be shown. When a cell is moved off the screen it is placed in a pool of cells for reuse. Just before a cell moves onto the screen it is configured by you, in cellForRowAtIndexPath. If you have configured something in the cell and you do not configure that explicitly every time you return a cell from cellForRowAtIndexPath then that configuration persists in the cell that is in the reuse pool. The function prepareForReuse is also called before each cell is reused - if you have subclassed UITableViewCell then you can implement this function to return the cell to a base configuration, so that settings like text color do not unexpectedly affect multiple cells.
This approach makes it possible to scroll through an entire table view smoothly with a minimum amount of memory used. It is an expensive operation to create and destroy cells every time one disappears and a new one is required.
The simplest fix is to always set the text color in cellForRowAtIndexPath - either to the base color or to the special color you want in some cells.

Can you store state in a custom UICollectionViewCell? Or can the state get erased (like when the cell scrolls off the screen)?

If I create a UICollectionViewCell subclass like:
class StudentCell: UICollectionViewCell {
var student: Student?
}
And in my controller I implement UICollectionView's didSelectItemAtIndexPath and set the variable:
func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
if let studentCell = collectionView.cellForItemAtIndexPath(indexPath) as? StudentCell {
studentCell.student = self.someStudent
}
}
When I click on the cell it should set student, but if the cell is scrolled off screen it seems like the whole cell might get wiped and when it comes back on screen it would need to rebuild itself in cellForItemAtIndexPath.
The problem I have is that I have a UICollectionView with cells that do not scroll off the screen that I'm storing data in. Because they don't leave the screen I assume they should not get wiped, but one of the cells does not seem to keep it's variable, which makes me think maybe it's getting wiped and I may need to move the state outside of the cells to a dictionary or something and in didSelectItemAtIndexPath instead of setting the variable in the cell I'd set the dictionary. Whenever I need to access the data instead of asking the cell for it I'd look it up in the dictionary.
But either way, I was wondering if it's possible (or a bad idea) to set it in the cell or not.
Yes, cells in both UICollectionView and UITableView can (will) be reused at the systems discretion and should not be used to store state information, only display information. Specifically, both views will reuse cells when they are scrolled off-screen, but there's no guarantee this is the only time they'll be reused. The usual way to handle this is to define some kind of cell data object which stores the data for each cell (visible and not) and refresh the cell view from that as needed/requested.
Tables display their data in cells. A cell is related to a row but it’s not exactly the same. A cell is a view that shows a row of data that happens to be visible at that moment. If your table can show 10 rows at a time on the screen, then it only has10 cells, even though there may be hundreds of rows with actual data. Whenever a row scrolls off the screen and becomes invisible, its cell will be re-used for a new row that scrolls into the screen.

Force UITableView to cache reusable cells

I use a table view with relatively 'heavy' custom table view cells - say there's is around 100 subviews in a single cell (full hierarchy). Despite of that I've been able to reach very smooth scrolling - using standart practices like working only with opaque views, hiding unused subviews (or even removing from the view hierarchy), pre-loading cells height etc.
The problem is that it's still relatively costly to create a single cell instance. Because of how the standard UITableView's cell reusage logic works this problem is only noticeable in specific conditions.
Usually when table view is loaded (and intitial content is displayed) it creates almost all required cells instances - so when you start scrolling it rarely needs to create any more reusable cells. But if for some reason it only displays a few cells at the beginning it would obviously need to create more when scrolling is initiated. In my case this is noticeable if there's a wide table view header or if first cells are big enough. When that happens once you start scrolling there's a noticeable lag for a few moments which is clearly caused by new cell instances being created. Nothing really ugly but still noticeable especially compared to absolutely smooth scrolling after that.
Now the obvious solution for that would be to make cells 'lighter' - like break them into different more specific types so each can contain less subviews. But because of the way my content is organized that would be a very complex solution.
So my question is - is there any good way to 'trick' table view logic to force loading and caching of specific number of reusable cell instances right away - despite of the number of cells actually visible?
One of the options I've been thinking of is to have own explicit cache of cells and pre-populate it with cells when needed (say in -viewDidLoad). The problem I have with this approach is that you have to known exact type of cells you will need beforehand - doesn't work well for me. The improvement would be to build cache when the initial data is loaded (so at least the exact type of content is known) but I've been wondering if there are any simple options.
You can do it by adding your own "secondary cache" layer to your data source.
When you initialize your data source, pre-create as many cells as needed, and put them into an array. When your dequeueReusableCellWithIdentifier fails, go for that array first, and take a cell from there. Once the array is exhausted, start creating new cells (if you create enough cells upfront, you shouldn't need to create new ones on the fly: dequeuing will eventually stop failing when you have enough cells rotating in and out of visibility).
I too had a similar situation wherein I wanted to load the several websites in different cells and loadrequest must be triggered only once. Idea is to avoid duplicate calls when the cell is made visible/invisible by scrolling up or down. say I have 5 cells in my tableview and each cell has a webview which loads the different websites. loadrequest will be called once and cell will be cached. here goes the simplest implementation.
Variable declaration
let weblinks:[String] = ["http://www.google.com",
"http://www.facebook.com",
"http://www.yahoo.com",
"http://www.puthiyathalaimurai.com/",
"http://www.github.com"]
var mycells:[MyTableViewCell] = [MyTableViewCell(),
MyTableViewCell(),
MyTableViewCell(),
MyTableViewCell(),
MyTableViewCell()]
CellForRow. I hope you know what it does,
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = String(describing: MyTableViewCell.self)
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) as? MyTableViewCell
if cell?.isNewCell == true { //return from cache
return mycells[indexPath.row]
}
cell?.title.text = weblinks[indexPath.row].uppercased()
cell?.web.loadRequest(URLRequest(url: URL(string: weblinks[indexPath.row])!))
cell?.isNewCell = true //mark it for cache
mycells[indexPath.row] = cell!
return cell!
}

Resources