Front-End Web & Mobile

Rapid iOS App Prototyping with Amplify Admin UI and SwiftUI

June 27, 2024:This blog post covers Amplify Gen 1. For new Amplify apps, we recommend using Amplify Gen 2. You can learn more about Gen 2 in ourlaunch blog post.

AWS Amplifynow offers a new way to model your app’s data schemas that is easier than ever. The newAdmin UIfeature provides an easy-to-use interface where you can layout the models of your app, create relationships between them, and pull the Swift representation of those models into your codebase.

In this post, you will be using the Admin UI to prototype a room booking app that allows a user to sign in, view seeded available rooms, book one of those rooms, and see the bookings associated with that user. Since this is a prototype, we will be focusing on building the concept of the app and feign other features like authenticating the user and storing photos.

This tutorial will show you how to configure the models required for the prototype and start using them in a native iOS app built in SwiftUI.

Topics we’ll be covering

  1. Data modeling with Amplify Admin UI
  2. Configuring Amplify in a native iOS app
  3. Implement prototype functionality with the generated models

Prerequisites

  1. Install Xcode version 12.2 or later
  2. Install CocoaPods
  3. Sign up for an AWS account
  4. Install the Amplify CLI
curl -sL https://aws-amplify.github.io/amplify-cli/install | bash && $SHELL

Data modeling

Start off by heading over tohttps://sandbox.amplifyapp.comand clicking the “Get started” button under “Create an app backend”.

Choose “Data” as the feature to setup, select “Blank schema”, and click “Create new schema”.

Now that we have a blank data modeling canvas, we can start adding our models.

The prototype will allow different users to “sign in” by providing a username. Let’s add aUsermodel that will simply have anidandusername.

With theusernamefield selected, select the “Is required” box on the right side of the screen.

Once the user is “signed in”, the first screen they will see is a list of rooms that are available to be booked. Add aRoommodel.

Each of the properties for ourRoomobject will be required, so you will need to select the checkbox for each of the fields.

We also get to see that these schemas can support a lot of different types when we switch the type ofpricetoInt.

Each of these properties will be represented by either a native Swift type or a type provided by the Amplify libraries, making our feature implementation feel native to the codebase.

Lastly, we need to create an object that represents the booking of a room by the user. Our app will show the user a list of theirBooking‘s which will include the data of theRoomitself. To keep eachBookingup to date with the associatedRoom,we will establish a one-to-one relationship in our schema.

Each of the properties on theBookingobject will be required, including the relationship to theRoom.

Now that the data schema of our app is complete, navigate to the “Deploy” tab and log in to or sign up for an AWS Account.

Once you’re signed in, you will be prompted to create your app’s backend.

Enter an app name, select an AWS region, and click deploy. Amplify will begin creating our backend environment and prep all the resources to start using our models.

After a few minutes, you will see that the Amplify app has been created. Open the Admin UI to see an overview of the staging environment created for us.

Navigate to the “Data” section, and you will see that our model schemas have been added to this Amplify app.

At this point, I’d recommend that you review the schemas for typos, correct property type, and/or properties being marked as “Is required”.

Next, click “Local setup instructions”. You will be presented with a popup containing the terminal command to pull down your Amplify project, as well as code snippets on how to initialize our model object in Swift code.

Configuring Amplify Locally

Now it’s time to open up Xcode and create a new project for our app.

Once the Xcode project is created, open the terminal, navigate to the root of the Xcode project and run the command provided in the “Local setup instructions” of the Admin UI. It should look something like this:

amplify pull --appId xxxxxxxxxxxxxx --envName staging

If you run$ lsat the root of your Xcode project, you should now see anamplifyfolder as well as two configuration files:awsconfiguration.jsonandamplifyconfiguration.json.We will need to add the configuration files and the generated models inside the amplify folder to our Xcode project.

In the navigation pane of Xcode, right click the source code folder and select “Add files to project”.

Add both configuration files and the generated models folder atpath/to/project/amplify/generated/models.The navigation pane will now look like this:

Added config files and models

All our files are ready and in place. Now we need to add the Amplify Libraries to our project by installing them with CocoaPods.

