Day 50
Day 50 êŽë š
Project 10, part 2
Today weâre going to be building the user interface for our app â everything apart from the part where we handle the networking.
Although the fundamentals of todayâs work will be familiar to you, thereâs still scope for new things as youâll see. This will become particularly common as we continue to push SwiftUIâs boundaries â everything is easy when your app is easy, but as we venture more into larger apps youâll find we need to spend more time getting the details right.
But thatâs OK. As American tire magnate Harvey Firestone once said, âsuccess is the sum of details.â I hope you can look at Appleâs iOS apps and be inspired by them: their UI is often uncomplicated, but they put a ton of work into getting the details right so the whole experience feels great.
When the user launches your app on their $1000 iPhone, it takes up the full screen. You owe it to them, and to yourself, to make sure youâve done your best to get things working as smoothly as possible. If Apple can do it, so can we!
Today you have three topics to work through, in which youâll use property observers, observed objects, disabled()
, and more.
Taking basic order details
Taking basic order details
The first step in this project will be to create an ordering screen that takes the basic details of an order: how many cupcakes they want, what kind they want, and whether there are any special customizations.
Before we get into the UI, we need to start by defining the data model. Previously weâve used @State
for simple value types and @StateObject
for reference types, and weâve looked at how itâs possible to have an ObservableObject
class containing structs inside it so that we get the benefits of both.
Here weâre going to take a different solution: weâre going to have a single class that stores all our data, which will be passed from screen to screen. This means all screens in our app share the same data, which will work really well as youâll see.
For now this class wonât need many properties:
- The type of cakes, plus a static array of all possible options.
- How many cakes the user wants to order.
- Whether the user wants to make special requests, which will show or hide extra options in our UI.
- Whether the user wants extra frosting on their cakes.
- Whether the user wants to add sprinkles on their cakes.
Each of those need to update the UI when changed, which means we need to mark them with @Published
and make the whole class conform to ObservableObject
.
So, please make a new Swift file called Order.swift
, change its Foundation import for SwiftUI, and give it this code:
class Order: ObservableObject {
static let types = ["Vanilla", "Strawberry", "Chocolate", "Rainbow"]
@Published var type = 0
@Published var quantity = 3
@Published var specialRequestEnabled = false
@Published var extraFrosting = false
@Published var addSprinkles = false
}
We can now create a single instance of that inside ContentView
by adding this property:
@StateObject var order = Order()
Thatâs the only place the order will be created â every other screen in our app will be passed that property so they all work with the same data.
Weâre going to build the UI for this screen in three sections, starting with cupcake type and quantity. This first section will show a picker letting users choose from Vanilla, Strawberry, Chocolate and Rainbow cakes, then a stepper with the range 3 through 20 to choose the amount. All that will be wrapped inside a form, which is itself inside a navigation view so we can set a title.
Thereâs a small speed bump here: our cupcake topping list is an array of strings, but weâre storing the userâs selection as an integer â how can we match the two? One easy solution is to use the indices
property of the array, which gives us a position of each item that we can then use with as an array index. This is a bad idea for mutable arrays because the order of your array can change at any time, but here our array order wonât ever change so itâs safe.
Put this into the body of ContentView
now:
NavigationView {
Form {
Section {
Picker("Select your cake type", selection: $order.type) {
ForEach(Order.types.indices) {
Text(Order.types[$0])
}
}
Stepper("Number of cakes: \(order.quantity)", value: $order.quantity, in: 3...20)
}
}
.navigationTitle("Cupcake Corner")
}
The second section of our form will hold three toggle switches bound to specialRequestEnabled
, extraFrosting
, and addSprinkles
respectively. However, the second and third switches should only be visible when the first one is enabled, so weâll wrap then in a condition.
Add this second section now:
Section {
Toggle("Any special requests?", isOn: $order.specialRequestEnabled.animation())
if order.specialRequestEnabled {
Toggle("Add extra frosting", isOn: $order.extraFrosting)
Toggle("Add extra sprinkles", isOn: $order.addSprinkles)
}
}
Go ahead and run the app again, and try it out â notice how I bound the first toggle with an animation()
modifier attached, so that the second and third toggles slide in and out smoothly.
However, thereâs another bug, and this time itâs one of our own making: if we enable special requests then enable one or both of âextra frostingâ and âextra sprinklesâ, then disable the special requests, our previous special request selection stays active. This means if we re-enable special requests, the previous special requests are still active.
This kind of problem isnât hard to work around if every layer of your code is aware of it â if the app, your server, your database, and so on are all programmed to ignore the values of extraFrosting
and addSprinkles
when specialRequestEnabled
is set to false. However, a better idea â a safer idea â is to make sure that both extraFrosting
and addSprinkles
are reset to false when specialRequestEnabled
is set to false.
We can make this happen by adding a didSet
property observer to specialRequestEnabled
. Add this now:
@Published var specialRequestEnabled = false {
didSet {
if specialRequestEnabled == false {
extraFrosting = false
addSprinkles = false
}
}
}
Our third section is the easiest, because itâs just going to be a NavigationLink
pointing to the next screen. We donât have a second screen, but we can add it quickly enough: create a new SwiftUI view called âAddressViewâ, and give it an order
observed object property like this:
struct AddressView: View {
@ObservedObject var order: Order
var body: some View {
Text("Hello World")
}
}
struct AddressView_Previews: PreviewProvider {
static var previews: some View {
AddressView(order: Order())
}
}
Weâll make that more useful shortly, but for now it means we can return to ContentView.swift
and add the final section for our form. This will create a NavigationLink
that points to an AddressView
, passing in the current order object.
Please add this final section now:
Section {
NavigationLink {
AddressView(order: order)
} label: {
Text("Delivery details")
}
}
That completes our first screen, so give it a try one last time before we move on â you should be able to select your cake type, choose a quantity, and toggle all the switches just fine.
Checking for a valid address
Checking for a valid address
The second step in our project will be to let the user enter their address into a form, but as part of that weâre going to add some validation â we only want to proceed to the third step if their address looks good.
We can accomplish this by adding a Form
view to the AddressView
struct we made previously, which will contain four text fields: name, street address, city, and zip. We can then add a NavigationLink
to move to the next screen, which is where the user will see their final price and can check out.
To make this easier to follow, weâre going to start by adding a new view called CheckoutView
, which is where this address view will push to once the user is ready. This just avoids us having to put a placeholder in now then remember to come back later.
So, create a new SwiftUI view called CheckoutView
and give it the same Order
observed object property and preview that AddressView
has:
struct CheckoutView: View {
@ObservedObject var order: Order
var body: some View {
Text("Hello, World!")
}
}
struct CheckoutView_Previews: PreviewProvider {
static var previews: some View {
CheckoutView(order: Order())
}
}
Again, weâll come back to that later, but first letâs implement AddressView
. Like I said, this needs to have a form with four text fields bound to four properties from our Order
object, plus a NavigationLink
passing control off to our check out view.
First, we need four new @Published
properties in Order
to store delivery details:
@Published var name = ""
@Published var streetAddress = ""
@Published var city = ""
@Published var zip = ""
Now replace the existing body
of AddressView
with this:
Form {
Section {
TextField("Name", text: $order.name)
TextField("Street Address", text: $order.streetAddress)
TextField("City", text: $order.city)
TextField("Zip", text: $order.zip)
}
Section {
NavigationLink {
CheckoutView(order: order)
} label: {
Text("Check out")
}
}
}
.navigationTitle("Delivery details")
.navigationBarTitleDisplayMode(.inline)
As you can see, that passes our order
object on one level deeper, to CheckoutView
, which means we now have three views pointing to the same data.
Go ahead and run the app again, because I want you to see why all this matters. Enter some data on the first screen, enter some data on the second screen, then try navigating back to the beginning then forward to the end â that is, go back to the first screen, then click the bottom button twice to get to the checkout view again.
What you should see is that all the data you entered stays saved no matter what screen youâre on. Yes, this is the natural side effect of using a class for our data, but itâs an instant feature in our app without having to do any work â if we had used a struct, then any address details we had entered would disappear if we moved back to the original view. If you really wanted to use a struct for your data, you should follow the same struct inside class approach we used back in project 7; itâs certainly worth keeping it in mind when you evaluate your options.
Now that AddressView
works, itâs time to stop the user progressing to the checkout unless some condition is satisfied. What condition? Well, thatâs down to us to decide. Although we could write length checks for each of our four text fields, this often trips people up â some names are only four or five letters, so if you try to add length validation you might accidentally exclude people.
So, instead weâre just going to check that the name
, streetAddress
, city
, and zip
properties of our order arenât empty. I prefer adding this kind of complex check inside my data, which means you need to add a new computed property to Order
like this one:
var hasValidAddress: Bool {
if name.isEmpty || streetAddress.isEmpty || city.isEmpty || zip.isEmpty {
return false
}
return true
}
We can now use that condition in conjunction with SwiftUIâs disabled()
modifier â attach that to any view along with a condition to check, and the view will stop responding to user interaction if the condition is true.
In our case, the condition we want to check is the computed property we just wrote, hasValidAddress
. If that is false, then the form section containing our NavigationLink
ought to be disabled, because we need users to fill in their delivery details first.
So, add this modifier to the end of the second section in AddressView
:
.disabled(order.hasValidAddress == false)
The code should look like this:
Section {
NavigationLink {
CheckoutView(order: order)
} label: {
Text("Check out")
}
}
.disabled(order.hasValidAddress == false)
Now if you run the app youâll see that all four address fields must contain at least one character in order to continue. Even better, SwiftUI automatically grays out the button when the condition isnât true, giving the user really clear feedback when it is and isnât interactive.
Preparing for checkout
Preparing for checkout
The final screen in our app is CheckoutView
, and itâs really a tale of two halves: the first half is the basic user interface, which should provide little real challenge for you; but the second half is all new: we need to encode our Order
class to JSON, send it over the internet, and get a response.
Weâre going to look at the whole encoding and transferring chunk of work soon enough, but first letâs tackle the easy part: giving CheckoutView
a user interface. More specifically, weâre going to create a ScrollView
with an image, the total price of their order, and a Place Order button to kick off the networking.
For the image, Iâve uploaded a cupcake image to my server that weâll load remotely with AsyncImage
â we could store it in the app, but having a remote image means we can dynamically switch it out for seasonal alternatives and promotions.
As for the order cost, we donât actually have any pricing for our cupcakes in our data, so we can just invent one â itâs not like weâre actually going to be charging people here. The pricing weâre going to use is as follows:
- Thereâs a base cost of $2 per cupcake.
- Weâll add a little to the cost for more complicated cakes.
- Extra frosting will cost $1 per cake.
- Adding sprinkles will be another 50 cents per cake.
We can wrap all that logic up in a new computed property for Order
, like this:
var cost: Double {
// $2 per cake
var cost = Double(quantity) * 2
// complicated cakes cost more
cost += (Double(type) / 2)
// $1/cake for extra frosting
if extraFrosting {
cost += Double(quantity)
}
// $0.50/cake for sprinkles
if addSprinkles {
cost += Double(quantity) / 2
}
return cost
}
The actual view itself is straightforward: weâll use a VStack
inside a vertical ScrollView
, then our image, the cost text, and button to place the order.
Weâll be filling in the buttonâs action in a minute, but first letâs get the basic layout done â replace the existing body
of CheckoutView
with this:
ScrollView {
VStack {
AsyncImage(url: URL(string: "https://hws.dev/img/cupcakes@3x.jpg"), scale: 3) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
ProgressView()
}
.frame(height: 233)
Text("Your total is \(order.cost, format: .currency(code: "USD"))")
.font(.title)
Button("Place Order", action: { })
.padding()
}
}
.navigationTitle("Check out")
.navigationBarTitleDisplayMode(.inline)
That should all be old news for you by now. But the tricky part comes nextâŠ