Charts lib lags on scroll - ios

I've just implemented the library Charts (https://github.com/danielgindi/Charts) in a tableview, but I experience pretty heavy lag during scrolling, when my charts hold a lot of data.
I have a method inside the ChartTableViewCell where I draw the chart based on the data I pass and call from my viewcontroller.
func updateCell(chartData: ChartData) {
DispatchQueue.global(qos: .background).async {
print("This is run on the background queue")
self.readings = (readings != nil) ? readings!.readings : []
self.period = period
if (chartData.isKind(of: LineChartData.self)) {
data.lineData = chartData as! LineChartData
}
else if (chartData.isKind(of: BarChartData.self)) {
data.barData = chartData as! BarChartData
}
}
DispatchQueue.main.async {
self.chartView.data = data
}
}
In my tableViewController I call the function after parsing the data:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let meta = chartMetas[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "chartCell", for: indexPath) as! ChartTableViewCell
cell.readings = currentReadings
cell.updateCell()
return cell
}
What did I miss? Since the view is lagging so hard when scrolling.
UPDATE:
I tried, as suggested, to prepare the chart data in the viewcontroller and pass it to the cell. However it seems like the problem in the resuseable cells, the lags appears when I scroll and a new cell enters the screen. How can I fix this? It is pretty annoying.
UPDATE 2:
It looks like the Charts aren't supposed to be used in a tableview...
https://github.com/danielgindi/Charts/issues/3395

To get a serious performance boost, you'll likely need to implement UITableViewDataSourcePrefecting. By doing this, the table view will call into your delegate to let you know that a cell will be needed ahead of time. This will give your code a chance to prepare and render any data it needs.
From the documentation:
You use a prefetch data source object in conjunction with your table
view’s data source to begin loading data for cells before the
tableView(_:cellForRowAt:) data source method is called.The following
steps are required to add a prefetch data source to your table view:
Create the table view and its data source.
Create an object that adopts the UITableViewDataSourcePrefetching
protocol, and assign it to the prefetchDataSource property on the
table view.
Initiate asynchronous loading of the data required for the cells at
the specified index paths in your implementation of
tableView(_:prefetchRowsAt:).
Prepare the cell for display using the prefetched data in your
tableView(_:cellForRowAt:) data source method.
Cancel pending data load operations when the table view informs you
that the data is no longer required in the
tableView(_:cancelPrefetchingForRowsAt:) method.

You chart library is probably doing some pretty heavy processing when there are large data sets. If the author of this library didn't account for this, you are executing this heavy processing on the main thread every time you scroll and a new cell appears on the screen. This will certainly cause stuttering.
I would look to change the cell setup method to simply display a spinner or placeholder table, and then kick off the loading of data and building of the chart on a background thread. This should cause the UITableView scrolling to be smooth, but you will see the cells with placeholders as you scroll through the table, where the charts get populated after the background thread processing completes.
Running things on a background thread in Swift is pretty easy.
Just make sure when you are ready to update the UI with the chart, you do that on the main thread (Al UI updates should be executed on the main thread or you will see weird visual oddities or delays in the UI being updated). So maybe hide the placeholder image and show the chart using animations on the main thread after the heavy processing is done on the background thread.
What if you did something like this?
func updateCell(chartData: ChartData) {
DispatchQueue.global(qos: .background).async {
print("This is run on the background queue")
self.readings = (readings != nil) ? readings!.readings : []
self.period = period
if (chartData.isKind(of: LineChartData.self)) {
data.lineData = chartData as! LineChartData
}
else if (chartData.isKind(of: BarChartData.self)) {
data.barData = chartData as! BarChartData
}
self.chartView.data = data
DispatchQueue.main.async {
// something that triggers redrawing of the chartView.
chartView.fitScreen()
}
}
}
Note that the main thread call is inside the background thread execution block. This means all the stuff will run on the background thread, and when that background thread stuff is done, it will call something in the chart library that will do the UI refresh. Again, this won't help if your chart library is written to force long running operations to happen on the main thread.

