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.
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.
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.
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.
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.
- 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.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let position = (Int)(scrollView.contentOffset.x / scrollView.visibleSize.width)
if position != currentIndex {
currentIndex = position
refreshScrollView()
}
}
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.