At the root of your Xcode project, run the following:

pod init && open Podfile

Replace the contents of the Podfile with this:

platform:ios, '14.0'

target 'room-booking' do
use_frameworks!

pod 'Amplify'
pod 'AmplifyPlugins/AWSAPIPlugin'
pod 'AmplifyPlugins/AWSDataStorePlugin'
end

We are specifying that the platform is iOS 14 and that we will be adding three pods: Amplify, the API plugin, and the DataStore plugin.

Save the file and install the dependencies at the root of the Xcode project:

pod install --repo-update

After the installation is complete, open the*.xcworkspacefile.

xed.

The*.xcodeprojneeds to be closed so the workspace will automatically be opened or else it will appear as if the Xcode project is missing files.

In Xcode, navigate to the*App.swiftfile where the@mainstruct is implemented. This is where we will configure the Amplify Libraries.

At the top add the following import statements:

import Amplify
import AmplifyPlugins

Now in the App struct itself, add aconfigureAmplify()function:

... // body closing }

func configureAmplify() {
let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels())
let apiPlugin = AWSAPIPlugin(modelRegistration: AmplifyModels())
do {
try Amplify.add(plugin: dataStorePlugin)
try Amplify.add(plugin: apiPlugin)
try Amplify.configure()
print( "Initialized Amplify" )
} catch {
print( "Could not initialize Amplify: \(error)" )
}
}

... // *App closing }

CallconfigureAmplify()in theinitmethod of the*Appobject.

... // struct *App: App {

init() {
configureAmplify()
}

... // var body: some Scene {

Run the app and you should see “Initialized Amplify” printed to your logs.

Implement Prototype Functionality

You’re now completely done setting up a backend that will support scalable, live data; giving you more time to focus on the app’s functionality.

The first screen of the room booking prototype will be the “login” screen. Here, a user will simply enter a username and tap a button to login.

Create a new file calledLoginView.swiftand add the following:

// 1
import Amplify
import SwiftUI

struct LoginView: View {

// 2
@StateObject var viewModel = ViewModel()

// 3
let onLogin: (User) -> Void

var body: some View {
VStack {
TextField( "Username", text: $viewModel.username)
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(Capsule())
.padding(.horizontal, 40)

Button( "Login" ) {
// 4
viewModel.login(completion: onLogin)
}
.padding()
.foregroundColor(.white)
.background(Color.purple)
.clipShape(Capsule())
}
}
}

extension LoginView {
class ViewModel: ObservableObject {
@Published var username = String()

// 5
func login(completion: @escaping (User) -> Void) {

}
}
}
  1. We will be usingAmplifyin ourViewModelto handle the “login” logic.
  2. TheViewModelis responsible for the state of our view. We are using an instance of aViewModelclass that is scoped to theLoginViewthrough anextension
  3. This view is responsible for sending the “logged in”Userback up the chain. This is done with a closure that will be implemented back in the*App.swiftfile.
  4. When the “Login” button is tapped, it will runViewModel.login.TheonLoginclosure is passed as an argument to send theUserup the chain.
  5. Theloginmethod is where we will implement the Amplify related logic.

The LoginView should now appear like this in the preview area:

LoginView preview

The prototype will only require a username to login. To make it so a user can have bookings associated with their account, we will create aUserobject for each username entered into the field. If the username has already been used, we can simply return the existingUserso they can log back in.

In order to simplify this process, we will be making theidandusernamethe same value when creating aUser.This makes it easier to query aUserbyid.

Add the following toViewModel.login:

... // func login(completion: @escaping (User) -> Void) {

// 1
Amplify.DataStore.query(User.self, byId: username) { result in

if let queriedUser = try? result.get() {
print( "queried user", queriedUser)
completion(queriedUser)

} else {

// 2
let newUser = User(id: username, username: username)
Amplify.DataStore.save(newUser) { result in
do {
let savedUser = try result.get()
print( "saved user", savedUser)
completion(savedUser)

} catch {
print(error)
}
}
}
}

