Chapters

Hide chapters

UIKit Apprentice

First Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 13 chapters
Show chapters Hide chapters

30. Image Picker
Written by Matthijs Hollemans & Fahim Farook

Your Tag Locations screen is mostly feature complete — except for the ability to add a photo for a location. Time to fix that!

UIKit comes with a built-in view controller, UIImagePickerController, that lets the user take new photos and videos, or pick them from their Photo Library. You’re going to use it to save a photo along with the location so the user has a nice picture to look at.

This is what your screen will look like when you’re done:

A photo in the Tag Location screen
A photo in the Tag Location screen

In this chapter, you will do the following:

  • Add an image picker: Add an image picker to your app to allow you to take photos with the camera or to select existing images from your photo library.
  • Show the image: Show the picked image in a table view cell.
  • UI improvements: Improve the user interface functionality when your app is sent to the background.
  • Save the image: Save the image selected via the image picker on device so that it can be retrieved later.
  • Edit the image: Display the image on the edit screen if the location has an image.
  • Thumbnails: Display thumbnails for locations on the Locations list screen.

Add an image picker

Just as you need to ask the user for permission before you can get GPS information from the device, you need to ask for permission to access the user’s photo library.

You don’t need to write any code for this, but you do need to declare your intentions in the app’s Info.plist. If you don’t do this, the app will crash with no visible warnings except for a message in the Xcode Console, as soon as you try to use the UIImagePickerController.

Info.plist changes

➤ Open Info.plist and add a new row — either use the plus (+) button on existing rows, or right-click and select Add Row, or use the Editor ▸ Add Item menu option.

For the key, choose Privacy - Photo Library Usage Description from the dropdown list.

For the value, type: Add photos to your locations.

Adding a usage description in Info.plist
Adding a usage description in Info.plist

➤ Also add the key Privacy - Camera Usage Description and give it the same description.

Now when the app opens the photo picker or the camera for the first time, iOS will tell the user what the app intends to use the photos for, using the description you just added to Info.plist.

Use camera to add image

➤ In LocationDetailsViewController.swift, add the following extension to the end of the source file:

extension LocationDetailsViewController: UIImagePickerControllerDelegate,
  UINavigationControllerDelegate {
  // MARK: - Image Helper Methods
  func takePhotoWithCamera() {
    let imagePicker = UIImagePickerController()
    imagePicker.sourceType = .camera
    imagePicker.delegate = self
    imagePicker.allowsEditing = true
    present(imagePicker, animated: true, completion: nil)
  }
}

The UIImagePickerController is a view controller like any other, but it is built into UIKit and it takes care of the entire process of taking new photos or picking them from the user’s photo library.

All you need to do is create a UIImagePickerController instance, set its properties to configure the picker, set its delegate, and then present it. When the user closes the image picker screen, the delegate methods will let you know the result of the operation.

That’s exactly how you’ve been designing your own view controllers — except that you don’t need to add the UIImagePickerController to the storyboard.

Note: You’re doing this in an extension because it allows you to group all the photo-picking related functionality together.

If you wanted to, you could put these methods in the main class body. That would work fine too, but view controllers tend to become very big with many methods that all do different things.

As a way to preserve your sanity, it’s nice to extract conceptually related methods — such as everything that has to do with picking photos — and place them together in their own extension.

You could even move each of these extensions to their own source file, for example “LocationDetailsViewController+PhotoPicking.swift”, but personally, I find having less files to manage to be a good thing :]

➤ Add the following methods to the extension:

// MARK: - Image Picker Delegates
func imagePickerController(
  _ picker: UIImagePickerController, 
  didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
  dismiss(animated: true, completion: nil)
}

func imagePickerControllerDidCancel(
  _ picker: UIImagePickerController
) {
  dismiss(animated: true, completion: nil)
}

Currently these delegate methods simply remove the image picker from the screen. Soon, you’ll take the image the user picked and add it to the Location object, but for now, you just want to make sure the image picker shows up.

Note that the view controller — in this case the extension — must conform to both UIImagePickerControllerDelegate and UINavigationControllerDelegate for this to work, but you don’t have to implement any of the UINavigationControllerDelegate methods.

➤ Now change tableView(_:didSelectRowAt:) in the class as follows:

override func tableView(
  _ tableView: UITableView, 
  didSelectRowAt indexPath: IndexPath
) {
  if indexPath.section == 0 && indexPath.row == 0 {
    . . . 
  } else if indexPath.section == 1 && indexPath.row == 0 {
    takePhotoWithCamera()
  }
}

Add Photo is the first row in the second section. When it’s tapped, you call the takePhotoWithCamera() method that you just added.

➤ Run the app, tag a new location or edit an existing one, and tap Add Photo.

If you’re running the app in the Simulator, bam! It crashes. The error message is this:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Source type 1 not available'

The culprit for the crash is the line:

imagePicker.sourceType = .camera

Not all devices have a camera, and the Simulator does not. If you try to use the UIImagePickerController with a sourceType that is not supported by the device or the Simulator, the app crashes.

If you run the app on your device — and if it has a camera, which it probably does if it’s a recent model — then you should see something like this:

The camera interface
The camera interface

That is very similar to what you see when you take pictures using the iPhone’s Camera app. MyLocations doesn’t let you record video, but you can certainly enable this feature in your own apps, if you wanted to.

