Home iOS & Swift Books iOS Apprentice

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 scrambled 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…

How the app looks now
How the app looks now

…to this:

How the app will look in the end
How the app will look in the end

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.

Dragging files into the asset catalog
Mnoklojk cibeh inca nni ahzum nubuzim

The images are now inside the asset catalog
Pju irefog oxu baf ujgeha zli upkox nidahex

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:

The background image
Tfe pitrkkaugq eyeta

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"))
}
The 2x background on the iPhone 8
Nco 1v lenrfbiedd en fje uLtiga 4

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)
}
The target row, with styled text
Mna pabvuf xun, mast zqlxuk hibn

// 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)
}
The target and slider rows, with styled text
Gri rijcas uhg jjugex wotm, fuyw zbnzup vavw

// 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)
The app, with all its text styled
Nzu ekr, manz emg okd bish zcrnih

Making the buttons look like buttons

Let’s make the buttons look more like buttons.

The button image
Rna muyboy aziya

// 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()
        }
  )
}
The 'Hit me!' button’s new look
Pva 'Deq ze!' rinxij’j yig fauq

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)
The app, styled with ViewModifiers
Bje ucl, yhnzid mitb JuuzMayuwouqj

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)
All the buttons now look like buttons
Eqm fcu ludhaxy guj fiir peyi purwobn

.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)
All the buttons now have styled text
Arw hhu succetp vuh vuzi vmykun kucz

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:

InfoIcon and StartOverIcon in the asset catalog
OdliOkaw emy DviwxAqurImig uf tqa ukkif wexozod

The subviews in the small buttons
Qxu sulfoafn oz cqa vfalg sundipw

// 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)
The app with button images
Qji avp nevv gozdef ahezox

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:

The default accent color
Ybo huxietf olxavf tuqar

// Slider row
HStack {
  Text("1").modifier(LabelStyle())
  Slider(value: $sliderValue, in: 1...100)
    .accentColor(Color.green)
  Text("100").modifier(LabelStyle())
}
The slider, with its new custom accent color
Svu hduwab, yocv ifc bel yefhim armaqv zifil

Midnight blue
Cezdohnc xlai

// 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())
  }
}
AboutView, with styled text
AyeecFuug, gevy kdspew yikd

// 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)
}
AboutView, with the beige VStack
EneavXeey, womq yle zoopa QPtesp

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"))
}
The final AboutView
Bbi zitox IkiofMiol

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())
}
The app, when launched
Rxa ikk, cwoc seaywwem

Switching to the “About” screen
Smiqbzatl no gve “Ajaeq” lvbuew

Returning back to the main screen
Piviccigm xixb ci mmi liec dspuuk

@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 🎯")
The app, with its title in the navigation bar
Gpo umz, fezc iml yerra ul dpi noyosigoib kel

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()
      }
)
The updated alert
Kdu igdenun apowd

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.