6.
Refactoring
Written by Joey deVilla
At this point, your game is fully playable. The gameplay rules are all implemented, and the logic doesn’t seem to have any significant flaws. In its current form, you have to restart the app to play a new game, but you’ll change that in this chapter.
As far as we can tell, there aren’t any bugs. That said, there’s still some room for improvement!
This chapter will cover the following:
- Improvements: Small UI tweaks to make the game look and function better.
- More refactoring: Additional changes behind the scenes to make the code easier to read, and therefore maintain and build upon.
- Starting over: Resetting the game to start fresh.
-
Making the code less
self-ish: The keywordselfis used all over the code. Are they all necessary? - Key points: A quick review of what you learned in this chapter.
Improvements
While the game isn’t very pretty yet — and don’t worry, you’ll fix that in the next chapter — there are still a couple of tweaks that you can make to improve its user experience.
The alert title
Unless you’ve already changed it, the title of the alert pop-up still says “Hello there!”. That’s something leftover from back when it was a single-button app. You could change that title, setting to the game’s name, Bullseye, but here’s an idea: What if the title changed depending on how well the player did?
Here are the details:
- If the player is either really lucky or has a very good eye and puts the slider right on the target, the alert’s title could say “Perfect!”
- If the player didn’t put the slider right on the target but got really close to the target but not quite there, say under five units away, the alert’s title could say “You almost had it!”
- A close-ish attempt, say 10 or fewer units away, could be rewarded with the title “Not bad.”
- In all other cases, the alert gives the player a little tough love with “Are you even trying?”
Here’s a flowchart that illustrates the process:
Exercise: Think of a way to accomplish this. How would you program it? Hint: There are an awful lot of “if’s” in the preceding sentences.
Before we write the code to set the alert pop-up’s title based on how close the slider and target are, let’s figure out where this code should go.
Let’s look at the code that defines the alert pop-up:
Alert(title: Text("Hello there!"),
message: Text(self.scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.score = self.score + self.pointsForCurrentRound()
self.target = Int.random(in: 1...100)
self.round = self.round + 1
}
)
The title of the alert pop-up is defined by the Alert’s message: parameter. Right now, the Text view that it’s filled with contains the string “Hello there!”. We need a way to change this string based on how well the player did in the current round.
In the previous chapter, we wrote the scoringMessage() method to generate the string that forms the main message of the alert pop-up:
func scoringMessage() -> String {
return "The slider's value is \(self.sliderValueRounded).\n" +
"The target value is \(self.target).\n" +
"You scored \(self.pointsForCurrentRound()) points this round."
}
We’ll write a similar method to generate the string that forms the title of the alert pop-up. Like the scoringMessage() method, this new method returns a value and therefore should have a name that describes the value it returns. We’ll call it alertTitle().
➤ Add the following to the end of ContentView’s Methods section, just after scoringMessage():
func alertTitle() -> String {
let difference: Int = abs(self.sliderValueRounded - self.target)
let title: String
if difference == 0 {
title = "Perfect!"
} else if difference < 5 {
title = "You almost had it!"
} else if difference <= 10 {
title = "Not bad."
} else {
title = "Are you even trying?"
}
return title
}
This code first calculates difference, the difference between the slider position and the target. It then creates a string constant named title, which will contain the title that this method returns. As you can see, most of this method is taken up by an if statement that runs through different possibilities to select a title.
The if statement has four different clauses. Here’s the first one:
if difference == 0 {
title = "Perfect!"
It says if the difference between the slider and the target is 0, set the value of title to Perfect!.
You’re probably wondering what the == means. There’s a critical difference between it — two “equals” signs in a row — and a single “equals” sign:
- The single equals sign,
=, means “Set this variable or constant to a given value.” To take an example from the code above,title = "Perfect!"means “Settitleto Perfect!.” - The double equals sign,
==, means “is equal to.” To take an example from the code above,difference == 0means “differenceis equal to 0”, a statement that is eithertrueorfalse.
When you start programming, you may find yourself using = in when you should really be using ==. Watch for this, especially when Xcode gives you an error message related to an if statement.
Here’s the if statement’s second clause:
} else if difference < 5 {
title = "You almost had it!"
If the first clause doesn’t apply, and difference is less than 5, title is set to You almost had it!.
The if statement reads as follows:
} else if difference <= 10 {
title = "Not bad."
If the first and second clause don’t apply, and difference is 10 or less, title is set to Not bad.
At the end of the if statement comes the else clause:
} else {
title = "Are you even trying?"
This handles the case when none of the other clauses apply. In this case, title is set to Are you even trying?.
Now that we have the alertTitle() method, it’s time to make use of it.
➤ Change the code in body that defines the alert pop-up to the following:
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.score = self.score + self.pointsForCurrentRound()
self.target = Int.random(in: 1...100)
self.round = self.round + 1
}
Note the change. You’ve replaced this hard-coded message:
Text("Hi there!")
With:
Text(scoringMessage())
➤ Run the app. When it starts, you’ll see something like this:
The example is a really lucky one because a target of 1 is very easy to hit — you move the slider all the way to the left. Here’s what the alert pop-up looked like after doing so:
Here’s what the alert looks like if Hit me! is pressed when the slider is positioned far from the target:
Here’s the alert when the slider is fewer than 5 units away from the target:
And finally, if the player presses Hit me! after putting the slider a “not bad” distance (between 5 and 10 units) from the target, they see this:
Bonus points
As it is, the game doesn’t give players much of an incentive to score a bullseye. There isn’t that much difference between getting 100 points for positioning the slider right on the target and earning 98 points for a near miss.
What if the game awarded bonuses for accuracy — say, 100 points for a bullseye and 50 points for being off by one?
Here’s a flowchart showing the bonus process:
Exercise: Before you continue reading and look at the code that follows, think about how you’d implement this bonus rule.
The pointsForCurrentRound() method calculates how many points to award to the player, so it’s probably where you should make the change.
➤ Update pointsForCurrentRound() to this:
func pointsForCurrentRound() -> Int {
let maximumScore: Int = 100
let difference = abs(self.sliderValueRounded - self.target)
let bonus: Int
if difference == 0 {
bonus = 100
} else if difference == 1 {
bonus = 50
} else {
bonus = 0
}
return maximumScore - difference + bonus
}
Here’s the code that determines if the player has earned a bonus:
let bonus: Int
if difference == 0 {
bonus = 100
} else if difference == 1 {
bonus = 50
} else {
bonus = 0
}
The let bonus: Int line declares a constant named bonus, where the calculated bonus will be stored.
The if statement that follows has three different clauses. Here’s the first one:
if difference == 0 {
bonus = 100
It says that if the difference between the slider and the target is 0, set the value of bonus to 100.
Here’s the if statement’s second clause:
} else if difference == 1 {
bonus = 50
If the first clause doesn’t apply, but this one does, bonus is set to 50. The clause applies only if difference is equal to 1.
The else clause at the end handles all the other cases not handled by the first or second clause:
} else {
bonus = 0
}
➤ Run the app to see if you can score some bonus points!
With the newly-added code for calculating a bonus score, here’s what players will see when they position the slider perfectly at the target value:
If they’re off by one, they won’t get the 100-point bonus, but they’ll get the 50-point consolation bonus:
More refactoring
Back in Chapter 4, I introduced you to the concept of refactoring. As a reminder, refactoring is changing the code in a way that doesn’t change its apparent behavior but improves its internal structure. Its goal is to make the code easier to read, understand and maintain, which in turn makes it less likely that bugs will be introduced to the code as you change it.
Let’s do some more refactoring now.
Refactoring the bonus algorithm
You may have noticed a couple of things about the bonus algorithm, either by looking carefully at its code or from playing the game and getting a perfect or off-by-one score:
- The player is always awarded 200 points for perfectly positioning the slider on the target value. This is because you can only get the 100-point bonus when you earn 100 points.
- If the player misses the target by one, they’re always awarded 149 points. This comes from the fact that being off by one earns 99 points, and adding the 50-point bonus yields a not-quite-round 149 points.
Let’s change the algorithm so that:
- If the slider value is equal to the target, give the player 200 points.
- If the slider value is not equal to the target but off by one, give the layer a nice round 150 points.
- If neither of the above applies, give the player the standard number of points, which is an arbitrary maximum value minus the difference between the slider value and the target value.
For those of you who like to think in pictures, here’s the algorithm in flowchart form:
➤ Let’s implement this revised algorithm. Change pointsForCurrentRound() so that it looks like this:
func pointsForCurrentRound() -> Int {
let maximumScore: Int = 100
let difference = abs(self.sliderValueRounded - self.target)
let points: Int
if difference == 0 {
points = 200
} else if difference == 1 {
points = 150
} else {
points = maximumScore - difference
}
return points
}
➤ Run the app and try to put the slider right on the target value. You’ll see this message when you press Hit me!:
➤ Try to put the slider just one unit off the target. You’ll see this:
➤ Set the slider way off the target, and this is what you’ll get:
DRYing up the code
The pointsForCurrentRound() method calculates the number of points to award to the user by looking at the difference between the slider‘s value and the target. It does so with this line of code:
let difference: Int = abs(self.sliderValueRounded - self.target)
The alertTitle() method determines the title that appears in the alert pop-up based on the difference between the slider‘s value and the target. Here’s the line of code that used to do this, and it should give you a sense of déjà vu:
let difference: Int = abs(self.sliderValueRounded - self.target)
This code isn’t DRY — Don’t Repeat Yourself — because we’re repeating ourselves! You might even say that it’s WET — Write Everything Twice!
If you decide to change the way that the difference between the slider value and the target is calculated, you’ll have to change it in two places. This increases the likelihood that you might forget to update one of those places or introduce an error by unintentionally varying how the difference is calculated.
Let’s remove this redundancy by calculating the difference between the slider and target in just once place. This can be done with a method or a computed property. Since the difference is a simple calculation based on two properties — sliderValueRounded and target — it makes sense to use a computed property.
➤ Add the following computed property to the end of the User interface views part of ContentView’s Properties section:
var sliderTargetDifference: Int {
abs(self.sliderValueRounded - self.target)
}
➤ Now that you have this method, use it in pointsForCurrentRound()…
func pointsForCurrentRound() -> Int {
let maximumScore = 100
let points: Int
if self.sliderTargetDifference == 0 {
points = 200
} else if self.sliderTargetDifference == 1 {
points = 150
} else {
points = maximumScore - self.sliderTargetDifference
}
return points
}
➤ …then use it in alertTitle():
func alertTitle() -> String {
let title: String
if self.sliderTargetDifference == 0 {
title = "Perfect!"
} else if self.sliderTargetDifference < 5 {
title = "You almost had it!"
} else if self.sliderTargetDifference <= 10 {
title = "Not bad."
} else {
title = "Are you even trying?"
}
return title
}
➤ Run the app. It works, without any changes that the player will notice. Once again, you’ve improved the underlying code without affecting the user experience.
Starting over
The Start over button at the lower-left corner of the screen does nothing at the moment. Let’s make it active! When the player presses it, the following should happen:
- The score should reset to 0.
- The round should reset to 1.
- The slider should return to its original midway position of 50.
- A new random target value between 1 and 100 inclusive should generate.
The code that does this should go into the action: parameter for the Button view for the Start over button. We could put some code directly in there, but the body variable is already pretty cluttered. Let’s put the steps listed above into a method and then call that method from the action: parameter.
This method won’t return a value; it will merely perform some actions. For this reason, we’ll give it a name that describes what it does: startNewGame.
➤ Add the following method to the end of ContentView’s Methods section:
func startNewGame() {
self.score = 0
self.round = 1
self.sliderValue = 50.0
self.target = Int.random(in: 1...100)
}
➤ Now that we have the startNewGame() method, let’s use it. Scroll to the Score row section of the body variable and change it so that it looks like the following:
// 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)
➤ Run the app, play a round or two, and then press the Start over button. You’ll start a new game, with the slider restored to its original position.
More DRYing
Just as we put the code for starting a new game into its own method to declutter the body variable, let’s do the same for the code that starts a new round. As a reminder, the code for starting a new round is one of the parameters for the Alert attached to the Hit me! button:
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.score = self.score + self.pointsForCurrentRound()
self.target = Int.random(in: 1...100)
self.round = self.round + 1
}
Let’s put the code from the last parameter listed above into its own method, which we’ll name startNewRound().
➤ Add the following method to the end of ContentView:
func startNewRound() {
self.score = self.score + self.pointsForCurrentRound()
self.round = self.round + 1
self.sliderValue = 50.0
self.target = Int.random(in: 1...100)
}
Note we’re also resetting the slider to the midpoint at the start of a new round. Just as we do when starting a new game.
➤ Let’s make use of the startNewRound() method. Scroll to the Button row section of the body variable and change it so that it looks like the following:
// Button row
Button(action: {
print("Button pressed!")
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.presentation(self.$alertIsVisible) {
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)
}
➤ Run the app and play a couple of rounds to confirm that the changes you made are working properly.
The methods you most recently added, startNewGame() and startNewRound() are at the end of ContentView:
func startNewGame() {
self.score = 0
self.round = 1
self.sliderValue = 50.0
self.target = Int.random(in: 1...100)
}
func startNewRound() {
self.score = self.score + self.pointsForCurrentRound()
self.round = self.round + 1
self.sliderValue = 50.0
self.target = Int.random(in: 1...100)
}
Both startNewGame() and startNewRound() end with the same two lines! We can DRY up this code by taking those two lines and putting them in their own method.
➤ Change the methods to look like this:
func startNewGame() {
self.score = 0
self.round = 1
self.resetSliderAndTarget()
}
func startNewRound() {
self.score = self.score + self.pointsForCurrentRound()
self.round = self.round + 1
self.resetSliderAndTarget()
}
func resetSliderAndTarget() {
self.sliderValue = 50.0
self.target = Int.random(in: 1...100)
}
➤ Once again, run the app to confirm that the changes you made work properly.
Making the code less self-ish
If you look at the code you’ve written so far, you’ll see the keyword self all over the place. What does self mean, anyway?
self is Swift’s way of saying “the current object.” In this case, that object is ContentView. Any time you’ve had to refer to one of ContentView’s properties, you’ve prefaced it with self. For example, to reset the slider’s position back to 50, you did it with this code:
self.sliderValue = 50.0
This is how you say “Set the sliderValue property of the current object to 50.0” in Swift.
It’s the same for calls to ContentView’s methods — you also prefaced them with self. For example, to call the method that resets the slider and target values, you did it with this code:
self.resetSliderAndTarget()
Most of the time, when referring to the properties or methods of an object from within that object, self isn’t necessary. This is another one of those cases where Swift is smart enough to infer what you mean.
Let’s declutter the code and make it easier to read (and therefore easier to maintain and expand upon) by removing the unnecessary instances of self. We’ll start with the methods:
➤ Remove all the instances of self from the Methods section so that it looks like this:
// Methods
// =======
func pointsForCurrentRound() -> Int {
let maximumScore = 100
let points: Int
if sliderTargetDifference == 0 {
points = 200
} else if sliderTargetDifference == 1 {
points = 150
} else {
points = maximumScore - sliderTargetDifference
}
return points
}
func scoringMessage() -> String {
return "The slider's value is \(sliderValueRounded).\n" +
"The target value is \(target).\n" +
"You scored \(pointsForCurrentRound()) points this round."
}
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 startNewGame() {
score = 0
round = 1
resetSliderAndTarget()
}
func startNewRound() {
score = score + pointsForCurrentRound()
round = round + 1
resetSliderAndTarget()
}
func resetSliderAndTarget() {
sliderValue = 50.0
target = Int.random(in: 1...100)
}
➤ Run the app to confirm that removing all those instances of self didn’t break it.
Now it’s time to work on the properties, starting with those that aren’t body. That’s a really big property, and we’ll look at it on its own in a moment.
➤ Remove all the instances of self from the User interface views part of the Properties section so that it looks like this:
// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
@State var target = Int.random(in: 1...100)
var sliderValueRounded: Int {
Int(sliderValue.rounded())
}
@State var score = 0
@State var round = 1
var sliderTargetDifference: Int {
abs(sliderValueRounded - target)
}
➤ Once again, run the app to confirm that removing those additional instances of self didn’t break it.
For the body property, let’s work on it in sections.
➤ Remove any instances of self from the Target row and Slider row sections so that they look like this:
// Target row
HStack {
Text("Put the bullseye as close as you can to:")
Text("\(target)")
}
Spacer()
// Slider row
HStack {
Text("1")
Slider(value: $sliderValue, in: 1...100)
Text("100")
}
➤ Run the app to confirm that it still works with these changes.
It’s time to work on the Button row section. Here’s what it looks like at the moment:
// Button row
Button(action: {
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.alert(isPresented: self.$alertIsVisible) {
Alert(title: Text(alertTitle()),
message: Text(self.scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)
}
➤ Change the button’s action: parameter so that it no longer includes the self keyword:
// Button row
Button(action: {
alertIsVisible = true
}) {
Within a second or two of making this change, Xcode shows this error message:
The error message is cryptic: “Reference to property ‘alertIsVisible’ in closure requires explicit ‘self.’ to make capture semantics explicit.” What does that mean?
The first unfamiliar word in the error message is closure. You could look up its meaning in Wikipedia, but it’s so dense with esoteric computer science terminology that you might know less about closures after reading it!
Instead of worrying about what the technical definition of a closure is, think of closures as code that you can put into variables or pass to methods and functions to be executed at a later time. That’s what the button’s action: parameter is a closure: it’s code for the button to execute whenever it’s pressed.
As a closure, the code in the button’s action: parameter isn’t part of ContentView, but separate from it. That means that they have no sense of the object they may be in or any of its properties or methods. However, they can access — or, as Xcode puts it, capture — the local variables around them.
That’s what the “capture” in the error message refers to. Inside an object, the self variable is available anywhere, and the closure captures it.
Simply put, closures need to use self when referring to the properties or methods of the object they’re in.
➤ With what you now know about closures and self in mind, remove only those instances of self from the Button row section that aren’t inside closures. The resulting code should look like this:
// Button row
Button(action: {
self.alertIsVisible = true
}) {
Text("Hit me!")
}
.alert(isPresented: $alertIsVisible) {
Alert(title: Text(alertTitle()),
message: Text(scoringMessage()),
dismissButton: .default(Text("Awesome!")) {
self.startNewRound()
}
)
}
➤ Run the app to confirm that it still works with these changes.
Note: In the beginning, it may not always be clear when you can drop the
selfkeyword and when it’s absolutely necessary to use it. Until you get the hang of it, you can always err on the side of not usingselfand rely on Xcode’s error messages to tell you when you need to include it.
➤ And finally, remove any instances of self from the Score row section that aren’t in closures. This should be the result:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("\(score)")
Spacer()
Text("Round:")
Text("\(round)")
Spacer()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
➤ Run the app to confirm that it still works with these changes.
You’ve now removed all the unnecessary instances of self from the code. It’s much easier to read now!
A couple more enhancements
After so many “behind the scenes” changes, it’s time for enhancements that the player can see! These will be easy to add, but they’ll also enhance the player experience.
Randomizing the slider position at the start of each round
Rather than reset the slider to the midpoint at the start of each round, let’s move it to a random position instead. Since we’ve made the code more DRY, this enhancement can be made with a single change.
➤ Update resetSliderAndTarget() to the following:
func resetSliderAndTarget() {
sliderValue = Double.random(in: 1...100)
target = Int.random(in: 1...100)
}
Remember that the slider is so precise that its values are Double, not Int. That’s why its value is randomized using Double.random() instead of Int.random().
Randomizing the slider position when the game launches
When the game launches, the target and slider values are determined by their initial values, which are set when their variables are declared:
- The slider is set to 50.0.
- The target is set to a random whole number between 1 and 100 inclusive.
After the very first round, the game uses the resetSliderAndTarget() method to set the slider and target values. resetSliderAndTarget() is called by two different methods:
-
startNewRound(): Called when the player dismisses the alert pop-up. -
startNewGame(): Called when the player presses the Start over button.
To make the game more consistent, it should call startNewGame() when the game launches. Luckily, there’s a way to do that.
Every View object has a built-in set of methods that get called when certain view-related events happen. One of these events is when the view first appears. The method that gets called when this happens is called onAppear(). You provide onAppear() with the code that should be executed when the view appears.
All the onscreen elements in the game appear when the game launches, so you can use the onAppear() method for any of the views in ContentView’s body property. Since the VStack in body acts as the container for all the onscreen elements, we’ll use its onAppear() method.
The call to onAppear() will look like this:
.onAppear() {
self.startNewGame()
}
It will be called only once: the very first time that the VStack is drawn on the screen, which will happen only when the game is launched.
➤ Change the end of body so that it looks like this. I’ve included a lot of the surrounding code so because it can be hard to tell where the code should go:
// Score row
HStack {
Button(action: {
self.startNewGame()
}) {
Text("Start over")
}
Spacer()
Text("Score:")
Text("\(score)")
Spacer()
Text("Round:")
Text("\(round)")
Spacer()
Button(action: {}) {
Text("Info")
}
}
.padding(.bottom, 20)
}
.onAppear() {
self.startNewGame()
}
}
// Methods
// =======
➤ Run the app. The slider position will now be randomized at the very start, instead of always starting at 50.0.
Key points
In this chapter, you did the following:
- You enhanced the message that appears when the player does particularly well.
- You also made improvements to the way points and bonuses are awarded.
- You enabled the Start over button.
- You did a fair bit of refactoring, which included removing redundant and unnecessary code.
- You learned about closures.
- You learned about views’
onAppear()method and used it to automatically perform a task when the app is first launched.
At this point, Bullseye is pretty polished, and your task list is getting shorter. In the next chapter, you’ll transform the game from its plain look and feel into something a little more polished.
You can find the project files for the current version of the app under 06 - Refactoring in the Source Code folder.