I've been trying to create a photo gallery in Swift 3.0 and I want to load images stored in the document directory into the collection view asynchronously. I tried DispatchQueue.main.async but it blocks the main thread and freezes the app for a few seconds:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let identifier = "ImageCell"
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) as! ImageCell
let url = self.imageURL[indexPath.row]
cell.lazyLoadImage(from: url)
return cell
}
And the ImageCell class:
class ImageCell: UICollectionViewCell {
#IBOutlet weak var imageView: UIImageView!
func lazyLoadImage(from url: URL) {
DispatchQueue.main.async {
if let image = UIImage(contentsOfFile: url.path) {
self.imageView.image = image
}
}
}
}
I appreciate your help. Thank you.
First switch to background thread and load image on it, and again switch to main thread and display image.
func lazyLoadImage(from url: URL) {
DispatchQueue.global(qos: .userInteractive).async {
if let image = UIImage(contentsOfFile: url.path) {
DispatchQueue.main.async {
self.imageView.image = image
}
}
}
}
You are doing it completing wrong. In short, I can say that's not the "lazy loading". What you are doing is blocking the threads to download the content from the URL and load it to UIImageView. The thread will not be release until the download happens and you are using it in "reusable Cells" and that's obvious your application will stuck!
Solution
There are many most popular libraries like AlamofireImage, SDWebImage , Kingfisher. From which I am picking up "SDWebImage" and you can call load image in async way via following code:
imageView.sd_setImage(with: url , placeholderImage: nil)
Also, please refer to this SO question (how to implement lazy loading of images in table view using swift) for more detail.
Related
I am trying to recreate this thing. I've created in Storyboard skeleton. Here's the idea of my code:
Fetch images from URL's array with help of the function getThumbnailFromImage
Add UIImage's with my thumbnails in array webImages
Add in ViewController reusable cell MyCollectionView
...
But here I am with this))) (Don't mind absence of Auto Layout). What am I doing wrong? I think that the problem is with reloadData() but I don't know where to put it.
ViewController:
//
// ViewController.swift
// youtube-clone
//
// Created by мас on 16.08.2022.
//
import Foundation
import UIKit
import YouTubePlayer
import AVFoundation
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
var url: [URL?] = [
URL(string: "https://www.youtube.com/watch?v=KhebpuFBD14"),
URL(string: "https://www.youtube.com/watch?v=UfNdNrRHpUw"),
URL(string: "https://www.youtube.com/watch?v=CX-BdDHW0Ho"),
URL(string: "https://www.youtube.com/watch?v=NIOMtSzfpck")
]
var webImages: [UIImage] = []
var currentPage: Int = 0
#IBOutlet var myPage: UIPageControl!
#IBOutlet weak var buttonInfo: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
myPage.currentPage = 0
myPage.numberOfPages = webImages.count
}
// MARK: - Collection View Setup
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return webImages.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionCell
getThumbnailFromImage(url: url[indexPath.row]!, completion: { image in
self.webImages.append(image!)
})
cell.myWebImage.image = webImages[indexPath.row]
cell.myWebImage.layer.cornerRadius = 20
return cell
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
myPage.currentPage = indexPath.row
}
// MARK: - Layout Setup // IGNORE IT
func setupLayout() {
buttonInfo.layer.cornerRadius = 25
buttonInfo.imageView!.transform = CGAffineTransform(rotationAngle: 180 * .pi / 180)
self.navigationController?.navigationBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
}
// MARK: - Videos Thumbnail Fetcher
func getThumbnailFromImage(url: URL, completion: #escaping ((_ image: UIImage?) -> Void)) {
DispatchQueue.global().async {
let asset = AVAsset(url: url)
let avAssetImageGenerator = AVAssetImageGenerator(asset: asset)
avAssetImageGenerator.appliesPreferredTrackTransform = true
let thumbnailTime = CMTimeMake(value: 7, timescale: 1)
do {
let cgThumbImage = try avAssetImageGenerator.copyCGImage(at: thumbnailTime, actualTime: nil)
let thumbImage = UIImage(cgImage: cgThumbImage)
DispatchQueue.main.async {
completion(thumbImage)
}
}
catch {
print(error.localizedDescription)
}
}
}
}
Reusable Cell AKA MyCollectionCell:
import UIKit
class MyCollectionCell: UICollectionViewCell {
#IBOutlet var myWebImage: UIImageView!
}
P.s.: YouTubePlayer is custom pod from GitHub, it's not currently used.
You do NOT have to use AVAssetImageGenerator, Simply you can use Youtube API to fetch the thumbnail images as .jpg image by video id,
and each YouTube video has four generated images.
https://img.youtube.com/vi/{id}/0.jpg
https://img.youtube.com/vi/{id}/1.jpg
https://img.youtube.com/vi/{id}/2.jpg
https://img.youtube.com/vi/{id}/3.jpg
Example
https://img.youtube.com/vi/KhebpuFBD14/0.jpg
And then it is preferred to use a third party to load this image as its displayed in a list, like https://github.com/SDWebImage/SDWebImage or https://github.com/onevcat/Kingfisher and you will NOT be worry about Concurrency or caching.
A couple of thoughts:
#matt is right in the comment - getThumbnailFromImage will likely not have called the completion block by the time cellForItemAt returns.
From what is visible in the code you posted, webImages.count will still be 0 when your collection view checks numberOfItemsInSection. If the number of items is 0, cellForItemAt may never get called so the call to getThumbnailFromImage wouldn't even be reached. (I'm not sure if the white box in your screenshot is part of a cell or another view element. If a cell is being displayed, I'm assuming you're populating webImages somewhere else before the collection view gets laid out).
One way you could work around these issues is by giving each cell a URL rather than a thumbnail. That way the cell can be displayed while the image is still loading. The cell could look something like this:
class MyCollectionCell: UICollectionViewCell {
#IBOutlet var myWebImage: UIImageView!
func configure(urlString: String) {
guard let self = self, let url = URL(string: urlString) else {
return
}
getThumbnailFromImage(url: url, completion: { [weak self] image in
self?.myWebImage.image = image
})
}
// Move `getThumbnailForImage` function to here, or give the cell a delegate to call back to the VC with if you don't want any networking in the view itself
}
The cellForItemAt function in the VC would need to be changed to something like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionCell
cell.configure(urlString: url[indexPath.row])
cell.myWebImage.layer.cornerRadius = 20 // This should probably live in the cell since the parent doesn't actually need to know about it!
return cell
}
An added benefit of this approach is that you're not referencing a separate array of images that could theoretically end up being in the wrong order if there's a mistake somewhere in the code. You could get rid of the webImages array entirely and use urls.count in numberOfItemsInSection instead - or eventually the number of elements returned from an API somewhere.
Side note - make sure you add [weak self] at the beginning of any closure that references self to avoid trying to access it after it's been deallocated! Currently the call to getThumbnailFromImage doesn't have that :)
Also, note that I changed to a guard statement for checking that the URL exists. This is much safer than force unwrapping a URL(string:) value, especially if you ever end up getting the strings from a dynamic source.
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.
The app I'm working on, it has many table views and each cell has an image. Problem is that each image is done using a network request and a different thread, so the app ends up using many threads to asynchronously download all the images (in the cellForRowAtIndex method) which results in huge battery drain.
Is there a way I can download them using one thread only, perhaps one after the other? Is there a better way of handling this issue?
What about the framework Kingfisher?
You could dispatch your network operation blocks onto an NSOperationQueue and set a lower value for maxConcurrentOperationCount.
Yes, there is actually a way, use Kingfisher
here is my sample code for you:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch indexPath.section {
case 0:
let cell = tableView.dequeueReusableCell(withIdentifier: "BannerCell", for: indexPath) as! BannerCell
cell.loading.stopAnimating()
let urlString = banners[indexPath.row].image ?? ""
let url = URL(string: urlString)
cell.bannerPic.kf.indicatorType = .activity
cell.bannerPic.kf.setImage(with: url, completionHandler: {
(image, error, cacheType, imageUrl) in
DispatchQueue.main.async {
if let image = image{
if !(self.loaded[indexPath] ?? false){
let aspectRatio = image.size.height/image.size.width
let imageHeight = self.view.frame.width*aspectRatio
tableView.beginUpdates()
self.rowHeights[indexPath.row] = imageHeight
tableView.endUpdates()
self.loaded[indexPath] = true
}
}
}
})
cell.selectionStyle = .none
return cell
}
I'm trying to load images extracted from the web URL into the image view of each cell.
However, when i scroll the table the screen will freeze as I believe it is attempting to grab the images for each cell 1 by 1.
Is there a way i can make it asynchronous? The resources available out there currently is outdated or incompatible(running obj c) as I'm running on Swift 2
The relevant code I'm using within the table view controller is below :
override func tableView(newsFeedTableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath)
let blogPost: BlogPost = blogPosts[indexPath.row]
cell.textLabel?.text = blogPost.postTitle
let unformattedDate = blogPost.postDate
//FORMATTING: Splitting of raw data into arrays based on delimiter '+" to print only useful information
let postDateArr = unformattedDate.characters.split{$0 == "+"}.map(String.init)
cell.detailTextLabel?.text = postDateArr[0]
let url = NSURL(string: blogPost.postImageUrl)
let data = NSData(contentsOfURL: url!)
cell.imageView!.image = UIImage(data: data!)//WHY SO SLOW!?
print(blogPost.postImageUrl)
return cell
}
Try this
var image: UIImage
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), {() -> Void in
// Background thread stuff.
let url = NSURL(string: blogPost.postImageUrl)
let data = NSData(contentsOfURL: url!)
image = UIImage(data:data)
dispatch_async(dispatch_get_main_queue(), {() -> Void in
// Main thread stuff.
cell.imageView.image = image
})
})
Lets clean your code a bit. First of all, you are trying to declear ALL your cells in your viewController. That means your app is not trying to load every image one byt one, but more like everything all together.
You need to create a separate file called PostCell what is going to be a type of UITableViewCell.
Then you need to go to your prototype cell and connect those view elements to that PostCell just like you would add those to any other ViewController.
Now, here is new code to your cellForRowAtIndexPath function:
override func tableView(newsFeedTableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let blogPost = blogPosts[indexPath.row]
if let cell = tableView.dequeueReusableCellWithIdentifier("Cell") as? PostCell {
cell.configureCell(blogPost)
return cell
}
return UITableViewCell() // You need to do this because of if let
}
And declear this on that PostCell:
func configureCell(post: BlogPost) {
this.textLabel.text = post.postTitle
let postDateArr = unformattedDate.characters.split{$0 == "+"}.map(String.init)
this.detailTextLabel.text = postDateArr[0]
// I would add few if let declarations here too, but if you are sure all these forced ! variables do exciest, then its ok
let url = NSURL(string: blogPost.postImageUrl)
let data = NSData(contentsOfURL: url!)
this.imageView.image = UIImage(data: data!)
}
Or something along those lines. When you connect those elements to your cell, you will get proper variable names for those.
That SHOULD help. There are plenty of tutorials how to make a custom tableviewcell. Some of them advice to put all the declarations inside that cellForRowAtIndexPath, but I have found that it get's problematic very fast.
So...my advice in a nutscell...create a custom tableviewcell.
Hope this helps! :)
To load the image on every cell use SDWebImage third party library. You can add it using pods as put pod 'SDWebImage' It provides various methods to load the image with caching or without caching asynchronously. With caching you don't really need to worry about loading image data every time cell appears on the screen. Try this
override func tableView(newsFeedTableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCellWithIdentifier("Cell") as? PostCell {
--reset your cell here--
// cell.imageView.image = nil
}
cell.imageView.sd_setImageWithURL(YOUR_URL, placeholderImage: UIImage(named: "")) {
(UIImage img, NSError err, SDImageCacheType cacheType, NSURL imgUrl) -> Void in
// Do awesome things
}
-- configure your cell here --
}
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.