Try to prepare all the chart data before you pass it to the cell. I mean
make all the stuff you do I the updateCell() func in your tableViewController probably in viewDidLoad and pass the generated chart Datas to an array.
Then in tableView(tableView: , cellForRowAt indexPath: ) just pass the previously generated data to your cell.
So your updateCell func should looks like so:
func updateCell(lineChartData: LineChartData) {
// SET DATA AND RELOAD
chartView.data = lineChartData
chartView.fitScreen()
}
And you tableView
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let meta = chartMetas[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "chartCell", for: indexPath) as! ChartTableViewCell
cell.readings = currentReadings
cell.updateCell(lineChartData: arrayOfGeneratedChartData[indexPath.row])
return cell
}
Seems that you keeps entries in the cell, so don't do this. Keep them in the tableViewController. Pass only required data to the cell and don't do hard tasks in the cell methods.

Related

How to fix cell reusability issue using MVVM pattern?

I am trying to implement MVVM pattern without 3-d party libraries for data binding.
I have a UICollectionView with a StoriesViewModel.
StoriesViewModel is a class which holds an array of models.
To each cell I assign a respective ViewModel which I create in a cellForItemAt method, like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.storiesCollectionViewCell, for: indexPath) as! StoriesCollectionViewCell
let story = viewModel.getStory(for: indexPath)
let storiesCellViewModel = StoriesCellViewModel(story: story)
cell.viewModel = storiesCellViewModel
cell.setup()
return cell
}
Cells are dequeueReusable and images are being reused, which I can't fix with simple line imageView.image = nil at the start of cellForItemAt method, which worked for me previously when I used MVC. I guess it's something with assigning viewModels to cells.
Cell code (without views and constraints):
class StoriesCollectionViewCell: UICollectionViewCell {
//
var viewModel: StoriesCellViewModel?
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func commonInit() {
//
setupViews()
}
private func setupViews() {
//
}
func setup() {
storyImageView.image = nil
viewModel?.fetchImageData(completion: { (imageData) in
DispatchQueue.main.async {
self.storyImageView.image = UIImage(data: imageData)
}
})
titleLabel.text = viewModel?.title
authorLabel.text = viewModel?.author
brandNameLabel.text = viewModel?.brandName
}
}
I also tried viewModel = nil in prepareForReuse method, and it didn't work. Is that a common problem in approaching MVVM pattern and what is the way to fix it? Thanks in advance.
What I suspect is going on is that by the time the image is loaded to be displayed in your cell, the cell itself might have already been dequeued to be reused for another row of the collection view.
This is a common issue that can occur when you're using reusable cells.
One way to solve this might be to add a check when the image data is finally loaded, to make sure you're still dealing with the same cell as before (i.e.: still configured with the same view model that initiated the image load).
Maybe you can use an unique identifier for each view model to do this:
viewModel?.fetchImageData(completion: { (imageData) in
DispatchQueue.main.async {
// Check here if it still makes sense to attribute this image to this cell
self.storyImageView.image = UIImage(data: imageData)
}
})
In general you shouldn't store specific image in a non-specific cell, i.e.; a reusable cell.
Because by the time your async fetch returns, that cell may already be reused to display another image.
The usual approach is to create a separate cache, which is updated by async fetch, followed by a table view reload in which indexPath.row is used as array index to access the cache.
This is pretty straight-forward in Apple MVC.
So the problem now is how to implement it in MVVM.
I'd say there's no benefit to using view model for reusable cells. You are just making things difficult for yourself.
But no one dares to challenge MVVM these days, it seems. Devs always blame themselves for not fully grasping the power of MVVM. No, challenge it. MVVM does not magically solve everything.
That being said, I'll attempt answering your question.
self.storyImageView.image = UIImage(data: imageData)
You store a specific image in a non-specific reusable (variable) cell.
If you call fetch in cell view model, then it needs a callback to pass that image to view controller which then triggers a table view reload. You should avoid capturing cell references and make run-time condition checks. The safest bet is to prepare data and trigger SDK to do the rest.
In fact, I would argue that view model is about updating view whenever model changes. But this requires proper binding, which is lacking in UIKit. So all these efforts are just so that you can do networking which produces side effects in callback and indirectly trigger a view update.
You may verify this by not calling fetch in cell view model. Then your view model becomes useless. MVVM becomes MVC with extra steps. There's no point passing a view model to setup cell properties when those properties are public and meant to be setup in cellForRow: by SDK design.

