Day 50 - 100 Days of Swift

10 minute read

Consolidation V

Day 50 is the fifth consolidation day, where you reflect on projects 10 through 12. You review a couple of the key points covered in those projects and then he gives you a challenge. The challenge is to make an app that lets users take photos and add captions to them, saving them in the app.

First, I made a Photo model to hold the information about a single photo:

1
2
3
4
5
6
7
8
9
class Photo: Codable {
    let fileName: String
    var caption: String

    init(fileName: String, caption: String = "") {
        self.fileName = fileName
        self.caption = caption
    }
}

Then I made a subclass of UITableViewController which will display the photos the user has saved. I gave it a variable to hold an array of Photo, and implemented the basic table view delegate methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class PhotoTableViewController: UITableViewController
{

private let cellIdentifier = "PhotoCell"

var photos: [Photo] = []

override func viewDidLoad() {
    super.viewDidLoad()

    title = "Photos"

    tableView.register(UITableViewCell.self,
                       forCellReuseIdentifier: cellIdentifier)
}

// MARK: - Table view data source
override func tableView(_ tableView: UITableView,
                        numberOfRowsInSection section: Int) -> Int {
    return photos.count
}

override func tableView(_ tableView: UITableView,
                        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier,
                                             for: indexPath)
    let photo = photos[indexPath.row]

    cell.textLabel?.text = photo.caption

    return cell
}
}

Then, because I wanted practice implementing my UI in code, I deleted Main.storyboard and added this method to AppDelegate.swift:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// In application(_:didFinishLaunchingWithOptions:)
loadInitialView()

private func loadInitialView() {

    let photoTableViewController = PhotoTableViewController()

    let navController = UINavigationController(rootViewController: photoTableViewController)

    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = navController
    window?.makeKeyAndVisible()

}

I also added a couple of extensions to UIView to make constraining things a little easier, but they are pretty long and ugly, so I won’t show them here. My plan is to eventually turn them into a framework, so when/if I do that, I’ll link to it here.

Next I added a function to present an alert asking the user where they would like to add a photo from:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@objc func addPhoto() {
    let alertController = UIAlertController(title: "Add a photo",
                                            message: "Where would you like to add a photo from?",
                                            preferredStyle: .actionSheet)

    let cameraAction = UIAlertAction(title: "Camera", style: .default) { _ in
        self.presentImagePicker(with: .camera)
    }
    alertController.addAction(cameraAction)

    let photoLibraryAction = UIAlertAction(title: "Photo Library", style: .default) { _ in
        self.presentImagePicker()
    }
    alertController.addAction(photoLibraryAction)

    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
    alertController.addAction(cancelAction)

    present(alertController, animated: true)
}

And a helper method to actually present the image picker. One flaw with this method is that if the user taps on “Camera” on a device where there is no camera available, they will just be sent to the Photo Library with no explanation as to why, but it is good enough for this app:

1
2
3
4
5
6
7
8
9
10
private func presentImagePicker(with source: UIImagePickerController.SourceType = .photoLibrary) {
    let imagePicker = UIImagePickerController()
    imagePicker.delegate = self

    if UIImagePickerController.isSourceTypeAvailable(source) {
        imagePicker.sourceType = source
    }

    present(imagePicker, animated: true)
}

Then I added a UIBarButtonItem to call the addPhoto method:

