How to asynchronously load images for UICollectionViewCell without reuse issues? - ios

Background of problem: I am using Kingfisher to download and set images for all of my collection views and table views in my app. This works well, but I have one specific use case that I'm pretty sure Kingfisher doesn't have built in.
What I am trying to do: Download multiple images for a single collection view cell, and then update the cell with the images.
Why I am not using .kf.setImage(...): The collection view cell UI is all drawn from draw(_ rect: CGRect)
What I am doing now: In my UICollectionViewCell I have an image array
var posters: [UIImage] = [] {
didSet {
setNeedsDisplay()
}
}
and I draw them into the cell
context.draw(image, in: CGRect(x: 0, y: -posterWindowFrame.minY, width: actualPosterWidth, height: actualPosterHeight))
In my view controller I load images in willDisplayCell:forItemAtIndexPath: like so
override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
let imageProviders = viewModel.imageProviders(at: indexPath)
var images = [UIImage]()
let group = DispatchGroup()
let cachedIndexPath = indexPath
imageProviders.forEach { provider in
guard let provider = provider else { return }
group.enter()
_ = KingfisherManager.shared.retrieveImage(with: .provider(provider), options: [.targetCache(posterCache)]) { result in
DispatchQueue.main.async {
defer {
group.leave()
}
switch result {
case .success(let imageResult):
images.append(imageResult.image)
case .failure:
print("No image available")
}
}
}
}
group.notify(queue: .main) {
guard
let cell = collectionView.cellForItem(at: cachedIndexPath) as? ListCollectionViewCell
else { return }
cell.posters = images
}
}
This works, but I can see the images changing (which could probably be fixed by pre-fetching) but I also notice a lot of lag when scrolling that I don't see when I comment this function out.
Besides fetching all of the images in ViewDidLoad (or anywhere else, because of API rate limiting), how can I load these images in a way that doesn't cause lag and won't have problems with cell reuse?
EDIT - 8/13/19
I used UICollectionViewDataSourcePrefetching to fetch the images and cache them locally, and then set the images in cellForRow and it seems to work well for now.

Related

Image on CollectionView cell changes when scrolling

I have a collectionView that has custom cells. Each custom cell has an image in it and other components. The problem I'm having is that when I scroll, the images changes to another images from other cells on the collectionView. I'm gonna put the code that I'm using so it's more clear to see how I'm implementing the collectionView and it's cell.
func registerCell() {
collectionView.register(PageCell.self, forCellWithReuseIdentifier: cellId)
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let pageNumber = Int(targetContentOffset.pointee.x / view.frame.width)
pageControl.currentPage = pageNumber
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return feedTitles.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! PageCell
cell.titleLabel.text = self.feedTitles[indexPath.item].uppercased()
cell.textView.text = self.feedDescription[indexPath.item]
guard let imageURL = URL(string: self.feedImages[indexPath.item]) else {return cell}
cell.imageView.load(url: imageURL)
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
This is the code for getting the images from a server:
var feeds: [Feed]?
func getFeed(){
feedService.getFeed() { [weak self](feedRes) in
switch feedRes {
case .success(let successResponse):
print("Success Feed")
if successResponse.data.count < 1 {
} else{
self?.feeds = successResponse.data
self?.feedTitles = self?.feeds?.compactMap({ $0.title
}) ?? ["Titulo Noticia"]
self?.feedImages = self?.feeds?.compactMap({ $0.image
}) ?? ["Imagen"]
self?.feedDescription = self?.feeds?.compactMap({ $0.content
}) ?? ["Descripcion Imagen"]
self?.collectionView.reloadData()
self?.pageControl.numberOfPages = (self?.feedTitles.count)!
self?.pageControl.isHidden = false
}
case .failure(let feedError):
print("Fail Feed \(feedError)")
print(feedError.localizedDescription)
self?.pageControl.isHidden = true
}
}
}
This is the "load" function:
func load(url: URL) {
DispatchQueue.global().async { [weak self] in
if let data = try? Data(contentsOf: url) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self?.image = image
}
}
}
}
}
The issue is inside your cell.imageView.load(url: imageURL) implementation.
Downloading an image is an asynchronous operation. When you create a cell - you start an operation and you don't know when it's going to complete. Whenever it completes, it sets the image on your UIImageView instance.
The cell on the other hand is a reuseable component for collectionView and when you scroll, same cell instance that disappeared by going out of screen from top will also reappear by coming in from bottom of the screen. This means that one cell instance can trigger multiple image download requests and it doesn't cancel it's previous in-flight image download request. It also does not know which image download call completed - the last one (or the one before that etc.)
What you need to do is - add some additional protection in the imageView load method so that when a cell (and hence imageView) instance is reused - it only sets the image for last request (not the ones done prior to the last one).
Copy paste following code into your project.
import UIKit
import ObjectiveC.runtime
private var keyTagIdentifier: String?
public extension UIView {
var tagIdentifier: String? {
get { return objc_getAssociatedObject(self, &keyTagIdentifier) as? String }
set { objc_setAssociatedObject(self, &keyTagIdentifier, newValue, .OBJC_ASSOCIATION_RETAIN) }
}
}
Update your image load extension like this
public extension UIImageView {
func load(url: URL) {
// 1. Assign the current request identifier on this instance
let lastRequestIdentifier = url.absoluteString
self.tagIdentifier = lastRequestIdentifier
// 2. Download the image as you are doing today
// 3. After the image has been downloaded and you set the image
// Please check whether we are still interested in same image or not
// This will be written inside your image download completion block
if self.tagIdentifier == lastRequestIdentifier {
self.image = // the image you downloaded
}
}
}
I believe the issue is due to UICollectionView reusing cells. Meaning the cell that disappears from the top, is rendered at the bottom, usually with the same state as the cell that disappeared from the top and vice-versa for the cells at the bottom. You can avoid this behaviour by adding the following to you code to your PageCell,
override func prepareForReuse() {
super.prepareForReuse()
imageView?.image = nil
}

