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)
}
}
}
Related
I'm trying to download images from firebase storage to display in my collectionview cells, but the images keep appearing in random order in the cells. The cells each have a label that is retrieved from firebase storage (item1, item2 etc) which displays nicely in the correct cell every time. The images stored in firebase storage each have their storage url as a child to their respective item name in the firebase database.
I'm sucesfully able to retrieve each image url, and download all the images and display them in the cells correctly, it's just that they keep appearing in randomized order every time I open the app, so the image does not correspond with the item name label.
I realize i need to asyncronously download the images, so each image finishes loading in the correct cell before continuing to the next, but I'm having trouble doing so. Heres my code so far:
func downloadImg(completion: #escaping (UIImage?) -> ()) {
let ref = Database.database().reference().child("somePath")
ref.observeSingleEvent(of: .value) { (snapshot) in
for item in snapshot.children {
let snap = item as! DataSnapshot
let imageSnap = snap.childSnapshot(forPath: "img/storageUrl")
if let url = imageSnap.value as? String {
let someRef = self.storageRef.reference(forURL: url)
someRef.getData(maxSize: 10 * 10024 * 10024) { data, error in
if let error = error {
print(error)
} else {
let image = UIImage(data: data!)
DispatchQueue.main.async {
completion(image)
}
}
}
}
}
}
}
Then I call my function in the viewdidload:
downloadImg { (completion) in
self.itemPicArray.append(completion!)
self.collectionView?.reloadData()
}
Finally i set my cell imageview to itemPicArray[indexPath.row]
Like I said, this works perfectly except the fact that the images keep showing up randomly. Help very much appreciated!
Your problem is that everytime an image comes in, you reload the entire collection view. Depending on the sizes of the images and the state of the network, the images will come in in a different order almost every time.
Consider downloading all of the images first and then reloading the collection view once. If there are a lot of images, consider paginating your results. You can enumerate the loop and sort the data source array by this original order. I've added a custom data object to help with that.
class CustomObject {
var image: UIImage?
let n: Int
init(image: UIImage?, n: Int) {
self.image = image
self.n = n
}
}
let dispatch = DispatchGroup()
for (n, item) in snapshot.children.enumerated() {
let object = CustomObject(image: nil, n: n) // init custom object with n (image is still nil)
dispatch.enter() // enter dispatch
someRef.getData(maxSize: 10 * 10024 * 10024) { data, error in // download image
if let error = error {
print(error)
} else {
let image = UIImage(data: data!)
object.image = image // inject custom object with image
itemPicArray.append(object) // append to array
}
dispatch.leave() // leave dispatch
}
}
dispatch.notify(queue: .global()) { // dispatch completion
itemPicArray.sort { $0.n < $1.n } // sort by n (original download order)
collectionView.reloadData() // reload collection view
}
Using a model could be a good idea.
struct Image {
var imageName: String
var image: UIImage
}
This way, no matter the order, the item name (image name) and the image will be paired.
Perhaps a better solution now is to configure method downloadImg so that it takes the imageName as a parameter. Then you can call the correct node to get the corresponding storageURL.
func downloadImg(imageName: String, completion: #escaping (Image?) -> ()) {
// Use the parameter to create your database reference
let ref = Database.database().reference().child(imageName)
ref.observeSingleEvent(of: .value) { (snapshot) in
for item in snapshot.children {
let snap = item as! DataSnapshot
let imageSnap = snap.childSnapshot(forPath: "img/storageUrl")
if let url = imageSnap.value as? String {
let someRef = self.storageRef.reference(forURL: url)
someRef.getData(maxSize: 10 * 10024 * 10024) { data, error in
if let error = error {
print(error)
return
}
if let image = UIImage(data: data) {
// Create a variable of type Image (your custom model)
let imageWithName = Image(imageName: imageName, image: image)
completion(imageWithName)
}
}
}
}
}
}
Calling and handling could be done like so:
// Create a variable to hold your item name/image-pairs
var imagesWithNames = [Image]()
let dispatchGroup = DispatchGroup()
// Iterate over your array of item names
for item in itemArray {
dispatchGroup.enter()
downloadImg(item) { (imageWithName) in
self.imagesWithNames.append(imageWithName)
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) { {
self.collectionView?.reloadData()
}
And to populate the collectionView you can go:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! YourCustomCell
// Get the pair at the given index
let imageWithName = self.imagesWithNames[indexPath.row]
DispatchQueue.main.async {
// Set image and item label (example below)
self.yourImageView.image = imageWithName.image
self.yourItemLabel.text = imageWithName.imageName
}
return cell
}
If anyone is having issues with this in the future, use -bsod answer and create a custom object. Also create a variable var counter: Int = 0. Here's how my code looks like Swift 5.X and it works perfectly.
class CustomObject {
var image: UIImage?
let n: Int
init(image: UIImage?, n: Int) {
self.image = image
self.n = n
}
}
func reloadStuff() {
dispatch.notify(queue: .main) {
self.imageArrayCells.sort { $0.n < $1.n }
self.contentViewProfile.collectionView.refreshControl?.endRefreshing()
self.contentViewProfile.collectionView.reloadData()
}
}
for i in 0 ..< self.imageArrayInfo.count {
let object = CustomObject(image: nil, n: i)
let urlString = self.imageArrayInfo[i]
print(object.n)
let url = URL(string: urlString)
self.dispatch.enter()
let task = URLSession.shared.dataTask(with: url!) { (data, response, error) in
self.dispatch.enter()
guard let data = data, error == nil else {
return
}
guard let image = UIImage(data: data) else {
return
}
object.image = image
self.imageArrayCells.append(object)
self.dispatch.leave()
self.counter += 1
if self.counter == self.imageArrayInfo.count {
for i in 0 ..< self.imageArrayInfo.count {
self.dispatch.leave()
}
self.counter = 0
}
}
task.resume()
}
self.reloadStuff()
Here's what I'm calling in collectionView(cellForItemAt:_)
cell.imageInProfileCollection.image = imageArrayCells[indexPath.row].image
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 have tried several different approaches and nothing has yet to work. I am pulling in album artwork for a recently played tableview for my radio station app. I get blank images when there is no album artwork to pull into the cell. I just want to have my station logo "WhiteLogo.png" as a placeholder whenever there is no album artwork pulled into the tableview cell. Any help in the right direction is much appreciated. Thanks
import UIKit
//----------
//MARK: JSON
//----------
//The Initial Response From The JSON
struct Response: Codable {
var playHistory: Album
}
//The Album Received Which Is An Array Of Song Data
struct Album: Codable {
var song: [SongData]
}
//The SongData From The PlayHistory Album
struct SongData: Codable{
var album: String
var artist: String
var cover: String
var duration: String
var programStartTS: String
var title: String
}
class TableViewController: UITableViewController {
//1. Create An Array To Store The SongData
var songs = [SongData]()
var currentStation: RadioStation!
var downloadTask: URLSessionDownloadTask?
override func viewDidLoad() { super.viewDidLoad()
self.tableView.delegate = self
self.tableView.dataSource = self
//2. Load The JSON From The Main Bundle
guard let urlText = URL (string: "http://streamdb3web.securenetsystems.net/player_status_update/JACKSON1_history.txt")
else { return }
do{
//a. Get The Data From The From The File
let data = try Data(contentsOf: urlText)
//b. Decode The Data To Our Structs
let albumData = try JSONDecoder().decode(Response.self, from: data)
//c. Append The Songs Array With The PlayHistory
albumData.playHistory.song.forEach { songs.append($0) }
//d. Test Some Data
print("""
**The First Album Details**
Album = \(songs[0].album)
Artist = \(songs[0].artist)
Cover = \(songs[0].cover)
Duration = \(songs[0].duration)
Start = \(songs[0].programStartTS)
Title = \(songs[0].title)
""")
//3. Load The Data
DispatchQueue.main.async {
self.tableView.reloadData()
}
}catch{
print(error)
}
}
//-----------------
//MARK: UITableView
//-----------------
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return songs.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//1. Create A Cell
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! TableViewCell
//2. Set It's Text
cell.songTitle.text = songs[indexPath.row].title
cell.artistLabel.text = songs[indexPath.row].artist
//3. Get The Image
if let imageURL = URL(string: songs[indexPath.row].cover){
let request = URLSession.shared.dataTask(with: imageURL) { (imageData, response, error) in
if let error = error{
print(error)
}else{
guard let image = imageData else { return }
DispatchQueue.main.async {
cell.songCover.image = UIImage(data: image)
cell.setNeedsLayout()
cell.layoutIfNeeded()
}
}
}
request.resume()
}
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
print("""
**Album \(indexPath.row) Selected**
Album = \(songs[indexPath.row].album)
Artist = \(songs[indexPath.row].artist)
Cover = \(songs[indexPath.row].cover)
Duration = \(songs[indexPath.row].duration)
Start = \(songs[indexPath.row].programStartTS)
Title = \(songs[indexPath.row].title)
""")
}
}
Just the right case handling is required.
I would set the placeholder image first and then proceed to download an image from a URL.
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//...
/*
Start with placeholder image so it shows until the image download completes.
And if the next `if let imageURL` condition fails, the placeholder image will stay
*/
cell.songCover.image = UIImage(named: "WhiteLogo")
//Continue with your logic, no change really but could be shortened to:
if let imageURL = URL(string: songs[indexPath.row].cover) {
let request = URLSession.shared.dataTask(with: imageURL) { (imageData, response, error) in
guard let imageData = imageData else { return }
DispatchQueue.main.async {
cell.songCover.image = UIImage(data: imageData)
}
}
request.resume()
}
//...
}
However, since the image download logic is async, it will misbehave if the cell is reused before the download completes.
i.e. Image download for the first song starts but you scroll fast enough to reuse the first cell for, lets say, the third song.
Now, when the download completes, the first image could show on the third cell.
If you face this issue then let me know and I shall update my answer.
Set "WhiteLogo.png" on above your code which download image for album or set logo image if album image data is nil like guard let image = imageData else { var image : UIImage = UIImage(named:"WhiteLogo.png")!
cell.songCover.image = UIImageView(image: image) }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//1. Create A Cell
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! TableViewCell
//2. Set It's Text
cell.songTitle.text = songs[indexPath.row].title
cell.artistLabel.text = songs[indexPath.row].artist
//set image
var image : UIImage = UIImage(named:"WhiteLogo.png")!
cell.songCover.image = UIImageView(image: image)
//3. Get The Image
if let imageURL = URL(string: songs[indexPath.row].cover){
let request = URLSession.shared.dataTask(with: imageURL) { (imageData, response, error) in
if let error = error{
print(error)
}else{
guard let image = imageData else { return }
DispatchQueue.main.async {
cell.songCover.image = UIImage(data: image)
cell.setNeedsLayout()
cell.layoutIfNeeded()
}
}
}
request.resume()
}
return cell
}
guard let image = imageData else { cell.songCover.image = UIImage(named : "your_image_name"); return }
Please use the Kingfisher library it will download image from url and set placeholder image.Library URL:- https://github.com/onevcat/Kingfisher
I have a TableView and I am filling it with data retrieved from database. Everything works fine except the images. Because of the cell reuse behaviour, and I am fetching image in cellForRowAtIndexPath. I chose to fetch images in cellForRowAtIndexPath because in the details retrieving function (which is triggered in viewDidLoad), I need to do another request, which is causing other problems (reloading tableview before storing image url)
The problem is that when I scroll fast, the resuable cells bugs while displaying user images
override func viewDidLoad() {
super.viewDidLoad()
fetchData()
}
var theUser =
func fetchData() {
//.. after data is retrieved
var innerDict = [String:String]()
if let user = details.value![key] {
if let name = user["name"] {
// works
innerDict["name"] = name
}
if let image = user["imageName"] {
// gets the image name but at this point I need to;
// a) retrieve the url here (with another call), which will eventually fail
// to catch up with `innerDict` so `innerDict` won't contain `image` variable.
// ie;
myRef.downloadURLWithCompletion { (URL, error) -> Void in }
// b) Store the `image` name in innerDict and download image from url
// in `cellForRowAtIndexPath`. I chose this method:
innerDict["image"] = image
}
user[id] = innerDict
tableView.reloadData()
}
Now the tableView as usual.
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = ...
// more stuff
if let imageName = user["image"] {
let storage = FIRStorage.storage()
let storageRef = storage.referenceForURL("gs://bucket.com").child(user[id]).child(imageName)
storageRef.downloadURLWithCompletion { (URL, error) -> Void in
if (error != nil) {
// handle
} else {
// I thought using Haneke would help to cache the image
cell.image.hnk_setImage(URL!)
}
}
}
This is the closest one I could reach. However images bug on displaying when I scroll fast.
Edit:
I also tried using this approach but it's downloading the same image multiple times with this method, so it takes time for the same images to displayed.
islandRef.dataWithMaxSize(1 * 1024 * 1024) { (data, error) -> Void in
if (error != nil) {
// Uh-oh, an error occurred!
} else {
let image: UIImage! = UIImage(data: data!)
cell.userImage.hnk_setImage(image, key: "\(userID)")
}
}
However, with top approach the speed was very fast. The only problem of the above code was the glitch when I scroll fast.
Edit 2
var images = [UIImage]()
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("ItemCell", forIndexPath: indexPath) as! ItemDetailTableViewCell
let item = items[indexPath.section][indexPath.row]
if let uid = item["owner"] as? String {
if let user = users[uid] {
if let imageName = user["image"] {
if let img: UIImage = images[indexPath.row] { // crash here "fatal error: Index out of range"
cell.userImage.image = img
}
} else {
let storage = FIRStorage.storage()
let storageRef = storage.referenceForURL("gs://bucket").child(uid).child(imageName)
storageRef.downloadURLWithCompletion { (URL, error) -> Void in
if (error != nil) {
} else {
dispatch_async(dispatch_get_main_queue(), {
cell.userImage.hnk_setImageFromURL(URL!)
self.images[indexPath.row] = cell.image.image!
})
}
}
}
}
}
I think you should save images from url and show images when the cell is going to reuse, hopefully this will fix your glitch
var myImages = [String: UIImage]()
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("ItemCell", forIndexPath: indexPath) as! ItemDetailTableViewCell
let item = items[indexPath.section][indexPath.row]
if let img: UIImage = myImages["\(indexPath.section)\(indexPath.row)"] {
cell.image.image = img
} else {
if let uid = item["owner"] as? String {
if let imageName = user["image"] {
let storage = FIRStorage.storage()
let storageRef = storage.referenceForURL("gs://bucket.com").child(user[id]).child(imageName)
storageRef.downloadURLWithCompletion { (URL, error) -> Void in
if (error != nil) {
cell.image = UIImage(named: "placeholder") // put default Image when failed to download Image
} else {
dispatch_async(dispatch_get_main_queue(), {
cell.image.hnk_setImage(URL!)
// Store the image in to our cache
self.myImages["\(indexPath.section)\(indexPath.row)"]= cell.image.image
})
}
}
}
}
}
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))