Use photo library to add image

You can still test the image picker on the Simulator, but instead of using the camera, you have to use the photo library.

➤ Add another method to the extension:

func choosePhotoFromLibrary() {
  let imagePicker = UIImagePickerController()
  imagePicker.sourceType = .photoLibrary
  imagePicker.delegate = self
  imagePicker.allowsEditing = true
  present(imagePicker, animated: true, completion: nil)
}

This method does essentially the same thing as takePhotoWithCamera, except now you set the sourceType to .photoLibrary.

➤ Change didSelectRowAt to call choosePhotoFromLibrary() instead of takePhotoWithCamera().

➤ Run the app in the Simulator and tap Add Photo.

At this point you should see a handful of stock images like this:

The iOS photos library on the simulator
The iOS photos library on the simulator

Note: If you don’t see any images for some reason, stop the app and click on the built-in Photos app in the Simulator. This should display a handful of sample photos. Run the app again and try picking a photo.

Adding photos to the simulator

There are several ways you can add new photos to the Simulator. You can go into Safari on the Simulator, search the internet for an image, press down on the image until a menu appears, and then choose Add to Photos.

Or, instead of surfing the internet for images, you can simply drag and drop an image file on to the Simulator window. This adds the picture to your library in the Photos app.

Finally, you can use the Terminal and the simctl command. Type the following, all on one line — the last part, ~/Desktop/MyPhoto.JPG, should be replaced with an actual path to an image you want to add:

/Applications/Xcode.app/Contents/Developer/usr/bin/simctl addmedia booted ~/Desktop/MyPhoto.JPG

The simctl tool can be used to manage your Simulators — type simctl help for a list of options. The command addmedia booted adds the specified media file to the active Simulator.

➤ Choose one of the photos. The screen now changes to:

The user can tweak the photo
The user can tweak the photo

This happens because you set the image picker’s allowsEditing property to true. With this setting enabled, the user can do some quick editing of the photo before making their final choice — in the Simulator you can hold down Alt/Option while dragging to zoom the photo.

So, there are two types of image pickers you can use: the camera and the Photo Library. But the camera won’t work everywhere. It’s a bit limiting to restrict the app to just picking photos from the library, though.

You’ll have to make the app a little smarter and allow the user to choose the camera when it is present.

Choose between camera and photo library

First, you check whether the camera is available. When it is, you show an action sheet to let the user choose between the camera and the Photo Library.

➤ Add the following methods to LocationDetailsViewController.swift, in the photo extension:

func pickPhoto() {
  if UIImagePickerController.isSourceTypeAvailable(.camera) {
    showPhotoMenu()
  } else {
    choosePhotoFromLibrary()
  }
}

func showPhotoMenu() {
  let alert = UIAlertController(
    title: nil, 
    message: nil, 
    preferredStyle: .actionSheet)

  let actCancel = UIAlertAction(
    title: "Cancel", 
    style: .cancel, 
    handler: nil)
  alert.addAction(actCancel)

  let actPhoto = UIAlertAction(
    title: "Take Photo", 
    style: .default, 
    handler: nil)
  alert.addAction(actPhoto)

  let actLibrary = UIAlertAction(
    title: "Choose From Library", 
    style: .default, 
    handler: nil)
  alert.addAction(actLibrary)

  present(alert, animated: true, completion: nil)
}

You use UIImagePickerController’s isSourceTypeAvailable() method to check whether there’s a camera present. If not, you call choosePhotoFromLibrary() as that is your only option. But when the device does have a camera, you show a UIAlertController on the screen.

Unlike the alert controllers you’ve used before, this one has the .actionSheet style. An action sheet works very much like an alert view, except that it slides in from the bottom of the screen and offers the user one of several choices.

➤ In didSelectRowAt, change the call to choosePhotoFromLibrary() to pickPhoto() instead. This is the last time you’ll change this line, honest.

➤ Run the app on your device to see the action sheet in action:

The action sheet that lets you choose between camera and photo library
The action sheet that lets you choose between camera and photo library

Tapping any of the buttons in the action sheet simply dismisses the action sheet but doesn’t do anything else yet.

By the way, if you want to test this action sheet in the Simulator, then you can fake the availability of the camera by writing the following in pickPhoto():