Collection View reusable cell issue while scrolling

I am using the collection view to show the gif's on the list.
Now facing the cell reusable issue while scrolling the cells up or down of collection view.
Like itemA is on first place in the list and itemB is on the second place in the list.
but when I scroll the data in the collection view. the places of items got misplaced. like some time itemA gone on 5th place or sometimes anywhere in the list.
i know i think this is the use with reusable cell, but don't know how to salve this.
Plss help.
Collection view cellForItemAt
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "GifCell", for: indexPath as IndexPath) as? GifCell else {
fatalError()
}
if gifArr.count > 0 {
let urlString = self.gifArr[indexPath.row]
let url = URL(string: urlString)!
DispatchQueue.global().async {
let imageData = try? Data(contentsOf: url)
let imageData3 = FLAnimatedImage(animatedGIFData: imageData) // this is the 3rd pary library to show the gifs on UIimageview's
DispatchQueue.main.async {
cell.imageView.animatedImage = imageData3
cell.textLabel.text = String(indexPath.row)
}
}
}
return cell
}
In GifCell you could implement prepareForReuse() method:
Performs any clean up necessary to prepare the view for use again.
override func prepareForReuse() {
super.prepareForReuse()
imageView.animatedImage = nil
textLabel.text = ""
}
Note:
at this point, each time cellForItemAt method gets called, the url will be reloaded, so later, you might want find a way to cache the images instead of keep reloading them.
First solution: You can cache data and every time check if there is, use your cache.
you can use this link, but replace UIImage with gift type!
or
try this, I did not test it
if let giftAny = UserDefaults.standard.value(forKey: "giftUrl") {
//cast giftAny to Data
// use cached gift
} else {
// cache gift
let giftData = try? Data(contentsOf: url)
UserDefaults.standard.setValue(giftData, forKeyPath: "giftUrl")
//use gift
}
Second Solution: Don't reuse cell
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = UICollectionViewCell(style: .default, reuseIdentifier:"Cell")
return cell
}
but in this case, if you have many cells, memory leak is unavoidable.

UICollectionView fast scrolling displays wrong images in the cell when new items are appended asynchronously