swift: load the image data in background

I need to load 100 images in cells. If I use this method in tableView cellForRowAt:
cell.cardImageView.image = UIImage(named: "\(indexPath.row + 1).jpg")
and start scrolling fast my tableView freezes.
I use this method to load the image data in background that fix freezes:
func loadImageAsync(imageName: String, completion: #escaping (UIImage) -> ()) {
DispatchQueue.global(qos: .userInteractive).async {
guard let image = UIImage(named: imageName) else {return}
DispatchQueue.main.async {
completion(image)
}
}
}
in tableView cellForRowAt call this:
loadImageAsync(imageName: "\(indexPath.row + 1).jpg") { (image) in
cell.cardImageView.image = image
}
But I have one bug may arise in this approach, such that while scrolling fast I may see old images for a while. How to fix this bug?
Your cells are being reused.
When cell goes out of screen it gets to internal UITableViews reuse pool, and when you dequeueReusableCell(withIdentifier:for:) in tableView(_:cellForRowAt:) you get this cell again (see, "reusable" in name). It is important to understand UITableViewCell's life cycle. UITableView does not hold 100 living UITableViewCells for 100 rows, that would kill performance and leave apps without memory pretty soon.
Why do you see old images in your cells?
Again, cells are being reused, they keep their old state after reuse, you'll need to reset the image, they won't reset it by themselves. You can do that when you configure a new cell or detect when the cell is about to be reused.
As simple as:
cell.cardImageView.image = nil // reset image
loadImageAsync(imageName: "\(indexPath.row + 1).jpg") { (image) in
cell.cardImageView.image = image
}
The other way is detecting reuse and resetting. In your cell subclass:
override func prepareForReuse() {
super.prepareForReuse()
self.cardImageView.image = nil // reset
}
Why do you see wrong images in your cells? By the time completion closure sets image into cardImageView, UITableViewCell has been reused (maybe, even, more than once). To prevent this you could test if you're setting image in the same cell, for example, store image name with your cell, and then:
// naive approach
let imageName = "\(indexPath.row + 1).jpg"
cell.imageName = imageName
loadImageAsync(imageName: imageName) { (image) in
guard cell.imageName == imageName else { return }
cell.cardImageView.image = image
}
There is a lot of stuff to take care of when designing lists, I won't be going into much detail here. I'd suggest to try the above approach and investigate the web on how to handle performance issues with lists.
in your cell class you need to declare
override func prepareForReuse() {
super.prepareForReuse()
}
to prepare the cell for the reuse

Need help setting UISwitch in custom cell (XIB, Swift 4, Xcode 9)

