45.
Building the Bullseye Interface in SwiftUI
Written by Joey deVilla
You’ve just finished writing the one-button app. It’s time to turn it into a basic version of Bullseye.
You’re no longer a new programmer. Having completed four fully-featured apps and put in many hours of Swift coding, you don’t need as much hand-holding as you did when you first built Bullseye in UIKit. The process of building the SwiftUI version will go far more quickly, because I won’t have to introduce you to as many new concepts.
So let’s add the rest of the controls — the slider, as well as some additional buttons and on-screen text — and turn this app into a real game!
When you’ve finished this chapter, the app will look like this:
As with your first few versions of Bullseye, it won’t be pretty, but it will be functional.
In this chapter, you’ll cover the following:
- Laying out the game’s views: You’ll set up the controls — or more accurately, the views — on Bullseye’s main screen, SwiftUI style!
- Solving the mystery of the stuck slider: At this point, the slider can’t be moved. Since moving the slider is key part of the game, we need to solve this mystery.
- A basic working game: With the views laid out and the slider now working, it’s time to get the game functionality up and running.
- Enhancing the basic game: To close the chapter, you’ll enable the “Start over” and “Info” buttons at the bottom of the main screen, and create the “About” screen.
Laying out the game’s views
Converting the app to landscape
As you saw in the UIKit version, Bullseye works when it displays its view only in landscape. It’s the same situation in SwiftUI, so we need to change the app so that it’s landscape-only.
➤ In Project Navigator, whose icon looks like a file folder. Click the blue Bullseye SwiftUI project icon at the top of the Project Navigator’s list. The Editor and Canvas will disappear and your project’s configuration will be displayed.
➤ Make sure that you’ve selected the General tab:
In the Deployment Info section, there are a number of checkboxes in an area marked Device Orientation.
➤ Make sure that you’ve unchecked Portrait and Upside Down, and that you’ve checked Landscape Left and Landscape Right:
➤ Build and run the app. You’ll see that no matter which way you rotate the simulator, the app always stays in landscape orientation:
Reviewing views
You’re going to see the word “view” a lot in the rest of this book, so take a moment to quickly go over what “view” means. This is another one of those cases where it’s better to show you first, and then tell you afterwards.
Once again, here’s what the Bullseye screen will look like with the views laid out, this time with all the apparent views highlighted and labeled:
Some of the views are invisible, since they’re container views. You’ve already seen one of them in action — the VStack view, and you’ll see more soon.
A view is anything that gets drawn on the screen. In the screenshot above, it seems that everything is a view: The text items, the buttons and the slider are all views. In fact, every user interface control is a view.
Some views can act as containers for other views. The biggest view in the screenshot is one of these: It’s the view representing the screen, and it contains all the other views on the screen: The text items, the buttons and the slider.
Different types of views
There are different types of views. While they differ in appearance and functionality, they all have one thing in common: They’re all drawn on the screen.
What makes each type different is a combination of what they look like and what they do. So far, you’ve worked with a few of them:
-
Text: A view that displays one or more lines of read-only text. The “Welcome to my first app!” message in the one-button app is a
Textview. -
Button: A view that performs an action when triggered. In iOS, a user triggers a button when they complete a button press by releasing the button after pressing down on it. A
Buttonview does more than just respond to the triggering action — it can also contain other views. For example, “Hit me!” in the one-button app from the previous chapter contains aTextview, which defines the text inside the button. -
VStack: A view that acts as a container for other views and arranges them into a vertical stack. You used this to arrange the screen so that “Welcome to my first app!” is above “Hit me!”. Unlike the Text and Button views, the VStack view is invisible.
-
View: This is the most generic kind of view.
ContentView, the struct that defines the app’s one screen, is one of theseViewviews. It represents the entire screen and acts as a container for all the other views on the screen. I wouldn’t call this view “invisible”, but it’s something that the user generally doesn’t notice.
Take a look at those Bullseye screen views again, but with the specific types of views called out this time:
As I mentioned earlier, some of the views that will go on the game screen are invisible. One of them is a VStack, which you’ve already used. You’ll continue to use it to arrange the views into rows.
The screenshot below shows the rows that you’ll create using the VStack:
You’ll also need to arrange some rows side by side. To do this, you’ll use a similar control, HStack, which acts as a container for other views and arranges them into a horizontal stack (hence the name). You’ll use three HStack views, and you’ll put each one inside a VStack cell, as shown below:
Reviewing what you’ve built so far
Here’s ContentView, which defines the game screen so far:
struct ContentView: View {
@State var alertIsVisible: Bool = false
var body: some View {
VStack {
Text("Welcome to my first app!")
.fontWeight(.black)
.foregroundColor(.green)
Button(action: {
print("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.alert(isPresented: $alertIsVisible) {
Alert(title: Text("Hello there!"),
message: Text("This is my first SwiftUI alert."),
dismissButton: .default(Text("Awesome!")))
}
}
}
}
We’ve gone over this code before, but here’s a quick review of how this all works:
ContentView is the view representing the screen. The first line of the code begins with struct ContentView: View, which means that it conforms to the View protocol. You can also think of the line as saying “ContentView is a View”.
ContentView has two properties:
-
alertIsVisible: This property istrueif the app is currently displaying the alert pop-up, andfalseotherwise. It’sfalseby default, but changes totruewhen the user presses the Hit me! button, and then changes back tofalsewhen the user dismisses the alert pop-up.@Statemarks it as a state property, and something that SwiftUI should watch, and tells SwiftUI that it should be ready to take action if its contents change. -
body: This property defines the content, layout and behavior of the contents ofContentView. Right now,ContentViewcontains a VStack of two views: The “Welcome to my first app!”Textview and the Hit me!Buttonview.
Notice that the definition of body starts with the line body: some View. You should read this as “body is a some View.” The grammar of that sentence is a little strange, but it means that the body property can hold either a plain View or some other type of View, such as Text, Button or in this case, a VStack. The exact type of view is specified by the return value of the closure that follows some View.
The definition of body implies that it can hold only one view at a time. That would normally make for very simple, very boring apps. Luckily, some views can hold other views. VStack, HStack, Button and even View are examples of these. For Bullseye, we’ll fill body with a VStack, and fill that VStack with the other views that make up the game.
Formatting the code to be a little more readable
SwiftUI code tends to be a sea of curly braces, indents, and method calls. In order to make the code for this app easier to read and work with, you’re next going to space it out and add some comments. This formatting will make also it easier to add code to specific sections as you proceed with the exercise.
➤ Edit the code in ContentView.swift so that it looks like the code shown below. You won’t be deleting anything or changing any existing lines. You’ll be simply be adding blank lines and comments:
import SwiftUI
struct ContentView: View {
// Properties
// ==========
// User interface views
@State var alertIsVisible: Bool = false
// User interface content and layout
var body: some View {
VStack {
// Target row
Text("Welcome to my first app!")
.fontWeight(.black)
.foregroundColor(.green)
// Slider row
// TODO: Add views for the slider row here.
// Button row
Button(action: {
print("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.alert(isPresented: self.$alertIsVisible) {
Alert(title: Text("Hello there!"),
message: Text("This is my first SwiftUI alert."),
dismissButton: .default(Text("Awesome!")))
}
// Score row
// TODO: Add views for the score, rounds, and start and info buttons here.
}
}
// Methods
// =======
}
// Preview
// =======
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Now that you’ve set up the code, let’s go about the business of converting the SwiftUI one-button app to SwiftUI Bullseye!
Laying out the target row
Let’s start with the text at the top of Bullseye’s screen, highlighted below. It tells the user the target value they’re aiming for:
We could use a single Text object with both the challenge and the target value, but let’s go with two: One for the challenge and one for the target value.
These two Text objects should be side by side, which sounds like an opportunity to use an HStack. Here’s a screenshot with some extra graphics showing how the Text objects and HStack fit together:
There are a couple of ways to create this HStack. One way is to type the necessary code into the Editor. But instead, just for completeness’ sake, you’ll do it another way: By using the Canvas. You’ll embed the existing “Welcome to my first app!“ text into an HStack, and then you’ll change its text.
➤ If the Resume button is visible in the upper-right corner of the Canvas, press it.
➤ In the Canvas, command-click on Welcome to my first app!. Select Embed in HStack from the menu that appears:
This embeds the “Welcome to my first app!” view into an HStack. If you look at the Editor, you’ll see that the code in the Target row section has been updated to reflect what you did on the Canvas:
// Target row
HStack {
Text("Welcome to my first app!")
.fontWeight(.black)
.foregroundColor(.green)
}
Let’s work with the Canvas once more and use it to change the text.
➤ In the Canvas, command-click on Welcome to my first app!. Select Show SwiftUI Inspector… from the menu that appears:
➤ Use the inspector to change the text to “Put the bullseye as close as you can to:”:
This changes the Text view, and will also update the code in the editor:
// Target row
HStack {
Text("Put the bullseye as close as you can to:")
.fontWeight(.black)
.foregroundColor(.green)
}
➤ We don’t want the “Put the bullseye as close as you can to:” text to be green and bold, so remove the calls to the Text view’s fontWeight() and foregroundColor() methods so that the code in the Target row section looks like this:
// Target row
HStack {
Text("Put the bullseye as close as you can to:")
}
The next step is to add a new Text view to the HStack, to the right of the “Put the bullseye as close as you can to:” view. For now, it will contain the placeholder value “100”.
➤ Add a Text view to the HStack in the Target Row section so that it looks as shown below:
// Target row
HStack {
Text("Put the bullseye as close as you can to:")
Text("100")
}
➤ Build and run the app. You’ll see that the two Text views have replaced the “Welcome to my first app!” message:
Laying out the slider row
Your next task is to lay out the slider and the markings of its minimum value of 1 and maximum value of 100. These can be represented by a Text view, followed by a Slider view, followed by a Text view, all wrapped up in an HStack view:
This time, try setting this up by writing some code. After all, you have some examples of using HStack and Text in code already!
➤ In the Slider row section, replace the // TODO: Add views for the slider row here. line so that code looks like this:
// Slider row
HStack {
Text("1")
Slider(value: .constant(10))
Text("100")
}
➤ Build and run the app. The Slider row is now visible:
If you tried to move the slider, you probably noticed that it’s stuck on the right side. This has something to do with the .constant(10) that you gave as the Slider’s value: argument.
➤ Move the cursor over the .constant in the line Slider(value: .constant(10)) and option-click it. You should see this:
The summary text — “Creates a binding with an immutable value” — may sound like yet more cryptic Xcode technobabble to you now, but the words binding and immutable should be hints that it has something to do with state.
You’ll deal with the mystery of the stuck slider soon enough, but you’ll finish setting up the controls first.
Laying out the Button row
Here’s a little gift for you: The Button row’s already done!
Laying out the Score row
The final row is the one at the bottom of the VStack: The Score row, which has a number of views:
These views are:
- A Button view labeled Start over: The user will press this button to start a new game. It will reset the score to 0 and the round to 1.
- Two Text views for the score: One containing the text “Score” and one containing the score value. For now, the score will be set to a placeholder value of 999999.
- Two Round views: One containing the text “Round”, and one containing the number of the current round. For now, this number will be set to a placeholder value of 999.
- A Button view labeled Info: The user will press this button to get more information about the game. It will take the user to another screen, where they’ll see the additional information.
Don’t forget that these should be all in a row, which means that you need an HStack, so start with that.
➤ Update the Score row section so that it looks like this:
// Score row
HStack {
Button(action: {}) {
Text("Start over")
}
Text("Score:")
Text("999999")
Text("Round:")
Text("999")
Button(action: {}) {
Text("Info")
}
}
➤ Build and run the app. The Simulator should display this:
All the controls are there, but the app looks somewhat compressed. In the next section, you’ll fix that.
Introducing spacers
It’s time to bring some Spacer views into your app. As their name implies, these views are designed to fill up space.
When you put a Spacer view into an HStack, it expands to fill up the remaining horizontal space.
Next, see what happens when you use a spacer in the Score row.
➤ Add Spacer views to the Score row code so that it looks like this:
// Score row
HStack {
Button(action: {}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("999999")
Spacer()
Text("Round:")
Text("999")
Spacer()
Button(action: {}) {
Text("Info")
}
}
➤ Build and run the app. The Score row should look a lot less compressed:
Spacer views also work in VStack views. There, they expand to fill up the remaining vertical space.
Let’s put some Spacer views in your app’s VStack in the following places:
- Above the Target row.
- Between the Target row and the Slider row.
- Between the Slider row and the Button row.
- Between the button row and the Score row.
➤ Change the code for ContentView so that it looks like this:
struct ContentView: View {
// Properties
// ==========
// User interface views
@State var alertIsVisible: Bool = false
// User interface content and layout
var body: some View {
VStack {
Spacer()
// Target row
HStack {
Text("Put the bullseye as close as you can to:")
Text("100")
}
Spacer()
// Slider row
HStack {
Text("1")
Slider(value: .constant(10))
Text("100")
}
Spacer()
// Button row
Button(action: {
print("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.alert(isPresented: self.$alertIsVisible) {
Alert(title: Text("Hello there!"),
message: Text("This is my first pop-up."),
dismissButton: .default(Text("Awesome!")))
}
Spacer()
// Score row
HStack {
Button(action: {}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("999999")
Spacer()
Text("Round:")
Text("999")
Spacer()
Button(action: {}) {
Text("Info")
}
}
}
}
// Methods
// =======
}
➤ Run the app. It’s looking a whole lot better!
There’s just one last little change you need to make to the app’s layout: The Score row is a little too close to the bottom. In the next section, you’ll find out how to fix that.
Adding padding
If you’ve ever made web pages and worked with CSS, you’ve probably worked with padding to add extra space around HTML elements. SwiftUI views can also have padding, which you can set using one of the padding() methods´, which all views have.
In this case, we want to add some padding to the bottom of the Score row. You can do this by calling the padding() method for the HStack containing the Score row.
➤ Add the following to the end of the Score row code, so that it ends up looking like this:
// Score row
HStack {
Button(action: {}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("999999")
Spacer()
Text("Round:")
Text("999")
Spacer()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
The padding() method takes two arguments:
- The set of edges to pad. There are a number of options for this argument including
.bottom(which you just used),.leadingandtrailing(for the leading and trailing edges, respectively),.top,.horizontal(which pads both leading and trailing edges),.vertical(which pads both top and bottom edges) and.all. - The amount of padding, expressed in points.
➤ Build and run the app. It looks a whole lot better!
Solving the mystery of the stuck slider
Let’s get back to why the slider doesn’t work. As mentioned earlier, it has to do with state.
If you’ve ever gone to a restaurant where the sign said they were open, only to find that they were closed when you tried to enter, you’ve experienced what happens when a user interface, in this case the “Open” sign, doesn’t match the state (the place was actually closed).
This kind of problem happens when the user interface and state aren’t connected. In the restaurant example, keeping the “open/closed” sign in sync with the restaurant’s actual open/closed state means that someone has to make sure that the sign is always providing the right information. If you’ve ever worked in the food service industry, you know that the kind of dedication and reliability needed to make sure that the sign is always right is rare.
You may have seen this sort of thing in software as well. One example is the user interface of an email app that tells you that you have a new message, but when you check, it turns out that you’d already read it. You’ve probably seen other examples of user interfaces that were wrong about their application’s state. As apps grow, their state becomes more complex, and it’s all too easy to forget to update some part of the user interface when some state detail changes.
SwiftUI solves the problem of the mismatch between user interface and application state by creating bindings between them. In a SwiftUI application, when you update some property that’s part of its state, any user interface elements bound to that property automatically update to reflect the change.
You can also choose to make two-way bindings, where if the user changes the value of some user interface element that’s bound to some state property by pressing a button, entering a value into a text field or moving a slider, that property is automatically updated.
This means that in SwiftUI, user interface controls have to be connected to some kind of value. Sometimes, that value is a constant, which is often the case with Text views:
Text("This is a constant value")
The code that currently sets up the slider in Bullseye is similar:
Slider(value: .constant(10))
In this case, the slider is bound to a state property that is set to 10 and can’t be changed; therefore the slider’s position can’t be changed, either.
Making the slider movable
The solution to the mystery of the stuck slider is to connect it to a state property, whose value can change. So now, declare one. You’ll call it sliderValue and set its initial value to 50.
➤ Add a declaration for the sliderValue variable to the Properties section of ContentView, so that the lines starting with the // User interface views comment look like this:
// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
Remember, @State marks the variable as part of the application’s state and tells Swift to watch it for changes to its value.
Now that there’s a state property for the slider, it’s time to connect the two together!
➤ Change the line where the slider is set up to the following:
Slider(value: $sliderValue, in: 1...100)
Here’s what Slider’s initializer’s parameters do:
-
value:Specifies the binding that connects a state property to the slider. If you think ofsliderValueas the value of the slider’s current position,$sliderValue(note the$) is the two-way connection between the slider and thesliderValue. Changing the value ofsliderValueaffects the position of the slider’s thumb (the thing on the slider that moves), and moving the thumb on the slider changes the value insliderValue. - \in:\ Specifies the range of values that the slider covers.
1...100represents the range of values starting at 1 at its minimum and ending and including 100 at the maximum.
➤ Build and run the app. The initial value of sliderValue is 50.0, which means that the slider’s initial position is midway between the left and right ends. You can also move the slider now!
Reading the slider’s value
In order for the game to work, we need to know the slider’s current position. Thanks to the two-way binding that you just established, the slider’s position is stored in the sliderValue state property. We can temporarily use the alert attached to the Hit me! button to display this value.
Here’s the code for the Button row:
// Button row
Button(action: {
print("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.alert(isPresented: self.$alertIsVisible) {
Alert(title: Text("Hello there!"),
message: Text("This is my first pop-up."),
dismissButton: .default(Text("Awesome!")))
}
Now, change the argument that you provide to the message: parameter so that it displays the slider’s current value.
➤ Change the call to the “Hit me!” button’s alert() method to:
.alert(isPresented: self.$alertIsVisible) {
Alert(title: Text("Hello there!"),
message: Text("The slider's value is \(sliderValue)."),
dismissButton: .default(Text("Awesome!")))
}
➤ Build and run the app, move the slider anywhere you like, then press the Hit me! button. The alert pop-up will appear, and it’ll give you a painfully precise readout of the slider’s value:
Let’s fix that painful precision by creating a computed value that rounds the slider value (a Double) to the nearest whole number and turns it into an Int.
➤ Add the following computed property to the end of the “User interface views”:
var sliderValueRounded: Int {
Int(sliderValue.rounded())
}
The “User interface views” properties should look like this now:
// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
var sliderValueRounded: Int {
Int(sliderValue.rounded())
}
Once again, since the code for the computed value is a single line, the return is implied and you don’t need to include it. It’s nice how concise Swift can be!
You should also note that we didn’t declare sliderValueRounded as a state property. There are two reasons for this:
- Computed properties cannot be declared as state properties.
-
sliderValueRoundeddoesn’t actually define the state of the game — it’s just a rounded integer version ofsliderValue, which defines the state of the game.
Now that we have sliderValueRounded, we can update the message in the alert.
➤ Change the call to the “Hit me!” button’s alert() method to:
.alert(isPresented: self.$alertIsVisible) {
Alert(title: Text("Hello there!"),
message: Text("The slider's value is \(sliderValueRounded)."),
dismissButton: .default(Text("Awesome!")))
}
➤ Build and run the app. Move the slider wherever you like, and then press Hit me!. You should now see nice round numbers for the slider value:
A basic working game
Generating and displaying the target value
You’ve coded Bullseye once before, so you know that a key part of the game is the random target value. “Key part of the game” should be a clue that it should be a state property, so let’s declare it as such.
➤ Add the following line to the ContentView’s properties:
@State var target = Int.random(in: 1...100)
The set of @State variables should now look like this:
@State var alertIsVisible = false
@State var sliderValue = 50.0
@State var target = Int.random(in: 1...100)
Now that there’s a target value, we can display it in the target row. Right now in the Target row section of the code, the text that displays the score value holds the placeholder text “100”:
Text("100")
➤ Change it so that it displays the value of our new state property target:
Text("\(target)")
➤ Build and run the app. There’s only a 1 in 100 chance that your target will be the old placeholder value of 100:
➤ Stop the app and run it again, then do that again a few more times. 99% of the time when you restart the app, the target value will be different from the previous one.
Storing and displaying the score and round
The score and round are also key values of the game. Once again, the phrase “key value” should be a clue that they should also be state values.
➤ Add the following to ContentView’s properties, just ahead of the ones marked “User interface views”, so that the set of properties prior to body looks like this:
// Game stats
@State var score = 0
@State var round = 1
// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
@State var target = Int.random(in: 1...100)
var sliderValueRounded: Int {
Int(sliderValue.rounded())
}
Now that we’ve got the score and round in state properties, we can use them instead of their placeholders in the Score row.
➤ Update the Score row code to the following:
// Score row
HStack {
Button(action: {}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("\(score)")
Spacer()
Text("Round:")
Text("\(round)")
Spacer()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ Run the app and look at the bottom row. It now displays the current score and round — 0 and 1, respectively:
As the score and round change, they will be updated in the bottom row.
Calculating the points to award the user
Now that we’re keeping track of the score, it’s time to write a function to calculate the number of points to award the user. In case you’ve forgotten — after all, it was about 40 chapters ago — here are the rules:
- If the user put the slider right on the target value, award the user 200 points.
- Otherwise, if the user put the slider within 1 unit of the target value, aware the user 150 points.
- Otherwise award the user number of points equal to 100 - (the difference between the slider value and the target value).
First, let’s write a computed property to calculate the difference between the slider value and the target value. We’re writing this as a computed property because we know from having written Bullseye once that we’ll need this value in a couple of places.
Here’s the computed property:
var sliderTargetDifference: Int {
abs(sliderValueRounded - target)
}
➤ Add sliderTargetDifference to the end of the “User interface views” properties so that they look like this:
// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
@State var target = Int.random(in: 1...100)
var sliderValueRounded: Int {
Int(sliderValue.rounded())
}
var sliderTargetDifference: Int {
abs(sliderValueRounded - target)
}
With sliderTargetDifference, we can write a very simple method to compute the points to award the user.
➤ Add the following method after the “Methods” comment:
func pointsForCurrentRound() -> Int {
let points: Int
if sliderTargetDifference == 0 {
points = 200
} else if sliderTargetDifference == 1 {
points = 150
} else {
points = 100 - sliderTargetDifference
}
return points
}
For now, we’ll display the points awarded in the alert.
➤ Update the code for the alert to the following:
Alert(title: Text("Hello there!"),
message: Text("The slider's value is \(sliderValueRounded).\n" +
"You earned \(pointsForCurrentRound()) points."),
dismissButton: .default(Text("Awesome!")))
➤ Run the app. Move the slider and press the “Hit me!” button. The alert will now show the slider value and points awarded to you:
Dismiss the alert, move the slider, and press “Hit me!” again. Feel free to repeat this. You’ll see that Bullseye can now calculate how many points to award the user based on the slider’s distance from the target value.
Updating the score and advancing the round
Now that we can calculate how many points to award the user, we can add those points to the score. Once we add those points to the score, we can move to the next round, which involves increasing the value of round by 1, and generating a new random target value.
All of these happen together and the result is a new round. Let’s create a method for this process.
➤ Add the following method to the end of ContentView’s “Methods” section:
func startNewRound() {
score += pointsForCurrentRound()
round += 1
target = Int.random(in: 1...100)
}
Now that we have the startNewRound() method, we need to call it. It should be called at a point in time just before the end of the round. One such point is when the alert is displayed:
The “Awesome!” button that dismisses the alert was set up by the dismissButton: parameter of the Alert initializer, shown below:
Alert(title: Text("Hello there!"),
message: Text("The slider's value is \(sliderValueRounded).\n" +
"You earned \(pointsForCurrentRound()) points."),
dismissButton: .default(Text("Awesome!")))
The perfect time to call the startNewRound() method would be at the very moment when the user dismisses the alert by pressing the “Awesome!” button. Perhaps a closer look at the method that generates the default button for dismissing the alert might help.
➤ Option click on the default() method in the Alert initializer’s dismissButton: parameter. A pop-up explaining the method’s parameters will appear:
It says that the default() method accepts two parameters:
- The first parameter doesn’t have an external name, but within the method, its name is
label. It takes aTextobject that specifies the label of the button that dismisses the alert. We’re currently passing it the “Awesome!” text object. - The second one has the name
action. Its type is(() -> Void)?, which means “void closure” or “closure that doesn’t return a value”. Its default value is an empty closure.
By providing the default() button with a closure that calls startNewRound(), we can update the player’s score and starts a new round when the user dismisses the alert.
➤ Update the Alert initializer to the following:
Alert(title: Text("Hello there!"),
message: Text("The slider's value is \(sliderValueRounded).\n" +
"You earned \(pointsForCurrentRound()) points."),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)
➤ Run the app and play the game. With each guess you make, it updates your score and the round. With each new round, you get a new target.
Thanks to the magic of bindings and state, updating the score, round and target properties automatically updates their text views onscreen.
Congratulations! You now have a basic working SwiftUI version of Bullseye. Don’t relax just yet — there’s still more work to do.
Enhancing the basic game
Enabling the “Start over” button
As with the original UIKit version of Bullseye, pressing the “Start over” button does the following:
- Resets the score to 0.
- Brings the game back to round 1.
- Generates a new target value.
Let’s create a method that will be called when the user presses “Start over”.
➤ Add the following method to ContentView, after the startNewRound() method:
func startNewGame() {
score = 0
round = 1
target = Int.random(in: 1...100)
}
Now that we have the startNewGame() method, we need to call it whenever the user presses the “Start over” button.
➤ Scroll to the Score row section of ContentView’s body property and change the “Start over” button’s code so that it looks like the following:
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
➤ Run the app, play a round or a few, and then press the “Start over” button. As soon as you do that, you should see the score set to 0, the round set to 1, and the target value set to a new random number.
Enabling the “Info” button and “About” screen
Now that you’ve enabled the button in the lower left corner of the screen — the “Start over” button — it’s time to enable the button in the lower right corner: The “Info” button. When pressed, the user should be taken to the “About” screen.
I’ve said it before, but when learning SwiftUI, it’s worth repeating: The term “view” can refer to any element on a screen in an app, but also the screen itself. Views can contain other views, and the screen is a view that contains all the views on that screen.
ContentView is the name that Xcode assigns to the single view (or screen) when creating a single view SwiftUI app. When Xcode created it, it also created the file that contains it: ContentView.swift. This is similar to the way that Xcode automatically creates a ViewController.swift file when it creates a single view UIKit app.
The first step is to add a new SwiftUI view to the project. Fortunately, it’s not all that different from adding a new view in a UIKIt project.
➤ Go to Xcode’s File menu and choose New ▸ File…. In the window that pops up, choose the SwiftUI Views template (if you don’t see it then make sure iOS is selected at the top).
➤ Click Next. Xcode will ask you what to name this new view file and where to save it. You’ll either see this…
…or this:
➤ In either case, change the contents of the Save As: field to AboutView, then click the Create button.
➤ Choose the Bullseye SwiftUI folder (this folder should already be selected).
Also make sure Group says Bullseye SwiftUI and that there is a checkmark in front of Bullseye in the list of Targets.
➤ Click Create.
Xcode will create a new file and add it to your project. As you might have guessed, the new file is AboutView.swift. Xcode will show you the contents of that new file. You should have a sense of deja vu: this is what ContentView.swift looked like when you started this app.
Now that AboutView exists, it’s time to make the “Info” button navigate to it.
The simplest way to navigate between views is to make use of a NavigationView, which is the SwiftUI equivalent of UIKit’s UINavigationController. Like UINavigationController, NavigationView acts as a container for screens in your app (which are also views) that maintains a navigation stack.
We’re going to take ContentView and put it inside a NavigationView. Doing this causes a couple of things to happen automatically:
- It sets up a Navigation Bar at the top of the view. This can house buttons that allow the user to easily navigate between views.
- It sets up
ContentViewso that it’s easy to navigate to other views. It also returns back toContentViewwith a Back button that appears in the navigation bar.
➤ Switch back to editing ContentView.swift. Scroll to the start of ContentView’s body property and select everything starting with VStack and ending with the .background(Image("Background")). The start of your selection should look like this:
And the end of your selection should look like this:
➤ With that code still selected, press ⌘+] to indent your selection one level.
➤ Scroll to the start of body and add a NavigationView so that it looks like this:
// User interface content and layout
var body: some View {
NavigationView {
VStack {
Spacer()
// Target row
...
➤ Scroll to the end of body and close the NavigationView with a closing brace and method call specifying the view’s style. The end result should look like this:
.padding(.bottom, 20)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
We’re specifying a “stack” style for the NavigationView, which makes it behave like UIKit’s UINavigationController.
➤ Run the app. It now displays a navigation bar at the top of the screen:
By putting ContentView inside a NavigationView, it’s now possible to make use of controls to take the user to a different view. We’re going to replace the Button that was used for Info and replace it with a NavigationLink.
The NavigationLink link won’t be all that different from a Button. Instead of giving it code to perform when it’s pressed, you specify a destination view.
➤ Go to the Score row section of ContentView’s body property and change the code for the “Info” button from this…
Button(action: {}) {
Text("Info")
}
…to this:
NavigationLink(destination: AboutView()) {
Text("Info")
}
The Score row section should now look like this:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("\(score)")
Spacer()
Text("Round:")
Text("\(round)")
Spacer()
NavigationLink(destination: AboutView()) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ Run the app and press the Info button. You’ll be taken to AboutView, which will look like this:
➤ Press the Back button in the navigation bar. You’ll be returned back to ContentView.
We’d like the “About” screen to contain something a little more informative than “Hello, World!”. We should fill it with text similar to the text in the “About” screen in the UIKit version.
The UIKit version of the “About” screen used a web view. This approach made it possible to use HTML and CSS to format the page, which required less work than building the page with Interface Builder. For the SwiftUI version, you’ll use SwiftUI instead. You’ll do the basic layout in this chapter, and in the next chapter, you’ll use styling to give the screen a more polished appearance.
➤ Switch to AboutView.swift and change AboutView’s body property to the following:
var body: some View {
VStack {
Text("🎯 Bullseye 🎯")
Text("This is Bullseye, the game where you can win points and earn fame by dragging a slider.")
Text("Your goal is to place the slider as close as possible to the target value. The closer you are, the more points you score.")
Text("Enjoy!")
}
}
In case you’ve forgotten, the keyboard command to enter emojis is control+⌘+space. You can then find the 🎯 character by typing bullseye into the emoji pop-up’s search text field.
➤ Run the app and press Info. AboutView now contains the proper text. It may not be pretty, but it’s there and you’ll fix it in the next chapter:
Congrats! This completes the basic game. All the functionality is there and – as far as I can tell – there are no bugs to spoil the fun. Bullseye doesn’t look terribly pretty right now, but we’ll fix that in the next chapter.
You can find the project files for the finished app under 45 - Building the Bullseye Interface in the Source Code folder.