I have a UICollectionView which displays images in a grid but as I scroll rapidly it displays the wrong image in the cell momentarily until the image is downloaded from the S3 storage and then the correct image is displayed.
I have seen questions and answers relating to this problem on SO before but none of the solutions are working for me. The dictionary let items = [[String: Any]]() is filled after an API call. I need the cell to discard the image from the recycled cell. Right now there is an unpleasant image "dancing" effect.
Here is my code:
var searchResults = [[String: Any]]()
let cellId = "cellId"
override func viewDidLoad() {
super.viewDidLoad()
collectionView = UICollectionView(frame: self.view.frame, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(MyCollectionViewCell.self, forCellWithReuseIdentifier: cellId)
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! MyCollectionViewCell
let item = searchResults[indexPath.item]
cell.backgroundColor = .white
cell.itemLabel.text = item["title"] as? String
let imageUrl = item["img_url"] as! String
let url = URL(string: imageUrl)
let request = Request(url: url!)
cell.itemImageView.image = nil
Nuke.loadImage(with: request, into: cell.itemImageView)
return cell
}
class MyCollectionViewCell: UICollectionViewCell {
var itemImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.layer.cornerRadius = 0
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.backgroundColor = .white
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
---------
}
override func prepareForReuse() {
super.prepareForReuse()
itemImageView.image = nil
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
I changed my cell image loading to the following code:
cell.itemImageView.image = nil
APIManager.sharedInstance.myImageQuery(url: imageUrl) { (image) in
guard let cell = collectionView.cellForItem(at: indexPath) as? MyCollectionViewCell
else { return }
cell.itemImageView.image = image
cell.activityIndicator.stopAnimating()
}
Here is my API manager.
struct APIManager {
static let sharedInstance = APIManager()
func myImageQuery(url: String, completionHandler: #escaping (UIImage?) -> ()) {
if let url = URL(string: url) {
Manager.shared.loadImage(with: url, token: nil) { // internal to Nuke
guard let image = $0.value as UIImage? else {
return completionHandler(nil)
}
completionHandler(image)
}
}
}
If the user scrolls past the content limit my collection view will load more items. This seems to be the root of the problem where cell reuse is reusing images. Other fields in the cell such as item title are also swapped as new items are loaded.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if (scrollView.contentOffset.y + view.frame.size.height) > (scrollView.contentSize.height * 0.8) {
loadItems()
}
}
Here is my data loading function
func loadItems() {
if loadingMoreItems {
return
}
if noMoreData {
return
}
if(!Utility.isConnectedToNetwork()){
return
}
loadingMoreItems = true
currentPage = currentPage + 1
LoadingOverlay.shared.showOverlay(view: self.view)
APIManager.sharedInstance.getItems(itemURL, page: currentPage) { (result, error) -> () in
if error != nil {
}
else {
self.parseData(jsonData: result!)
}
self.loadingMoreItems = false
LoadingOverlay.shared.hideOverlayView()
}
}
func parseData(jsonData: [String: Any]) {
guard let items = jsonData["items"] as? [[String: Any]] else {
return
}
if items.count == 0 {
noMoreData = true
return
}
for item in items {
searchResults.append(item)
}
for index in 0..<self.searchResults.count {
let url = URL(string: self.searchResults[index]["img_url"] as! String)
let request = Request(url: url!)
self.preHeatedImages.append(request)
}
self.preheater.startPreheating(with: self.preHeatedImages)
DispatchQueue.main.async {
// appendCollectionView(numberOfItems: items.count)
self.collectionView.reloadData()
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
When the collection view scrolls a cell out of bounds, the collection view may reuse the cell to display a different item. This is why you get cells from a method named dequeueReusableCell(withReuseIdentifier:for:).
You need to make sure, when the image is ready, that the image view is still supposed to display that item's image.
I recommend you change your Nuke.loadImage(with:into:) method to take a closure instead of an image view:
struct Nuke {
static func loadImage(with request: URLRequest, body: #escaping (UIImage) -> ()) {
// ...
}
}
That way, in collectionView(_:cellForItemAt:), you can load the image like this:
Nuke.loadImage(with: request) { [weak collectionView] (image) in
guard let cell = collectionView?.cellForItem(at: indexPath) as? MyCollectionViewCell
else { return }
cell.itemImageView.image = image
}
If the collection view is no longer displaying the item in any cell, the image will be discarded. If the collection view is displaying the item in any cell (even in a different cell), you'll store the image in the correct image view.
Try this:
Whenever the cell is reused, its prepareForReuse method is called. You can reset your cell here. In your case, you can set a default image in the image view here till the original image is downloaded.
override func prepareForReuse()
{
super.prepareForReuse()
self.imageView.image = UIImage(named: "DefaultImage"
}
#discardableResult
public func loadImage(with url: URL,
options: ImageLoadingOptions = ImageLoadingOptions.shared,
into view: ImageDisplayingView,
progress: ImageTask.ProgressHandler? = nil,
completion: ImageTask.Completion? = nil) -> ImageTask? {
return loadImage(with: ImageRequest(url: url), options: options, into: view, progress: progress, completion: completion)
}
all Nuke API's return an ImageTask when requesting unless the image was in the cache. Hold reference to this ImageTask if there is one. In the prepareForReuse function. call ImageTask.cancel() in it and set the imageTask to nil.
From the Nuke project page:
Nuke.loadImage(with:into:) method cancels previous outstanding request associated with the target. Nuke holds a weak reference to a target, when the target is deallocated the associated request gets cancelled automatically.
And as far as I can tell you are using their example code correctly:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
...
cell.imageView.image = nil
Nuke.loadImage(with: url, into: cell.imageView)
...
}
You are explicitly setting the UIImageViews image to nil so there should actually be no way it could display an image until Nuke loads a new image into the target.
Also Nuke will cancel a previous request. I'm using Nuke myself and don't have these issues. Are you sure your "changed code" isn't the faulty piece here? For me everything seems to be correct and I don't see an issue why it shouldn't work.
One possible issue could be the reload of the whole collection view though. We should always try to provide the best user experience so if you alter the datasource of the collection view, try to use performBatchUpdates to animate the inserts/updates/moves/deletes.
A nice library which automatically can take care of this is Dwifft for example.

Realm/iOS: Bumpy scrolling performance for UICollectionView

I am using Realm as the alternative for coredata for the first time.
Sadly, I had this bumpy scrolling issue(It is not too bad, but quite obvious) for collectionView when I try Realm out. No data were downloaded blocking the main thread, I use local stored image instead.
Another issue is when I push to another collectionVC, if the current VC will pass data to the other one, the segue is also quite bumpy.
I am guessing it is because of the way I write this children property in the Realm Model. But I do not know what might be the good way to compute this array of array value (merging different types of list into one)
A big thank you in advance!!
Here is the main model I use for the collectionView
class STInstitution: STHierarchy, STContainer {
let boxes = List<STBox>()
let collections = List<STCollection>()
let volumes = List<STVolume>()
override dynamic var _type: ReamlEnum {
return ReamlEnum(value: ["rawValue": STHierarchyType.institution.rawValue])
}
var children: [[AnyObject]] {
var result = [[AnyObject]]()
var tempArr = [AnyObject]()
boxes.forEach{ tempArr.append($0) }
result.append(tempArr)
tempArr.removeAll()
collections.forEach{ tempArr.append($0) }
result.append(tempArr)
tempArr.removeAll()
volumes.forEach{ tempArr.append($0) }
result.append(tempArr)
return result
}
var hierarchyProperties: [String] {
return ["boxes", "collections", "volumes"]
}
}
Here is how I implement the UICollectionViewController:
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView?.alwaysBounceVertical = true
dataSource = STRealmDB.query(fromRealm: realm, ofType: STInstitution.self, query: "ownerId = '\(STUser.currentUserId)'")
}
// MARK: - datasource:
override func numberOfSections(in collectionView: UICollectionView) -> Int {
// #warning Incomplete implementation, return the number of sections
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of items
guard let dataSource = dataSource else { return 0 }
return dataSource.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! STArchiveCollectionViewCell
guard let dataSource = dataSource,
dataSource.count > indexPath.row else {
return cell
}
let item = dataSource[indexPath.row]
DispatchQueue.main.async {
cell.configureUI(withHierarchy: item)
}
return cell
}
// MARK: - Open Item
func pushToDetailView(dataSource: [[AnyObject]], titles: [String]) {
guard let vc = storyboard?.instantiateViewController(withIdentifier: STStoryboardIds.archiveDetailVC.rawValue) as? STArchiveDetailVC
else { return }
vc.dataSource = dataSource
vc.sectionTitles = titles
self.navigationController?.pushViewController(vc, animated: true)
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let dataSource = self.dataSource,
dataSource.count > indexPath.row else {
return
}
let item = dataSource[indexPath.row]
self.pushToDetailView(dataSource: item.children, titles: item.hierarchyProperties)
}
Modification(more codes on configureUI):
// configureUI
// data.type is an enum type
func configureUI<T: STHierarchy>(withHierarchy data: T) {
print("data", kHierarchyCoverImage + "\(data.type)")
titleLabel.text = data.title
let image = data.type.toUIImage()
self.imageView.image = image
}
// toUIImage of enum data.type
func toUIImage() -> UIImage {
let key = kHierarchyCoverImage + "\(self.rawValue)" as NSString
if let image = STCache.imageCache.object(forKey: key) {
return image
}else{
print("toUIImage")
let defaultImage = UIImage(named: "institution")
let image = UIImage(named: "\(self)") ?? defaultImage!
STCache.imageCache.setObject(image, forKey: key)
return image
}
}
If your UI is bumpy when you're scrolling, it simply means the operations you're performing in collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell are too heavy.
Realm itself is structured in such a way that reading data from objects is very fast, so you shouldn't be seeing substantial dropped frames if all you're doing is populating a cell with values from Realm.
A couple of considerations:
If you're calling item.children inside the cellForItem block method, since you're manually looping through and paging in every Realm object doing that, that will cause frame drops. If you are, it'd be best to either do that ahead of time, or re-desing the logic to only access those arrays when absolutely needed.
You mentioned you're including images. Even if the images are on disk, unless you force image decompression ahead of time, Core Animation will lazily decompress the image at draw time on the main thread which can severely kill scroll performance. See this question for more info.
The cellForItemAt method call should already be on the main thread, so configuring your cell in a DispatchQueue.main.async closure seems un-necessary, and given that it's not synchronous, may be causing additional issues by running out of order.
Collection views are notoriously hard for performance since entire rows of cells used to be created and configured in one run loop iteration. This behavior was changed in iOS 10 to spread cell creation out across multiple run loop iterations. See this WWDC video for tips on optimizing your collection view code to take advantage of this.
If you're still having trouble, please post up more of your sample code; most importantly, the contents of configureUI. Thanks!
Turned out I was focusing on the wrong side. My lack of experience with Realm made me feel that there must be something wrong I did with Realm. However, the true culprit was I forgot to define the path for shadow of my customed cell, which is really expensive to draw repeatedly. I did not find this until I used the time profile to check which methods are taking the most CPU, and I should have done it in the first place.

collectionView can't reloadItemsAtIndexPaths at current scene

First, I create a collection view controller with a storyboard, and subclass a cell (called RouteCardCell).
The cell lazy loads an image from Web. To accomplish this, I create a thread to load the image. After the image loads, I call the method reloadItemsAtIndexPaths: to display the image.
Loading the image works correctly, but there's a problem displaying the image. My cells display the new image only after scrolling them off-screen and back on.
Why don't my images display properly after reloading the item?
Here's the relevant code:
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as RouteCardCell
let road = currentRoads[indexPath.item]
cell.setText(road.title)
var imageData = self.imageCache.objectForKey(NSString(format: "%d", indexPath.item)) as? NSData
if let imageData_ = imageData{
cell.setImage(UIImage(data: imageData_))
}
else{
cell.setImage(nil)
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
var Data = self.getImageFromModel(road, index:indexPath.item)
if let Data_ = Data{
self.imageCache.setObject(Data_, forKey: NSString(format: "%d", indexPath.item))
NSLog("Download Image for %d", indexPath.item)
}
else{
println("nil Image")
}
})
self.reloadCollectionViewDataAtIndexPath(indexPath)
}
return cell
}
func reloadCollectionViewDataAtIndexPath(indexPath:NSIndexPath){
var indexArray = NSArray(object: indexPath)
self.collectionView!.reloadItemsAtIndexPaths(indexArray)
}
func getImageFromModel(road:Road, index:Int)->NSData?{
var images = self.PickTheData!.pickRoadImage(road.roadId)
var image: Road_Image? = images.firstObject as? Road_Image
if let img = image{
return img.image
}
else{
return nil
}
}
You're calling reloadCollectionViewDataAtIndexPath(indexPath) before the image is done downloading. Instead of calling it outside of your dispatch_async block, add another block to go back on the main queue once it's done.
For example:
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
// download the imageā€¦
// got the image, now update the UI
dispatch_async(dispatch_get_main_queue()) {
self.reloadCollectionViewDataAtIndexPath(indexPath)
}
})
This is a pretty tough problem in iOS development. There are other cases you haven't handled, like what happens if the user is scrolling really quickly and you end up with a bunch of downloads that the user doesn't even need to see. You may want to try using a library like SDWebImage instead, which has many improvements over your current implementation.

Resources