Successes so far: I have a remote data source. Data gets pulled dynamically into a View Controller. The data is used to name a .title and .subtitle on each of the reusable custom cells. Also, each custom cell has a UISwitch, which I have been able to get functional for sending out both a “subscribe” signal for push notifications (for a given group identified by the cell’s title/subtitle) and an “unsubscribe” signal as well.
My one remaining issue: Whenever the user "revisits" the settings VC, while my code is "resetting" the UISwitches, it causes the following warnings in Xcode 9.2:
UISwitch.on must be used from main thread
UISwitch.setOn(_:animated:) must be used from main thread only
-[UISwitch setOn:animated:notifyingVisualElement:] must be used from main thread
The code below "works" -- however the desired result happens rather slowly (the UISwitches that are indeed supposed to be "on" take a good while to finally flip to "on").
More details:
What is needed: Whenever the VC is either shown or "re-shown," I need to "reset" the custom cell’s UISwitch to "on" if the user is subscribed to the given group, and to "off" if the user is not subscribed. Ideally, each time the VC is displayed, something should reach out and touch the OneSignal server and find out that user’s “subscribe state” for each group, using the OneSignal.getTags() function. I have that part working. This code is in the VC. But I need to do it the right way, to suit proper protocols regarding threading.
VC file, “ViewController_13_Settings.swift” holds a Table View with the reusable custom cell.
Table View file is named "CustomTableViewCell.swift"
The custom cell is called "customCell" (I know, my names are all really creative).
The custom cell (designed in XIB) has only three items inside it:
Title – A displayed “friendly name” of a “group” to be subscribed to or unsubscribed from. Set from the remote data source
Subtitle – A hidden “database name” of the aforementioned group. Hidden from the user. Set from the remote data source.
UISwitch - named "switchMinistryGroupList"
How do I properly set the UISwitch programmatically?
Here is the code in ViewController_13_Settings.swift that seems pertinent:
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as! CustomTableViewCell
// set cell's title and subtitle
cell.textLabelMinistryGroupList?.text = MinistryGroupArray[indexPath.row]
cell.textHiddenUserTagName?.text = OneSignalUserTagArray[indexPath.row]
// set the custom cell's UISwitch.
OneSignal.getTags({ tags in
print("tags - \(tags!)")
self.OneSignalUserTags = String(describing: tags)
print("OneSignalUserTags, from within the OneSignal func, = \(self.OneSignalUserTags)")
if self.OneSignalUserTags.range(of: cell.textHiddenUserTagName.text!) != nil {
print("The \(cell.textHiddenUserTagName.text!) UserTag exists for this device.")
cell.switchMinistryGroupList.isOn = true
} else {
cell.switchMinistryGroupList.isOn = false
}
}, onFailure: { error in
print("Error getting tags - \(String(describing: error?.localizedDescription))")
// errorWithDomain - OneSignalError
// code - HTTP error code from the OneSignal server
// userInfo - JSON OneSignal responded with
})
viewWillAppear(true)
return cell
}
}
In the above portion of the VC code, this part (below) is what is functioning but apparently not in a way the uses threading properly:
if OneSignalUserTags.range(of: cell.textHiddenUserTagName.text!) != nil {
print("The \(cell.textHiddenUserTagName.text!) UserTag exists for this device.")
cell.switchMinistryGroupList.isOn = true
} else {
cell.switchMinistryGroupList.isOn = false
}
It's not entirely clear what your code is doing, but there seems to be a few things that need sorting out, that will help you solve your problem.
1) Improve the naming of your objects. This helps others see what's going on when asking questions.
Don't call your cell CustomTableViewCell - call it, say, MinistryCell or something that represents the data its displaying. Rather than textLabelMinistryGroupList and textHiddenUserTagName tree ministryGroup and userTagName etc.
2) Let the cell populate itself. Make your IBOutlets in your cell private so you can't assign to them directly in your view controller. This is a bad habit!
3) Create an object (Ministry, say) that corresponds to the data you're assigning to the cell. Assign this to the cell and let the cell assign to its Outlets.
4) Never call viewWillAppear, or anything like it! These are called by the system.
You'll end up with something like this:
In your view controller
struct Ministry {
let group: String
let userTag: String
var tagExists: Bool?
}
You should create an array var ministries: [Ministry] and populate it at the start, rather than dealing with MinistryGroupArray and OneSignalUserTagArray separately.
In your cell
class MinistryCell: UITableViewCell {
#IBOutlet private weak var ministryGroup: UILabel!
#IBOutlet private weak var userTagName: UILabel!
#IBOutlet private weak var switch: UISwitch!
var ministry: Ministry? {
didSet {
ministryGroup.text = ministry?.group
userTagName.text = ministry?.userTag
if let tagExists = ministry?.tagExists {
switch.isEnabled = false
switch.isOn = tagExists
} else {
// We don't know the current state - disable the switch?
switch.isEnabled = false
}
}
}
}
Then you dataSource method will look like…
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as! MinistryCell
let ministry = ministries[indexPath.row]
cell.ministry = ministry
if ministry.tagExists == nil {
OneSignal.getTags { tags in
// Success - so update the corresponding ministry.tagExists
// then reload the cell at this indexPath
}, onFailure: { error in
print("Error")
})
}
return cell
}

