46.
Polishing Bullseye
Written by Joey deVilla
Bullseye works! The gameplay elements are complete. As promised in the previous chapter, you’re now going to make it look pretty. SwiftUI makes this rather easy.
You’ll also do a little refactoring. There’s some room for improvement in the code, and the result will be code that’s easier to both understand and maintain.
In this chapter, you’ll cover the following:
- Spicing up the graphics: You’ll learn the SwiftUI way to break views free from their default appearance and even create reusable styles.
- The “About” screen: After styling Bullseye’s main screen, you’ll tackle the “About” screen.
- Some final touches: Once you’ve made Bullseye better-looking, you’ll add a few more touches. There’s always room for improvement!
Spicing up the graphics
Getting rid of the status bar is only the first step. We want to go from this…
…to this:
In making these changes to the app’s look, you’ll add images to views, and even add additional views within existing views. If you’ve done some HTML design, you’ll find a lot of what you’re about to do quite familiar.
Adding the image assets
Like UIKit projects, SwiftUI uses assets stored in good ol’ Assets.xcassets. Let’s add the Bullseye images to the project.
➤ In the Project navigator, find Assets.xcassets and click on it.
➤ Open the Resources folder that comes with this book and open the Images subfolder. Drag the files within into the Xcode project:
Xcode will copy all the image files from the images folder into the project’s asset catalog:
Note: If Xcode added a folder named Images instead of the individual image files, then try again. This time, make sure that you drag the files inside the Images folder into Xcode rather than the folder itself.
Putting up the wallpaper
Let’s begin by replacing Bullseye’s drab white background with the more appealing Background image that you added to the app’s asset catalog:
SwiftUI makes it easy to change the background of any view. This is done using view’s background() method, which lets you specify another view to use as the background. In this case, we’ll create an Image view containing the Background image asset and use it as the background for the main screen.
Remember that ContentView’s body property contains all the user interface elements on the app’s screen. If you look at its contents, you can see that it contains a VStack. This, in turn, contains all other user interface elements:
var body: some View {
VStack {
Spacer()
// Target row
...
We’ll use the background() method of this VStack to set its background image.
➤ Scroll to the end of the VStack at the end of the body property and change it so that it looks like this. Add the call to the VStack’s background() method on the line after the call the onAppear() method:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("\(self.score)")
Spacer()
Text("Round:")
Text("\(self.round)")
Spacer()
NavigationLink(destination: AboutView()) {
Text("Info")
}
}
.padding(.bottom, 20)
}
.background(Image("Background"))
}
The line of code that you just added, .background(Image("Background")), creates an Image view and fills it with the appropriate Background image from the asset catalog. This is either the 2x or 3x version, depending on the device it’s running on. The code then makes it the background view for the VStack.
➤ Let’s see what this code does. Run the app.
Here’s what the app looks like on the Simulator when it’s simulating the iPhone 8, which uses the 2x background:
That takes care of the background. Let’s work on the text.
Changing the text
Now that Bullseye has its new background image, the black text is now nearly illegible. We’ll need to change it so that it stands out better. Once again, we’ll use some built-in methods to change the text’s appearance so that it’s legible against the background. Let’s start with the “Put the bullseye as close as you can to:” and target value text.
➤ Scroll to the part of the body property marked Target row and change it so that it becomes the following:
// Target row
HStack {
Text("Put the bullseye as close as you can to:")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Text("\(target)")
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
}
On both Text objects, three methods are being called in a chain:
-
font(), which specifies the typeface that theTextobject should use. It expects aFontobject, and we’re using itscustommethod to create one with a specified typeface — Arial Rounded MT Bold — and size in points. We’ll make the target value a little bigger for emphasis.font()’s output is a newTextobject in the specified typeface, which is then immediately fed to… -
foregroundColor(), which specifies the color that theTextobject should be. It expects aColorobject. We’re using two built-in values:Color.whitefor the instruction text andColor.yellowfor the target value text. Its output is aTextobject in the new color, which is passed to… -
shadow(), which draws a shadow behind theTextobject. It expects the size of the shadow’s radius (how far it spreads) and its x and y-offsets in points. Its output is aTextobject with a shadow, and this is the object that’s drawn onscreen.
➤ Run the app. You should be able to read the instructions and the target value now:
Let’s apply similar changes to the “1” and “100” on either side of the slider.
➤ Scroll to the part of the body property marked Slider row and change it so that it becomes the following:
// Slider row
HStack {
Text("1")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Slider(value: $sliderValue, in: 1...100)
Text("100")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
}
➤ Run the app. The numbers on either side of the slider should be legible:
At the bottom of the screen are the text elements that display the score and round. We’ll give this row the same treatment as the target row: white text for titles, and larger yellow text for values.
➤ Scroll to the part of the body property marked Score row and change it so that it becomes the following:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
Spacer()
Text("Score:")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Text("\(score)")
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Spacer()
Text("Round")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Text("\(round)")
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Spacer()
NavigationLink(destination: AboutView()) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ Run the app to see all the text changes. It will look like this:
The app’s looking a lot better now. Let’s work on those buttons next!
Making the buttons look like buttons
Let’s make the buttons look more like buttons.
Just as we used the background() method to change the background of the VStack that contains the app’s user interface, we’ll do the same for the buttons. We’ll do so by using each Button view’s background() method and the image in the asset catalog named Button:
We’ll also use the font() and color() methods of the Text objects contained within those buttons to customize their typeface and color, and the shadow() method on the button’s Image object.
Let’s update the appearance of the Hit me! button with the button background image and the Arial Rounded MT Bold typeface at size 18.
➤ Scroll to the part of the body property marked Button row and change it so that it becomes the following:
// Button row
Button(action: {
print("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.black)
}
.background(Image("Button")
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
)
.alert(isPresented: $alertIsVisible) {
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 to see the Hit me! button’s new look:
You’re probably getting physically and mentally tired from all that typing, especially since you’ve been entering the same code over and over. Let’s do something about that.
Introducing ViewModifier
If you look at body in its current state, you’ll see a lot of repetition. For starters, there are five instances where the following methods are called on a Text view:
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
There are also three instances where the following methods are called on a Text view:
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
And there are nine instances where this method is called on Text and Image views to give it a shadow:
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
If there was some way to DRY up body’s code and put these repeated calls to these methods into a package that can be called again and again, it might help the compiler get past its stumbling block.
Fortunately for us, there is a way: The ViewModifier protocol. Remember the definition for “protocol” from a couple of chapters back: It’s a set of properties and methods that an object uses for some kind of functionality.
The ViewModifier protocol states that any object that adopts it agrees to furnish a method named body(), which accepts some content. That content can then have a number of calls to any number of View methods made on it. We’re going to create some objects that adopt the ViewModifier protocol and use them to create packages of methods that we’ll use to style different parts of the user interface.
It might be easier to show you how to use ViewModifier instead of explaining it.
➤ Add the following in the space between the end of ContentView and the start of the preview code:
// View modifiers
// ==============
struct LabelStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
}
}
The most important part of this object it its body() method. The body() method accepts a single piece of information, content, which contains the content of the view that called it. It then calls a set of specified methods on that content.
In the case of LabelStyle, it calls the font(), foregroundColor() and shadow() methods on the content to change its font to Arial Rounded MT Bold with a size of 18 points, its color to white and with a shadow. We’ll use this to style the Text objects that display the instructions, as well as the labels for the slider, score, and number of rounds.
➤ Add the following in the space between the end of LabelStyle and the start of the preview code:
struct ValueStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
}
}
ValueStyle is almost identical to LabelStyle. The key difference is its calls to font() and foregroundColor() change the content’s font to Arial Rounded MT Bold with a size of 24 points, and its color to yellow. We’ll use this to style the Text objects that display the game values: the target, score, and number of rounds.
If you’ve done some web development, you may have noticed that this isn’t all that different from defining a CSS style.
Now that we have these two objects that adopt the ViewModifier protocol — LabelStyle and ValueStyle — we can use them to style the views in ContentView’s body property.
➤ Change the Target row section of ContentView’s body property to the following:
// Target row
HStack {
Text("Put the bullseye as close as you can to:").modifier(LabelStyle())
Text("\(target)").modifier(ValueStyle())
}
The code’s a lot simpler! Instead of calling a chain of font(), foregroundColor() and shadow methods on the Text objects in this row, we’re using View’s modifier() method to style them. The modifier() method takes a single argument — an object that has adopted the ViewModifier protocol — and uses that object to style its View.
In the code above, modifier() uses LabelStyle to style the “Put the bullseye as close as you can to:” text, and ValueStyle to style the displayed value of target.
➤ Change the Slider row section of ContentView’s body property to the following:
// Slider row
HStack {
Text("1").modifier(LabelStyle())
Slider(value: $sliderValue, in: 1...100)
Text("100").modifier(LabelStyle())
}
Again, we’re using modifier() and LabelStyle, this time to style the text on either side of the slider. This leaves us with one more row to update.
➤ Change the Score row section of ContentView’s body property to the following:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
Spacer()
Text("Score:").modifier(LabelStyle())
Text("\(score)").modifier(ValueStyle())
Spacer()
Text("Round").modifier(LabelStyle())
Text("\(round)").modifier(ValueStyle())
Spacer()
NavigationLink(destination: AboutView()) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ It’s time to see if all these changes worked. Run the app. You’ll see that all the text and button styling is there:
Some refactoring and more styling
You may have noticed that both LabelStyle and ValueStyle have one line of code in common — the line that adds a shadow:
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
You also may have noticed that this method is also called on the Image views inside the Button views. This repetition suggests that we do some DRYing and put this code in a single place where we can call it. It’s time to create a ViewModifier for shadows!
➤ Add the following after ValueStyle and before the start of the preview code:
struct Shadow: ViewModifier {
func body(content: Content) -> some View {
content
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
}
}
Before we continue, take a look at the signature of the body method of every ViewModifier object:
func body(content: Content) -> some View
This says that the body method returns either a View or something that behaves like a View. If you couple this with the fact that View objects use ViewModifier objects by way of the modifier() method, it means that ViewModifiers can use other ViewModifiers!
➤ Change LabelStyle and ValueStyle so that they incorporate Shadow:
struct LabelStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.white)
.modifier(Shadow())
}
}
struct ValueStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.modifier(Shadow())
}
}
➤ Run the app to confirm that these changes work.
Now that there’s a ViewModifier for shadows, we can use it on the button image in the button row.
➤ Scroll to the part of the body property marked Button row and change it so that it becomes the following:
// Button row
Button(action: {
print("Points awarded: \(self.pointsForCurrentRound())")
self.alertIsVisible = true
}) {
Text("Hit me!")
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.black)
}
.background(Image("Button")
.modifier(Shadow())
)
.alert(isPresented: $alertIsVisible) {
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
})
}
It’s time to make the buttons in the score row look like buttons, complete with shadows.
➤ Scroll to the part of the body property marked Score row and change it so that it becomes the following:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
.background(Image("Button")
.modifier(Shadow())
)
Spacer()
Text("Score:").modifier(LabelStyle())
Text("\(score)").modifier(ValueStyle())
Spacer()
Text("Round").modifier(LabelStyle())
Text("\(round)").modifier(ValueStyle())
Spacer()
NavigationLink(destination: AboutView()) {
Text("Info")
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
➤ Run the app and marvel at its complete set of buttons:
The buttons protrude past the edge of the screen. Let’s fix that by adjusting the padding for the score row.
➤ Add a couple of calls to padding() at the end of the Score row section so that the code looks like this:
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
Let’s create some ViewModifiers for the button text. We’ll create one with larger text called ButtonLargeTextStyle for the Hit me! button, and one with smaller text called ButtonSmallTextStyle for the Start over and Info buttons.
➤ Add the following after Shadow and before the start of the preview code:
struct ButtonLargeTextStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 18))
.foregroundColor(Color.black)
}
}
struct ButtonSmallTextStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 12))
.foregroundColor(Color.black)
}
}
With these new ViewModifiers, we can style the button text.
➤ Scroll to the part of the body property marked Button row and update it to the following:
// Button row
Button(action: {
print("Points awarded: \(self.pointsForCurrentRound())")
self.alertIsVisible = true
}) {
Text("Hit me!").modifier(ButtonLargeTextStyle())
}
.background(Image("Button")
.modifier(Shadow())
)
.alert(isPresented: $alertIsVisible) {
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
})
}
➤ Scroll to the part of the body property marked Score row and update it to the following:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over").modifier(ButtonSmallTextStyle())
}
.background(Image("Button")
.modifier(Shadow())
)
Spacer()
Text("Score:").modifier(LabelStyle())
Text("\(score)").modifier(ValueStyle())
Spacer()
Text("Round").modifier(LabelStyle())
Text("\(round)").modifier(ValueStyle())
Spacer()
NavigationLink(destination: AboutView()) {
Text("Info").modifier(ButtonSmallTextStyle())
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
➤ Run the app. It looks pretty nice now!
Putting images inside buttons
Let’s add some more visual flair to Bullseye: icons for the Start over and Info buttons. They’re in the StartOverIcon and InfoIcon image sets in the asset catalog:
Button objects are a kind of View, and like all views, they can contain other views. This makes it possible to create buttons that contain more than a single line of text. We’ll customize the Start over button by combining an Image view and a Text view inside an HStack, as shown below:
➤ Change the Score row section of ContentView’s body property to the following:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
HStack {
Image("StartOverIcon")
Text("Start over").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
Spacer()
Text("Score:").modifier(LabelStyle())
Text("\(score)").modifier(ValueStyle())
Spacer()
Text("Round").modifier(LabelStyle())
Text("\(round)").modifier(ValueStyle())
Spacer()
NavigationLink(destination: AboutView()) {
HStack {
Image("InfoIcon")
Text("Info").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
➤ Run the app to see the changes:
Adding accent colors
iOS subtly applies colors to user interface elements to give the user a hint that something is active, tappable, moveable or highlighted. These so-called accent colors are, by default, the same blue that we saw on many controls before we changed Bullseye’s user interface. Even with all the tweaks you’ve made, you can still see the default accent color on the slider, and in the button icons:
You can change a view’s accent color, along with the accent color of any views it contains, using the accentColor() method. Let’s change the slider’s accent color to green, which should stand out against its background.
➤ Change the Score row section of ContentView’s body property to the following:
// Slider row
HStack {
Text("1").modifier(LabelStyle())
Slider(value: $sliderValue, in: 1...100)
.accentColor(Color.green)
Text("100").modifier(LabelStyle())
}
➤ Run the app. You’ll now see the slider’s accent color, which highlights the left side of its track, is now green:
You’re not limited to using pre-defined colors. Let’s create a custom color, midnight blue, and use it as the accent color for the Start over and Info buttons.
First, we need to define what midnight blue is. If you’re familiar with web development, you probably know the RGB (red, green and blue) color model. If not, you specify colors as a combination of three numbers representing red, green and blue on a scale of 0 through 255.
We’ll define midnight blue as this color:
In web development, you specify RGB colors as a set of three hexadecimal (base 16) numbers. The color we’re calling midnight blue is defined by these values:
- red: 0 in hexadecimal, which is also 0 in decimal.
- green: 33 in hexadecimal, which is 51 in decimal.
- blue: 66 in hexadecimal, which is 102 in decimal.
When you instantiate a Color object in SwiftUI, it expects to get the values for red, green and blue on a scale of 0 to 1. Converting the decimal values for midnight blue to this scale is simple: Divide each one by 255, which gives us:
- red: 0
- green: 0.2
- blue: 0.4
First, you need to define midnight blue.
➤ Add the following to the start of ContentView, just before the Game stats properties:
// Colors
let midnightBlue = Color(red: 0,
green: 0.2,
blue: 0.4)
Now that we have defined midnightBlue, let’s apply it to the HStack containing the score row. This will set the accent color for all the views contained within.
➤ Change the Score row section of ContentView’s body property to the following:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
HStack {
Image("StartOverIcon")
Text("Start over").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
Spacer()
Text("Score:").modifier(LabelStyle())
Text("\(score)").modifier(ValueStyle())
Spacer()
Text("Round").modifier(LabelStyle())
Text("\(round)").modifier(ValueStyle())
Spacer()
NavigationLink(destination: AboutView()) {
HStack {
Image("InfoIcon")
Text("Info").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
.accentColor(midnightBlue)
➤ Run the app. The accent color for the Start over and Info buttons is now midnight blue.
Some SwiftUI limitations
SwiftUI is still a new framework, and you should expect it to have limitations. It can’t (yet) do everything that UIKit can do.
One example: You may have noticed that you didn’t customize the the look of the slider beyond its accent color. This means that we can’t customize the slider handle to look like a target, which you were able to do when building the UIKit version. During these early days of SwiftUI, you should be prepared for limitations like this.
The “About” screen
Now that you’ve styled the main screen, let’s do the same for the “About” screen with a similar treatment.
As with the main screen, we’ll improve the “About” screen’s formatting with a couple of ViewModifiers. We’ll make one for the heading, and one for the body text beneath it.
➤ In AboutView.swift, add the following between AboutView and AboutView_Previews:
// View modifiers
// ==============
struct AboutHeadingStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 30))
.foregroundColor(Color.black)
.padding(.top, 20)
.padding(.bottom, 20)
}
}
struct AboutBodyStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(Font.custom("Arial Rounded MT Bold", size: 16))
.foregroundColor(Color.black)
.padding(.leading, 60)
.padding(.trailing, 60)
.padding(.bottom, 20)
}
}
These are similar to the ViewModifiers in ContentView. The only significant difference is that these include some padding for spacing between the heading and paragraphs.
Now that there are some ViewModifiers, it’s time to apply them to the text. You’ll apply AboutHeadingStyle to the heading, and AboutBodyStyle to the body text.
➤ Change AboutView’s body property to the following:
var body: some View {
VStack {
Text("🎯 Bullseye 🎯")
.modifier(AboutHeadingStyle())
Text("This is Bullseye, the game where you can win points and earn fame by dragging a slider.")
.modifier(AboutBodyStyle())
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.")
.modifier(AboutBodyStyle())
Text("Enjoy!")
.modifier(AboutBodyStyle())
}
}
➤ Run the app and press Info. You’ll see this:
The text looks great, but the background is plain compared to the main screen. Let’s create a plain beige background for the text, and behind that, we’ll use the same background image as ContentView.
The first step is to create a custom beige color. It’s like creating the midnight blue color, just with different values for red, green, and blue.
➤ Add the following to AboutView above the body property:
// Constants
let beige = Color(red: 1.0,
green: 0.84,
blue: 0.70)
Now that we have the beige color defined, let’s make it the background of the VStack that holds all the Text views.
➤ Change AboutView’s body property to the following:
var body: some View {
VStack {
Text("🎯 Bullseye 🎯")
.modifier(AboutHeadingStyle())
Text("This is Bullseye, the game where you can win points and earn fame by dragging a slider.")
.modifier(AboutBodyStyle())
.lineLimit(nil)
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.")
.modifier(AboutBodyStyle())
Text("Enjoy!")
.modifier(AboutBodyStyle())
}
.background(beige)
}
➤ Run the app and press Info. The VStack is now visible as a beige rectangle. It’s large enough to accommodate the views it contains, complete with padding:
It’s now time to add the background image for the “About” screen. The problem is that there’s nothing to hold it; at the moment, the VStack is the highest-level view in the view in body.
We need some kind of view whose only purpose is to act as a container for the VStack, to which we can add the background image. We’ll use a type of View called Group, whose purpose is to group views together. It also expands to fill the view which contains it, which would be the entire screen.
Let’s put the VStack inside a Group, and then set the Group’s background to the background image.
➤ Change the body property so that the VStack it contains is inside a Group view, and use its background() method to set its background image. It should end up looking like this:
var body: some View {
Group {
VStack {
Text("🎯 Bullseye 🎯")
.modifier(AboutHeadingStyle())
Text("This is Bullseye, the game where you can win points and earn fame by dragging a slider.")
.modifier(AboutBodyStyle())
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.")
.modifier(AboutBodyStyle())
Text("Enjoy!")
.modifier(AboutBodyStyle())
}
.background(beige)
}
.background(Image("Background"))
}
➤ Run the app and press Info. The app now has a nice, consistent look across both its screens:
Some final touches
Let’s add some additional features to bring the SwiftUI version of Bullseye a little closer to the original UIKit version.
Randomizing the slider’s position at the start of each game and the start of each round
Let’s make the game a little more challenging by randomizing the slider’s position at the start of each round, including the round at the start of the game.
➤ In ContentView.swift, update the startNewRound() and startNewGame() methods to the following:
func startNewRound() {
score = score + pointsForCurrentRound()
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
}
func startNewGame() {
score = 0
round = 1
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
}
Note that both methods now end with the same two lines:
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
This kind of repetition is a sign that you should write a method. Let’s do that.
➤ Add this method just below the startNewRound() and startNewGame() methods:
func resetSliderAndTarget() {
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
}
➤ Update the startNewRound() and startNewGame() methods to use the resetSliderAndTarget() method:
func startNewRound() {
score = score + pointsForCurrentRound()
resetSliderAndTarget()
}
func startNewGame() {
score = 0
round = 1
resetSliderAndTarget()
}
➤ Run the app a couple of times, making sure to press the “Start over” button at least once or twice. Everything seems to be working properly.
➤ Launch the app, make a note of the slider’s starting pisition and then stop the app from Xcode. Do this a few more times, paying attention to the slider’s starting position.
When you launch the game, the slider always starts at 50. That’s because its value is set to 50 when the slider property is declared. This can be fixed by calling startNewGame() when the screen is first drawn.
You already know how to do that in UIKit: You’d put the call to startNewGame() in the view controller’s viewDidLoad() method. How do you do it in SwiftUI?
It turns out that the View protocol contains the methods onAppear() and onDisappear(), which are called when the view appears and disappears. You can attach a closure to these methods to make code execute whenever these methods are triggered.
Let’s add a call to onAppear() to the VStack that defines Bullseye’s main screen.
➤ Add a call to onAppear() just after the call to background() that sets the background image for the main screen. The end of the declaration for the body property should look like this:
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
.accentColor(midnightBlue)
}
.background(Image("Background"))
.onAppear() {
self.startNewGame()
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
➤ Once again, launch the app, make a note of the slider’s starting pisition and then stop the app from Xcode. Do this a few more times. You should notice that the slider’s position is now randomized when the app is launched.
This solution seems like it works, but there’s a problem.
➤ Launch the app. Make a note of the slider’s position and the target value:
➤ Press the “Info” button, which takes you to the “About” screen:
➤ Press the “Back” button, which returns you to the main screen. Make a note of the slider’s position and the target value:
They’ve changed! This happened because the onAppear() method got called when you returned from the “About” screen back to the main screen. The main screen appeared again, which triggered the call to onAppear().
In the end, this is one of those cases where you can’t avoid redundancy. It’s better to simply set the sliderValue property to a random number when it’s declared.
➤ Change the declaration of ContentView’s sliderValue property from this…
@State var sliderValue = 50.0
…to this:
@State var sliderValue = Double.random(in: 1...100)
➤ Remove the call to onAppear(). The end of the declaration of the body property should look like this:
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
.accentColor(midnightBlue)
}
.background(Image("Background"))
}
.navigationViewStyle(StackNavigationViewStyle())
}
➤ Launch the app. Make a note of the slider’s position and the target value, press the “Info” button, and the return back to the main screen. This time, the slider’s position is randomized at launch, yet going to the “About” screen and returning to the main screen doesn’t change the slider’s position or target value.
Adding a title to the main screen’s navigation bar
On the main screen, the navigation bar looks like a white translucent strip that does nothing. Users might even think it’s a bug. Let’s spruce it up by displaying its title in the navigation bar.
You can add a title to a NavigationView’s navigation bar by using the navigationBarTitle() method on any view inside the NavigationView. I like to do this as close as possible to the start of the NavigationView’s code.
➤ Add a call to navigationBarTitle() to the first Spacer in the view. The start of the body declaration should look like this:
var body: some View {
NavigationView {
VStack {
Spacer().navigationBarTitle("🎯 Bullseye 🎯")
➤ Run the app. It now has a title at the top of the main screen:
Improving the alert messages
Let’s update the alert so that it shows a title that varies with the user’s accuracy. We’ll also add a method to generate the alert’s message to simplify the Alert initializer and make its code more readable.
➤ Add these methods to ContentView, at the end of the Methods section:
func alertTitle() -> String {
let title: String
if sliderTargetDifference == 0 {
title = "Perfect!"
} else if sliderTargetDifference < 5 {
title = "You almost had it!"
} else if sliderTargetDifference <= 10 {
title = "Not bad."
} else {
title = "Are you even trying?"
}
return title
}
func scoringMessage() -> String {
return "The slider's value is \(sliderValueRounded).\n" +
"The target value is \(target).\n" +
"You scored \(pointsForCurrentRound()) points this round."
}
➤ Update the Alert initializer in the Button row so that its code looks like this:
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)
➤ Run the app and press the “Hit me!” button. You’ll see the updated alert:
You’ve just completed the SwiftUI version of Bullseye. You’re now ready to tackle Checklist, the SwiftUI version of the “to do list” app you wrote in UIKIt about 30 chapters ago.
You can find the project files for the finished app under 46 - Polishing Bullseye in the Source Code folder.