... // login closing }
  1. Run a query to see if theUseralready exists and pass the queriedUserto thecompletionclosure.
  2. If theUserdoesn’t exist, create thatUserand send it throughcompletion.

Back in*App.swift,make theLoginViewthe main view in thebody:

... // WindowGroup {

LoginView { user in
sessionManager.currentUser = user
}

... // WindowGroup closing }

This will initialize theLoginViewwith a closure where the providedUseris passed to aSessionManagerobject.

In a new fileSessionManager.swift,add this code:

class SessionManager: ObservableObject {
@Published var currentUser: User?
}

This object will be responsible managing the logged inUser.

Back in*App.swift,create an observed instance ofSessionManager.

... // struct room_bookingApp: App {

@ObservedObject var sessionManager = SessionManager()

... // init() {

Once the user has “logged in”, they will be presented with multiple room listings. They would also be able to tab over to their bookings where a similar listing would be displayed, but would include their booking dates.

Let’s create a view that can be used to show the details of available rooms as well as booked rooms in a file calledRoomItemView.swift:

import SwiftUI

struct RoomItemView: View {

let room: Room
// 1
let bookingDates: (checkInDate: Date, checkOutDate: Date)?

// 2
var bookingDescription: String? {
guard let bookingDates = self.bookingDates else { return nil }
let formatter = DateFormatter()
formatter.dateStyle =.short
formatter.timeStyle =.none

let checkIn = formatter.string(from: bookingDates.checkInDate)
let checkOut = formatter.string(from: bookingDates.checkOutDate)

return "Booked from \(checkIn) to \(checkOut)"
}

var body: some View {
VStack(alignment:.leading) {
Image(room.imageKey)
.resizable()
.aspectRatio(contentMode:.fill)
.frame(width:.infinity, height: 250)
.background(Color.gray)
.cornerRadius(10)

Text( "\(room.description) - \(room.city)" )
.font(.title3)

// 3
if let bookingDescription = self.bookingDescription {
Text(bookingDescription)
} else {
Text( "$\(room.price)" ).bold()
+ Text( "/ night" )
}

}
}
}
  1. By allowingbookingDatesto be optional,RoomItemViewcan be used for both listings and bookings.
  2. ThebookingDescriptionwill format thebookingDatestuple into a human readable description of the booked dates.
  3. If abookingDescriptioncan be generated, then it will be displayed. If not, we assume the view is presented in the context of a listing and should display the price per night.

RoomItemView preview

Next, createRoomsView.swiftas the view responsible for showing the room listings.

import Amplify
import SwiftUI

struct RoomsView: View {

@StateObject var viewModel = ViewModel()

var body: some View {
NavigationView {
ScrollView {
LazyVStack {
// 1
ForEach(viewModel.rooms) { room in
NavigationLink(
// 2
destination: RoomDetailsView(room: room),
label: { RoomItemView(room: room, bookingDates: nil) }
)
.accentColor(Color(.label))
.padding()
}
}
}
.navigationTitle( "Rooms" )
}
// 3
.onAppear(perform: viewModel.getRooms)
}
}

extension RoomsView {
class ViewModel: ObservableObject {
@Published var rooms = [Room]()

// 4
func getRooms() {

}
}
}
  1. We’ll loop over each of theRoomobjects provided by theViewModeland pass them into the destination and label. There will be an error presented sinceRoomdoesn’t conform toIdentifiableat the moment.
  2. The destination will be aRoomDetailsViewthat we will be implementing next.
  3. Whenever theRoomViewappears, we will query the database for the latestRoomobjects.
  4. getRooms()will be responsible for querying DataStore for all theRoom‘s.

RoomsView preview

Let’s implement the functionality ofgetRooms():

... // func getRooms() {

Amplify.DataStore.query(Room.self) { result in
do {
let rooms = try result.get()
print(rooms)
DispatchQueue.main.async { [weak self] in
self?.rooms = rooms
}
} catch {
print(error)
}
}

... // getRooms closing }

This is as simple as querying theRoom'sand updating theViewModelwith whatever comes back.

There are two errors we need to resolve at this point,Roomneeds to conform toIdentifiableandRoomDetailsViewneeds to be implemented.

