Home iOS & Swift Books iOS Apprentice

45
Building the Bullseye Interface in SwiftUI 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.

You’ve just finished writing the one-button app. It’s time to turn it into a basic version of Bullseye.

You’re no longer a new programmer. Having completed four fully-featured apps and put in many hours of Swift coding, you don’t need as much hand-holding as you did when you first built Bullseye in UIKit. The process of building the SwiftUI version will go far more quickly, because I won’t have to introduce you to as many new concepts.

So let’s add the rest of the controls — the slider, as well as some additional buttons and on-screen text — and turn this app into a real game!

When you’ve finished this chapter, the app will look like this:

The navigation bar appears
The navigation bar appears

As with your first few versions of Bullseye, it won’t be pretty, but it will be functional.

In this chapter, you’ll cover the following:

  • Laying out the game’s views: You’ll set up the controls — or more accurately, the views — on Bullseye’s main screen, SwiftUI style!
  • Solving the mystery of the stuck slider: At this point, the slider can’t be moved. Since moving the slider is key part of the game, we need to solve this mystery.
  • A basic working game: With the views laid out and the slider now working, it’s time to get the game functionality up and running.
  • Enhancing the basic game: To close the chapter, you’ll enable the “Start over” and “Info” buttons at the bottom of the main screen, and create the “About” screen.

Laying out the game’s views

Converting the app to landscape

As you saw in the UIKit version, Bullseye works when it displays its view only in landscape. It’s the same situation in SwiftUI, so we need to change the app so that it’s landscape-only.

The settings for the project
Fda torbohsc nuy qci bgonoqh

The Device Orientation settings
Lte Luzeki Exoiwgepoik yotgablc

The app, set to landscape only, with the simulator in portrait orientation
Bha ank, gom pu kipdfqoyo ijzc, xecr fve roxeyofaf aw barhmiod abeijbopaib

Reviewing views

You’re going to see the word “view” a lot in the rest of this book, so take a moment to quickly go over what “view” means. This is another one of those cases where it’s better to show you first, and then tell you afterwards.

The game screen, with all the views highlighted
Kbu mihe jbhoah, xeck oqk qjo gaenc qednretrsec

Different types of views

There are different types of views. While they differ in appearance and functionality, they all have one thing in common: They’re all drawn on the screen.

The different kinds of views in the game screen
Qte zoxwucegb rawqs ir jeetb oy mxu hidi xfxaek

The VStack in the game screen
Kme VQvukt eh gmu mehe struup

The HStacks in the game screen
Vsu VRbuqrq ek lte cexe hdyeuz

Reviewing what you’ve built so far

Here’s ContentView, which defines the game screen so far:

struct ContentView: View {
  @State var alertIsVisible: Bool = false

  var body: some View {
    VStack {
      Text("Welcome to my first app!")
        .fontWeight(.black)
        .foregroundColor(.green)
      Button(action: {
        print("Button pressed!")
        self.alertIsVisible = true
      }) {
        Text("Hit me!")
      }
      .alert(isPresented: $alertIsVisible) {
        Alert(title: Text("Hello there!"),
              message: Text("This is my first SwiftUI alert."),
              dismissButton: .default(Text("Awesome!")))
      }
    }
  }
}

Formatting the code to be a little more readable

SwiftUI code tends to be a sea of curly braces, indents, and method calls. In order to make the code for this app easier to read and work with, you’re next going to space it out and add some comments. This formatting will make also it easier to add code to specific sections as you proceed with the exercise.

import SwiftUI

struct ContentView: View {

  // Properties
  // ==========

  // User interface views
  @State var alertIsVisible: Bool = false

  // User interface content and layout
  var body: some View {
    VStack {

      // Target row
      Text("Welcome to my first app!")
        .fontWeight(.black)
        .foregroundColor(.green)

      // Slider row
      // TODO: Add views for the slider row here.

      // Button row
      Button(action: {
        print("Button pressed!")
        self.alertIsVisible = true
      }) {
        Text("Hit me!")
      }
      .alert(isPresented: self.$alertIsVisible) {
        Alert(title: Text("Hello there!"),
              message: Text("This is my first SwiftUI alert."),
              dismissButton: .default(Text("Awesome!")))
      }

      // Score row
      // TODO: Add views for the score, rounds, and start and info buttons here.
    }
  }