if true || UIImagePickerController.isSourceTypeAvailable(.camera) {

That will always show the action sheet because the condition is now always true.

The choices in the action sheet are provided by UIAlertAction objects. The handler: parameter is a closure which determines what happens when you press the corresponding button in the action sheet.

Right now the handlers for all three choices — Take Photo, Choose From Library, Cancel — are nil, so nothing will happen.

➤ Change these lines to the following:

let actPhoto = UIAlertAction(
  title: "Take Photo", 
  style: .default) { _ in
    self.takePhotoWithCamera() 
  }
let actLibrary = UIAlertAction(
  title: "Choose From Library", 
  style: .default) { _ in 
    self.choosePhotoFromLibrary() 
  }

This turns handler: into a trailing closure that calls the corresponding method from the extension. You use the _ wildcard to ignore the parameter that is passed to this closure — a reference to the UIAlertAction itself.

➤ Run the app and make sure the buttons from the action sheet work properly.

There may be a small delay between pressing any of these buttons before the image picker appears, but that’s because it’s a big component and iOS needs a few seconds to load it up.

Notice that the Add Photo cell remains selected, showing a dark gray background, when you cancel the action sheet. That doesn’t look so good.

➤ In tableView(_:didSelectRowAt), add the following line before the call to pickPhoto():

tableView.deselectRow(at: indexPath, animated: true)

This first deselects the Add Photo row. Try it out, it looks better this way. The cell background quickly fades from gray back to white as the action sheet slides into the screen.

Show the image

Now that the user can pick a photo, you should display it somewhere — what’s the point otherwise, right? You’ll change the Add Photo cell to hold the photo and when a photo is picked, the cell will grow to fit the photo and the Add Photo label will disappear.

➤ Add two new outlets to the class in LocationDetailsViewController.swift:

@IBOutlet var imageView: UIImageView!
@IBOutlet var addPhotoLabel: UILabel!

➤ In the storyboard, drag an Image View into the Add Photo cell. It doesn’t really matter how big it is or where you put it. You’ll programmatically move it to the proper place later.

This is the reason you made this a custom cell way back when, so you could add this image view to it.

Adding an Image View to the Add Photo cell
Adding an Image View to the Add Photo cell

➤ Connect the Image View to the view controller’s imageView outlet. Also connect the Add Photo label to the addPhotoLabel outlet.

➤ Select the Image View. In the Attributes inspector, check its Hidden attribute from the Drawing section. This makes the image view initially invisible, until you have a photo to give it.

➤ Add left, top, right, bottom, and height Auto Layout constraints to the Image View:

Image View Auto Layout constraints
Image View Auto Layout constraints

We will use some of these Auto Layout constraints to move things out of the way, or to expand the image view to fill the cell when we are displaying an image. But first, we need a variable to hold the picked image.

➤ Add a new instance variable to LocationDetailsViewController.swift:

var image: UIImage?

If no photo is picked yet, image will be nil, so the variable has to be an optional.

➤ Add a new method to the class:

func show(image: UIImage) {
  imageView.image = image
  imageView.isHidden = false
  addPhotoLabel.text = ""
}

This puts the image from the parameter into the image view, makes the image view visible, and removes the title from the Add Photo label so that the Auto Layout constraints would move the image over into the space occupied by the label.

➤ Change the imagePickerController(_:didFinishPickingMediaWithInfo:) method from the photo picking extension to the following:

func imagePickerController(
  _ picker: UIImagePickerController, 
  didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
  image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage
  if let theImage = image {
    show(image: theImage)
  }
  dismiss(animated: true, completion: nil)
}

This is the method that gets called when the user has selected a photo in the image picker.

You can tell by the notation [UIImagePickerController.InfoKey: Any] that the info parameter is a dictionary. Whenever you see [ A : B ] you’re dealing with a dictionary that has keys of type “A” and values of type “B”.

The info dictionary contains data describing the image that the user picked. You use the UIImagePickerController.InfoKey.editedImage key to retrieve a UIImage object that contains the final image after the user moved and/or scaled it — you can also get the original image if you wish, using a different key.

Once you have the photo, you store it in the image instance variable so you can use it later.

Dictionaries always return optionals, because there is a theoretical possibility that the key you asked for — UIImagePickerController.InfoKey.editedImage in this case — doesn’t actually exist in the dictionary.

Since the image instance variable is an optional, you simply assign the value from the dictionary.

If info[UIImagePickerController.InfoKey.editedImage] is nil, then image will be nil too. You do need to cast the value from the meaningless Any to UIImage using the as? operator. In this case you need to use the optional cast, as? instead of as!, because image is an optional instance variable.

Once you have the image and it is not nil, the call to show(image:) puts it in the Add Photo cell.

Exercise. See if you can rewrite the above logic to use a didSet property observer on the image instance variable. If you succeed, then placing the photo into image will automatically update the UIImageView, without needing to call show(image:).

➤ Run the app and choose a photo. Whoops, it looks like you have a small problem here:

The photo is tiny
The photo is tiny

If you recall, we set the height for the Image View to 24 points when we set the Auto Layout constraints earlier because that’s how tall the image needed to be to fit the original row. However, when we’re displaying the image, we need a larger value — something like 260 points.

But of course, if we set the Image View height to 260 at the outset, the image picker cell would start out too tall. So how do we fix this?

Simple enough — you can actually set up connections for Auto Layout constraints too and change the constraint values via code during runtime!

Resize table view cell to show image

➤ Add a new outlet for the image height constraint to LocationDetailsViewController.swift:

@IBOutlet var imageHeight: NSLayoutConstraint!

➤ Switch to the storyboard and then connect the new outlet to the height constraint for the image — the easiest way to do this is via the Document Outline since you can pick the exact constraint you want from there. Simply Control-drag from the circle for the view controller to the correct constraint in the Document Outline and then pick the outlet name, imageHeight, from the popup menu:

Connect the outlet for the constraint
Connect the outlet for the constraint

Now, all you have to do is change the Image View’s height constraint to 260 when you display an image!

➤ Change the show(image:) method:

func show(image: UIImage) {
  ...
  // Add the following lines
  imageHeight.constant = 260
  tableView.reloadData()
}

You simply change the height of the image to 260 points and then refresh the table view to set the photo row to the proper height.

➤ Try it out. The cell now resizes and is big enough for the whole photo.

The photo displays correctly
The photo displays correctly

There’s one small issue – there is too much space around the image. This is due to the Content Mode for the image. You learnt about Content Mode in Chapter 8 and how the default Content Mode for images fits the image to the available space so that the longest side fills the available space fully.

Here, the longest side is the width and so the width takes up the available space but that might mean that there is a bit more space above and below the image.

Exercise. Make the height of the photo table view cell dynamic, depending on the aspect ratio of the image. This is a tough one! You can keep the width of the image view at 260 points. This should correspond to the width of the UIImage object. You get the aspect ratio by doing image.size.width / image.size.height. With this ratio you can calculate what the height of the image view and the cell should be. Good luck! You can find solutions from other readers at forums.raywenderlich.com

UI improvements

The user can take a photo — or pick one — now but the app doesn’t save it to the data store yet. Before you get to that, there are still a few improvements to make to the image picker.

Apple recommends that apps remove any alert or action sheet from the screen when the user presses the Home button to move the app to the background.

The user may return to the app hours or days later and they will have forgotten what they were going to do. The presence of the alert or action sheet is confusing and the user might think, “What’s that thing doing here?!”

To prevent this from happening, you’ll make the Tag Location screen a little more attentive. When the app goes to the background, it will dismiss the action sheet if that is currently showing. You’ll do the same for the image picker.

Handle background mode

You saw in the Checklists app that the SceneDelegate is notified by the operating system when the app is about to go to the background through its sceneDidEnterBackground(_:) method.

View controllers don’t have such a method, but fortunately, iOS sends out “going to the background” notifications through NotificationCenter that you can configure the view controller to listen to.

Earlier you used the notification center to observe notifications from Core Data. This time you’ll listen for the UIScene.didEnterBackgroundNotification notification.

➤ In LocationDetailsViewController.swift, add a new method:

func listenForBackgroundNotification() {
  NotificationCenter.default.addObserver(
    forName: UIScene.didEnterBackgroundNotification,
    object: nil, 
    queue: OperationQueue.main) { _ in
    if self.presentedViewController != nil {
      self.dismiss(animated: false, completion: nil)
    }
    self.descriptionTextView.resignFirstResponder()
  }
}

This adds an observer for UIScene.didEnterBackgroundNotification. When this notification is received, NotificationCenter will call the closure.

If there is an active image picker or action sheet, you dismiss it. You also hide the keyboard if the text view is active.

The image picker and action sheet are both presented as modal view controllers that appear above everything else. If such a modal view controller is active, UIViewController’s presentedViewController property has a reference to that modal view controller.

So, if presentedViewController is not nil you call dismiss() to close the modal screen. By the way, this has no effect on the category picker; that does not use a modal segue but a push segue.

➤ Call the listenForBackgroundNotification() method from within viewDidLoad().

➤ Try it out. Open the image picker, or the action sheet if you’re on a device that has a camera, and exit to the home screen to put the app to sleep.

Then tap the app’s icon to activate the app again. You should now be back on the Tag Location screen, or Edit Location screen if you opted to edit an existing one. The image picker, or action sheet, has automatically closed.

That seems to work, cool!

Remove notification observers

Now that you’ve add a notification to the NotificationCenter, prior to iOS 9.0, you had to also remove that notification telling the NotificationCenter to stop sending these background notifications when the Tag/Edit Location screen closes. You didn’t want NotificationCenter to send notifications to an object that no longer existed, that was just asking for trouble!

However, as of iOS 9.0, this is no longer necessary since the system handles all of this for you. But, we’ll go ahead and unregister the observer just so that you can see how that works — and also to illustrate another issue that we’ll get to soon :]