cell reuse another image in collectionview inside tableview

Currently,
I have a feed for the user and
it uses uitableview and
there is a uicollectionview inside tableview for multi-images.
'Like' or 'Comment' functions are working well
but some issues happen when user taps 'Like'.
I do not want to show several changes,
but when user taps 'Like', I need to reload a cell and it shows another picture for a short time(bcs of reuse) and back to original image.
I tried to use the function, prepareForReuse().
However, I am not sure how to maintain the same image currently on the screen when they are reloading. Any idea u have?
For ur more information,
let me show my tableview's part of 'CellForItemAt' and collectionview's same method.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "storyCell", for: indexPath) as! StoryPhotoCollectionViewCell
let imageURL = photoList[indexPath.row]
let url = URL(string: imageURL)
DispatchQueue.main.async {
cell.imageView.kf.setImage(with: url, placeholder: #imageLiteral(resourceName: "imageplaceholder"), options: nil, progressBlock: nil, completionHandler: nil)
}
cell.imageView.kf.indicatorType = .activity
return cell
}
The collectionView's datasource is photoList array, so in the tableview, I have this code.
DispatchQueue.main.async {
cell.photoList = (story.imageArray?.components(separatedBy: ",").sorted())!
}
The issue should be due to delay in asynchronous download of the image,
I believe your code would be like this
cell.image = downloadFromURL(SOME_URL)
Add this single line code before assigning the image
cell.image = nil; // or some placeholder image
cell.image = downloadFromURL(SOME_URL)
If possible please provide more info
If I understand well your question, you need:
When the status change, you change the image so, this image can be stored as part of the app or external. If it is the same image, you need to keep it cached.
You start a timer like:
let countTimeBeforeChangeMyImageBack = DispatchTime.now() + .seconds(waitTimeBeforeStartCounting)
DispatchQueue.main.asyncAfter(deadline: deadlineTime, execute: {
self.YourMethodToChangeYourImageBack
})
At your method YourMethodToChangeYourImageBack you set the original image back.
Here, remember you could need to join back to the main thread before updating the UI:
// Re join the main queue after the callback
DispatchQueue.main.sync() {
// Your code to update the image
}
Hope it Helps.

Set Image in visible UITableviewCell after async download

