46
Polishing Bullseye
Written by Joey deVilla
Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as
text.You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.
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.
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:
var body: some View {
VStack {
Spacer()
// Target row
...
// 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"))
}
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.
// 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)
}
// 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)
}
// 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)
Making the buttons look like buttons
Let’s make the buttons look more like buttons.
// 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()
}
)
}
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)
.font(Font.custom("Arial Rounded MT Bold", size: 24))
.foregroundColor(Color.yellow)
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
// 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)
}
}
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)
}
}
// Target row
HStack {
Text("Put the bullseye as close as you can to:").modifier(LabelStyle())
Text("\(target)").modifier(ValueStyle())
}
// Slider row
HStack {
Text("1").modifier(LabelStyle())
Slider(value: $sliderValue, in: 1...100)
Text("100").modifier(LabelStyle())
}
// 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)
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)
struct Shadow: ViewModifier {
func body(content: Content) -> some View {
content
.shadow(color: Color.black, radius: 5, x: 2, y: 2)
}
}
func body(content: Content) -> some View
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())
}
}
// 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()
})
}
// 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)
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
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)
}
}
// 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()
})
}
// 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)
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:
// 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)
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:
// Slider row
HStack {
Text("1").modifier(LabelStyle())
Slider(value: $sliderValue, in: 1...100)
.accentColor(Color.green)
Text("100").modifier(LabelStyle())
}
// Colors
let midnightBlue = Color(red: 0,
green: 0.2,
blue: 0.4)
// 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)
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.
The “About” screen
Now that you’ve styled the main screen, let’s do the same for the “About” screen with a similar treatment.
// 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)
}
}
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())
}
}
// Constants
let beige = Color(red: 1.0,
green: 0.84,
blue: 0.70)
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)
}
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"))
}
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.
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)
}
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
func resetSliderAndTarget() {
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
}
func startNewRound() {
score = score + pointsForCurrentRound()
resetSliderAndTarget()
}
func startNewGame() {
score = 0
round = 1
resetSliderAndTarget()
}
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
.accentColor(midnightBlue)
}
.background(Image("Background"))
.onAppear() {
self.startNewGame()
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
@State var sliderValue = 50.0
@State var sliderValue = Double.random(in: 1...100)
.padding(.bottom, 20)
.padding(.leading, 20)
.padding(.trailing, 40)
.accentColor(midnightBlue)
}
.background(Image("Background"))
}
.navigationViewStyle(StackNavigationViewStyle())
}
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.
var body: some View {
NavigationView {
VStack {
Spacer().navigationBarTitle("🎯 Bullseye 🎯")
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.
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."
}
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)