In a new file namedRoom+Extensions.swiftadd the following:

import Foundation

extension Room: Identifiable {}

Any extensions on generated objects should take place in a separate file so the changes aren’t overwritten when updating any model schemas in the future.

Add the following to a new file namedRoomDetailsView.swift:

import Amplify
import SwiftUI

struct RoomDetailsView: View {

@Environment(\.presentationMode) var presentationMode
// 1
@EnvironmentObject var sessionManager: SessionManager
@StateObject var viewModel = ViewModel()

let room: Room

// 2
var currentUser: User? {
sessionManager.currentUser
}

var body: some View {
VStack {
Image(room.imageKey)
.resizable()
.aspectRatio(contentMode:.fill)
.frame(height: 250)

VStack(alignment:.leading) {

Text(room.description)
.font(.title)

Text(room.city)
.font(.title3)

Spacer()

DatePicker( "Check In", selection: $viewModel.checkInDate, displayedComponents:.date)

DatePicker( "Check Out", selection: $viewModel.checkOutDate, displayedComponents:.date)

Button( "Book for $\(room.price)" ) {
guard let currentUser = self.currentUser else { return }
viewModel.book(room, for: currentUser)
presentationMode.wrappedValue.dismiss()
}
.frame(maxWidth:.infinity)
.padding()
.foregroundColor(.white)
.background(Color.purple)
.clipShape(Capsule())
.padding(.vertical)
}
.padding()
}
.navigationBarTitle( "Details", displayMode:.inline)
}
}

extension RoomDetailsView {
class ViewModel: ObservableObject {

@Published var checkInDate: Date = Calendar.current.date(
byAdding:.day,
value: 1,
to: Date()
)!

@Published var checkOutDate = Calendar.current.date(
byAdding:.day,
value: 2,
to: Date()
)!

// 3
func book(_ room: Room, for user: User) {

}
}
}
  1. RoomDetailsView will be passed the instance of the SessionManager created in the *App object as an EnvironmentObject.
  2. The SessionManager provides the currentUser which can be used to book a Room.
  3. The book(_:for:) method will be responsible for booking a Room for a specified User.

Room details view

To book a room, we will create a newBookinginstance and save it to DataStore. Update thebook(_:for:)method.

... // func book(_ room: Room, for user: User) {

let booking = Booking(
room: room,
guestId: user.id,
checkInDate: Temporal.Date(checkInDate),
checkOutDate: Temporal.Date(checkOutDate)
)

Amplify.DataStore.save(booking) { result in
do {
let savedBooking = try result.get()
print( "booked", savedBooking)

} catch {
print(error)
}
}

... // book(_:for:) closing }

The only view we’re missing isMyBookingsViewwhich will be responsible for showing the user the differentRooms they booked and for which dates.

import Amplify
import SwiftUI

struct MyBookingsView: View {

@EnvironmentObject var sessionManager: SessionManager
@StateObject var viewModel = ViewModel()

var currentUser: User? {
sessionManager.currentUser
}

var body: some View {
NavigationView {
ScrollView {
LazyVStack {
// 1
ForEach(viewModel.bookings) { booking in
RoomItemView(
room: booking.room,
// 2
bookingDates: booking.bookingDates
)
}
}
.padding(.horizontal)
}
.navigationTitle( "My Bookings" )
}
// 3
.onAppear {
guard let currentUser = self.currentUser else { return }
viewModel.getBookings(for: currentUser)
}
}
}

extension MyBookingsView {
class ViewModel: ObservableObject {
@Published var bookings = [Booking]()

func getBookings(for user: User) {

}
}
}
  1. We will display aRoomItemViewfor eachBookingprovided by theViewModel.Similar toRoom,Bookingwill need to conform toIdentifiableto be used byForEach.
  2. Booking also doesn’t have a property calledbookingDateswhich is a tuple of thecheckInDateandcheckOutDate.
  3. Every timeMyBookingsViewappears, we will query DataStore for the latestBooking‘s for the current user.

MyBookingsView preview