The deinit method is a good place to unregister observers.

➤ First, add a new instance variable:

var observer: Any!

This will hold a reference to the observer, which is necessary to unregister it later.

The type of this variable is Any!, meaning that you don’t really care what sort of object this is.

➤ In listenForBackgroundNotification(), change the first line so that it stores the return value of the call to addObserver() into this new instance variable:

func listenForBackgroundNotification() {
  observer = NotificationCenter.default.addObserver(forName: . . .

➤ Finally, add the deinit method:

deinit {
  print("*** deinit \(self)")
  NotificationCenter.default.removeObserver(observer!)
}

You add a print() here so you have proof that the view controller really does get destroyed when you close the Tag/Edit Location screen.

➤ Run the app, edit an existing location, and tap Done to close the screen.

I don’t know about you, but I don’t see the *** deinit message anywhere in the Xcode Console.

Guess what? The LocationDetailsViewController doesn’t get destroyed for some reason. That means the app is leaking memory… Of course, this was all a big setup on my part so I can tell you about closures and capturing :]

Closures and capturing

Remember how in closures you always have to specify self when you want to access an instance variable or call a method? That is because closures capture any variables that are used inside the closure.

When it captures a variable, the closure simply stores a reference to that variable. This allows it to use the variable at some later point when the closure is actually performed.

Why is this important? If the code inside the closure uses a local variable, the method that created this variable may no longer be active by the time the closure is performed. After all, when a method ends all locals are destroyed. But when such a local is captured by a closure, it stays alive until the closure is also done with it.

Because the closure needs to keep the objects from those captured variables alive in the time between capturing and actually performing the closure, it stores a strong reference to those objects. In other words, capturing means the closure becomes a shared owner of the captured objects.

What may not be immediately obvious is that self is also one of those variables and therefore gets captured by the closure. Sneaky! That’s why Swift requires you to explicitly write out self inside closures, so you won’t forget this value is being captured.

In the context of LocationDetailsViewController, self refers to the view controller itself. So, as the closure captures self, it creates a strong reference to the LocationDetailsViewController object, and the closure becomes a co-owner of this view controller. I bet you didn’t expect that!

Remember, as long as an object has owners, it is kept alive. So this closure is keeping the view controller alive, even after you closed it!

This is known as an ownership cycle, because the view controller itself has a strong reference back to the closure through the observer variable.

The relationship between the view controller and the closure
The relationship between the view controller and the closure

In case you’re wondering, the view controller’s other owner is UIKit. The observer is also being kept alive by NotificationCenter.

This sounds like a classic catch-22 problem! Fortunately, there is a way to break the ownership cycle. You can give the closure a capture list. What’s that you ask? All will be explained soon!

➤ Change listenForBackgroundNotification() to the following:

func listenForBackgroundNotification() {
  observer = NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification, 
    object: nil, 
    queue: OperationQueue.main) { [weak self] _ in

    if let weakSelf = self {
      if weakSelf.presentedViewController != nil {
        weakSelf.dismiss(animated: false, completion: nil)
      }
      weakSelf.descriptionTextView.resignFirstResponder()
    }
  }
}

