Permanently saving a photo

Our app works pretty well for saving pictures, but as soon as the app quits, all of the photos are lost. We need to add a way to save the photos permanently. Our refactoring of the code allows us to work primarily within the model layer now.

Before we write any code, we have to decide how we are going to store the photos permanently. There are many ways in which we can choose to save the photos, but one of the easiest is to save it to the file system, which is what we conceived of in our conception phase. Every app is provided a documents directory that is automatically backed up by the operating system as a part of normal backups. We can store our photos in there as files named after the label the user gives them. To avoid any problems with duplicate labels, where we would have multiple files named the same thing, we can nest every file inside a subdirectory named after the time the photos is saved. The time stamp will always be unique because we will never save two photos at the exact same time.

Now that we have that decided, we can start to update our photo store code. First, we will want to have an easy way to use a consistent directory for saving. We can create that by adding a method called getSaveDirectory. This method can be private and, as a convention, I like to group private code in a private extension:

private extension PhotoStore {
    func getSaveDirectory() throws -> NSURL {
        let fileManager = NSFileManager.defaultManager()
        return try fileManager.URLForDirectory(
            .DocumentDirectory,
            inDomain: .UserDomainMask,
            appropriateForURL: nil,
            create: true
        )
    }
}

This code first gets a URL representing the documents directory from an Apple-provided class called NSFileManager. You may notice that NSFileManager has a shared instance that can be accessed through the defaultManager class method. We then call the URLForDirectory method, give it information indicating that we want the documents directory for the current user, and return the result. Note that this method can throw an error, so we marked our own method as throwing and did not allow any errors to propagate.

Now we can move on to saving all added images to disk. There are a number of things that we will need to be done. First, we need to get the current time stamp. We can do this by creating an NSDate instance, asking that for the time stamp and using string interpolation to turn it into a string:

let timeStamp = "(NSDate().timeIntervalSince1970)"

NSDate instances can represent any sort of time on any date. By default, all NSDate instances are created to represent the current time.

Next, we are going to want to append that onto our save directory to get the path where we are going to save the file. For that, we can use the URLByAppendingPathComponent: method of NSURL:

let fullDirectory = directory.URLByAppendingPathComponent(
    timestamp
    )

This will ensure that the proper path slash is added, if it is not already there. Now we need to make sure that this directory exists before we try to save a file to it. This is done using a method on NSFileManager:

NSFileManager.defaultManager().createDirectoryAtURL(
    fullDirectory,
    withIntermediateDirectories: true,
    attributes: nil
)

This method can throw if there is an error, which we will need to handle later. It is still considered a success if the directory already exists. Once we are sure that the directory has been created, we will want to create the path to the specific file using the label text:

let fileName = "(self.label).jpg"
let filePath = fullDirectory
    .URLByAppendingPathComponent(fileName)

Here we used string interpolation to add a .jpg extension to the file name.

Most importantly, we will need to convert our image to data that can be saved to a file. For that, UIKit provides a function called UIImageJPEGRepresentation that takes the UIImage and returns an NSData instance:

let data = UIImageJPEGRepresentation(self.image, 1)

The second parameter is a value between zero and one representing the compression quality we want. In this case, we want to save the file at full quality, so we use 1. It then returns an optional data instance, so we will need to handle the scenario where it returns nil.

Finally, we need to save that data to the file path we created:

data.writeToURL(filePath, atomically: true)

This method on NSData simply takes the file path and a Boolean indicating if we want it to write to a temporary location before it overwrites any existing file. It also returns true or false depending on if it is successful. Unlike directory creation, this will fail if the file already exists. However, since we are using the current time stamp that should never be a problem.

Lets combine all of this logic into a method on our photo structure that we can use later to save it to disk, which throws an error in case of an error:

struct Photo {
    // ...

    enum Error: String, ErrorType {
        case CouldntGetImageData = "Couldn't get data from image"
        case CouldntWriteImageData = "Couldn't write image data"
    }

    func saveToDirectory(directory: NSURL) throws {
        let timeStamp = "(NSDate().timeIntervalSince1970)"
        let fullDirectory = directory
            .URLByAppendingPathComponent(timeStamp)
        try NSFileManager.defaultManager().createDirectoryAtURL(
            fullDirectory,
            withIntermediateDirectories: true,
            attributes: nil
        )
        let fileName = "(self.label).jpg"
        let filePath = fullDirectory
            .URLByAppendingPathComponent(fileName)
        if let data = UIImageJPEGRepresentation(self.image, 1) {
            if !data.writeToURL(filePath, atomically: true) {
                throw Error.CouldntWriteImageData
            }
        }
        else {
            throw Error.CouldntGetImageData
        }
    }
}

First, we define a nested enumeration for our possible errors. Then we define the method to take the root level directory where it should be saved. We allow any errors from the directory creation to propagate. We also need to throw our errors if the data comes back nil or if the writeToURL:automatically: method fails.

Now we need to update our saveNewPhotoWithImage:labeled: to use the saveToDirectory: method. Ultimately, if an error is thrown while saving the photo, we will want to display something to the user. That means that this method will need to just propagate the error, because the model should not be the one to display something to the user. That results in the following code:

func saveNewPhotoWithImage(
    image: UIImage,
    labeled label: String
    ) throws -> NSIndexPath
{
    let photo = Photo(image: image, label: label)
    try photo.saveToDirectory(self.getSaveDirectory())
    self.photos.append(photo)
    return NSIndexPath(
        forItem: self.photos.count - 1,
        inSection: 0
    )
}

If the saving to directory fails, we will skip the rest of the method so we won't add it to our photos list. That means we need to update the view controller code that calls it to handle the error. First, let's add a method to make it easy to display an error with a given title and message:

func displayErrorWithTitle(title: String?, message: String) {
    let alert = UIAlertController(
        title: title,
        message: message,
        preferredStyle: .Alert
    )
    alert.addAction(UIAlertAction(
        title: "OK",
        style: .Default,
        handler: nil
    ))
    self.presentViewController(
        alert,
        animated: true,
        completion: nil
    )
}

This method is simple. It just creates an alert with an OK button and then presents it. Next, we can add a function to display any kind of error we will expect. It will take a title for the alert that will pop-up, so we can customize the error we are displaying for the scenario that produced it:

func displayError(error: ErrorType, withTitle: String) {
    switch error {
    case let error as NSError:
        self.displayErrorWithTitle(
            title,
            message: error.localizedDescription
        )
    case let error as Photo.Error:
        self.displayErrorWithTitle(
            title,
            message: error.rawValue
        )
    default:
        self.displayErrorWithTitle(
            title,
            message: "Unknown Error"
        )
    }
}

We expect either the built-in error type of NSError that will come from Apple's APIs or the error type we defined in our photo type. The localized description property of Apple's errors just creates a description in the locale the device is currently configured for. We also handle any other error scenarios by just reporting it as an unknown error.

I would also extract our save action creation to a separate method so we don't overcomplicate things when we add in our do-catch blocks. This will be very similar to our previous code but we will wrap the call to saveNewPhotoWithImage:labeled: in a do-catch block and call our error handling method on any thrown errors:

func createSaveActionWithTextField(
    textField: UITextField,
    andImage image: UIImage
    ) -> UIAlertAction
{
    return UIAlertAction(
        title: "Save",
        style: .Default
        ) { action in
        do {
            let indexPath = try self.photoStore
               .saveNewPhotoWithImage(
                image,
                labeled: textField.text ?? ""
                )
            self.collectionView.insertItemsAtIndexPaths([indexPath]
            )
        }
        catch let error {
            self.displayError(
                error,
                withTitle: "Error Saving Photo"
            )
        }
    }
}

That leaves us with just needing to update the imagePickerController:didFinishPickingImage:editingInfo: method to use our new save action creating method:

// ..

alertController.addTextFieldWithConfigurationHandler()
{
    textField in
    let saveAction = self.createSaveActionWithTextField(
        textField,
        andImage: image
    )
    alertController.addAction(saveAction)
}

// ..

That completes the first half of permanently storing our photos. We are now saving the images to disk but that is useless if we don't load them from disk at all.

To load an image from disk, we can use the contentsOfFile: initializer of UIImage that returns an optional image:

let image = UIImage(contentsOfFile: filePath.relativePath!)

To convert our file path URL to a string, which is what the initializer requires, we can use the relative path property.

We can get the label for the photo by removing the file extension and getting the last component of the path:

let label = filePath.URLByDeletingPathExtension?
    .lastPathComponent ?? ""

Now we can combine this logic into an initializer on our Photo struct. To do this, we will also have to create a simple initializer that takes the image and label so that our other code that uses the default initializer still works:

init(image: UIImage, label: String) {
    self.image = image
    self.label = label
}

init?(filePath: NSURL) {
    if let image = UIImage(
        contentsOfFile: filePath.relativePath!
        )
    {
        let label = filePath.URLByDeletingPathExtension?
            .lastPathComponent ?? ""
        self.init(image: image, label: label)
    }
    else {
        return nil
    }
}

Lastly, we need to have the image store enumerate through the files in the documents directory calling this initializer for each one. To enumerate through a directory, NSFileManager has an enumeratorAtFilePath: method. It returns an enumerator instance that has a nextObject method. Each time it is called, it returns the next file or directory inside the original directory. Note that this will enumerate all children of each subdirectory it finds. This is a great example of the iterator pattern we saw in Chapter 9, Writing Code the Swift Way – Design Patterns and Techniques. We can determine if the current object is a file using the fileAttributes property. All of that lets us write a loadPhotos method like this:

func loadPhotos() throws {
    self.photos.removeAll(keepCapacity: true)
    
    let fileManager = NSFileManager.defaultManager()
    let saveDirectory = try self.getSaveDirectory()
    let enumerator = fileManager.enumeratorAtPath(
        saveDirectory.relativePath!
    )
    while let file = enumerator?.nextObject() as? String {
        let fileType = enumerator!.fileAttributes![NSFileType]
            as! String
        if fileType == NSFileTypeRegular {
            let fullPath = saveDirectory
                .URLByAppendingPathComponent(file)
            if let photo = Photo(filePath: fullPath) {
                self.photos.append(photo)
            }
        }
    }
}

The first thing we do in this method is remove all existing photos. This is to protect against calling this method when there are already photos in it. Next, we create an enumerator from our save directory. Then, we use a while loop to continue to get each next object until there are none left. Inside the loop we check if the object we just got is actually a file. If it is and we create the photo successfully with the full path, we add the photo to our photos array.

Finally, all we have to do is make sure this method is called at the appropriate time to load the photos. A great time to do this, considering we want to be able to show errors to the user, is right before the view will be displayed. As the view controllers have a method for right after the view has been loaded, there is also a method called viewWillAppear: that is called every time the view is about to appear. In here we can load the photos and also display any errors to the user with our displayError:withTitle: method:

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)

    do {
        try self.photoStore.loadPhotos()
        self.collectionView.reloadData()
    }
    catch let error {
        self.displayError(
            error,
            withTitle: "Error Loading Photos"
        )
    }
}

Now if you run the app, save some photos, and quit it, your previously saved photos will be there when you run it again. We have completed the saving photos functionality!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset