Build an iOS Application

Create a simple iOS application using AWS Amplify

Module 5: Add the Ability to Store Images

In this module, you will add storage and the ability to associate an image with the notes in your app

Overview

Now that we have the notes app working, let's add the ability to associate an image with each note. In this module, you will use the Amplify CLI and libraries to create a storage service using Amazon S3. Finally, you will update the iOS app to enable image uploading, fetching, and rendering.

What you will accomplish

In this module, you will:
  • Create a storage service
  • Update your iOS app with logic to upload and download images
  • Update the UI of your iOS app

Key concepts

Storage service – Storing and querying of files, such as images and videos, is a common requirement for applications. One option to do this is to Base64 encode the file and send it as a string to save in the database. This comes with disadvantages, such as the encoded file being larger than the original binary, the operation being computationally expensive, and the added complexity around encoding and decoding properly. Another option is to have a storage service specifically built and optimized for file storage. Storage services like Amazon S3 exist to make this as easy, performant, and inexpensive as possible.

 Time to complete

10 minutes

 Services used

Implementation

Create the storage service

To add image storage functionality, we will use the Amplify storage category.

amplify add storage

? Please select from one of the below mentioned services:,accept the default Content (Images, audio, video, etc.) and press enter
? Please provide a friendly name for your resource that will be used to label this category in the project: type image and press enter
? Please provide bucket name:, accept the default and press enter
? Who should have access:, accept the default Auth users only and press enter
? What kind of access do you want for Authenticated users? select all three options create/update, read, delete using the space and arrows keys, then press enter
? Do you want to add a Lambda Trigger for your S3 Bucket?, accept the default No and press enter

After a while, you should see:

Successfully added resource image locally

Deploy the storage service

To deploy the storage service we have just created, go to your terminal and run the following command:

amplify push

Press Y to confirm and, after a while, you should see:

✔ Successfully pulled backend environment amplify from the cloud.

Add Amplify storage libraries to the Xcode project

Before going to the code, add the Amplify storage library to the dependencies of your project. Navigate back to the General tab of your target and select AWSS3StoragePlugin and then choose Add.

You have now added AWSS3StoragePlugin as a dependency for your project.

Initialize Amplify storage plugin at runtime

Return to Xcode, open the Backend.swift file, and add a line in the Amplify initialization sequence in private init() method. The code block should look like this:

// at the top of the file
import AWSS3StoragePlugin

// initialize amplify
do {
   try Amplify.add(plugin: AWSCognitoAuthPlugin())
   try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
   try Amplify.add(plugin: AWSS3StoragePlugin())
   try Amplify.configure()
   print("Initialized Amplify");
} catch {
   print("Could not initialize Amplify: \(error)")
}

Add image CRUD methods to the backend class

Open Backend.swift. Anywhere in the backend class, add the following methods:

// MARK: - Image Storage
func storeImage(name: String, image: Data) {

    let options = StorageUploadDataRequest.Options(accessLevel: .private)
    let uploadTask = Amplify.Storage.uploadData(
        key: name, 
        data: image, 
        options: options
    )
    Task {
        for await progress in await uploadTask.progress {
            // optionally update a progress bar here
        }
        do {
            let data = try await uploadTask.value
            print("Image upload completed: \(data)")
        } catch {
            print("Image upload failed: \(error.errorDescription). \(error.recoverySuggestion)")
        }
    }
}
func retrieveImage(name: String, completed: @escaping (Data) -> Void) {

    let options = StorageDownloadDataRequest.Options(accessLevel: .private)
    let downloadTask = Amplify.Storage.downloadData(
        key: name, 
        options: options
    )
    Task {
        for await progress in await downloadTask.progress {
            // optionally update a progress bar here
        }
        do {
            let data = try await downloadTask.value
            print("Image \(name) loaded")
            completed(data)
        } catch {
            print("Can not download image: \(error.errorDescription). \(error.recoverySuggestion)")
        }
    }
}
func deleteImage(name: String) {

    let options = StorageRemoveRequest.Options(accessLevel: .private)
    Task {
        do {
            let key = try await Amplify.Storage.remove(key: name, options: options)
            print("Image \(key) deleted")
        } catch {
            print("Can not delete image: \(error.errorDescription). \(error.recoverySuggestion)")
        }
    }
}