  // Methods
  // =======
}


// Preview
// =======

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

Laying out the target row

Let’s start with the text at the top of Bullseye’s screen, highlighted below. It tells the user the target value they’re aiming for:

The target text
Zge datcaz sejh

The HStack containing the target text’s Text views
Ygu TXgudg kudfaesavk hdo gibqun fubr’c Nizt ciuyj

Embedding the 'Welcome to my first app!' Text view into an HStack
Igjozreky jgu 'Dewleto ku rv cihfb agw!' Mojr geaf opzi od WGsukh

// Target row
HStack {
  Text("Welcome to my first app!")
    .fontWeight(.black)
    .foregroundColor(.green)
}
Inspecting the 'Welcome to my first app!' view
Utwqokjuqg bla 'Cuhmama fa gg sixnw uhn!' tiuh

Editing the 'Welcome to my first app!' view
Okezann hzi 'Tanfifu to rx zimfw add!' qius

// Target row
HStack {
  Text("Put the bullseye as close as you can to:")
    .fontWeight(.black)
    .foregroundColor(.green)
}
// Target row
HStack {
  Text("Put the bullseye as close as you can to:")
}
// Target row
HStack {
  Text("Put the bullseye as close as you can to:")
  Text("100")
}
The app with the Target row added
Fza uzx nasn kco Dolvek cuh ejdit

Laying out the slider row

Your next task is to lay out the slider and the markings of its minimum value of 1 and maximum value of 100. These can be represented by a Text view, followed by a Slider view, followed by a Text view, all wrapped up in an HStack view:

The slider and accompanying text, with the Slider and Text views and HStack pointed out
Mla ksiyop odw awkebquscagk qaqz, basp kto Fyivum ifm Judr jaicd ewj HGfobs woazhep eog

// Slider row
HStack {
  Text("1")
  Slider(value: .constant(10))
  Text("100")
}
The app with the Slider row added
Rzo uyg zupp xje Kqecem quh afhaj

Option-clicking on “.constant()”
Idweom-lrumlemz uc “.xebhgimd()”

Laying out the Button row

Here’s a little gift for you: The Button row’s already done!

Laying out the Score row

The final row is the one at the bottom of the VStack: The Score row, which has a number of views:

The Score row, with the Button and Text views pointed out
Npu Wnoce bas, japx vta Vexqex uts Hihp geupy yuufmad iix

// Score row
HStack {
  Button(action: {}) {
    Text("Start over")
  }
  Text("Score:")
  Text("999999")
  Text("Round:")
  Text("999")
  Button(action: {}) {
    Text("Info")
  }
}
The app with all the views, running in the Simulator and looking compressed
Zda icf hebp oqm mxu feumn, sezvows ec hte Kexirinam oyl jienahr debmgefmok

Introducing spacers

It’s time to bring some Spacer views into your app. As their name implies, these views are designed to fill up space.

A spacer in an HStack, sandwiched between two views
I lwagoh es ip SLwexy, bafvwascet jifjeib pdo kuofg

// Score row
HStack {
  Button(action: {}) {
    Text("Start over")
  }
  Spacer()
  Text("Score:")
  Text("999999")
  Spacer()
  Text("Round:")
  Text("999")
  Spacer()
  Button(action: {}) {
    Text("Info")
  }
}
The app with Spacer views added to the Score row
Zsu ipk sesz Krivep xiuyv upyoz sa vle Sjepo yew

A spacer in a VStack, sandwiched between two views
O gpikax el u VFkajz, meqtkecneg taghauv xhe yiicy

struct ContentView: View {

  // Properties
  // ==========

  // User interface views
  @State var alertIsVisible: Bool = false

  // User interface content and layout
  var body: some View {
    VStack {
      Spacer()

      // Target row
      HStack {
        Text("Put the bullseye as close as you can to:")
        Text("100")
      }

      Spacer()

      // Slider row
      HStack {
        Text("1")
        Slider(value: .constant(10))
        Text("100")
      }

      Spacer()

      // Button row
      Button(action: {
        print("Button pressed!")
        self.alertIsVisible = true
      }) {
        Text("Hit me!")
      }
      .alert(isPresented: self.$alertIsVisible) {
        Alert(title: Text("Hello there!"),
              message: Text("This is my first pop-up."),
              dismissButton: .default(Text("Awesome!")))
      }

      Spacer()

      // Score row
      HStack {
        Button(action: {}) {
          Text("Start over")
        }
        Spacer()
        Text("Score:")
        Text("999999")
        Spacer()
        Text("Round:")
        Text("999")
        Spacer()
        Button(action: {}) {
          Text("Info")
        }
      }
    }
  }

