Front-End Web & Mobile
Building a Cash Flow App with Amplify DataStore and SwiftUI
This article was written by Kyle Lee, Sr. Developer Advocate, AWS.
In May 2020, AWS launched the open-source Amplify Libraries for both iOS and Android, adding to AWS Amplify’s set of tools and services for mobile and front-end web developers. Amplify Libraries make it easy for developers to implement common features like authentication, data synchronization, file storage, and more.
In this post, you will learn how to build Native iOS apps with SwiftUI and Amplify DataStore, a library that focuses on providing a seamless experience for shared and distributed data that offers a simple solution to local persistence.
In this tutorial, you will be using SwiftUI and DataStore’s local persistence to create an app called Cash Flow. This will be a simple app that allows the user to keep track of their transactions by adding and subtracting inputted amounts to and from their account balance.
Topics we’ll be covering
- Configuring AWS Amplify
- Simple GraphQL data modeling
- Using Amplify DataStore as a persistence layer
- Create
- Read
- Update
Prerequisites
- Install Node.js version 10 or higher
- Install Xcode version 10.2 or later
- Install CocoaPods
- Install Amplify CLI version 4.21.0 or later
Building the App
Configuring the project
First, open Xcode and create a new Single View App.
Name the project whatever you would like; I will be using the name Cash-Flow.
Note: It is strongly recommended that your project name does not contain spaces as this could cause complications with Amplify as well as any other dependencies you decide to add to the project.
In this tutorial, we will be using SwiftUI.
Laying out the UI
If we examine the UI that we are trying to build, we can see that it is made up of several VStack
views (shown in shades of blue) and an HStack
view (shown in orange). We also have some Spacer
views (shown in black) that help align our content towards the top of the screen.
Let’s start building this out in code, starting with each of the stacks views we will be using. In the body
property of ContentView.swift, add the following:
var body: some View {
// 1
VStack {
// 2
Spacer()
.frame(height: 50)
// 3
VStack {
Text("Current Balance")
.fontWeight(.medium)
Text("$0.00")
.font(.system(size: 60))
}
// 4
Spacer()
}
}
- The
VStack
that will hold all the content on our screen. - A
Spacer
that adds 50px of space from the top of the screen. - The
VStack
that groups together the “Current Balance”Text
view and anotherText
view that will be responsible for displaying the dynamic account balance. We are using “$0.00” as a placeholder for now. - Another
Spacer
responsible for pushing the content towards the top of the screen.
Next, let’s add the VStack
that is responsible for holding the interactable views (TextField
and two Button
views). This will be below the VStack
holding the Current Balance Text
view value and above the bottom Spacer
.
... // Current balance VStack
// 1
Spacer()
.frame(height: 150)
// 2
VStack(spacing: 20) {
// 3
VStack {
Text("Transaction Amount")
.fontWeight(.medium)
// 4
TextField("Amount", text: amountFormatterBinding)
.font(.largeTitle)
.multilineTextAlignment(.center)
.keyboardType(.decimalPad)
}
// 5
HStack(spacing: 50) {
Button(action: {}, label: {
Image(systemName: "plus")
.padding()
.background(Color.green)
.clipShape(Circle())
.font(.largeTitle)
.foregroundColor(.white)
})
Button(action: {}, label: {
Image(systemName: "minus")
.padding()
.background(Color.red)
.clipShape(Circle())
.font(.largeTitle)
.foregroundColor(.white)
})
}
}
... // Bottom Spacer
- Add some padding between the Current Balance
VStack
. - We have setup a new
VStack
that is adding 20px of spacing between each of its children. In this case, theVStack
only has two children, anotherVStack
and aHStack
. - This
VStack
will group ourText
view andTextField
together. - We are creating a
TextField
that will be binding its text to a property calledamountFormatterBinding
which we have not defined yet. - The
HStack
will group our two circular buttons together. The green plus button will be used to add the amount to the balance and the red minus button will subtract the amount from the balance. We will provide the actions soon.
In step 3 above, we used a property called amountFormatterBinding
which hasn’t been defined yet. amountFormatterBinding
will be responsible for setting the entered text to a Double
as well as getting the Double
and converting that to a currency formatted String
. This means we will need to have a stored @State
property for our Double
value as well as a NumberFormatter
to handle the conversion between String
and Double
Add the following at the above the body property in ContentView.swift:
... // struct ContentView
// 1
@State var amount: Double = 0.0
// 2
private let currencyFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter
}()
// 3
var amountFormatterBinding: Binding<String> {
Binding<String>(
get: {
self.currencyFormatter
.string(from: NSNumber(value: self.amount))
?? ""
},
set: { newAmount in
self.amount = self.currencyFormatter.number(from: newAmount)?
.doubleValue
?? 0
}
)
}
... // var body
- Our stored
@State
property which will hold theDouble
value of the amount to be applied towards the balance. (Note: This specific value will always be positive)\ - A
NumberFormatter
configured to format numbers as currency. - We are creating a
Binding<String>
by reading fromself.amount
(defined in step 1) as a currency formattedString
and setting thenewAmount
as a number from the currency formattedString
.
Note: We are forced to manually create
amountFormatterBinding
because we needamount
to be updated as the user types, but using something likeTextField(_ title: StringProtocol, value: Binding<T>, formatter: Formatter)
will only updateamount
whenever the user taps the Return key on the keyboard, which a.decimalPad
does not have.
The UI layout is complete at this point:
Let’s finish up the basics by creating the add()
and subtract()
methods which can be set as the actions for our buttons. Below the body
property and before the ContentView
closing brace, add the following:
... // body closing }
// 1
func resetAmount() {
amount = 0
}
// 2
func add() {
resetAmount()
}
func subtract() {
resetAmount()
}
... // ContentView closing }
- This will be used to zero out the amount after it has been applied to the account balance.
- We have two very similar functions, one to add to the account balance and the other to subtract from it. After either action, we will need to reset the amount to avoid duplicated transactions.
Configure Dependencies
Now that we have our UI laid out, we can start adding DataStore to the app.
Let’s start by adding CocoaPods to our project. Open your terminal and run the following:
pod init
Now that our project contains a Podfile
, go ahead and open it in your favorite editor and add the following pods:
pod 'Amplify'
pod 'Amplify/Tools'
pod 'AmplifyPlugins/AWSAPIPlugin'
pod 'AmplifyPlugins/AWSDataStorePlugin'
The entire file should look something like this:
platform :ios, '13.0'
target 'Cash-Flow' do
use_frameworks!
# Pods for Cash-Flow
pod 'Amplify'
pod 'Amplify/Tools'
pod 'AmplifyPlugins/AWSAPIPlugin'
pod 'AmplifyPlugins/AWSDataStorePlugin'
end
Keep in mind that the platform should be set to iOS 11 or higher or else Amplify won’t be able to properly install.
Next will will start the installation of the pods by running the following in the Terminal:
pod install --repo-update
Note: It is recommended that you close Xcode while the pods are installed.
Once the pods are installed, open up the .xcworkspace
file. You can do this by running the following command in the Terminal:
xed .
Try building and running the project just to make sure everything is still working. Cmd + R
Next we will be adding a new run script to the project so that the Amplify Tools can run some magic in the background and configure our project for us.
Open up Build Phases
Create a new Run Script Phase
Drag the new Run Script Phase above the Compile Sources phase.
Rename the new Run Script Phase to “Run Amplify Tools“.
Add the following line to the “Run Amplify Tools” script area:
"${PODS_ROOT}/AmplifyTools/amplify-tools.sh"
Your Build Phases area should now look like this:
Now build the project. Cmd + B
Note: If you run into any errors during this build, try cleaning the project (
Cmd + Shift + K
) and building again (Cmd + B
).
Generating Account model
Now that our project is properly configured with Amplify, we can finally create our Account
model.
When we built our project, Amplify Tools generated some files and folders for us, one of which is schema.graphql in the AmplifyConfig folder. (Cash-Flow > AmplifyConfig > schema.graphql)
Open the file and replace all contents of the file with the following:
type Account @model {
id: ID!
balance: Float!
}
This is the schema for our Account
model. Let’s break this down a bit:
type
– this defines ourAccount
as an object, allowing a Swift Struct to be generated@model
– signifies thatAccount
should be stored in a database tableID
– the UUID for our object to distinguish between different objectsFloat
– a floating point number which will be generated as a Swift Double!
– indicates that this is a non-optional value (not necessarily force unwrapped though)
We are all set to generate our Account
object. Navigate to amplifytools.xcconfig (Cash-Flow > AmplifyConfig > amplifytools.xcconfig) and update the following value:
modelgen=true
This will tell Amplify Tools that we want our model to be generated.
Build the project. Cmd + B
Note: Again, if errors occur during the build, clean the project (Cmd + Shift + K) then build again (Cmd + B).
Once we see the AmplifyModels folder generated in our Navigation Panel, we can let Amplify Tools know that we no longer need to generate models:
modelgen=false
Using DataStore
Before we can start using DataStore, we need to make sure we properly configure Amplify.
Navigate to AppDelegate.swift and add the following import statements at the top of the file:
import Amplify
import AmplifyPlugins
Now in the didFinishLaunchingWithOptions
method, add the Amplify configuration code:
... // didFinishLaunchingWithOptions {
do {
// 1
try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: AmplifyModels()))
// 2
try Amplify.configure()
print("Amplify initialized successfully")
} catch {
print("Could not initialize Amplify \(error)")
}
... // return tru
- We need to initialize
AWSDataStorePlugin
, passing in the generated models (Account
). - We configure Amplify now that we have added the
AWSDataStorePlugin
plugin.
Run the app to make sure Amplify is configured successfully. Cmd + R
Once we have Amplify initialized in our project, we can start using DataStore. Let’s head back over to ContentView.swift.
Start by adding the import
statement at the top of the file:
import Amplify
... // import SwiftUI
Earlier we set a placeholder value ($0.00) for the user’s account balance. We are going to replace that placeholder with a real balance. However, we need to keep in mind that Account.balance
is of type Double
and we need to have the displayed balance as a currency formatted String
. We will create a computed property called balance
that will handle this conversion for us.
Add the following towards the top of the ContentView:
... // @State var amount: Double = 0.0
// 1
@State var account: Account?
// 2
var balance: String {
let accountBalance = NSNumber(value: self.account?.balance ?? 0)
let currencyBalance = self.currencyFormatter.string(from: accountBalance)
return currencyBalance ?? "$0.00"
}
... // private let currencyFormatter: NumberFormatter = {
- We will be retrieving the
Account
object from DataStore through an asynchronous operation so we need account to be optional as it will have no value when the view initially loads. - The
balance
will read fromaccount.balance
and convert the value to a currency formattedString
and will return our placeholder $0.00 if something goes wrong.
Now we can update the placeholder value ($0.00) that is currently being used by the “Current Balance” Text
view:
... // Text("Current Balance")
Text(balance)
... // .font(.system(size: 60))
If we run the app now, we should see the same thing as before since the account balance default value is $0.00.
In order to populate the account balance with real data, we will need to create an Account
object in DataStore. Let’s write a function that will handle the creation process for us:
... // body closing }
func createAccount() {
// 1
let account = Account(id: Self.currentUserId, balance: 0)
// 2
Amplify.DataStore.save(account) { result in
switch result {
case .success(let createdAccount):
print("Account created - \(createdAccount)")
self.account = createdAccount
case .failure(let error):
print("Could not create account - \(error)")
}
}
}
... // func resetAmount() {
- We start by creating an Account by using a constant
currentUserId
(we haven’t defined this yet) for theid
and a balance of 0. - Now we will attempt to save the newly created Account to DataStore. As long as the save is successful, we can set
self.account
to thecreatedAccount
.
Let also define our constant that we will be using as the id
of our Account
:
... // ContentView closing }
extension ContentView {
fileprivate static let currentUserId = "currentUserId"
}
... // struct ContentView_Previews: PreviewProvider {
The createAccount()
method is ready to go, but we only want to create an account if there isn’t one already. We should run a query once the ContentView appears that will check DataStore for our Account
object and only create an Account
if one doesn’t exist. Add the following method:
... // createAccount() closing }
func getAccount() {
// 1
Amplify.DataStore.query(Account.self, byId: Self.currentUserId) { (result) in
// 2
switch result {
case .success(let queriedAccount):
// 3
if let queriedAccount = queriedAccount {
print("Found account - \(queriedAccount)")
self.account = queriedAccount
} else {
print("No account found")
self.createAccount()
}
case .failure(let error):
print("Could not perform query for account - \(error)")
}
}
}
... // func resetAmount() {
- We query DataStore for an
Account
object that has our constantcurrentUserId
. - We then receive a result that determines whether the query itself was successful or not. This is not whether the object exists or not.
- If the query was successful, we can verify if there was an
Account
returned or not. If there was an account, we setself.account
; but if not, we create the account.
Now we add getAccount()
to body.onAppear
:
... // VStack closing }
.onAppear(perform: getAccount)
... // body closing }
If we run the app now, we should see “Account created” in the logs. If we run the app a second time, we should see “Found account” in the logs.
At this point, we are ready to start updating Account.balance
and saving the changes to DataStore:
... // subtract() closing }
func updateBalance(to newBalance: Double) {
// 1
guard var account = self.account else { return }
// 2
account.balance = newBalance
// 3
Amplify.DataStore.save(account) { result in
// 4
switch result {
case .success(let updatedAccount):
print("Updated balance to - \(updatedAccount.balance)")
self.account = updatedAccount
case .failure(let error):
print("Could not update account - \(error)")
}
}
}
... // ContentView closing }
- Verify that
self.account
has successfully been set by thegetAccount()
method. - Update the local
account.balance
to thenewBalance
. - Attempt to save the updated
Account
to DataStore - Check the result to see if the save was successful or not. If the
Account
was saved, then we can set theupdatedAccount
toself.account
which is used to populate our UI.
Now we need to add the updateBalance(to:)
method to our add()
and subtract()
methods, and we are good to go.
func add() {
let currentBalance = account?.balance ?? 0
// 1
let newBalance = currentBalance + amount
//2
updateBalance(to: newBalance)
resetAmount()
}
func subtract() {
let currentBalance = account?.balance ?? 0
// 1
let newBalance = currentBalance - amount
//2
updateBalance(to: newBalance)
resetAmount()
}
- Depending on which method is called, we will either add or subtract the amount from the unwrapped
currentBalance
. - We then pass the
newBalance
toupdateBalance(to:)
so the updated balance is saved.
Finally, update the Buttons to call the add and subtract methods:
// ...HStack(spacing: 50) {
Button(action: add, label: {
// ...})
Button(action: subtract, label: {
// ...Image(systemName: "minus")
Now we’re all done with the code ?
Run the code and give the app a try ?
Deploying to the cloud
So far the app is only working locally with the DataStore engine, so let’s deploy the API service to AWS.
To do so, first make sure you have the Amplify CLI installed and configured:
npm install @aws-amplify/cli
amplify configure
For a video walkthrough of how to install and configure the Amplify CLI, click here.
Next, initialize an Amplify project from the root of your Xcode project:
amplify init
? Enter a name for the environment: dev
? Choose your default editor: (your default text editor)
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use: (your AWS profile)
Once the project has finished initializing, deploy the back end by running the push
command:
amplify push
? Are you sure you want to continue? Y
? Do you want to generate code for your newly created GraphQL API: N
Your back end has been deployed.
Next, update AppDelegate.swift to add the AWSAPIPlugin
plugin:
// ... do {
try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
// ... try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: AmplifyModels()))
Now, rebuild your app to sync to the cloud. CMD + B
When you make an update, the changes should now be synced to your back end.
You can view your app and database at any time in the Amplify Console by running the following command in the terminal:
amplify console
To learn more about Amplify DataStore, check out the documentation here. And to learn more about dedicated tools and services for mobile and front-end web developers visit AWS Amplify.