These three methods simply call their Amplify counterpart. Amplify storage has three file protection levels:

  • Public – Accessible by all users
  • Protected – Readable by all users, but only writable by the creating user
  • Private – Readable and writable only by the creating user
 
For this app, we want the images to only be available to the note owner. We are using the accessLevel: .private property.

Load image when data are retrieved from the API

Now that we have our backend functions available, let's load the images when the API call returns. The central place to add this behavior is when the app constructs a Note UI object from the NoteData returned by the API.

Open ContentView.swift and update the Note's initializer.

// add a publishable's object property
@Published var image : Image?

// update init's code
convenience init(from data: NoteData) {
    self.init(id: data.id, name: data.name, description: data.description, image: data.image)

    if let name = self.imageName {
        // asynchronously download the image
               Backend.shared.retrieveImage(name: name) { (data) in
// update the UI on the main thread
MainActor.run {
                let uim = UIImage(data: data)
                self.image = Image(uiImage: uim!)
            }
        }
    }
    // store API object for easy retrieval later
    self._data = data
}
When an image name is present in the instance of Note, the code calls retrieveImage. This is an asynchronous function. It takes a function to call when the image is downloaded. The function creates an Image UI object and assign it to the instance of Note. Notice that this assignment triggers a User Interface update, hence it happens on the main thread of the application MainActor.run.

Add UI code to capture an image

First, we add generic code to support image capture. This code can be reused in many applications; it shows an image selector that allows the user to choose an image from their image library.

In Xcode, create the new Swift file (⌘N, then select Swift). Name the file CaptureImageView.swift and add this code:

import Foundation
import UIKit
import SwiftUI

struct CaptureImageView {

  /// MARK: - Properties
  @Binding var isShown: Bool
  @Binding var image: UIImage?

  func makeCoordinator() -> Coordinator {
    return Coordinator(isShown: $isShown, image: $image)
  }
}

class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
  @Binding var isCoordinatorShown: Bool
  @Binding var imageInCoordinator: UIImage?
  init(isShown: Binding<Bool>, image: Binding<UIImage?>) {
    _isCoordinatorShown = isShown
    _imageInCoordinator = image
  }
  func imagePickerController(_ picker: UIImagePickerController,
                didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
     guard let unwrapImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return }
     imageInCoordinator = unwrapImage
     isCoordinatorShown = false
  }
  func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
     isCoordinatorShown = false
  }
}

extension CaptureImageView: UIViewControllerRepresentable {
    func makeUIViewController(context: UIViewControllerRepresentableContext<CaptureImageView>) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.delegate = context.coordinator

        // picker.sourceType = .camera // on real devices, you can capture image from the camera
        // see https://medium.com/better-programming/how-to-pick-an-image-from-camera-or-photo-library-in-swiftui-a596a0a2ece

        return picker
    }

    func updateUIViewController(_ uiViewController: UIImagePickerController,
                                context: UIViewControllerRepresentableContext<CaptureImageView>) {

    }
}

Store image when notes are created

Let's invoke the storage methods from the backend when a note is created. Open ContentView.swift and modify the AddNoteView to add an ImagePicker component.

// at the start of the Content View struct 
@State var image : UIImage? // replace the previous declaration of image
@State var showCaptureImageView = false