I have a tableView with dynamic cells. Each cell has got an imageView and a textLabel. I'm working with Alamofire & AlamofireImage by the way.
My problem is that the first cells (which you see if view did appear) don't show the images. But if i scroll down i see the images of the other cells. And if scroll up again the images of the first cells appear too.
First of all i download user data in the viewDidLoad method. After this is done I update the tableview with tableView.reloadData().
override func viewDidLoad() {
super.viewDidLoad()
MYRequest_getUsers(//...
,
success: {response -> Void in
self.users = response
dispatch_async(dispatch_get_main_queue(),{
self.tableView.reloadData()
})
},
failure: {NSError -> Void in
debugPrint(NSError)
})
}
TableView counts the users and reloads itself.
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users?.count ?? 0
}
In tableView_cellForRowAtIndexPath method i fill the cells with user data.
let cell = tableView.dequeueReusableCellWithIdentifier(self.identifier) as! UserCell
cell.userNameLabel.tag = indexPath.row
let rowData: User = self.users?[cell.userNameLabel.tag] as User!
cell.userNameLabel.text = rowData.getName()
let avatarURL = rowData.getAvatarURL() ?? ""
cell.userImageView.af_setImageWithURL(imgURL, placeholderImage: placeholderImage)
return cell
My idea was that after user data was loaded, the tableView begin to draw itself and place the name (because it is already in userData). But the image is downloaded (i suppose) asynchronously, so that they come from the backend after some delay and tableView is already drawn. After scrolling up and down i see them.
I want to open the screen and without scrolling up and down. Tableview should set automatically the images after they were loaded. So i thought to make a callback and use instead of
cell.userImageView.af_setImageWithURL(imgURL, placeholderImage: placeholderImage)
this
Alamofire.request(.GET, avatarURL)
.responseImage { response in
cell.userImageView.image = response.2.value
dispatch_async(dispatch_get_main_queue(), { () -> Void in
tableView.setNeedsLayout()
})
}
But nothing changes... I've tried a lot and looked similar problems but can't solve it... I hope you can give me some hints or much better a solution! :)
Thanks in advance
I would like to give you a direction by posting the steps (best practice to follow in such case):
Step 1 : Fetch images in background as soon as you have image URLs available from server. I prefer doing this on operation queue where I download multiple images concurrently. You can write a helper class which takes a completion block and implement NSOperation/NSOperationQueue.
Step 2 : While images are being downloaded, for the eyes of users, put a loading overlay on image view. You can do this in cellForRowAtIndexPath:.
Step 3 : Once images are available, cache them in file system. This is to avoid any scrolling abrupt behaviour.
Step 4 : Once images are available, call the completion block.
Step 5 : In completion block, reload your table on main thread. Ensure your cellForRowAtIndexPath: first check the image in cache, if present use it, if not present show loading overlay if image fetch is on flight. If image is not available in cache and download is also not going on, show some default image.
This question is couple of years old, but maybe this answer can be useful to someone else with a similar problem, like me before to find the right solution.
Since the images are loaded asynchronously, we have to force a cell update when the download is finished if we didn't provide a fixed height for the UIIMageView. This because cell updating (i.e. AutoLayout Constraints recalculation) is done automatically only after cellForRowAt method, that is called when a cell is displayed for the first time, or when the table is scrolled to show other cells. In both cases probably the images are not yet downloaded by the af_setImage() method, so nothing but the placeholder will be displayed as their sizes are unknown for the moment.
To force a cell update we need to use beginUpdates() and endUpdates() methods, putting them inside the completion handler of .af_setImage(). This way, every time the downloading is completed, the cell will be updated.
But, to avoid a loop, before to call beginUpdates()/endUpdates() we have to check if we have already update the cell before, because by calling these methods, the cellForRowAt method is called again and consequently the af_setImage() and its completion closure with beginUpdates()/endUpdates() inside it).
This means that we have to update the cell only when the download is just finished, and not when the imaged is cashed (because, if it is cashed, it means that we have already updated the cell). This can be accomplished by checking the response of the completion handler: if it is not nil, the image was just downoladed, if it is nil, the image was cashed.
As a side benefit the cell height will be automagically adjusted (remember to put tableView.estimatedRowHeight = 400 and tableView.rowHeight = UITableViewAutomaticDimension in your viewDidLoad() method)
Finally, here it is the code:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// Dequeue your cell
let cell = self.tableView.dequeueReusableCell(withIdentifier: "YourCustomCellIdentifier") as! YourCustomTableViewCell
// get picture url from the data array
let pictureUrl = self.yourCellsData[indexPath.row].pictureUrl
// async download
cell.pictureView.af_setImage(
withURL: URL(string: pictureUrl)!,
placeholderImage: UIImage(named: "YourPlaceholder.png"),
filter: nil,
imageTransition: UIImageView.ImageTransition.crossDissolve(0.5),
runImageTransitionIfCached: false) {
// Completion closure
response in
if response.response != nil {
// Force the cell update
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
return cell
}
That's all folks! ;-)
Few things to achieve this.
call data download in viewwillappear and not in viewdidload.
in viewdidload, hide the tableview.
in viewdidAppear: check the data has downloaded and if so then call
[tableview reloaddata] method.

Resources