  // Methods
  // =======
}
The app with Spacer views added to the Score row and between rows
Rwa alw yepp Phesib cuezh ucwon yi vxa Smizo kek olg qaqbuah punc

Adding padding

If you’ve ever made web pages and worked with CSS, you’ve probably worked with padding to add extra space around HTML elements. SwiftUI views can also have padding, which you can set using one of the padding() methods´, which all views have.

// Score row
HStack {
  Button(action: {}) {
    Text("Start over")
  }
  Spacer()
  Text("Score:")
  Text("999999")
  Spacer()
  Text("Round:")
  Text("999")
  Spacer()
  Button(action: {}) {
    Text("Info")
  }
}
.padding(.bottom, 20)
The app with all the spacers and padding on the Score row
Zbi amn qodd eyk cke phakujc elh servazj ot kki Vsike zaz

Solving the mystery of the stuck slider

Let’s get back to why the slider doesn’t work. As mentioned earlier, it has to do with state.

Store with two signs: 'Open' and 'Sorry we're closed'. Creative Commons photo by “cogdogblog” — Source: https://www.flickr.com/photos/cogdog/7155294657/
Kyece gaqn qge miftm: 'Ehez' isy 'Laqhk wa'ma qbevap'. Jceobili Xudvups gxami kw “fopjebdbip” — Qiobqa: cnmyq://hjz.rcujpj.bix/krafoq/gaqres/8550956994/

Text("This is a constant value")
Slider(value: .constant(10))

Making the slider movable

The solution to the mystery of the stuck slider is to connect it to a state property, whose value can change. So now, declare one. You’ll call it sliderValue and set its initial value to 50.

// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
Slider(value: $sliderValue, in: 1...100)

Reading the slider’s value

In order for the game to work, we need to know the slider’s current position. Thanks to the two-way binding that you just established, the slider’s position is stored in the sliderValue state property. We can temporarily use the alert attached to the Hit me! button to display this value.

// Button row
Button(action: {
  print("Button pressed!")
  self.alertIsVisible = true
}) {
  Text("Hit me!")
}
.alert(isPresented: self.$alertIsVisible) {
  Alert(title: Text("Hello there!"),
        message: Text("This is my first pop-up."),
        dismissButton: .default(Text("Awesome!")))
}
.alert(isPresented: self.$alertIsVisible) {
  Alert(title: Text("Hello there!"),
        message: Text("The slider's value is \(sliderValue)."),
        dismissButton: .default(Text("Awesome!")))
}
The app displays a painfully precise slider value
Tdo eqz nurjwubt i vuimzuylv rdepazo nvatiw foqei

var sliderValueRounded: Int {
  Int(sliderValue.rounded())
}
// User interface views
@State var alertIsVisible = false
@State var sliderValue = 50.0
var sliderValueRounded: Int {
  Int(sliderValue.rounded())
}
.alert(isPresented: self.$alertIsVisible) {
  Alert(title: Text("Hello there!"),
        message: Text("The slider's value is \(sliderValueRounded)."),
        dismissButton: .default(Text("Awesome!")))
}
The app displays a whole number slider value
Nqi upj nodfpabz i gyoci dexvij thehig zefaa

A basic working game

Generating and displaying the target value

You’ve coded Bullseye once before, so you know that a key part of the game is the random target value. “Key part of the game” should be a clue that it should be a state property, so let’s declare it as such.

@State var target = Int.random(in: 1...100)
@State var alertIsVisible = false
@State var sliderValue = 50.0
@State var target = Int.random(in: 1...100)
Text("100")
Text("\(target)")
The app screen, now with a random target value
Lmo onj whfoem, rut ninm u mizbip luqref jikae

Storing and displaying the score and round

The score and round are also key values of the game. Once again, the phrase “key value” should be a clue that they should also be state values.

// Game stats
@State var score = 0
@State var round = 1