There are a couple of new things here. Let’s look at the first part of the closure:

{ [weak self] _ in
  . . . 
}

The [weak self] bit is the capture list for the closure. It tells the closure that the variable self will still be captured, but as a weak reference. As a result, the closure no longer keeps the view controller alive.

Weak references are allowed to become nil, which means the captured self is now an optional inside the closure. You need to unwrap it with if let before you can send messages to the view controller.

Other than that, the closure still does the exact same things as before.

➤ Try it out. Open the Tag/Edit Location screen and close it again. You should now see the print() from deinit in the Xcode Console.

That means the view controller gets destroyed properly and the notification observer is removed from NotificationCenter. Good riddance!

Do note that as of iOS 9.0 and above, even if you do not remove the observer explicitly, the system would handle this for you and automatically remove the observer when the view controller is deallocated. So you don’t have to worry about any side effects from an errant observer any longer.

But it’s always a good idea to clean up after yourself. Use print()’s to make sure your objects really get deallocated! Xcode also comes with Instruments, a handy tool that you can use to detect such issues.

Save the image

The ability to pick photos is rather useless if the app doesn’t also save them. So, that’s what you’ll do here.

It is possible to store images in the Core Data store as BLOBs (Binary Large OBjects), but that is not recommended. Large blocks of data are better off stored as regular files in the app’s Documents directory.

Note: Core Data has an “Allows external storage” feature that is designed to make this process completely transparent for the developer. In theory, you can put data of any size into your entities and Core Data automatically decides whether to put the data into the SQLite database or store it as an external file.

Unfortunately, this feature doesn’t work very well in practice. Last time I checked, it had too many bugs to be useful. So, until this part of Core Data becomes rock solid, we’ll be doing it by hand.

When the image picker gives you a UIImage object for a photo, that image only lives in the iPhone’s working memory.

The image may also be stored as a file somewhere if the user picked it from the photo library, but that’s not the case if they just snapped a new picture. Besides, the user may have resized or cropped the image.

So you have to save that UIImage to a file of your own if you want to keep it. The photos in MyLocations will be saved in JPEG format.

You need a way to associate that JPEG file with your Location object. The obvious solution is to store the filename in the Location object. You won’t store the entire filename, just an ID, which is a positive number. The image file itself will be named Photo-XXX.jpg, where XXX is the numeric ID.

Data model changes

➤ Open the Data Model editor. Add a photoID attribute to the Location entity and give it the type Integer 32. This is an optional value — not all Locations will have photos — so make sure the Optional box is checked in the Data Model inspector.

➤ Add a property for this new attribute to Location+CoreDataProperties.swift:

@NSManaged public var photoID: NSNumber?

Remember that for an object that is managed by Core Data, you have to declare the property as @NSManaged.

You may be wondering why you’re declaring the type of photoID as NSNumber and not as Int or, more precisely, Int32. Remember that Core Data is an Objective-C framework, so you’re limited by the possibilities of that language. NSNumber is how number objects are handled in Objective-C.

For various reasons, you can’t represent an Int value as an optional in Objective-C. Instead, you’ll use the NSNumber class. Swift will automatically convert between Int values and this NSNumber, so it’s no big deal.

You’ll now add some other properties to the Location object to make working with photos a little easier.

➤ Add the hasPhoto computed property to Location+CoreDataClass.swift:

var hasPhoto: Bool {
  return photoID != nil
}

This determines whether the Location object has a photo associated with it or not. Swift’s optionals make this easy.

➤ Also add the photoURL property:

var photoURL: URL {
  assert(photoID != nil, "No photo ID set")
  let filename = "Photo-\(photoID!.intValue).jpg"
  return applicationDocumentsDirectory.appendingPathComponent(filename)
}