MyBookingsViewis simply showingRoomItemView‘s the same wayRoomsViewis; however, we need to provide theRoomfrom a booking that is associated with the current user and not queried directly onRoomitself. To accomplish getting the list ofRoom‘s, we must perform a query onBookingwhere theguestIdmatches the current user ID.

Add the following togetBookings(for:):

let booking = Booking.keys

Amplify.DataStore.query(
Booking.self,
where: booking.guestId == user.id,
sort:.ascending(booking.checkInDate)
) { result in
do {
let bookings = try result.get()
print( "bookings", bookings)
DispatchQueue.main.async { [weak self] in
self?.bookings = bookings
}
} catch {
print(error)
}
}

This query provides a predicate that specifies thatbooking.guestIdmatchesuser.idand all results that come back should be sorted in ascending order based on the check in date. Then we update thebookingsof theViewModelso the list can be shown in the view.

To fix the compiler errors, create another file namedBooking+Extensions.swiftwith the following code:

import Foundation

extension Booking: Identifiable {}

extension Booking {
var bookingDates: (checkInDate: Date, checkOutDate: Date) {
(checkInDate.foundationDate, checkOutDate.foundationDate)
}
}

MakingBookingconform toIdentifiablewill allow each object to be managed by SwiftUI when iterating over them in aForEach.ThebookingDatesproperty makes for a cleaner call site when passing the tuple of check in and check out dates; especially since those properties are Amplify wrappers aroundFoundation.Dateand notFoundation.Date‘s themselves.

With that last view out of the way, we can update the*Appobject to handle a user logging in and selecting a tab. Add this to thebodyin*App.swift:

... // WindowGroup {

if sessionManager.currentUser!= nil {
TabView {
RoomsView()
.tabItem { Image(systemName: "list.bullet.below.rectangle" ) }

MyBookingsView()
.tabItem { Image(systemName: "calendar" ) }
}
.environmentObject(sessionManager)

} else {
LoginView { user in
sessionManager.currentUser = user
}
}

... // WindowGroup closing }

When aUseris provided byLoginView,the user will be displayed a tabbed view of available rooms and their bookings. Everything is all setup and ready to work, the problem right now is that there aren’t anyRoom‘s to display or book. Since this is a prototype, we can simply seed someRoom‘s into our backend.

In a file calledDataSeeder.swiftcreate a method that will seed someRoom‘s into DataStore.

import Amplify
import SwiftUI

class DataSeeder {
static func seedRooms() {
let seededRooms: [Room] = [
Room(
description: "One king size bed",
city: "Los Angeles",
price: 100,
imageKey: "stockphoto-1"
),
Room(
description: "Two full sized beds",
city: "Pasadena",
price: 64,
imageKey: "stockphoto-2"
),
Room(
description: "Whole studio apartment",
city: "El Segundo",
price: 120,
imageKey: "stockphoto-3"
),
]

seededRooms.forEach { room in
Amplify.DataStore.save(room) { print( "seeded", $0) }
}
}
}

Back in*App.swift,create a function that will check if there are anyRoom‘s already saved; if not, seed thoseRoom‘s.

func seedRoomsIfNeeded() {
Amplify.DataStore.query(Room.self) { result in
guard (try? result.get().isEmpty) == true else { return }
DataSeeder.seedRooms()
}
}

Now call that method in the*Appinitializer:

... // configureAmplify()

seedRoomsIfNeeded()

... // init closing }

Now if you run the app, you should be able to “login”, view rooms as well as their details, book a room, and see the rooms booked by that account.

App flow gif

Conclusion

AWS Amplify is a powerful toolset that makes life easier for developers; whether that means building out a quick prototype, like we did here, or creating a large app with thousands of users. The new Admin UI makes it easier to visualize your model schema and create connections between different objects, saving time by making connections between objects more apparent and easy to understand. All of this shortens the time to get the backend configured and spend more time on the client facing app.

This was just a glimpse of what is possible with Amplify Admin UI; there’s still Authentication, Storage, Predictions, and much more that can be managed with the new interface. It looks like prototyping apps is only going to get easier from here?