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!
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
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.
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
}
.resume()
on the data taskNext, 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.
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!
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:
curentDownloads
dictonary to see if it can find any matches with the same original url string.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.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.
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?
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:
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:
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 thenindexPath
property. Once we have the cell, we can assign our new image and hide the circular progress view.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 🌈
So this mostly works, but there are still a few issues. The first issue is:
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
}
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!