iOS Tutorial--Creating a Circular Loading Animation in a UITableViewCell

1216 Views

In a slight deviation form the norm (Vapor tutorials), today I'll be doing some iOS! In this tutorial I'll go over how to create a circular loading view in a UITableViewCell. There are a few tutorials or libraries out there for how to implement a circular loading animation, but none were quite exctly what I was looking for (in the context of a tableview, where cell reuse can pose additional problems), hence this tutorial :) Below is an example of what we'll be recreating:

So, let's get started!

Download the starter project

First, download the starter project from GitHub here.

You can look through the project, but it's quite bare bones. Most files are completely empty at this point. I have just simply setup a tableview that loads and displays photos.

Also, I have gotten rid of the storyboard, and everything is done in code--no xibs either--pure code, as it was meant to be :D

ImageDownloader

Let's start with our ImageDownlader. This is the class that will manage our URLSession object. It will also keep track of all current downloads in progress, and all downloads that have finished as well. Add the following properties to the ImageDownloader class:

var session: URLSession!
var currentDownloads = [String: ImageDownload]()
var finishedDownloads = [String: UIImage]()

The currentDownloads will be a dictionary that keeps track of our current downlads, and the finishedDownloads will be used to store our images after they're already downloaded, for quick loading on cell reuse.

ImageDownload

Next, let's go to the ImageDownload class. This class represents a download object--we will need to create 1 instance of this for each photo we download.

It will be responsible for managing the data task that actually fetches the data, as well as reporting back the current progress to the cell.

var task: URLSessionDownloadTask?
var url: String
var progress: Float
var indexPath: IndexPath

init(url: String, indexPath: IndexPath) {
    self.url = url
    self.indexPath = indexPath
    progress = 0
}

We need to keep track of the index path, because as you will shortly see, we will need a way to access the cell from our URLSessionDelegate method. There are a number of ways you could do this, but for the sake of simplicity, we'll just pass in the indexPath for now.

Now, switching back to our ImageDownloader class, add the following method in order to start a download:

func downloadImage(with urlString: String, withIndexPath indexPath: IndexPath) {
    guard let url = URL(string: urlString) else { return } 
    let download = ImageDownload(url: urlString, indexPath: indexPath) // 1
    download.task = session.downloadTask(with: url) // 2
    download.task?.resume() // 3
    currentDownloads[urlString] = download // 4
}
  • At 1 we simply initialize a new download object and pass in the url as a string.
  • At 2 we create a new download task with the url object
  • At 3 we actually make the request by calling .resume() on the data task
  • And here at 4 we create a new key value pair in our dictionary of current downloads.

ViewController

Next, let's go to the ViewController. At line 15 we have already initialized our ImageDownloader object. So now, we need to create a URLSession to attach to our object. In viewDidLoad, add the following:

imageDownloader.session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: nil)

Here, we are initializing a URLSession object and assigning it to be the session property on our imageDownloader object. We also set the delegate to be our ViewController, as we need to get callbacks on the current progress of our photos' downloads.

URLSessionDownloadDelegate

In order to get the current progress of our downloads, we will need to implement some delegate methods. One method will tell us the total amount of bytes of data downloaded up to this point, along with the total amount of bytes it expects to receive. The other will tell us when the file has finished downloading and let us know which download task finished, and which temporary url the data was downloaded to.

So, in ViewController+SessionDelegate.swift, add the following code:

extension ViewController: URLSessionDownloadDelegate {
    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {

    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        print("total progress: bytes written is:\(totalBytesWritten) and expected is: \(totalBytesExpectedToWrite)"))
    }
}

At this point, if you build and run, you still won't notice any difference. That's becaue we haven't actually changed the process of how we load the image in the cell. So, let's do that next!

ImageCell

In ImageCell.swift, change the configure method to read the following:

func configure(with downloader: ImageDownloader, andUrl url: String, forIndexPath indexPath: IndexPath) {
    downloader.downloadImage(with: url, withIndexPath: indexPath)
}
Now, build and run. you should see something printing in the console. That's the delegate, it's working!

Next, in ViewController+SessionDelegate.swift, replace the content of the ...didWriteData bytesWritten: Int64, totalBytesWritten: Int64... delegate method to read the following:

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
    guard let originalImageUrl = downloadTask.originalRequest?.url?.absoluteString else { return } // 1
    if let imageDownload = imageDownloader.currentDownloads[originalImageUrl] { // 2
        let totalProgress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) // 3
        imageDownload.progress = totalProgress
        DispatchQueue.main.async {
            if let cell = self.tableView.cellForRow(at: imageDownload.indexPath) as? ImageCell { // 4
                // need to update cell progress here
            }
        }
    }
}

So the logic is like this: when this delegate method is called, it can be called for any of our URLSessionDataTasks--the data it's giving us could be the progress of any of our current downloads. So, our job is to use the downloadTask object the delegate method gives us, and find which cell in our table view this data belongs to. So, with that in mind:

  • At 1, we retrieve the original photo url associated with this download task
  • At 2, we search our curentDownloadsdictonary to see if it can find any matches with the same original url string.
  • Once we are at 3, we are sure that we've found our correct imageDownload object, and we can just divide the supplied data to get our current progress
  • At 4, we use the indexPath property of the imageDownload object to find exactly which cell this download belongs to. Once we have this cell, we can pass the progress data back to it.

The Circular Progress View

This is the most exciting part! And although it looks cool and possibly complicated, it actually isn't. We will simply animate a CAShapeLayer and give it our current progress as its strokeEnd property.

First, add the following variable declarations in CircularProgressView:

 let progressCircleLayer = CAShapeLayer()

 var circlePath: UIBezierPath {
    return UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 150, height: 150).insetBy(dx: lineWidth / 2, dy: lineWidth / 2))
}

var lineWidth: CGFloat { return 5 }

So our strategy with this is we want 2 circles: one grey background circle that we won't animate, and a red cirlce that will animate and "fill in" the grey one as more data comes in.

First, let's simply draw the grey background circle. Implement the drawRect method, with the following implementation:

override func draw(_ rect: CGRect) {
    super.draw(rect)
    let greyPath = UIBezierPath(ovalIn: circlePath.bounds)
    UIColor.lightGray.setStroke()
    greyPath.lineWidth = lineWidth

    greyPath.stroke()
}

Here we simple are drawing a grey circle and setting its lineWidth property.

Next, add the following method for creating the red circle:

fileprivate func setupRedCirlce() {
    progressCircleLayer.path = circlePath.cgPath // 1
    layer.addSublayer(progressCircleLayer)

    progressCircleLayer.strokeColor = UIColor.red.cgColor
    progressCircleLayer.fillColor = UIColor.clear.cgColor
    progressCircleLayer.strokeEnd = 0 // 2
    progressCircleLayer.lineCap = kCALineCapRound
    progressCircleLayer.lineWidth = lineWidth
}

You cannot animate the drawing of a UIBezier path, so for our animation we'll be using a CAShapeLayer. CAShapeLayers have already been extensively written about (this article is a great resource), so I won't go over too much here. But the important thing to remember is that a CAShapeLayer takes a cgPath object (like you see at 1), and this path defines its shape. Once defined, you can set the shape's strokeEnd property--which is exactly what we're doing at 2. The strokeEnd property is a value between 0 and 1.0 that dictates how much of the layer's path we're going to draw. Since this is the initialization, we are setting the strokeEnd to 0, since nothing has loaded yet when it's initialized. We then set its color properties as well, and make the end be round to make it look a little nicer.

Next, add the following method to the CircularProgressView class. This method is for setting the progress value:

func setProgress(with newValue: Float) {
    progressCircleLayer.strokeEnd = CGFloat(newValue) // 1
}

Here at 1, we're just setting the strokeEnd value to the value that the URLSessionDelegate will pass back to us. As this increases, it will look like it's animating on screen.

And lastly, add the following to the init method, underneath the super.init call:

setupRedCirlce()
backgroundColor = UIColor.clear

Build and run! And you'll still see..nothing! That's because we haven't added the circlular progress view to our cell's contentView yet.

Add the Progress View

In ImageCell.swift, add the following to the bottom of the layout function:

circularProgress.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(circularProgress)
circularProgress.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true
circularProgress.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
circularProgress.heightAnchor.constraint(equalToConstant: 150).isActive = true
circularProgress.widthAnchor.constraint(equalToConstant: 150).isActive = true

This is simply adding the circular progress view as a subview and setting up a few constraints. Then, add the following function to your cell:

func updateProgressBar(with progress: Float) {
    circularProgress.setProgress(with: progress)
}

This method will be called from the delegate method when we get our progress update. We'll get the progresss from the delegate, and then in the cell set the progress to the new value. Then the circularProgress will set its strokeEnd value to this new value.

So now in ViewController+SessionDelegate.swift, replace the commented out line that says // need to update cell progress here with:

cell.updateProgressBar(with: totalProgress)

Build and run, and it should be working! But, where are the images?

Displaying the Images

