Day 50 - 100 Days of Swift
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:

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.