7.
The New Look
Written by Joey deVilla
Bullseye is looking good! The gameplay elements are complete and there’s one item left in your to-do list: “Make it look pretty.”
As satisfying as it was to get the game working, it’s far from pretty. If you were to put it on the App Store in its current form, very few people would get excited to download it. Fortunately, iOS and SwiftUI make it easy for you to create good-looking apps. So, let’s give Bullseye a makeover and add some visual flair.
This chapter covers the following:
- Landscape orientation revisited: Making changes to the project to improve its support for landscape oreintation.
- Spicing up the graphics: Adding custom graphics to the app’s user interface to give it a more polished look.
- The “About” screen: It’s time to make the “Info” button work, which means that pressing it should take the player to Bullseye’s “About” screen.
Landscape orientation revisited
Let’s revisit another item in the to-do list — “Put the app in landscape orientation.” Didn’t you already do this?
You did! By changing the project settings so that the app supported only the Landscape Left and Landscape Right orientations. There’s one last bit of cleaning up that you need to do to make landscape orientation support complete.
Apps in landscape mode don’t display the iPhone status bar — the display at the top of the screen — unless you tell them to. That’s great for Bullseye. Games require a more immersive experience and the status bar detracts from that.
The system automatically handles hiding the status bar for your game. But, you can improve the way Bullseye handles the status bar by making sure that it’s always hidden, even when the app is launching.
➤ Go to the Project Settings screen and scroll down to Deployment Info. In the section marked Status Bar Style, check Hide status bar.
It’s a good idea to hide the status bar while the app is launching. It takes a few seconds for the operating system to load the app into memory and start it up. During that time the status bar remains visible unless you hide it using this option.
It’s only a small detail, but the difference between a mediocre app and a great one is the small details.
➤ That’s it! Run the app and you’ll see that the status bar is history.
Info.plist
Most of the options from the Project Settings screen, such as the supported device orientations and whether the status bar is visible during launch, are stored in a configuration file called Info.plist.
The information in Info.plist tells iOS how the app will behave. It also describes certain characteristics of the app that don’t fit anywhere else. Such as the app’s version number.
In earlier versions of Xcode, you often had to edit Info.plist by hand. This was a tedious and sometimes error-prone process. With the latest versions of Xcode, this is hardly necessary anymore. You can make most of the changes directly from the Project Settings screen.
Even with the changes to Xcode that minimize the amount of time you have to work directly with Info.plist, it’s still good to know of its existence and what it looks like.
➤ Go to the Project navigator and select the file named Info.plist to take a peek at its contents.
The Info.plist file is a list of configuration options and their values. Most of these may not make sense to you, but that’s OK. They don’t always make sense to many experienced developers either.
Notice the option Status bar is initially hidden. It has the value YES. This is the option that you just changed.
Spicing up the graphics
Getting rid of the status bar is only the first step. We want to go from this…
…to something that’s more like this:
The actual controls won’t change. You’ll simply be using images to spruce up their look. You’ll also adjust the user interface’s colors and typefaces.
You can put an image in the background, on the buttons, and even on the slider, to customize the appearance of each. The images you use should generally be in PNG format, though JPG files would work too.
Adding the image assets
If you’re artistically challenged, then don’t worry: we’ve provided a set of images for you. But if you do have mad Photoshop skillz, then by all means feel free to design and use your own images.
The Resources folder that comes with this book contains a subfolder named Images. You’ll import these images into the Xcode project.
➤ In the Project navigator, find Assets.xcassets and click on it.
This item is the app’s asset catalog, which stores all the images that go into it. Right now, it’s empty and contains a placeholder for the app icon. You’ll add an icon and images soon:
➤ Open the Resources folder that comes with this book, the open the subfolder named Images. 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.
1x, 2x, and 3x displays
For each image you dragged into the asset catalog, you created an image set. Image sets allow an app to support different devices with different screen resolutions. Each image set has a slot for the 1x, 2x and 3x version of the image:
- 1x images are for low-resolution screens, with pixels that seem big and chunky by today’s standards. Only the first iPhones — the original, 2G, 3G, and 3GS — have these screens. None of these devices can run a version of iOS released after 2012. You’re pretty unlikely to write apps that use 1x graphics.
- 2x images are for high-resolution Retina screens. As the name implies, they’re drawn with twice the number of pixels as a 1x image. A wide range of iPhones — from the iPhone 4 through 8 and the iPhone XR — and iPads and late-model iPods these screens.
- 3x images are for high-resolution Retina HD screens, which the iPhone X, XS, XR and any iPhone with a “+” in its name have. These images are drawn with three times the number of pixels as a 1x image.
When an app displays an image, iOS tries to use the version of the image that best matches the device’s screen resolution. If that’s not available, it uses the next best version.
When you dragged the images into the asset catalog, Xcode used their filenames to determine which image set and slot to drop them into. For example, the images in the Background@2x.png and Background@3.png files go into the Background image set. The Background@2x.png image goes into the 2x slot and the Background@3x.png image goes into the 3x slot. Had there been a Background.png file, it would go into the Background image set’s 1x slot. Any file whose name doesn’t end with 2x or 3x is assumed to be a 1x image.
If you’d rather determine which images are 1x, 2x and 3x, you can also drag and drop invididual images into their respective slots.
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()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
}
.onAppear() {
self.startNewGame()
}
.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 XR, which uses the 3x background:
Let’s try the app on a smaller device without the “notch”. We’ll use the iPhone 8, which uses the 2x background.
➤ Select iPhone 8 and run the app:
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("\(self.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.
➤ Switch the Simulator back to iPhone XR and 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: self.$sliderValue, from: 1.0, through: 100.0)
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("\(self.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("\(self.round)")
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
Spacer()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ Run the app to see all the text changes. You might notice that Xcode is taking more than the usual amount of time to compile your code.
Once the app starts on the Simulator, 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: self.$alertIsVisible) {
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)
}
➤ Run the app to see the Hit me! button’s new look. You may find that Xcode seems to come to a halt while compiling your app as it displays something like this at the top of its window:
Something’s gone wrong, and it’s time to find out what that is. Let Xcode try to compile your code for a little longer. It will eventually give up, and you’ll get a notification that the build failed. You’ll see something like this at the top of the Xcode window:
➤ Tap that red error icon. This error message will appear:
➤ Tap the error message’s red error icon to see it in full:
The message may sound cryptic: The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions. Simply put, Xcode is saying: “That thing that you’ve put into the body property is a lot to deal with. Is there any way you can simplify it?”
It turns out that all those extra methods to change fonts, colors and backgrounds and add shadows to the text and button views in body are too much for the compiler to handle.
With this news, you might be tempted to throw your hands in the air and walk away from your computer in frustration. Don’t worry, there is a solution.
Introducing ViewModifier
In programming, you’ll sometimes find that the solution to an error is embedded in its error message. It’s true for this particular case. The fix to our compiler problem is in the last part of the message: try breaking up the expression into distinct sub-expressions. In other words, Xcode is asking us to break that big body property into smaller parts.
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 agrees to include as part of its code.
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.
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("\(self.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: self.$sliderValue, from: 1.0, through: 100.0)
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("\(self.score)").modifier(ValueStyle())
Spacer()
Text("Round:").modifier(LabelStyle())
Text("\(self.round)").modifier(ValueStyle())
Spacer()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ It’s time to see if all these changes worked. Run the app. This time, you’ll see that it compiles quickly, and that all the text and button styling has taken effect:
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("Button pressed!")
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: self.$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("\(self.score)").modifier(ValueStyle())
Spacer()
Text("Round:").modifier(LabelStyle())
Text("\(self.round)").modifier(ValueStyle())
Spacer()
Button(action: {}) {
Text("Info")
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
➤ Run the app and marvel at its complete set of buttons:
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("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!").modifier(ButtonLargeTextStyle())
}
.background(Image("Button")
.modifier(Shadow())
)
.alert(isPresented: self.$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("\(self.score)").modifier(ValueStyle())
Spacer()
Text("Round:").modifier(LabelStyle())
Text("\(self.round)").modifier(ValueStyle())
Spacer()
Button(action: {}) {
Text("Info").modifier(ButtonSmallTextStyle())
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
➤ Run the app. It’s looking 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:
HStack {
Button(action: {
self.startNewGame()
}) {
HStack {
Image("StartOverIcon")
Text("Start over").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
Spacer()
Text("Score:").modifier(LabelStyle())
Text("\(self.score)").modifier(ValueStyle())
Spacer()
Text("Round:").modifier(LabelStyle())
Text("\(self.round)").modifier(ValueStyle())
Spacer()
Button(action: {}) {
HStack {
Image("InfoIcon")
Text("Info").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
➤ 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: self.$sliderValue, from: 1.0, through: 100.0)
.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.
➤ Change the start of ContentView so that it looks like this:
// Properties
// ==========
// Colors
let midnightBlue = Color(red: 0,
green: 0.2,
blue: 0.4)
// Game stats
@State var target: Int = Int.random(in: 1...100)
@State var score: Int = 0
@State var round: Int = 1
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("\(self.score)").modifier(ValueStyle())
Spacer()
Text("Round:").modifier(LabelStyle())
Text("\(self.round)").modifier(ValueStyle())
Spacer()
Button(action: {}) {
HStack {
Image("InfoIcon")
Text("Info").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
.accentColor(midnightBlue)
➤ Run the app. The accent color for the Start over and Info buttons is now midnight blue.
The “About” screen
Your game looks awesome and your to-do list is done. Does this mean that you are done with Bullseye?
Not so fast! Remember the Info button at the lower right corner of the screen? Try tapping it. Does it do anything? No?
Ooops! Looks as if we forgot to add any functionality to that button! It’s time to rectify that — let’s add an “About” screen to the game. It’ll appear whenever the player presses Info.
Here’s what it will look like at the end:
Most apps, even very simple games, have more than one screen. This is as good a time as any to learn how to add additional screens to your apps.
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 it creates a single view app. When Xcode created it, it also created the file that contains it: ContentView.swift.
Xcode makes it easy to create additional views and their containing files. Let’s
Xcode automatically created the main ViewController object for you. But you’ll have to create the view controller for the About screen yourself. Fortunately, it’s pretty easy to do this.
Adding a new view
➤ 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 folder (this folder should already be selected).
Also make sure Group says Bullseye 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 at the start of Chapter 2. You’ve come a long way:
Connecting the “Info” button to AboutView
It’s time to make the Info button on ContentView do its thing!
➤ Switch back to editing ContentView.swift by clicking on it in the Project Navigator:
The simplest way to navigate between views is to make use of a NavigationView. It’s a special kind of view with just one purpose: To make it simple to navigate back and forth between other views.
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.
Let’s add a NavigationView to ContentView.
➤ 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 theNavigationView with a closing brace and a couple of methods. The end result should look like this:
.onAppear() {
self.startNewGame()
}
.background(Image("Background"))
}
.navigationViewStyle(.stack)
}
➤ 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. It will still contain an HStack the button icon and text, and it will still use the button background image. However, 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 this line…
Button(action: {}) {
…to this:
The section should now look like this:
// 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("\(self.score)").modifier(ValueStyle())
Spacer()
Text("Round:").modifier(LabelStyle())
Text("\(self.round)").modifier(ValueStyle())
Spacer()
NavigationLink(destination: AboutView()) {
HStack {
Image("InfoIcon")
Text("Info").modifier(ButtonSmallTextStyle())
}
}
.background(Image("Button")
.modifier(Shadow())
)
}
.padding(.bottom, 20)
.accentColor(midnightBlue)
➤ Run the app and press Info. 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.
Now that the player can navigate between views, it’s time to fill AboutView.
➤ Switch to AboutView.swift in Xcode 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. AboutViewnow contains the proper text, but the formatting needs work:
Let’s improve the formatting with a couple of ViewModifiers. We’ll make one for the heading, and one for the body text beneath it.
➤ Add the following between AboutView and the preview section:
// 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.
➤ 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. We’re getting closer…:
That second paragraph — the one that begins with “Your goal is to place the slider as close as possible to the target value” keeps getting cut off. We want it to display the full text.
Text views, it turns out, display a single line by default. Any text that goes beyond a single line is cut off and replaced with an ellipsis (the “…”). This default setting can be overridden with the lineLimit() method, which lets you specify the maximum number of lines the Text view will display. You can also give lineLimit() a value of nil, which means “no limit”. That’s what we’ll use for the first and second paragraphs.
➤ 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())
.lineLimit(nil)
Text("Enjoy!")
.modifier(AboutBodyStyle())
}
}
➤ Run the app and press Info. Now all the text is displayed:
The clever reader may ask “Why did we attach make two separate calls to
lineLimit()with two differentTextviews? Wouldn’t it be more DRY to put one call tolineLimit()from withinAboutBodyStyle?”If you asked this question, you should congratulate yourself. Under normal circumstances, you’d be right. Unfortunately, as of this writing (we’re using Xcode 11 beta 4),
lineLimit()seems to work only if you call it directly from the object you want to apply it to, and not from within aViewModifier. This may change as newer versions of Xcode come out.
There are only a couple of tasks left. We need to 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())
.lineLimit(nil)
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:
We now need some kind of view whose only purpose is to act as a container for the background image. There’s a type of View called Group, and it’s used 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.
➤ In the body property, select everything starting with VStack and ending with the .background(beige). Your selection should look 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())
.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())
.lineLimit(nil)
Text("Enjoy!")
.modifier(AboutBodyStyle())
}
.background(beige)
}
.background(Image("Background"))
}
➤ Run the app and press Info. It looks like we’ve made it:
Congrats! This completes the game. All the functionality is there and – as far as I can tell – there are no bugs to spoil the fun.
You can find the project files for the finished app under 07 - The New Look in the Source Code folder.