I have a bucket in my AWS S3 backend full of images that I want to load onto my UICollectionViewCell's. What method should I implement to do so? I'm looking for the most efficient method as well.
I may note that currently in my project the frameworks I have are Alamofire, swiftyJSON, and Haneke (for caching purposes) although I do not know how to correctly use them to achieve my goal. I may also add that I'm using Parse.com as my BaaS, so if there is a method that can integrate parse in it, that would be welcomed as well.
So any suggestions In Swift?
The solution I suggested is to use Parse.com as the source of metadata for your books and s3 as the storage. I don't think you need any other components (and I'm particularly suspicious of net-code convenience wrappers for basic iOS stuff).
So I would (and did, to prove it works) setup a parse model like this...
And here's a vanilla ViewController in swift that has a collection view and an array of custom swift "Book" objects...
// ViewController.swift
import UIKit
import Parse
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
#IBOutlet weak private var collectionView : UICollectionView!
var books : Array<Book>!
override func viewDidLoad() {
super.viewDidLoad()
self.books = []
}
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
self.loadBooks()
}
func loadBooks() {
var query = PFQuery(className: "Book")
query.findObjectsInBackgroundWithBlock { (objects: [AnyObject]?, error: NSError?) -> Void in
if error == nil {
let bookObjects = objects as! [PFObject]
for (index, object) in enumerate(bookObjects) {
self.books.append(Book(pfBook: object))
}
}
self.collectionView.reloadData()
}
}
Basic stuff. When the view appears, query parse for book objects, when they are returned, create swift "Book" wrappers around them and reload the collection view. Here's the swift Book class ...
// Book.swift
import Parse
class Book: NSObject {
var pfBook : PFObject
var coverImage : UIImage!
init(pfBook: PFObject) {
self.pfBook = pfBook
}
func fetchCoverImage(completion: (image: UIImage?, error: NSError?) -> Void) {
let urlString = self.pfBook["coverUrl"] as! String
let url = NSURL(string: urlString)
let request = NSURLRequest(URL: url!)
let queue = dispatch_get_main_queue()
NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue.mainQueue()) { (response: NSURLResponse?, data: NSData?, error: NSError?) in
if error == nil {
self.coverImage = UIImage(data: data!)
completion(image: self.coverImage, error: nil)
} else {
completion(image: nil, error: error)
}
}
}
}
The interesting method fetches the cover image from the url saved in the PFObject. (Check out the url column in the parse.com data browser. I took a shortcut here and used an nice dummy image generator on the web. You would need to implement a method with the same signature, but have it get the image from s3).
The only thing left is the collection view datasource. Back again in ViewController.swift...
// ViewController.swift
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.books.count
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell",
forIndexPath:indexPath) as! UICollectionViewCell
if cell.backgroundView == nil { // brand new cell
let v = UIImageView(frame:cell.bounds)
v.tag = 32
v.contentMode = .ScaleToFill
cell.backgroundView = v
}
let book = self.books[indexPath.row]
let coverImage = book.coverImage
if coverImage == nil {
book.fetchCoverImage({ (image, error) -> Void in
collectionView.reloadItemsAtIndexPaths([indexPath])
})
} else {
let imageView = cell.viewWithTag(32) as! UIImageView
imageView.image = book.coverImage
}
return cell
}
Notice what we do in the cellForRow... method: if the book object has a cached coverImage, we place that in the image view. Otherwise we tell the book to fetch it's cover art and, when finished, reload the cell at the current index path (we don't depend on the cell being current anymore, since time passes while the image is being fetched).
Here's what it looks like running...
I was facing this problem and have a nice and well working solution, how to load images from AWS S3 to UIImageView in cell or other views. To do it I additional use a SDWebImage SDK SDWebImage
So let's create an extension for UIImageView and import SDWebImage, then add image cache to caching image links only
import UIKit
import SDWebImage
let imageCache = NSCache<NSString, NSString>()
extension UIImageView {
func loadAsyncWithCacheFromAWSS3(_ link: String?, placeholder: UIImage? = UIImage(named: "placeholder")) {
guard let unwrappedlink = link else { return self.image = placeholder }
// Use your own image appearing style
self.sd_imageTransition = .fade
if !loadFromCache(unwrappedlink as NSString, placeholder: placeholder) {
let file = File(bucket: 'YOUR BUCKET', key: unwrappedlink, region: 'YOUR REGION')
AWSManager.shared.getFileURL(file: file) { [weak self] (string, url, error) in
guard let validURL = url else { self?.image = placeholder; return }
imageCache.setObject(NSString(string: validURL.absoluteString), forKey: NSString(string: unwrappedlink))
guard unwrappedlink == link else {
guard let key = link else { return }
self?.loadFromCashe(key as NSString, placeholder: placeholder)
return
}
self?.sd_setImage(with: validURL, placeholderImage: placeholder, options: [.scaleDownLargeImages])
}
}
}
// quick load from existing cashed link
#discardableResult
private func loadFromCache(_ key: NSString, placeholder: UIImage?) -> Bool {
guard let string = imageCache.object(forKey: key) else { return false }
let link = URL(string: String(string))
self.sd_setImage(with: link, placeholderImage: placeholder, options: [.scaleDownLargeImages])
return true
}
}
Here is a singleton class with getFileURL method
func getFileURL(file: File, completionHandler: #escaping (String?, URL?, Error?) -> Void) {
let getPreSignedURLRequest = AWSS3GetPreSignedURLRequest()
getPreSignedURLRequest.bucket = file.bucket
getPreSignedURLRequest.key = file.key
getPreSignedURLRequest.httpMethod = .GET
getPreSignedURLRequest.expires = Date().adjust(hour: 24, minute: 0, second: 0)
getPreSignedURLRequest.minimumCredentialsExpirationInterval = 10
AWSS3PreSignedURLBuilder.s3PreSignedURLBuilder(forKey: 'YOUR BUILDER URL').getPreSignedURL(getPreSignedURLRequest).continueWith { (task: AWSTask<NSURL>) -> Any? in
if let error = task.error as NSError? {
completionHandler(nil, nil, error)
return nil
}
let presignedURL = task.result
completionHandler(file.key, presignedURL as URL?, nil)
return nil
}
}
in Appdelegate in func applicationDidReceiveMemoryWarning(_ application: UIApplication) simple call imageCache.removeAllObjects()
Related
For study purposes, I'm creating a app to show a list of some star wars ships. It fetches my json (locally) for the ship objects (it has 4 ships for this example).
It's using a custom cell for the table view.
The table populates without problems, if I already have the images downloaded (in user documents) or not.
My starshipData array is populated by my DataManager class by delegate.
I removed some code to make the class smaller, I can show everything if needed.
Ok, so the problem happens (very rarely) when I press the sorting button.
The way I'm doing it is after recovering or downloading the image, I update the image field in starshipData array.
Here is my sorting method, pretty basic.
#objc private func sortByCost(sender: UIBarButtonItem) {
starshipData.sort { $0.costInCredits < $1.costInCredits }
starshipTableView.reloadData()
}
Here are the implementations of the tableView.
First I use the cellForRowAt method to populate the fast/light data.
// MARK: -> cellForRowAt
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "StarshipCell", for: indexPath) as! StarshipCell
let starship = starshipData[indexPath.row]
// update cell properties
cell.starshipNameLabel.text = starship.name
cell.starshipManufacturerLabel.text = starship.manufacturer
cell.starshipCostLabel.text = currencyFormatter(value: starship.costInCredits)
// only populate the image if the array has one (the first time the table is populated,
// the array doesn't have an image, it'll need to download or fetch it in user documents)
if starship.image != nil {
cell.starshipImgView.image = starship.image
}
// adds right arrow indicator on the cell
cell.accessoryType = .disclosureIndicator
return cell
}
Here I use the willDisplay method to download or fetch the images, basically the heavier data.
// MARK: -> willDisplay
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// update cell image
let cell = cell as! StarshipCell
let imageUrl = starshipData[indexPath.row].imageUrl
let starshipName = starshipData[indexPath.row].name
let index = indexPath.row
// if there isn't any image on the cell, proceed to manage the image
if cell.starshipImgView.image == nil {
// only instantiate spinner on imageView position if no images are set
let spinner = UIActivityIndicatorView(style: .medium)
startSpinner(spinner: spinner, cell: cell)
// manage the image
imageManager(starshipName: starshipName, imageUrl: imageUrl, spinner: spinner, cell: cell, index: index) { (image) in
self.addImageToCell(cell: cell, spinner: spinner, image: image)
}
}
}
Here is where I think the problem is as my knowledge in swift and background threads are still in development.
I found out with print logs that the times the cell doesn't show the correct image is because the array does not have the image for that index, so the cell shows the image from the last time the table was populated/loaded.
I wonder if it's because the background threads didn't have enough time to update the starshipArray with the fetched/downloaded image before the user pushing the sort button.
The thing is, if the table was populated correctly the first time, when the sort button is pushed, the starshipData array should already have all images, as you can see in the imageManager method, after the image is unwrappedFromDocuments, I call updateArrayImage to update the image.
Maybe it's the amount of dispatchesQueues being used? Are the completion handler and dispatchQueues used correctly?
private func imageManager(starshipName: String, imageUrl: URL?, spinner: UIActivityIndicatorView, cell: StarshipCell, index: Int, completion: #escaping (UIImage) -> Void) {
// if json has a string on image_url value
if let unwrappedImageUrl = imageUrl {
// open a background thread to prevent ui freeze
DispatchQueue.global().async {
// tries to retrieve the image from documents folder
let imageFromDocuments = self.retrieveImage(imageName: starshipName)
// if image was retrieved from folder, upload it
if let unwrappedImageFromDocuments = imageFromDocuments {
// TO FORCE THE PROBLEM DESCRIBED, PREVENT ONE SHIP TO HAVE IT'S IMAGE UPDATED
// if (starshipName != "Star Destroyer") {
self.updateArrayImage(index: index, image: unwrappedImageFromDocuments)
// }
completion(unwrappedImageFromDocuments)
}
// if image wasn't retrieved or doesn't exists, try to download from the internet
else {
var image: UIImage?
self.downloadManager(imageUrl: unwrappedImageUrl) { data in
// if download was successful
if let unwrappedData = data {
// convert image data to image
image = UIImage(data: unwrappedData)
if let unwrappedImage = image {
self.updateArrayImage(index: index, image: unwrappedImage)
// save images locally on user documents folder so it can be used whenever it's needed
self.storeImage(image: unwrappedImage, imageName: starshipName)
completion(unwrappedImage)
}
}
// if download was not successful
else {
self.addImageNotFound(spinner: spinner, cell: cell)
}
}
}
}
}
// if json has null on image_url value
else {
addImageNotFound(spinner: spinner, cell: cell)
}
}
Here are some of the helper methods I use on imageManager, if necessary.
// MARK: - Helper Methods
private func updateArrayImage(index: Int, image: UIImage) {
// save image in the array so it can be used when cells are sorted
self.starshipData[index].image = image
}
private func downloadManager(imageUrl: URL, completion: #escaping (Data?) -> Void) {
let session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 5
return URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
}()
var dataTask: URLSessionDataTask?
dataTask?.cancel()
dataTask = session.dataTask(with: imageUrl) { [weak self] data, response, error in
defer {
dataTask = nil
}
if let error = error {
// use error if necessary
DispatchQueue.main.async {
completion(nil)
}
}
else if let response = response as? HTTPURLResponse,
response.statusCode != 200 {
DispatchQueue.main.async {
completion(nil)
}
}
else if let data = data,
let response = response as? HTTPURLResponse,
response.statusCode == 200 { // Ok response
DispatchQueue.main.async {
completion(data)
}
}
}
dataTask?.resume()
}
private func addImageNotFound(spinner: UIActivityIndicatorView, cell: StarshipCell) {
spinner.stopAnimating()
cell.starshipImgView.image = #imageLiteral(resourceName: "ImageNotFound")
}
private func addImageToCell(cell: StarshipCell, spinner: UIActivityIndicatorView, image: UIImage) {
DispatchQueue.main.async {
spinner.stopAnimating()
cell.starshipImgView.image = image
}
}
private func imagePath(imageName: String) -> URL? {
let fileManager = FileManager.default
// path to save the images on documents directory
guard let documentPath = fileManager.urls(for: .documentDirectory,
in: FileManager.SearchPathDomainMask.userDomainMask).first else { return nil }
let appendedDocumentPath = documentPath.appendingPathComponent(imageName)
return appendedDocumentPath
}
private func retrieveImage(imageName: String) -> UIImage? {
if let imagePath = self.imagePath(imageName: imageName),
let imageData = FileManager.default.contents(atPath: imagePath.path),
let image = UIImage(data: imageData) {
return image
}
return nil
}
private func storeImage(image: UIImage, imageName: String) {
if let jpgRepresentation = image.jpegData(compressionQuality: 1) {
if let imagePath = self.imagePath(imageName: imageName) {
do {
try jpgRepresentation.write(to: imagePath,
options: .atomic)
} catch let err {
}
}
}
}
private func startSpinner(spinner: UIActivityIndicatorView, cell: StarshipCell) {
spinner.center = cell.starshipImgView.center
cell.starshipContentView.addSubview(spinner)
spinner.startAnimating()
}
}
To sum all up, here is the unordered table, when you open the app: unordered
The expected result (happens majority of time), after pushing the sort button: ordered
The wrong result (rarely happens), after pushing the sort button: error
I'll gladly add more info if needed, ty!
First, consider move the cell configuration for the UITableViewCell class. something like this:
class StarshipCell {
private var starshipNameLabel = UILabel()
private var starshipImgView = UIImageView()
func configure(with model: Starship) {
starshipNameLabel.text = model.name
starshipImgView.downloadedFrom(link: model.imageUrl)
}
}
Call the configure(with: Starship) method in tableView(_:cellForRowAt:).
The method downloadedFrom(link: ) called inside the configure(with: Starship) is provide by following extension
extension UIImageView {
func downloadedFrom(url: URL, contentMode mode: UIView.ContentMode = .scaleAspectFit) {
contentMode = mode
URLSession.shared.dataTask(with: url) { data, response, error in
guard let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let image = UIImage(data: data)
else { return }
DispatchQueue.main.async() {
self.image = image
}
}.resume()
}
func downloadedFrom(link: String?, contentMode mode: UIView.ContentMode = .scaleAspectFit) {
if let link = link {
guard let url = URL(string: link) else { return }
downloadedFrom(url: url, contentMode: mode)
}
}
}
I am new to parsing json in Swift and in my app I created an inbox. In this inbox, I load a profile image and a name in every cell. I found an API online with video game characters and their images for a test. However, when the json is parsed and put in the cell, the app loads the cells and occasionally the images move around to other cells or duplicate. I have seen this posted before, but none of the past answers have solved my solution.
Here is what it looked like when it loaded which is incorrect
Here is what happened one second later which is still incorrect and you can see duplication
This is my CollectionViewCell.Swift File
class CollectionViewCell: UICollectionViewCell {
#IBOutlet weak var imageCell: UIImageView!
#IBOutlet weak var dateCell: UILabel!
override func prepareForReuse() {
self.imageCell.image = nil
self.imageCell.setNeedsDisplay() // tried adding after some recommendations
self.setNeedsDisplay() // tried adding after some recommendations
super.prepareForReuse()
}
}
This is my main inbox view controller extension for the image I found online
extension UIImageView {
func downloadedFrom(url: URL, contentMode mode: UIViewContentMode = .scaleAspectFit) {
contentMode = mode
URLSession.shared.dataTask(with: url) { data, response, error in
guard
let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let image = UIImage(data: data)
else { return }
DispatchQueue.main.async() {
self.image = image
}
}.resume()
}
func downloadedFrom(link: String, contentMode mode: UIViewContentMode = .scaleAspectFit) {
guard let url = URL(string: link) else { return }
downloadedFrom(url: url, contentMode: mode)
}
}
This is the rest of the inbox view controller code (deleted unrelated code from it for the purpose of this question)
class InboxViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
#IBOutlet weak var inboxCollection: UICollectionView!
struct Hero: Decodable {
let localized_name: String
let img: String
}
var heroes = [Hero]()
override func viewDidLoad() {
super.viewDidLoad()
inboxCollection.dataSource = self
let url = URL(string: "https://api.opendota.com/api/heroStats")
URLSession.shared.dataTask(with: url!) { (data, response, error) in
if error == nil {
do {
self.heroes = try JSONDecoder().decode([Hero].self, from: data!)
}catch {
print("Parse Error")
}
DispatchQueue.main.async {
self.inboxCollection.reloadData()
}
}
}.resume()
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.heroes.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "collectionViewCell", for: indexPath) as! CollectionViewCell
cell.dateCell.text = heroes[indexPath.row].localized_name.capitalized
let defaultLink = "https://api.opendota.com"
cell.imageCell.image = nil
let completelink = defaultLink + heroes[indexPath.row].img
cell.imageCell.image = nil
cell.imageCell.downloadedFrom(link: completelink)
cell.imageCell.clipsToBounds = true
cell.imageCell.layer.cornerRadius = cell.imageCell.frame.height / 2
cell.imageCell.contentMode = .scaleAspectFill
cell.imageCell.image = nil
return cell
}
You shouldn't be setting UIImageView's image within the UIImageView. In fact, don't even create two separate methods - you have no good reason to. Download each image in your UIViewController and use a dictionary to map a hero's name to their image like so:
var heroImages = [String:URL]()
func getDataFromUrl(url: URL, completion: #escaping (Data?, URLResponse?, Error?) -> ()) {
URLSession.shared.dataTask(with: url) { data, response, error in
completion(data, response, error)
}.resume()
}
func getImages() {
for hero in self.heroes {
let url = NSURL(string: hero)
getDataFromURL(url: url, completion: {(data: Data?, response:URLResponse?, error: Error?) in
if (data != nil) {
image = UIImage(data: data)
heroImages[hero] = image }
if (heroImages.count == self.heroes.count) {
// We've downloaded all the images, update collection view
DispatchQueue.main.async { self.collectionView.reloadData() }
}
}
}
I need to perform correctly this, I think common, scenario:
I have an UITableViewController whose table's cells should download an image and show it. Number of cells depends on an user input, and I first get the URLs of the images I need to download, one per cell. In this UITableViewController I have this method:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard results.count > 0 else {
return UITableViewCell()
}
let myCell = tableView.dequeueReusableCell(withIdentifier: CustomCell.cellIdentifier, for: indexPath) as! CustomCell
let result = results[indexPath.row]
myCell.model = result
myCell.imageProvider = imageProvider
return myCell
}
Where CustomCell is like this:
class CustomCell: UITableViewCell {
// Several IBOutlets
static let cellIdentifier = "myCell"
var imageProvider: ImageProvider?
var model: MyModel? {
willSet {
activityIndicator.startAnimating()
configureImage(showImage: false, showActivity: true)
}
didSet {
guard let modelUrlStr = model?.imageUrlStr, let imageUrl = URL(string: modelUrlStr) else {
activityIndicator.stopAnimating()
configureImage(showImage: false, showActivity: false)
return
}
imageProvider?.getImage(imageUrl: imageUrl, completion: {[weak self] (image, error) in
DispatchQueue.main.async {
guard error == nil else {
self?.activityIndicator.stopAnimating()
self?.configureImage(showImage: false, showActivity: false)
return
}
self?.imageView.image = image
self?.activityIndicator.stopAnimating()
self?.configureImage(showCoverImage: true, showActivity: false)
}
})
}
}
override func awakeFromNib() {
super.awakeFromNib()
configureImage(showCoverImage: false, showActivity: false)
}
override func prepareForReuse() {
super.prepareForReuse()
model = nil
}
private func configureImage(showImage: Bool, showActivity: Bool) {
// Update image view
}
}
And ImageProvider is this:
class ImageProvider {
var imageTask: URLSessionDownloadTask?
func getImage(imageUrl: URL, completion: #escaping DownloadResult) {
imageTask?.cancel()
imageTask = NetworkManager.sharedInstance.getImageInBackground(imageUrl: imageUrl, completion: { (image, error) -> Void in
if let error = error {
completion(nil, error)
} else if let image = image {
completion(image, nil)
} else {
completion(nil, nil)
}
})
}
}
What I need to do is:
The user asks for a search typing a text, then I perform the search given that text parameter and get a number of results (that is, the number of cells to show in the table, and also the URLs for the images in the cells, one per cell)
The user can change the text to search or delete it at any moment, which means that I should refresh the table after every change in that text (cancel previous search if it was in progress -> new search of results -> new images URLs -> new images download).
I need the images keep downloading if the app goes to background state, to be able to show them when the app goes again to foreground.
Then, finally, this is NetworkManager:
class NetworkManager: NSObject {
static let sharedInstance = NetworkManager()
fileprivate var defaultSession: URLSession
fileprivate var backgroundSession: URLSession?
fileprivate var completionHandlers = [URL : ImageResult]()
override init() {
let configuration = URLSessionConfiguration.default
defaultSession = URLSession(configuration: configuration)
super.init()
let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "com.example.mysearch")
backgroundSession = URLSession(configuration: backgroundConfiguration, delegate: self, delegateQueue: nil)
}
func getImageInBackground(imageUrl url: URL, completion: ImageResult?) -> URLSessionDownloadTask? {
guard let backgroundSession = self.backgroundSession else {
return nil
}
completionHandlers[url] = completion
let request = URLRequest(url: url)
let task = backgroundSession.downloadTask(with: request)
task.resume()
return task
}
}
extension NetworkManager: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error, let url = task.originalRequest?.url, let completion = completionHandlers[url] {
completionHandlers[url] = nil
completion(nil, error)
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
if let data = try? Data(contentsOf: location), let image = UIImage(data: data), let request = downloadTask.originalRequest, let response = downloadTask.response {
let cachedResponse = CachedURLResponse(response: response, data: data)
self.defaultSession.configuration.urlCache?.storeCachedResponse(cachedResponse, for: request)
if let url = downloadTask.originalRequest?.url, let completion = completionHandlers[url] {
completionHandlers[url] = nil
completion(image, nil)
}
} else {
if let url = downloadTask.originalRequest?.url, let completion = completionHandlers[url] {
completionHandlers[url] = nil
completion(nil, error)
}
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
if let appDelegate = UIApplication.shared.delegate as? AppDelegate, let completionHandler = appDelegate.backgroundSessionCompletionHandler {
appDelegate.backgroundSessionCompletionHandler = nil
completionHandler()
}
}
}
When I run the app, I find that firstly most of images are not downloaded, and urlSession(session:task:didCompleteWithError:) is called with an error like this:
Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=http://xxxxx.jpg, NSLocalizedDescription=cancelled, NSErrorFailingURLKey=http://http://xxxxx.jpg}
and in addition sometimes an alert view with the text "cancelled" is show by the system. On the other hand, I don't understand why, when I scroll the table up and down looks like then most of the images are downloaded and shown.
I was following a similar example provided in a course in the Ray Wenderlich website, but I don't know why my app is not working well.
What am I doing wrong? I need your help to figure it out.
EDIT: I updated the code in a way that I always have an ImageProvider in the custom cell. This is in CustomCell class:
private let imageProvider = ImageProvider()
But I still get the same error and "cabcelled" alert view.
Since you are initiating image download in didSet method of the model, intern it requires the imageProvider to start downloading. You are setting model before setting imageProvider.
Try by updating code as below,
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard results.count > 0 else {
return UITableViewCell()
}
let myCell = tableView.dequeueReusableCell(withIdentifier: CustomCell.cellIdentifier, for: indexPath) as! CustomCell
let result = results[indexPath.row]
myCell.imageProvider = imageProvider
myCell.model = result
return myCell
}
I'm trying to async load pictures inside my FriendsTableView (UITableView) cell. The images load fine but when I'll scroll the table the images will change a few times and wrong images are getting assigned to wrong cells.
I've tried all methods I could find in StackOverflow including adding a tag to the raw and then checking it but that didn't work. I'm also verifying the cell that should update with indexPath and check if the cell exists. So I have no idea why this is happening.
Here is my code:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("friendCell", forIndexPath: indexPath) as! FriendTableViewCell
var avatar_url: NSURL
let friend = sortedFriends[indexPath.row]
//Style the cell image to be round
cell.friendAvatar.layer.cornerRadius = 36
cell.friendAvatar.layer.masksToBounds = true
//Load friend photo asyncronisly
avatar_url = NSURL(string: String(friend["friend_photo_url"]))!
if avatar_url != "" {
getDataFromUrl(avatar_url) { (data, response, error) in
dispatch_async(dispatch_get_main_queue()) { () -> Void in
guard let data = data where error == nil else { return }
let thisCell = tableView.cellForRowAtIndexPath(indexPath)
if (thisCell) != nil {
let updateCell = thisCell as! FriendTableViewCell
updateCell.friendAvatar.image = UIImage(data: data)
}
}
}
}
cell.friendNameLabel.text = friend["friend_name"].string
cell.friendHealthPoints.text = String(friend["friend_health_points"])
return cell
}
On cellForRowAtIndexPath:
1) Assign an index value to your custom cell. For instance,
cell.tag = indexPath.row
2) On main thread, before assigning the image, check if the image belongs the corresponding cell by matching it with the tag.
dispatch_async(dispatch_get_main_queue(), ^{
if(cell.tag == indexPath.row) {
UIImage *tmpImage = [[UIImage alloc] initWithData:imgData];
thumbnailImageView.image = tmpImage;
}});
});
This is because UITableView reuses cells. Loading them in this way causes the async requests to return at different time and mess up the order.
I suggest that you use some library which would make your life easier like Kingfisher. It will download and cache images for you. Also you wouldn't have to worry about async calls.
https://github.com/onevcat/Kingfisher
Your code with it would look something like this:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("friendCell", forIndexPath: indexPath) as! FriendTableViewCell
var avatar_url: NSURL
let friend = sortedFriends[indexPath.row]
//Style the cell image to be round
cell.friendAvatar.layer.cornerRadius = 36
cell.friendAvatar.layer.masksToBounds = true
//Load friend photo asyncronisly
avatar_url = NSURL(string: String(friend["friend_photo_url"]))!
if avatar_url != "" {
cell.friendAvatar.kf_setImageWithURL(avatar_url)
}
cell.friendNameLabel.text = friend["friend_name"].string
cell.friendHealthPoints.text = String(friend["friend_health_points"])
return cell
}
UPDATE
There are some great open source libraries for image caching such as KingFisher and SDWebImage. I would recommend that you try one of them rather than writing your own implementation.
END UPDATE
So there are several things you need to do in order for this to work. First let's look at the caching code.
// Global variable or stored in a singleton / top level object (Ex: AppCoordinator, AppDelegate)
let imageCache = NSCache<NSString, UIImage>()
extension UIImageView {
func downloadImage(from imgURL: String) -> URLSessionDataTask? {
guard let url = URL(string: imgURL) else { return nil }
// set initial image to nil so it doesn't use the image from a reused cell
image = nil
// check if the image is already in the cache
if let imageToCache = imageCache.object(forKey: imgURL as NSString) {
self.image = imageToCache
return nil
}
// download the image asynchronously
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let err = error {
print(err)
return
}
DispatchQueue.main.async {
// create UIImage
let imageToCache = UIImage(data: data!)
// add image to cache
imageCache.setObject(imageToCache!, forKey: imgURL as NSString)
self.image = imageToCache
}
}
task.resume()
return task
}
}
You can use this outside of a TableView or CollectionView cell like this
let imageView = UIImageView()
let imageTask = imageView.downloadImage(from: "https://unsplash.com/photos/cssvEZacHvQ")
To use this in a TableView or CollectionView cell you'll need to reset the image to nil in prepareForReuse and cancel the download task. (Thanks for pointing that out #rob
final class ImageCell: UICollectionViewCell {
#IBOutlet weak var imageView: UIImageView!
private var task: URLSessionDataTask?
override func prepareForReuse() {
super.prepareForReuse()
task?.cancel()
task = nil
imageView.image = nil
}
// Called in cellForRowAt / cellForItemAt
func configureWith(urlString: String) {
if task == nil {
// Ignore calls when reloading
task = imageView.downloadImage(from: urlString)
}
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "imageCell", for: indexPath) as! ImageCell
cell.configureWith(urlString: "https://unsplash.com/photos/cssvEZacHvQ") // Url for indexPath
return cell
}
Keep in mind that even if you use a 3rd party library you'll still want to nil out the image and cancel the task in prepareForReuse
If targeting iOS 13 or later, you can use Combine and dataTaskPublisher(for:). See WWDC 2019 video Advances in Networking, Part 1.
The idea is to let the cell keep track of the “publisher”, and have prepareForReuse:
cancel the prior image request;
set the image property of the image view to nil (or a placeholder); and then
start another image request.
For example:
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return objects.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell
let url = ...
cell.setImage(to: url)
return cell
}
}
class CustomCell: UITableViewCell {
#IBOutlet weak var customImageView: UIImageView!
private var subscriber: AnyCancellable?
override func prepareForReuse() {
super.prepareForReuse()
subscriber?.cancel()
customImageView?.image = nil
}
func setImage(to url: URL) {
subscriber = ImageManager.shared.imagePublisher(for: url, errorImage: UIImage(systemName: "xmark.octagon"))
.assign(to: \.customImageView.image, on: self)
}
}
Where:
class ImageManager {
static let shared = ImageManager()
private init() { }
private let session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .returnCacheDataElseLoad
let session = URLSession(configuration: configuration)
return session
}()
enum ImageManagerError: Error {
case invalidResponse
}
func imagePublisher(for url: URL, errorImage: UIImage? = nil) -> AnyPublisher<UIImage?, Never> {
session.dataTaskPublisher(for: url)
.tryMap { data, response in
guard
let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode,
let image = UIImage(data: data)
else {
throw ImageManagerError.invalidResponse
}
return image
}
.replaceError(with: errorImage)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
If targeting earlier iOS versions, rather than using Combine, you can use URLSession, with the same idea of canceling the prior request in prepareForReuse:
class CustomCell: UITableViewCell {
#IBOutlet weak var customImageView: UIImageView!
private weak var task: URLSessionTask?
override func prepareForReuse() {
super.prepareForReuse()
task?.cancel()
customImageView?.image = nil
}
func setImage(to url: URL) {
task = ImageManager.shared.imageTask(for: url) { result in
switch result {
case .failure(let error): print(error)
case .success(let image): self.customImageView.image = image
}
}
}
}
Where:
class ImageManager {
static let shared = ImageManager()
private init() { }
private let session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .returnCacheDataElseLoad
let session = URLSession(configuration: configuration)
return session
}()
enum ImageManagerError: Error {
case invalidResponse
}
#discardableResult
func imageTask(for url: URL, completion: #escaping (Result<UIImage, Error>) -> Void) -> URLSessionTask {
let task = session.dataTask(with: url) { data, response, error in
guard let data = data else {
DispatchQueue.main.async { completion(.failure(error!)) }
return
}
guard
let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode,
let image = UIImage(data: data)
else {
DispatchQueue.main.async { completion(.failure(ImageManagerError.invalidResponse)) }
return
}
DispatchQueue.main.async { completion(.success(image)) }
}
task.resume()
return task
}
}
Depending on the implementation there can be many things that will cause all of the answers here to not work (including mine). Checking the tag did not work for me, checking the cache neither, i have a custom Photo class that carries the full image, thumbnail and more data, so i have to take care of that too and not just prevent the image from being reused improperly. Since you will probably be assigning the images to the cell imageView after they're done downloading, you will need to cancel the download and reset anything you need on prepareForReuse()
Example if you're using something like SDWebImage
override func prepareForReuse() {
super.prepareForReuse()
self.imageView.sd_cancelCurrentImageLoad()
self.imageView = nil
//Stop or reset anything else that is needed here
}
If you have subclassed the imageview and handle the download yourself make sure you setup a way to cancel the download before the completion is called and call the cancel on prepareForReuse()
e.g.
imageView.cancelDownload()
You can cancel this from the UIViewController too. This on itself or combined with some of the answers will most likely solve this issue.
I solve the problem just implementing a custom UIImage class and I did a String condition as the code below:
let imageCache = NSCache<NSString, UIImage>()
class CustomImageView: UIImageView {
var imageUrlString: String?
func downloadImageFrom(withUrl urlString : String) {
imageUrlString = urlString
let url = URL(string: urlString)
self.image = nil
if let cachedImage = imageCache.object(forKey: urlString as NSString) {
self.image = cachedImage
return
}
URLSession.shared.dataTask(with: url!, completionHandler: { (data, response, error) in
if error != nil {
print(error!)
return
}
DispatchQueue.main.async {
if let image = UIImage(data: data!) {
imageCache.setObject(image, forKey: NSString(string: urlString))
if self.imageUrlString == urlString {
self.image = image
}
}
}
}).resume()
}
}
It works for me.
TableView reuses cells. Try this:
import UIKit
class CustomViewCell: UITableViewCell {
#IBOutlet weak var imageView: UIImageView!
private var task: URLSessionDataTask?
override func prepareForReuse() {
super.prepareForReuse()
task?.cancel()
imageView.image = nil
}
func configureWith(url string: String) {
guard let url = URL(string: string) else { return }
task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.imageView.image = image
}
}
}
task?.resume()
}
}
Because TableView reuses cells. In your cell class try this code:
class CustomViewCell: UITableViewCell {
#IBOutlet weak var catImageView: UIImageView!
private var task: URLSessionDataTask?
override func prepareForReuse() {
super.prepareForReuse()
task?.cancel()
catImageView.image = nil
}
func configureWith(url string: String) {
guard let url = URL(string: string) else { return }
task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.catImageView.image = image
}
}
}
task?.resume()
}
}
the Best Solution for This Problem i have is for Swift 3 or Swift 4
Simply write these two lines
cell.videoImage.image = nil
cell.thumbnailimage.setImageWith(imageurl!)
Swift 3
DispatchQueue.main.async(execute: {() -> Void in
if cell.tag == indexPath.row {
var tmpImage = UIImage(data: imgData)
thumbnailImageView.image = tmpImage
}
})
I created a new UIImage variable in my model and load the image/placeholder from there when creating a new model instance. It worked perfectly fine.
It is an example that using Kingfisher caching at memory and disk after downloaded.
It replace UrlSession downloading traditional and avoid re-download UIImageView after scroll down TableViewCell
https://gist.github.com/andreconghau/4c3b04205195f452800d2892e91a079a
Example Output
sucess
Image Size:
(460.0, 460.0)
Cache:
disk
Source:
network(Kingfisher.ImageResource(cacheKey: "https://avatars0.githubusercontent.com/u/5936?v=4", downloadURL: https://avatars0.githubusercontent.com/u/5936?v=4))
Original source:
network(Kingfisher.ImageResource(cacheKey: "https://avatars0.githubusercontent.com/u/5936?v=4", downloadURL: https://avatars0.githubusercontent.com/u/5936?v=4))
I am loading images into a UICollectionView with Swift. I am getting the images asynchronously. While the UI is scrolling and not locking up, there are still some problems. I am getting some repeats, and they are loading into the view fairly slow. I created a video to show exactly the problem:
https://www.youtube.com/watch?v=nQmAXpbrv1w&feature=youtu.be
My code takes mostly from the accepted answer to this question: Loading/Downloading image from URL on Swift.
Here is my exact code.
Below my UICollectionViewController class declaration, I declare a variable:
let API = APIGet()
In the viewDidLoad of the UICollectionViewController, I call:
override func viewDidLoad() {
API.getRequest()
API.delegate = self
}
In my APIGet class, I have the function, getRequest:
func getRequest() {
let string = "https://api.imgur.com/3/gallery/t/cats"
let url = NSURL(string: string)
let request = NSMutableURLRequest(URL: url!)
request.setValue("Client-ID \(cliendID)", forHTTPHeaderField: "Authorization")
let session = NSURLSession.sharedSession()
let tache = session.dataTaskWithRequest(request) { (data, response, error) -> Void in
if let antwort = response as? NSHTTPURLResponse {
let code = antwort.statusCode
print(code)
do {
let json = try NSJSONSerialization.JSONObjectWithData(data!, options: NSJSONReadingOptions.MutableContainers)
dispatch_async(dispatch_get_main_queue(), { () -> Void in
if let data = json["data"] as? NSDictionary {
if let items = data["items"] as? NSArray {
var x = 0
while x < items.count {
if let url2 = items[x]["link"] {
self.urls.append(url2! as! String)
}
x++
}
self.delegate?.retrievedUrls(self.urls)
}
}
})
}
catch {
}
}
}
tache.resume()
}
The above function does exactly what it should: It gets the JSON from the endpoint, and gets the URLs of the images from the JSON. Once all the URLs are retrieved, it passes them back to the UICollectionViewController.
Now, for my code in the UICOllectionViewController. This is the function that receives the URLs from the APIGet class.:
func retrievedUrls(x: [String]) {
//
self.URLs = x
var y = 0
while y < self.URLs.count {
if let checkedURL = NSURL(string: self.URLs[y]) {
z = y
downloadImage(checkedURL)
}
y++
}
}
Then, these 2 functions take care of the downloading of the image:
func getDataFromUrl(url:NSURL, completion: ((data: NSData?, response: NSURLResponse?, error: NSError? ) -> Void)) {
NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) in
completion(data: data, response: response, error: error)
}.resume()
}
func downloadImage(url: NSURL){
getDataFromUrl(url) { (data, response, error) in
dispatch_async(dispatch_get_main_queue()) { () -> Void in
guard let data = data where error == nil else { return }
if let image = UIImage(data: data) {
self.images.append(image)
self.collectionView?.reloadData()
}
}
}
}
When I call self.collectionView.reloadData() after the image is loaded, then every time a new image is loaded, the collectionView flashes and the cells shift around. Yet, if I don't call self.collectionView.reloadData() at all, then the collectionView never reloads after the images are loaded, and the cells remain empty. How can I get around this? I want the images to just load right into their respective cells, and not have the collectionView blink/flash or have cells shift around, or get temporary duplicates.
My cellForRowAtIndexPath is simply:
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("cell1", forIndexPath: indexPath) as! cell1
if indexPath.row > (images.count - 1) {
cell.image.backgroundColor = UIColor.grayColor()
}
else {
cell.image.image = images[indexPath.row]
}
return cell
}
This is my first foray into networking in iOS, so I would prefer not to default to a library (alamofire, AFNetowrking, SDWebImage, etc), so I can learn how to do it from the ground up. Thanks for reading all this!
EDIT: Here is my final code, taken mostly from the answer below:
func retrievedUrls(x: [String]) {
//
self.URLs = x
self.collectionView?.reloadData()
var y = 0
while y < self.URLs.count {
if let checkedURL = NSURL(string: self.URLs[y]) {
downloadImage(checkedURL, completion: { (image) -> Void in
})
}
y++
}
}
func getDataFromUrl(url:NSURL, completion: ((data: NSData?, response: NSURLResponse?, error: NSError? ) -> Void)) {
NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) in
completion(data: data, response: response, error: error)
}.resume()
}
func downloadImage(url: NSURL, completion: (UIImage) -> Void) {
getDataFromUrl(url) { (data, response, error) -> Void in
dispatch_async(dispatch_get_main_queue()) { () -> Void in
guard let data = data where error == nil else { return }
if let image = UIImage(data: data) {
self.images.append(image)
let index = self.images.indexOf(image)
let indexPath = NSIndexPath(forRow: index!, inSection: 0)
self.collectionView?.reloadItemsAtIndexPaths([indexPath])
}
}
}
}
My cellForItemAtIndexPath didn't change.
I'm not sure how familiar you are with closures and the like, but you are using them as part of NSURLSession so I'll assume a passing familiarity.
In your function downloadImage(_:) you could rewrite it instead to have the signature
func downloadImage(url: NSURL, completion: (UIImage) -> Void)
Which allows you to at-time-of-calling decide what else to do with that image. downloadImage would be modified as:
func downloadImage(url: NSURL, completion: (UIImage) -> Void) {
getDataFromUrl(url) { (data, response, error) -> Void in
dispatch_async(dispatch_get_main_queue()) { () -> Void in
guard let data = data where error == nil else { return }
if let image = UIImage(data: data) {
self.images.append(image)
completion(image)
}
}
}
}
All that we changed, is we pass our image into the newly defined completion block.
Now, why is this useful? retreivedUrls(_:) can be modified in one of two ways: either to assign the image to the cell directly, or to reload the cell at the corresponding index (whichever is easier; assigning directly is guaranteed not to cause flicker, though in my experience, reloading a single cell doesn't either).
func retrievedUrls(x: [String]) {
//
self.URLs = x
var y = 0
while y < self.URLs.count {
if let checkedURL = NSURL(string: self.URLs[y]) {
z = y
downloadImage(checkedURL, completion: { (image) -> Void in
// either assign directly:
let indexPath = NSIndexPath(forRow: y, inSection: 0)
let cell = collectionView.cellForItemAtIndexPath(indexPath) as? YourCellType
cell?.image.image = image
// or reload the index
let indexPath = NSIndexPath(forRow: y, inSection: 0)
collectionView?.reloadItemsAtIndexPaths([indexPath])
})
}
y++
}
}
Note that we use the completion block, which will only be called after the image has been downloaded and appended to the images array (same time that collectionView?.reloadData() used to be called).
However! Of note in your implementation, you are appending images to the array, but may not be maintaining the same ordering as your URLs! If some images are not downloaded in the exactly same order as that in which you pass in URLs (which is quite likely, since it's all async), they will get mixed up. I don't know what your application does, but presumably you will want images to be assigned to cells in the same order as the URLs you were given. My suggestion is to change your images array to be of type Array<UIImage?>, and filling it with the same amount of nils as you have URLs, and then modifying cellForItemAtIndexPath to check if the image is nil: if nil, assign the UIColor.grayColor(), but if it's non-nil, assign it to the cell's image. This way you maintain ordering. It's not a perfect solution, but it requires the least modification on your end.