This property computes the full URL for the JPEG file for the photo. Note that iOS uses URLs to refer to files, even those saved on the local device.

You’ll save these JPEG files in the app’s Documents directory. To get the URL to that directory, you use the global variable applicationDocumentsDirectory that you added to Functions.swift earlier.

Notice the use of assert() to make sure the photoID is not nil. An assertion is a special debugging tool that is used to check that your code always does something valid. If not, the app will crash with a helpful error message. You’ll see more of this later when we talk about finding bugs — and squashing them.

Assertions are a form of defensive programming. Most of the crashes you’ve seen so far were actually caused by assertions in UIKit. They allow the app to crash in a controlled manner. Without these assertions, programming mistakes could crash the app at random moments, making it very hard to find out what went wrong.

If the app were to ask a Location object for its photoURL without having given it a valid photoID earlier, the app will crash with the message “No photo ID set”. If so, there is a bug in the code somewhere because this is not supposed to happen. Internal consistency checks like this can be very useful.

Assertions are usually enabled only while you’re developing and testing your app and disabled when you upload the final build of your app to the App Store. By then, there should be no more bugs in your app — or so you would hope! It’s a good idea to use assert() in strategic places to catch yourself making programming errors.

➤ Add a photoImage property:

var photoImage: UIImage? {
  return UIImage(contentsOfFile: photoURL.path)
}

This returns a UIImage object by loading the image file. You’ll need this later to show the photos for existing Location objects.

Note that this property has the optional type UIImage? — that’s because loading the image may fail if the file is damaged or removed. Of course, that shouldn’t happen, but no doubt you’ve heard of Murphy’s Law… As I’ve repeatedly said, it’s good to get into the habit of defensive programming.

There is one more thing to add, a nextPhotoID() method. This is a class method, meaning that you don’t need to have a Location instance to call it. You can call this method anytime from anywhere.

➤ Add the method:

class func nextPhotoID() -> Int {
  let userDefaults = UserDefaults.standard
  let currentID = userDefaults.integer(forKey: "PhotoID") + 1
  userDefaults.set(currentID, forKey: "PhotoID")
  return currentID
}

You need to have some way to generate a unique ID for each Location object. All NSManagedObjects have an objectID method, but that returns something unreadable such as:

<x-coredata://C26CC559-959C-49F6-BEF0-F221D6F3F04A/Location/p1>

You can’t really use that in a filename. So instead, you’re going to put a simple integer in UserDefaults and update it every time the app asks for a new ID — this is similar to what you did in the last app to make ChecklistItem IDs for use with local notifications.

It may seem a little silly to use UserDefaults for this when you’re already using Core Data as the data store, but with UserDefaults, the nextPhotoID() method is only five lines. You’ve seen how verbose the code is for fetching something from Core Data and then saving it again. This is just as easy. Of course, if you wanted to, as an exercise, you could try to implement these IDs using Core Data…

That does it for Location. Now you have to save the image and fill in the Location object’s photoID field. This happens in the Location Details View Controller’s done() action.

Save the image to a file

➤ In LocationDetailsViewController.swift, in the done() method, add the following in between where you set the properties of the Location object and where you save the managed object context:

// Save image
if let image = image {
  // 1
  if !location.hasPhoto {
    location.photoID = Location.nextPhotoID() as NSNumber
  }
  // 2
  if let data = image.jpegData(compressionQuality: 0.5) {
    // 3
    do {
      try data.write(to: location.photoURL, options: .atomic)
    } catch {
      print("Error writing file: \(error)")
    }
  }
}

This code is only performed if image is not nil — in other words, when the user has picked a photo.

  1. You need to get a new ID and assign it to the Location’s photoID property, but only if you’re adding a photo to a Location that didn’t already have one. If a photo existed, you simply keep the same ID and overwrite the existing JPEG file.
  2. The image.jpegData(compressionQuality: 0.5) call converts the UIImage to JPEG format and returns a Data object. Data is an object that represents a blob of binary data, usually the contents of a file.
  3. You save the Data object to the path given by the photoURL property. Also notice the use of a do-try-catch block again.

➤ Run the app, tag a location, choose a photo, and press Done to exit the screen. Now the photo you picked should be saved in the app’s Documents directory as a regular JPEG file.

The photo is saved in the app’s Documents folder
The photo is saved in the app’s Documents folder

Note: The first time you run the app after adding a new attribute to the data model (photoID), the NSPersistentContainer performs a migration of the data store behind the scenes to make sure the data store is in sync again with the data model. If this doesn’t work for you for some reason, then remove the old MyLocations.sqlite file from the Library/Application Support folder and try again — or, simply reset the Simulator or remove the app from your test device.

➤ Tag another location and add a photo to it. Hmm… if you look into the app’s Documents directory, this seems to have overwritten the previous photo.

Exercise. Try to debug this one on your own. What is going wrong here? This is a tough one!

Answer: When you create a new Location object, its photoID property gets a default value of 0. That means each Location initially has a photoID of 0. That should really be nil, which means “no photo”.

➤ In LocationDetailsViewController.swift, add the following line near the top of done():