// in the view, replace the existing PICTURE section
Section(header: Text("PICTURE")) {
    VStack {
        Button(action: {
            self.showCaptureImageView.toggle()
        }) {
            Text("Choose photo")
        }.sheet(isPresented: $showCaptureImageView) {
            CaptureImageView(isShown: self.$showCaptureImageView, image: self.$image)
        }
        if (image != nil ) {
            HStack {
                Spacer()
                Image(uiImage: image!)
                    .resizable()
                    .frame(width: 250, height: 200)
                    .clipShape(Circle())
                    .overlay(Circle().stroke(Color.white, lineWidth: 4))
                    .shadow(radius: 10)
                Spacer()
            }
        }
    }
}

Modify the Create Note section to store the image as well as the note.

Section {
    Button(action: {
        self.isPresented = false

        let note = Note(id : UUID().uuidString,
                        name: self.$name.wrappedValue,
                        description: self.$description.wrappedValue)

        if let i = self.image  {
            note.imageName = UUID().uuidString
            note.image = Image(uiImage: i)

            // asynchronously store the image (and assume it will work)
            Backend.shared.storeImage(name: note.imageName!, image: (i.pngData())!)
        }

        // asynchronously store the note (and assume it will succeed)
        Backend.shared.createNote(note: note)

        // add the new note in our userdata, this will refresh UI
        withAnimation { self.userData.notes.append(note) }
    }) {
        Text("Create this note")
    }
}

Build and test

To verify everything works as expected, build and run the project. Select the Product menu and select Run or press ⌘R. There should be no error.

Assuming you are still signed in, the app starts on the list with one note. Use the + sign again to create a note. This time, add a picture selected from the local image store.

Here is the complete flow.

Conclusion

You have built an iOS application using AWS Amplify! You have added authentication to your app allowing users to sign up, sign in, and manage their account. The app also has a scalable GraphQL API configured with an Amazon DynamoDB database allowing users to create and delete notes. You have also added file storage using Amazon S3 allowing users to upload images and view them in their app.

In the last section, you will find instructions to reuse or to delete the backend we just created.

  • Amplify makes it easy to share a single backend between multiple front end applications.

    In a terminal, navigate to your other project directory and execute the following command:

    mkdir other-project
    cd other-project
    
    amplify pull
    
    ? Do you want to use an AWS profile? accept the default Yes and press enter
    ? Please choose the profile you want to use select the profile you want to use and press enter
    ? Which app are you working on? select the backend you want to share and press enter
    ? Choose your default editor: select you prefered text editor and press enter
    ? Choose the type of app that you're building select the operating system for your new project and press enter
    ? Do you plan on modifying this backend? most of the time, select No and press enter. All backend modifications can be done from the original iOS project.

    After a few seconds, you will see the following message:

    Added backend environment config object to your project.
    Run 'amplify pull' to sync upstream changes.

    You can see the two configurations files that have been pulled out. When you answer 'Yes' to the question 'Do you plan on modifying this backend?', you also see a amplify directory

    ➜  other-project git:(master) ✗ ls -al
    total 24
    drwxr-xr-x   5 stormacq  admin   160 Jul 10 10:28 .
    drwxr-xr-x  19 stormacq  admin   608 Jul 10 10:27 ..
    -rw-r--r--   1 stormacq  admin   315 Jul 10 10:28 .gitignore
    -rw-r--r--   1 stormacq  admin  3421 Jul 10 10:28 amplifyconfiguration.json
    -rw-r--r--   1 stormacq  admin  1897 Jul 10 10:28 awsconfiguration.json
  • When creating a backend for a test or a prototype, or just for learning purposes, just like when you follow this tutorial, you want to delete the cloud resources that have been created.

    Although the usage of this resources in the context of this tutorial fall under the free tier, it is a best practice to clean up unused resources in the cloud.

    To clean your amplify project, in a terminal, execute the following command:

    amplify delete

    After a while, you will see the below message confirming all cloud resources have been deleted.

    ✔ Project deleted in the cloud
    Project deleted locally.

Was this page helpful?

Congratulations!

You successfully built a web application on AWS! As a great next step, dive deeper into specific AWS technologies and take your application to the next level.