Posted 9/18/2016.
An updated version of this tutorial for Swift 4 is available here.
Goal: Download and cache images asynchronously for use in a UICollectionView.
Downloading and caching images are common tasks in iOS development, especially when using collection and table views. In this tutorial, we're going to use the popular Swift networking library Alamofire and its companion image library AlamofireImage to build an app that displays images of Glacier National Park. The app is called "Glacier Scenics" and uses images from the National Park Service which can be found here.
The first part of the tutorial will cover asynchronously downloading and caching images. The second part will add an asynchronous decoding step that will further improve scrolling performance.
Here's what we'll be building:
The next section will go over the setup details of the project, data, and collection view. If you're just interested in the downloading and caching of images you can skip this section.
I'm using CocoaPods to pull the Alamofire and AlamofireImage dependencies. Here's this project's Podfile:
platform :ios, '9.0'
use_frameworks!
target 'GlacierScenics' do
pod 'Alamofire', '~> 4.0'
pod 'AlamofireImage', '~> 3.0'
end
The image names and URLs are taken from a property list, which is just an array of dictionaries with two keys: "name" and "imageURL".
The collection view controller is set up in Storyboard using the basic template embedded in a navigation controller. The only changes were setting the minimum spacing in the default flow layout to 1 for both cells and lines and setting the class to PhotosCollectionViewController (described below).
The collection view cell uses a separate Xib file with the following views:
The first step is to create our model, which in this case is a simple struct called Photo. We'll be reading from a property list that contains an array of dictionaries, so we'll give Photo an initializer that takes a dictionary.
struct Photo {
let name: String
let url: String
init(info: [String: Any]) {
self.name = info["name"] as! String
self.url = info["imageURL"] as! String
}
}
Next we create a manager class to read the property list and store the photo information.
import UIKit
class PhotosManager {
static let shared = PhotosManager()
private var dataPath: String {
return Bundle.main.path(forResource: "GlacierScenics", ofType: "plist")!
}
lazy var photos: [Photo] = {
var photos = [Photo]()
guard let data = NSArray(contentsOfFile: self.dataPath) as? [[String: Any]] else { return photos }
for info in data {
let photo = Photo(info: info)
photos.append(photo)
}
return photos
}()
}
Note that the array of photos is a lazy var so we only read from the plist once.
Next let's look at our collection view controller code.
import UIKit
private let photoCellIdentifier = "PhotoCell"
class PhotosCollectionViewController: UICollectionViewController {
var photosManager: PhotosManager { return .shared }
override var prefersStatusBarHidden: Bool {
return true
}
//MARK: - View Controller Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
registerCollectionViewCells()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
collectionView?.collectionViewLayout.invalidateLayout()
}
//MARK: - Collection View Setup
func registerCollectionViewCells() {
let nib = UINib(nibName: "PhotoCollectionViewCell", bundle: nil)
collectionView?.register(nib, forCellWithReuseIdentifier: photoCellIdentifier)
}
// MARK: - UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return photosManager.photos.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: photoCellIdentifier, for: indexPath) as! PhotoCollectionViewCell
cell.configure(with: photo(at: indexPath))
return cell
}
func photo(at indexPath: IndexPath) -> Photo {
let photos = photosManager.photos
return photos[indexPath.row]
}
}
This is all pretty straightforward setup for a collection view and data source. One thing to note is that we're supporting landscape orientations, so in "viewWillTransitionToSize" we invalidate the collection view layout.
Speaking of the collection view layout, let's add an extension here for our implementation of UICollectionViewDelegateFlowLayout.
extension PhotosCollectionViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let viewSize = view.bounds.size
let spacing: CGFloat = 0.5
let width = (viewSize.width / 2) - spacing
let height = (viewSize.width / 3) - spacing
return CGSize(width: width, height: height)
}
}
The sizing here gives us two collection view cells that span the width of the screen in either portrait or landscape orientation.
Now that we have our basic collection view setup, it's time to configure the cells and asynchronously download the images. We'll do that in the collection view cell subclass.
import UIKit
import Alamofire
class PhotoCollectionViewCell: UICollectionViewCell {
var photosManager: PhotosManager { return .shared }
@IBOutlet var imageView: UIImageView!
@IBOutlet var captionLabel: UILabel!
@IBOutlet var loadingIndicator: UIActivityIndicatorView!
var request: Request?
var photo: Photo!
func configure(with photo: Photo) {
self.photo = photo
reset()
loadImage()
}
func reset() {
imageView.image = nil
request?.cancel()
captionLabel.isHidden = true
}
func loadImage() {
loadingIndicator.startAnimating()
request = photosManager.retrieveImage(for: photo.url) { image in
self.populate(with: image)
}
}
func populate(with image: UIImage) {
loadingIndicator.stopAnimating()
imageView.image = image
captionLabel.text = photo.name
captionLabel.isHidden = false
}
}
First thing to note here is that we have a "request" variable. Since collection view cells are reused, we need to make sure we are loading the correct image for each cell as we scroll. If the request is still in-flight when the cell is reused, the cell could be populated with the wrong image when the request returns. Before we load the image then, we need to reset the cell by setting the cell's current image to nil, canceling an in-flight request if one exists, and hiding the caption label while we load the new image.
This class makes a call on our shared photos manager to "retrieveImage", which we haven't defined yet. This function will use Alamofire to actually download the image. So let's add that now in PhotosManager below our "photos" variable declaration.
//MARK: - Image Downloading
func retrieveImage(for url: String, completion: @escaping (UIImage) -> Void) -> Request {
return Alamofire.request(url, method: .get).responseImage { response in
guard let image = response.result.value else { return }
completion(image)
}
}
You'll also need to import the Alamofire libraries at the top of the file.
import Alamofire
import AlamofireImage
Here we use AlamofireImage's convenient image response serializer and call the completion block with the returned image if it exists. Now look back at where we call "retrieveImage" in our collection view cell's "loadImage" function. Notice that we store the returned request so that it can be canceled later in "reset" if necessary. In the completion block, we populate the cell by setting the image and caption text.
NOTE: The National Park Service website is not using HTTPS, which App Transport Security enforces. For this project, I enabled "Allow Arbitrary Loads" in the Info.plist "App Transport Security Settings". If you're following along with sample image URLs you may need to do this, but it is not a good practice to enable this in a production app.
With our current implementation, a network request is made for each cell whenever we scroll the collection view and "cellForItemAt:" is called. To avoid this, we'll use AlamofireImage to implement caching. AlamofireImage has a class called AutoPurgingImageCache, which allows us to set a maximum cache size as well as a preferred size to cut down to when the maximum is reached. Let's add in this caching support to our PhotosManager class.
First we'll add an "imageCache" property above the "Image Downloading" section.
let imageCache = AutoPurgingImageCache(
memoryCapacity: UInt64(100).megabytes(),
preferredMemoryUsageAfterPurge: UInt64(60).megabytes()
)
Here I'm also using a simple extension on UInt64 that converts megabytes to bytes. You can place this at the top of the file above the class declaration for PhotosManager.
extension UInt64 {
func megabytes() -> UInt64 {
return self * 1024 * 1024
}
}
The cache is set to have a maximum capacity of 100MB and a preferred memory usage once the limit is reached of 60MB. Next let's update our "retrieveImage" function and add the caching functions.
func retrieveImage(for url: String, completion: @escaping (UIImage) -> Void) -> Request {
return Alamofire.request(url, method: .get).responseImage { response in
guard let image = response.result.value else { return }
completion(image)
self.cache(image, for: url)
}
}
//MARK: = Image Caching
func cache(_ image: Image, for url: String) {
imageCache.add(image, withIdentifier: url)
}
func cachedImage(for url: String) -> Image? {
return imageCache.image(withIdentifier: url)
}
For the cache identifier we're using the image URL, and we cache the image in the "retrieveImage" function when the request returns. Now in our collection view cell, we just need to check if we have a cached image before downloading one. Replace "loadImage" with the following:
func loadImage() {
if let image = photosManager.cachedImage(for: photo.url) {
populate(with: image)
return
}
downloadImage()
}
func downloadImage() {
loadingIndicator.startAnimating()
request = photosManager.retrieveImage(for: photo.url) { image in
self.populate(with: image)
}
}
We've changed our "loadImage" function to first check if the image has already been cached. If it has, we use it to populate the cell. If it hasn't, we call a new function called "downloadImage" to get the image from the network as we did before.
As you can see, Alamofire and AlamofireImage make it really easy to implement asynchronous downloading and caching of images, and the libraries have a lot of additional functionality for downloading and working with images. However, UIImage by default waits until the last minute to decode images before displaying them, and it does the decoding synchronously. This can cause performance issues when scrolling, especially with large images like we have in this project. Part 2 of this tutorial will focus on fixing that problem by decoding images asynchronously.
The source code for this project is available in the "GlacierScenicsAlamofire" folder here.