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 leveraging Amazon S3. Finally, you will update the iOS app to enable image uploading, fetching, and rendering.
- Create a storage service
- Update your iOS app - the logic to upload and download images
- Update your iOS app - the user interface
Storage service - Storing and querying for files like images and videos is a common requirement for most applications. One option to do this is to Base64 encode the file and send as a string to save in the database. This comes with disadvantages like 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.
To add image storage functionality, we'll 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
To deploy the storage service we have just created, go to your terminal and execute the command:
amplify push
Press Y to confirm and, after a while, you should see:
✔ Successfully pulled backend environment amplify from the cloud.
Before going to the code, you add the Amplify Storage Library to the dependencies of your project. Navigate back to the General tab of your target and select AWSS3StoragePlugin then click Add:
You have now added AWSS3StoragePlugin as a dependency for your project:
Back to Xcode, open Backend.swift
and add a line in the Amplify initialisation sequence in private init()
method. Complete 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)")
}
Open Backend.swift
. Anywhere in the Backend
class, add the the following methods:
// MARK: - Image Storage
func storeImage(name: String, image: Data) {
let options = StorageUploadDataRequest.Options(accessLevel: .private)
Amplify.Storage.uploadData(key: name, data: image, options: options,
progressListener: { progress in
// optionally update a progress bar here
}, resultListener: { event in
switch event {
case .success(let data):
print("Image upload completed: \(data)")
case .failure(let storageError):
print("Image upload failed: \(storageError.errorDescription). \(storageError.recoverySuggestion)")
}
})
}
func retrieveImage(name: String, completed: @escaping (Data) -> Void) {
let options = StorageDownloadDataRequest.Options(accessLevel: .private)
Amplify.Storage.downloadData(key: name, options: options,
progressListener: { progress in
// in case you want to monitor progress
}, resultListener: { (event) in
switch event {
case let .success(data):
print("Image \(name) loaded")
completed(data)
case let .failure(storageError):
print("Can not download image: \(storageError.errorDescription). \(storageError.recoverySuggestion)")
}
}
)
}
func deleteImage(name: String) {
let options = StorageRemoveRequest.Options(accessLevel: .private)
Amplify.Storage.remove(key: name, options: options,
resultListener: { (event) in
switch event {
case let .success(data):
print("Image \(data) deleted")
case let .failure(storageError):
print("Can not delete image: \(storageError.errorDescription). \(storageError.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.
Now that we have our backend functions available, let's load the images when the API call returns. The central place to add this behaviour is when the app construct 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
DispatchQueue.main.async() {
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 DispatchQueue.main.async
.
First, we add generic code to support image capture. This code can be reused in many appications, it shows an image selector allowing the user to chose an image from its image library.
In Xcode, create a new swift file (⌘N, then select Swift). Name the file CaptureImageView.swift
file 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>) {
}
}
Let's invoke the storage methods from 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")
}
}
To verify everything works as expected, build and run the project. Click Product menu and select Run or type ⌘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.
One Note in the List | Create a Note | Pick Image 1 | Pick Image 2 | Note with Image |
---|---|---|---|---|
You have build 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.
Next : Cleanup.