1
2
3
4
5
// In viewDidLoad
navigationItem.rightBarButtonItem =
    UIBarButtonItem(barButtonSystemItem: .add,
                    target: self,
                    action: #selector(addPhoto))

And conformed to UIImagePickerControllerDelegate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PhotoTableViewController: UITableViewController,
                                UINavigationControllerDelegate,
                                UIImagePickerControllerDelegate {

// Inside the class
func imagePickerController(_ picker: UIImagePickerController,
                           didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
    guard let image = info[.originalImage] as? UIImage else { return }

    let fileName = UUID().uuidString
    let filePath = URL.documentPath(for: fileName)

    guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return }

    do {
        try jpegData.write(to: filePath)
    } catch {
        print("Error saving image:\n\(error)")
        return
    }

    let photo = Photo(fileName: fileName)

    photos.append(photo)
    tableView.insertRows(at: [IndexPath(row: photos.count-1, section: 0)], with: .automatic)
    dismiss(animated: true)

}

That uses an extension I added to URL(line 11). I don’t know that that is the best place to keep it long term, but it works for this simple little project. Other than that it is pretty much identical to the function we had in Project 10:

1
2
3
4
5
6
7
8
9
10
extension URL {
    static func documentPath(for string: String? = nil) -> URL {
        var documents = FileManager.default.urls(for: .documentDirectory,
                                                 in: .userDomainMask).first!
        if let string = string {
            documents.appendPathComponent(string)
        }
        return documents
    }
}

Then I added save and load methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private func save() {
    do {
        let jsonData = try JSONEncoder().encode(photos)
        UserDefaults.standard.set(jsonData, forKey: "photos")
    } catch {
        print("Error saving photos:\n\(error)")
    }
}

private func load() {
    guard let savedData = UserDefaults.standard.object(forKey: "photos") as? Data else { return }
    do {
        photos = try JSONDecoder().decode([Photo].self, from: savedData)
    } catch {
        print("Error loading photos:\n\(error)")
    }
}

And called them in appropriate places:

1
2
3
4
5
// In viewDidLoad
load()

// At the end of imagePicker(_:didFinishPickingMediaWithInfo:)
save()

Then I added a DetailViewController to display the image when it is tapped on. Here’s the whole class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import UIKit

class DetailViewController: ShiftableViewController {
    var photo: Photo?
    var saveHandler: (() -> Void)?

    private var imageView: UIImageView!
    private var textField: UITextField!

    override func viewDidLoad() {
        super.viewDidLoad()

        setupViews()
    }

    private func setupViews() {
        view.backgroundColor = .white

        navigationItem.rightBarButtonItem =
            UIBarButtonItem(barButtonSystemItem: .save,
                            target: self,
                            action: #selector(savePhoto))

        imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.constrainToSuperView(view,
                                       top: 8,
                                       leading: 8,
                                       trailing: 8)
        imageView.constrain(aspectWidth: 1)

        textField = UITextField()
        textField.delegate = self
        textField.placeholder = "Your caption"
        textField.constrainToSuperView(view,
                                       leading: 20,
                                       trailing: 20)
        textField.constrainToSiblingView(imageView,
                                         below: 20)

        updateViews()
    }

    private func updateViews() {
        guard isViewLoaded, imageView != nil else { return }
        guard let photo = photo else { return }

        let filePath = URL.documentPath(for: photo.fileName).path
        imageView.image = UIImage(contentsOfFile: filePath)

        textField.text = photo.caption
    }

    @objc private func savePhoto() {
        guard let caption = textField.text else { return }

        photo?.caption = caption

        if let saveHandler = saveHandler { saveHandler() }
        navigationController?.popViewController(animated: true)
    }
}

There are a few interesting things here. ShiftableViewController is a subclass of UIViewController that handles moving the view around when the keyboard is displayed and dismissing the keyboard when you tap outside of the view. constrain, constrainToSuperView, and constrainToSiblingView are all variations of the helper method I wrote to add constraints. The implementation isn’t really important and it should be fairly clear what they are doing (at least once you see the screenshot). saveHandler is a closure that this view controller can be handed to be run when the user taps the “Save” button. This lets me reload the tableView and save the data in PhotoTableViewController from this view controller.

Now that I have a view to display the data, I instantiate one in tableView(_:didSelectRowAt:):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override func tableView(_ tableView: UITableView,
                        didSelectRowAt indexPath: IndexPath) {
    let photo = photos[indexPath.row]

    let detailVC = DetailViewController()
    detailVC.photo = photo
    detailVC.saveHandler = { [weak self] in
        self?.tableView.reloadData()
        self?.save()
    }

    navigationController?.pushViewController(detailVC,
                                             animated: true)
}

Here you can see what giving the DetailViewController its saveHandler might look like. In this case, I just reload the tableView (so that the new caption shows up) and save the data, so that it is there when the view is restored.

Finally, I implemented a couple more UITableViewDelegate methods to make the rows a little taller and to give the user the option to delete items:

1
2
3
4
5
6
7
8
9
10
11
12
13
override func tableView(_ tableView: UITableView,
                        heightForRowAt indexPath: IndexPath) -> CGFloat {
    return 120
}

override func tableView(_ tableView: UITableView,
                        commit editingStyle: UITableViewCell.EditingStyle,
                        forRowAt indexPath: IndexPath) {
    guard editingStyle == .delete else { return }
    photos.remove(at: indexPath.row)
    tableView.deleteRows(at: [indexPath], with: .automatic)
    save()
}

With that, the app looks like this:

Screenshot of working app.

You can find my version of this project at the end of day 50 on Github here.

Reflections

Halfway done! I’ve really been enjoying making the UI programmatically lately and I am definitely getting to be way more comfortable with it. This was good practice and it helps me to run into the pitfalls and try to figure out ways around them. I’ve also been playing around with handing off closures between view controllers. When I came to the problem of how to save the caption, my first thought was to write a delegate, but that would require more typing, so I tried doing the same thing with a closure and it works great.

I’m not super happy with how the tableview looks, but it is mostly just a matter of making a custom cell, which I did not want to spend the time on today. Maybe I’ll come back to it later.