Building an image gallery app

I’ve just committed a project on GitHub for an app I’d like to make for myself. The idea is to show a hierarchy of images, organised in folders, via a table view for a quick preview and then in a scroll view once I select a particular folder.

https://github.com/gualtierofrigerio/FolderImageViewer

The idea is to show pictures and folders into the table view, then if you select an image it opens a new view controller with a UIScrollView showing all the images in that folder so you can see one, pinch to zoom and eventually mark it as favourite and share it.

I’d say filling the table view is the least interesting part of the project, sure I’ll make it fancier and I could implement a collection view instead, maybe it is more suited for an image gallery, but in this article I’d like to focus on the data source and how to cache images in order to display them on the table view and on the scroll view. If the folder contains 100 pictures you don’t want to instantiate 100 UIImageView and put them on a UIScrollView. It is something you can do, but would hurt performances.

The data source

Let’s start with my data source class you can find here
https://github.com/gualtierofrigerio/FolderImageViewer/blob/master/FolderImageViewer/DataSource.swift
I created a protocol named FilesystemDataSource. Even if I have only the class DataSource conforming to the protocol I think it is usually a good idea to have a protocol for abstraction. What if I want to implement the data source in a very different way in the future? I could have a JSON with a set of URLs and be able to treat it like a folder, hiding implementation details to the classes that need a set of URLs for showing images. My protocol would need some more functions maybe, but the function returning an array of FilesystemEntry could remain the same, as it returns an URL that could be relative to the local file system or remote.


protocol FilesystemDataSource {
    func setBasePath(path:String)
    func getFiles(inSubfolder folder:String) -> [FilesystemEntry]?
    func getSubFolders(ofFolder:String) -> [String]?
}

struct FilesystemEntry {
    var path:String!
    var fullPath:String!
    var url:URL!
}


At the moment we can set the base path (I’m using the app’s bundle in my example), get files in a folder and get the subfolders.
The class has a private method getContents returning a list of files in a folder, and two booleans to specify whether you want subfolders and regular files. That way, with a single function, I can get only subfolders, only files, or everything.
I use the FileManager.default singleton to perform all the operations with the FS, then I use filter and map on the arrays to return the results. I like filter and map a lot and I miss them in Objective-C, although it is possibile to make an extension of NSArray and provide a similar functionality.


var contents = try fileManager.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil)
    contents = contents.filter({
         do {
             let resourceValues = try $0.resourceValues(forKeys: [.isDirectoryKey])
             if includeFiles && includeDirectories {
                 return true
             }
             else if includeFiles {
                 return !resourceValues.isDirectory!
             }
             else {
                 return resourceValues.isDirectory!
             }
         }
         catch {
             return false
         }
})

Remember that $0 refers to the first parameter, in this case the sole, of the closure. When using filter you can return a boolean for each element passed to the closure so you can filter out elements by returning false.


let filesystemEntries = contents.map{(url) -> FilesystemEntry in
    let fullPath = url.absoluteString
    var path = fullPath.replacingOccurrences(of: "file://", with: "")
    path = path.replacingOccurrences(of: folderPath, with: "")
    if path.last == "/" {
        path = String(path.dropLast())
    }
    if path.first == "/" {
        path = String(path.dropFirst())
    }
    return FilesystemEntry(path: path, fullPath: fullPath, url: url)
}

Here I’m using map on the array to convert an array of URLs, returned by the FileManager to an array of FilesystemEntry so I can have the url, and absolute path and the file name.

The image provider

It wasn’t strictly necessary, but I decided to create a class to provide a simple caching mechanism. Usually the operating systems takes care of that for you, even when you deal with the local file system, but having a class allows me to manage cache in a different way in the future, for example if I have to deal with network requests. Again, those can be cached as well, but having a recently viewed image in memory saves time as you don’t need to make a network request, even if it is handled fast by the OS.
https://github.com/gualtierofrigerio/FolderImageViewer/blob/master/FolderImageViewer/ImageProvider.swift
There isn’t much to tell about the implementation, there is an array of images and if you request one not contained into the array a new image is loaded from the FS and then the first entry is removed to make room for the new one. Not really smart, just a FIFO array.
I’d say the main advantage of having a class deal with images is I can have the same instance of ImageProvider (with the same cached images) in the table view and the scroll view. So if you’re scrolling through images in the table view and then select one of them that particular image and the one just before and after it have already been requested, and cached, for the table view and you can access them in the scroll view from memory, so the initial scrolling doesn’t require access to the file system.

Showing images

As I stated at the beginning I don’t think the table view is really interesting. I made a table view controller called FoldersTableViewController and set it to be the delegate and the datasource for its tableview.
https://github.com/gualtierofrigerio/FolderImageViewer/blob/master/FolderImageViewer/FoldersTableViewController.swift
That’s maybe the easiest and quickest implementation, but it requires subclassing UITableViewController. Another possibility would be having our data source, or maybe a different one, as the tableview’s data source and a separate class as the delegate to handle cell selection.

I created a protocol for the delegate, so a different view controller (the main one in the example) takes care of displaying another table or a scrollview after a cell is selected. Why did I make that choice? My FoldersTableViewController knows that the first section of the table is made of folders, and the second contains files. When a cell is selected the class calls a delegate telling a folder, or a single file, was selected. An external class, not knowing how the data is presented in the table view, couldn’t know if a file or a folder was selected and would have forced me to have also the table view datasource implemented by an external class.

Once an image is selected in the table view we want to display it in a scrollview alongside the other images contained into the same folder.
I find UIScrollView to be a perfect fit for the job, as it handles paging and zooming.
Let’s take a look at the source:
https://github.com/gualtierofrigerio/FolderImageViewer/blob/master/FolderImageViewer/ImageScrollViewController.swift
First thing you see is the ImageViewInfo struct I’ll describe later, I need it to know which view is currently being displayed and which one I can reuse while scrolling left or right to load a new image. The gist is I place 3 UIImageView into the scroll view so while you scroll you can immediately see the next image. While scrolling right I take the leftmost view and place it to the right, and viceversa. If you’re scrolling really fast and images are loaded from the network you may want to consider doubling the cache, so having 2 images prefetched on the left and 2 on the right. I decided to cache stuff on the ImageProvider, so I think having one image on the left and one on the right is enough, as fetching a new image should be fast.
There is only one public method in this class and it is the one to set the array of images. The first function necessary to set everything up is initScrollView, called either during the view loading if the files were already set, or immediately after setting them.
UIScrollView has the concept of contentSize, so the view itself doesn’t need to be big enough to contain all its subviews, but will scroll horizontally or vertically based on the contentSize property.


private func initScrollView() {
    let x = scrollView.visibleSize.width * CGFloat(currentIndex)
    scrollView.contentOffset = CGPoint(x: x, y: 0)
    scrollView.isPagingEnabled = true
    scrollView.maximumZoomScale = 2.0
    scrollView.minimumZoomScale = 1.0
    let totalWidth = scrollView.visibleSize.width * CGFloat(files!.count)
    let height = scrollView.visibleSize.height
    scrollView.contentSize = CGSize(width: totalWidth, height: height)
    internalView.frame = CGRect(x:0, y:0, width:totalWidth, height:height)
    scrollView.addSubview(internalView)
    refreshScrollView()
}

Notice I set the contentSize to have the height of the scroll view, as we’re going to scroll horizontally across the images, and the width as the products of its width times the number of images.
If I had only a few images, like 3 or 4, I’d just instantiate all of them and place them on the scroll view, but we could have hundreds of images so it is important to only load a few of them, then remove an image and replace it with another one as the user scrolls.
I’m adding only a view to the scrollview, this is necessary as I want to be able to pinch to zoom. The scroll view can handle it almost with no code aside from a delegate call, but having all the images as subviews would be quite messy, while a single view, containing all the images, makes everything a lot easier.

After the scrollview is configured we can start populating it via refreshScrollView


private func refreshScrollView() {
    markValidEntries(startIndex: currentIndex - 1, endIndex: currentIndex + 1)
        
    loadImageView(atIndex: currentIndex)
    loadImageView(atIndex: currentIndex - 1)
    loadImageView(atIndex: currentIndex + 1)
}

currentIndex is storing the current position of the scroll view, so we know where we are and which images we need to load on the left and the right of the current one.
The function markValidEntries will take care of an array of ImageViewInfo, where for each view (an UIImageView) I set the index and whether it is disposable or not.

Let’s make a simple example: we are displaying image 3 and we scroll right, so we are about to display the 4th image.
The array will have 3 entries:

  • index 2 with the second image
  • index 3 with current image
  • index 4 with the next image

as we scroll right we want to have the image of index 2 replaced with the 5th image in our set, so the function will make entry with index 2 disposable.


private func loadImageView(atIndex index:Int) {
    if index < 0 || index >= files!.count {
        return
    }
    let entryIndex = getImageViewEntryIndex(forImageAtIndex: index)
    var entry = imageViews[entryIndex]
    if entry.view == nil {
        entry.view = createImageView(withImageAtIndex: index)
    }
    entry.disposable = false
    entry.index = index
    imageViews[entryIndex] = entry
    if entry.view!.superview == nil {
       internalView.addSubview(entry.view!)
    }
    var frame = entry.view!.frame
    frame.origin.x = frame.size.width * CGFloat(index)
    entry.view!.frame = frame
}

loadImageView is responsible for loading an image at an index. getImageViewEntryIndex returns the index of an available UIImageView, which can be nil as in viewDidLoad I created the entries in the array but not the views.
The frame origin is computed so the view is inserted at the right place. Either the leftmost view is set to be the one on the right or viceversa.

To make scrolling work, so we continue to see images as we scroll, it is necessary to implement a delegate method of UIScrollView, viewDidScroll.


func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let position = (Int)(scrollView.contentOffset.x / scrollView.visibleSize.width)
    if position != currentIndex {
        currentIndex = position
        refreshScrollView()
    }
}

This function is called multiple times as the user scroll, by accessing the scrollView contentOffset property we know where the current X is,
from 0 to the contentSize width so we know the position. If it is different from the currentIndex we call refreshScrollView so the view currently off screen will be loaded with a new image.

I said implementing pinch to zoom is really easy, and that’s all you need to do


func viewForZooming(in scrollView: UIScrollView) -> UIView? {
    return internalView
}

If you return nil, or don’t implement this delegate method, pinch to zoom doesn’t work.

That’s all folks. I’ll keep this article updated with the latest code from GitHub. As I mentioned I’d like to improve the UI, maybe I’ll use a UICollectionView for folders and file and I’m planning to add a favourite feature.