To display the images, we'll need to make use of the ...didFinishDownloadingTo location: URL)... delegate method. In this method, we will need to take the newly downloaded data, convert that to an image, and then find the cell that this image belongs to and assign this image to its postImageView property. Our strategey is like this:

  • First, get the data from the temporary URL
  • Get the original url string from the downloadTask provided by the delegate method. This will be the photo's url
  • Convert the data from the URL to a UIImage
  • Given the url string, find the original cell which this image belongs to
  • Assign the image to the cell's postImageViewProperty
  • Hide the progress view once loading is done

So in code, those steps would be:

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
    do {
        let data = try Data(contentsOf: location) // 1
        guard let originalUrl = downloadTask.originalRequest?.url?.absoluteString else { return } // 2
        DispatchQueue.main.async {
            guard let image = UIImage(data: data) else { return }
            if let imageDownload =  self.imageDownloader.currentDownloads[originalUrl], let cell = self.tableView.cellForRow(at: imageDownload.indexPath) as? ImageCell { // 3
                cell.postImageView.image = image
                cell.circularProgress.alpha = 0
            }
            self.imageDownloader.finishedDownloads[originalUrl] = image // 4
        }
    } catch { print("could not convert to data from url") }
}

This is fairly straightforward, A quick run-through:

  • At 1, we take the location object (a file URL) and we convert it to data. We will use this data to convert it to an image soon. It's important that we do this first, because the location url is temporary, and its contents are deleted usually less than 1 second after it's downloaded. If you try to get the data from the URL in the Dispatch.main.async {} closure, you will probably get nil (works ~50% of the time), as the url and its data will have already been deleted by the system by then
  • At 2, we use the same technique as the other delegate method to retrieve the original url string associated with this downloadTask. Once we have the original url string, we can then find its corresponding imageDownload object in 3
  • 3 Here we find the imageDownload object, and get the cell from its indexPath property. Once we have the cell, we can assign our new image and hide the circular progress view.
  • 4 We add this download to our finished downloads

Finally, in your ImageCell class, in prepareForReuse, add the following line:

circularProgress.alpha = 1

This will reset our circular progress view's alpha back to 1 as we recycle the cell to be reused.

Build and run. It works! It's a miracle 🌈

Finishing Touches

So this mostly works, but there are still a few issues. The first issue is:

  • right now we are making a network call to redownload the image every time cellForRowAtIndexPath is called, which isn't good.
  • The second issue is that when we are scrolling, when a cell gets reused, we aren't setting the current progress value of that cell. It could be that the first photo that used this cell has its download finished, but the second photo that is now reusing this cell does not have its download finished.

We have a finishedDownloads dictionary, so let's use it to retrieve the images that have already been downloaded. First, add the following helper methods to the ImageDownloader class:

func downloadStarted(for url: String) -> Bool {
    return currentDownloads.keys.contains(url)
}

func downloadFinished(for url: String) -> Bool {
    return finishedDownloads.keys.contains(url)
}

We will use these in our cell to check for the current status of our download. Then, in our ImageCell, replace the configure method with the following implementation:

func configure(with downloader: ImageDownloader, andUrl url: String, forIndexPath indexPath: IndexPath) {
    if downloader.downloadFinished(for: url) {
        if let image = downloader.finishedDownloads[url] { setImageView(with: image) } // 1
        circularProgress.alpha = 0
    } else if downloader.downloadStarted(for: url) { // 2
        if let currentProgress = downloader.currentDownloads[url]?.progress {
            circularProgress.setProgress(with: currentProgress)
        }
    }
    else { downloader.downloadImage(with: url, withIndexPath: indexPath)  } // 3
}
  • At 1, we first check if the download is finished. If it is, then we get the image from the finishedDownloads dictionary, and just assign that image to the image view, and then hide the circular progress view.
  • At 2, we then check if the download has already started. If it has, then we find the progress from the currentDownloads dictionary, and set the circularProgress to that.
  • Finally at 3, if the download has neither started nor finished, we start a new download process.

So essentially here we are using the finishedDownloads array as our temporary cache. In a real world application, it would probably be preferrable to implement some sort of mechanism for clearing this cache once it has a certain number of elements (this could be done in didReceiveMemoryWarning method in your view controller), but that is out of the scope of this tutorial.

And that's it! Hopefully this tutorial was a good exercise with both using URLSession delegate methods to monitor download process, as well as implementing a nifty animation to show your users. And no third party libraries, stroryboards, or xibs needed :) You can find the completed version of this project on GitHub here Leave any questions/comments below, thanks for reading!



Enjoy this article? Consider supporting VaporForums by following us on Twitter! Get the latest Vapor articles, tutorials, and news.