@IBAction func done() {
  . . .
  if let temp = locationToEdit {
    . . .
  } else {
    . . .
    location.photoID = nil           // add this
  }
  . . .

You now set the photoID of a new Location object to nil so that the hasPhoto property correctly recognizes that these Locations as not having a photo yet.

➤ Run the app again, delete your existing data — or create a few new tags — and tag multiple locations with photos. Verify that now each photo is saved individually.

Verify photoID in SQLite

If you have Liya or another SQLite inspection tool, you can verify that each Location object has been given a unique photoID value (in the ZPHOTOID column):

The Location objects with unique photoId values in Liya
The Location objects with unique photoId values in Liya

Edit the image

So far, all the changes you’ve made were for the Tag Location screen and adding new locations. Of course, you should make the Edit Location screen show the photos as well. The change to LocationDetailsViewController is quite simple.

➤ Change viewDidLoad() in LocationDetailsViewController.swift to:

override func viewDidLoad() {
  super.viewDidLoad()

  if let location = locationToEdit {
    title = "Edit Location"
    // New code block
    if location.hasPhoto {
      if let theImage = location.photoImage {
        show(image: theImage)
      }
    }
    // End of new code
  }
  . . .

If the Location that you’re editing has a photo, this calls show(image:) to display it in the photo cell.

Recall that the photoImage property returns an optional, UIImage?, so you use if let to unwrap it. This is another bit of defensive programming.

Sure, if hasPhoto is true there should always be a valid image file present. But it’s possible to imagine a scenario where there isn’t — the JPEG file could have been erased or corrupted — even though that “should” never happen. I’m sure you’ve had your own share of computer gremlins eating important files.

Note also what you don’t do here: the Location’s image is not assigned to the image instance variable. If the user doesn’t change the photo, then you don’t need to write it out to a file again — it’s already in that file and doing perfectly fine, thank you.

If you were to put the photo in the image variable, then done() would overwrite the existing file with the exact same data, which is a little silly. Therefore, the image instance variable will only be set when the user picks a new photo.

➤ Run the app and take a peek at the existing locations from the Locations or Map tabs. The Edit Location screen should now show the photos for the locations you’re editing.

➤ Verify that you can also change the photo and that the JPEG file in the app’s Documents directory gets overwritten when you press the Done button.

Clean up on location deletion

There’s another editing operation the user can perform on a location: deletion. What happens to the image file when a location is deleted? At the moment nothing. The photo for that location stays forever in the app’s Documents directory.

Let’s add some code to remove the photo file, if it exists, when a Location object is deleted.

➤ First add a new method to Location+CoreDataClass.swift:

func removePhotoFile() {
  if hasPhoto {
    do {
      try FileManager.default.removeItem(at: photoURL)
    } catch {
      print("Error removing file: \(error)")
    }
  }
}

This code snippet can be used to remove any file or folder. The FileManager class has all kinds of useful methods for dealing with the file system.

➤ Deleting locations happens in LocationsViewController.swift. Add the following line to tableView(_:commit:forRowAt:):

override func tableView(
  _ tableView: UITableView, 
  commit editingStyle: UITableViewCell.EditingStyle, 
  forRowAt indexPath: IndexPath
) {
  if editingStyle == .delete {
    let location = fetchedResultsController.object(at: indexPath)

    location.removePhotoFile()              // add this line   
    managedObjectContext.delete(location)
    . . .

The new line calls removePhotoFile() on the Location object just before it is deleted from the Core Data context.

➤ Try it out. Add a new location and give it a photo. You should see the JPEG file in the Documents directory.

From the Locations screen, delete the location you just added and look in the Documents directory to make sure the JPEG file truly is a goner.

Thumbnails

Now that locations can have photos, it’s a good idea to show thumbnails for these photos in the Locations tab. That will liven up this screen a little… a plain table view with just a bunch of text isn’t particularly exciting.

Storyboard changes

➤ Go to the storyboard editor. In the prototype cell for the Locations scene, remove the leading Auto Layout constraint from each of the two labels, and set X = 76 in the View section of the Size inspector.

➤ Drag a new Image View into the cell. Place it at the top-left corner of the cell. Give it the following position: X = 16, Y = 6. Make it 52 by 52 points big.

The table view cell has an image view
The table view cell has an image view

➤ Add top, left, height, and width Auto Layout constraints for the currently set values for the new Image View.

➤ Select each of the labels and set their left constraint again so that each one is positioned relative to the image view — the spacing should be 8 points (or standard spacing) if you set all the positions and sizes above correctly.

➤ Connect the image view to a new UIImageView outlet on LocationCell, named photoImageView.

Exercise. Make this connection with the Assistant editor. Tip: you should connect the image view to the cell, not to the view controller.

Now you can put any image into the table view cell simply by passing it to the LocationCell’s photoImageView property.

Code changes

➤ Go to LocationCell.swift and add the following method:

func thumbnail(for location: Location) -> UIImage {
  if location.hasPhoto, let image = location.photoImage {
    return image
  }
  return UIImage()
}

This returns either the image from the Location or an empty placeholder image.

You should read this if statement as, “if the location has a photo, and I can unwrap location.photoImage, then return the unwrapped image.”

You have previously seen the && (logical and) used to combine two conditions, but you cannot write the above like this:

if location.hasPhoto && let image = location.photoImage

The && only works if both conditions are booleans, but here you’re unwrapping an optional as well. In that case you must combine the two conditions with a comma.

➤ Call this new method from the end of configure(for:):

photoImageView.image = thumbnail(for: location)

➤ Try it out. The Locations tab should now look something like this:

Images in the Locations table view
Images in the Locations table view

You’ve got thumbnails, all right!

There’s a tiny — or rather, literally huge — problem here. These photos are potentially big — 2592 by 1936 pixels or more — even though the image view is only 52 pixels square. To make them fit, the image view needs to scale down the images by a lot — which is why they might look a little “gritty”.

What if you have tens or even hundreds of locations? That is going to require a ton of memory and processing speed just to display these tiny thumbnails. A better solution is to scale down the images before you put them into the table view cell.

And what better way to do that than using an extension?

Extensions

So far you’ve used extensions on your view controllers to group related functionality together, such as delegate methods. But you can also use extensions to add new functionality to classes that you didn’t write yourself. That includes classes such as UIImage from the iOS frameworks.

If you ever catch yourself thinking, “Gee, I wish object X had such-and-such a method”, then you can probably add that method by using an extension.

Extensions are pretty cool because they make it simple to add new functionality to an existing class. In other programming languages you would have to make a subclass and put your new methods in there, but extensions are often a cleaner solution.

Besides new methods, you can also add new computed properties, but you can’t add regular instance variables. You can also use extensions on types that don’t even allow inheritance, such as structs and enums.

Thumbnails via UIImage extension

You are going to add an extension to UIImage that lets you resize the image. You’ll use it as follows:

return image.resized(withBounds: CGSize(width: 52, height: 52))

The resized(withBounds:) method is new. The “bounds” is the size of the rectangle, or square in this case, that encloses the image. If the image itself is not square, then the resized image may actually be smaller than the bounds.

Let’s write the extension.

➤ Add a new file to the project and choose the Swift File template. Name the file UIImage+Resize.swift.

➤ Replace the contents of this new file with:

import UIKit

extension UIImage {
  func resized(withBounds bounds: CGSize) -> UIImage {
    let horizontalRatio = bounds.width / size.width
    let verticalRatio = bounds.height / size.height
    let ratio = min(horizontalRatio, verticalRatio)
    let newSize = CGSize(
      width: size.width * ratio, 
      height: size.height * ratio)
    UIGraphicsBeginImageContextWithOptions(newSize, true, 0)
    draw(in: CGRect(origin: CGPoint.zero, size: newSize))
    let newImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    return newImage!
  }
}

This method first calculates how big the image should be in order to fit inside the bounds rectangle. It uses the “aspect fit” approach to keep the aspect ratio intact.

Then it creates a new image context and draws the image into that. We haven’t really dealt with graphics contexts before, but they are an important concept in Core Graphics — in case you’re wondering, it has nothing to do with the managed object context from Core Data, even though they both use the term “context”.

Let’s put this extension to work.

➤ Switch to LocationCell.swift. Update the thumbnail(for:) method:

func thumbnail(for location: Location) -> UIImage {
  if location.hasPhoto, let image = location.photoImage {
    return image.resized(
      withBounds: CGSize(width: 52, height: 52))
  }
  return UIImage()
}

➤ Run the app. The thumbnails should look A-OK:

The thumbnails now look great
The thumbnails now look great

Exercise: Change the resizing function in the UIImage extension to resize using the “Aspect Fill” rules instead of the “Aspect Fit” rules. Both keep the aspect ratio intact but Aspect Fit keeps the entire image visible while Aspect Fill fills up the entire rectangle and may cut off parts on the sides. In other words, Aspect Fit scales to the longest side but Aspect Fill scales to the shortest side.

Aspect Fit vs. Aspect Fill
Aspect Fit vs. Aspect Fill

Handling low-memory situations

The UIImagePickerController is very memory-hungry. Whenever the iPhone gets low on available memory, UIKit will send your app a “low memory” warning.

When that happens, you should reclaim as much memory as possible, or iOS might be forced to terminate the app. And that’s something to avoid — users generally don’t like apps that suddenly quit on them!

Chances are that your app gets one or more low-memory warnings while the image picker is open, especially when you run it on a device that has other apps suspended in the background. Photos take up a lot of space — especially when your camera is 5 or more megapixels — so it’s no wonder that memory fills up quickly.

You can respond to memory warnings by overriding the didReceiveMemoryWarning() method in your view controllers to free up any memory you no longer need. This is often done for things that can easily be recalculated or recreated later, such as thumbnails or other cached objects.

UIKit is already pretty smart about low memory situations and it will do everything it can to release memory, including the thumbnail images of rows that are not (or no longer) visible in your table view.

For MyLocations there’s not much that you need to do to free up additional memory, you can rely on UIKit to automatically take care of it. But in your own apps you might want to take extra measures, depending on the sort of cached data that you have.

By the way, on the Simulator you can trigger a low memory warning using the Debug ▸ Simulate Memory Warning menu item. It’s smart to test your apps under low memory conditions, because they are likely to encounter such situations out in the wild once they’re running on user devices.

Great! That concludes all the functionality for this app. Now it’s time to fine-tune its looks.

You can find the project files for this chapter under 30-Image-picker in the Source Code folder.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.