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.
@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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With