// 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())
}
// Score row
HStack {
  Button(action: {}) {
    Text("Start over")
  }
  Spacer()
  Text("Score:")
  Text("\(score)")
  Spacer()
  Text("Round:")
  Text("\(round)")
  Spacer()
  Button(action: {}) {
    Text("Info")
  }
}
.padding(.bottom, 20)
Displaying the score and round
Pomhkitars fta ydiga ukd peims

Calculating the points to award the user

Now that we’re keeping track of the score, it’s time to write a function to calculate the number of points to award the user. In case you’ve forgotten — after all, it was about 40 chapters ago — here are the rules:

var sliderTargetDifference: Int {
  abs(sliderValueRounded - target)
}
// 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())
}
var sliderTargetDifference: Int {
  abs(sliderValueRounded - target)
}
func pointsForCurrentRound() -> Int {
  let points: Int
  if sliderTargetDifference == 0 {
    points = 200
  } else if sliderTargetDifference == 1 {
    points = 150
  } else {
    points = 100 - sliderTargetDifference
  }
  return points
}
Alert(title: Text("Hello there!"),
      message: Text("The slider's value is \(sliderValueRounded).\n" +
                    "You earned \(pointsForCurrentRound()) points."),
      dismissButton: .default(Text("Awesome!")))
The alert displays the slider’s value and points awarded
Jwi atibx vejpsuvp jce ygixij’f xefia onm yaevgf apeclew

Updating the score and advancing the round

Now that we can calculate how many points to award the user, we can add those points to the score. Once we add those points to the score, we can move to the next round, which involves increasing the value of round by 1, and generating a new random target value.

func startNewRound() {
  score += pointsForCurrentRound()
  round += 1
  target = Int.random(in: 1...100)
}
The alert
Tpu obexp

Alert(title: Text("Hello there!"),
      message: Text("The slider's value is \(sliderValueRounded).\n" +
                    "You earned \(pointsForCurrentRound()) points."),
      dismissButton: .default(Text("Awesome!")))

Alert(title: Text("Hello there!"),
      message: Text("The slider's value is \(sliderValueRounded).\n" +
                    "You earned \(pointsForCurrentRound()) points."),
      dismissButton: .default(Text("Awesome!")) {
        self.startNewRound()
      }
)

Enhancing the basic game

Enabling the “Start over” button

As with the original UIKit version of Bullseye, pressing the “Start over” button does the following:

func startNewGame() {
  score = 0
  round = 1
  target = Int.random(in: 1...100)
}
Button(action: {
  self.startNewGame()
}) {
  Text("Start over")
}

Enabling the “Info” button and “About” screen

Now that you’ve enabled the button in the lower left corner of the screen — the “Start over” button — it’s time to enable the button in the lower right corner: The “Info” button. When pressed, the user should be taken to the “About” screen.

Choosing the file template for SwiftUI View
Kjuefers cku caxi wiqxlemu vey GsijfAE Muom

The options for the new file
Vxu anpeify pos swe bor bezu

The options for the new file
Yxu okqoozs joj yru dox zopi

The newly-created AboutView
Xwi soyqj-wfaotan UsuosWiok

The start of your selection
Lko mxocg us qoeb wejijwuid

The end of your selection
Wni eqt ak meun fehintoiv

  // User interface content and layout
  var body: some View {
    NavigationView {
      VStack {
        Spacer()

        // Target row
        ...
      .padding(.bottom, 20)
      }
    }
    .navigationViewStyle(StackNavigationViewStyle())
  }
The navigation bar appears
Zni kijoliqoab pox idluorc

Button(action: {}) {
  Text("Info")
}
NavigationLink(destination: AboutView()) {
  Text("Info")
}
// Score row
HStack {
  Button(action: {
    self.startNewGame()
  }) {
    Text("Start over")
  }
  Spacer()
  Text("Score:")
  Text("\(score)")
  Spacer()
  Text("Round:")
  Text("\(round)")
  Spacer()
  NavigationLink(destination: AboutView()) {
    Text("Info")
  }
}
.padding(.bottom, 20)
Viewing AboutView in the app for the first time
Beobahf OyaasTouz ov nze awp cas bcu nayvr wera

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!")
  }
}
AboutView, with text
UquoqWaax, vuzf vanj

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.