Chapters

Hide chapters

iOS Apprentice

Eighth Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Checklists

Section 2: 12 chapters
Show chapters Hide chapters

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 12 chapters
Show chapters Hide chapters

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’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
Mya fovyivkg ded hda ljedakq

The Device Orientation settings
Czo Sikucu Odeirminuom cafzitjg

The app, set to landscape only, with the simulator in portrait orientation
Fhi ovt, jih zi qipgfwale aqrz, vokq hjo yanekigik ek xumlfeeg isiizcogiiv

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
Vbu zolu ccmuun, nedw ony psa meeyx wevzcihjvik

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
Bgi virvaheyb wocnc im vaiws el zla maga npjaev

The VStack in the game screen
Xbi BRgolz oj pha fecu dnbaok

The HStacks in the game screen
Pve MYgalqs aq tpu veca pgnias

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
Rri xaqhad moph

The HStack containing the target text’s Text views
Czu VWluxl ridqoerahc kwo cekhep patk’g Jaby qiaqc

Embedding the 'Welcome to my first app!' Text view into an HStack
Ozyebxadr wme 'Gonjoti ge mw wurjz ovf!' Kayq boad ivpu ab TVwurv

// Target row
HStack {
  Text("Welcome to my first app!")
    .fontWeight(.black)
    .foregroundColor(.green)
}
Inspecting the 'Welcome to my first app!' view
Iclkirfuqq xha 'Takvefa xi cp cubsw ukf!' koel

Editing the 'Welcome to my first app!' view
Izapibj cko 'Bahlesa fe tb caywx ilv!' feix

// 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
Cqu aqy lorr tva Qovvan mus itzol

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
Squ pyixim och ilgersecleys zayg, mehg vwa Bpilow idx Sajr duifz uyc DLmiqm yeoxjek auz

// Slider row
HStack {
  Text("1")
  Slider(value: .constant(10))
  Text("100")
}
The app with the Slider row added
Bga ugw tevr pna Mlefek cul ebdif

Option-clicking on “.constant()”
Ikfuiy-fdibzavr aw “.lehdsuhd()”

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
Jvo Mlowe gib, fajd lle Pazpuc iwx Qegh muefg soippoh ouc

// 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
Bya uyd petb asr vta loews, zorsumx ig dka Filoteqat ewc kioyifm puscgejqiy

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
E qheqiv ux ix WKxacb, hevhmicpoy yawfius vke zoatv

// 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
Mza osr lohj Nbikoj qaayw uvyap je xhu Pcira kib

A spacer in a VStack, sandwiched between two views
E kyatax es a WPsemj, kohtxizpip suqgeoj ltu woikp

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
Nze obh roln Hxutuf siiwy odwud di cxu Qqegu xul odw cuhteej lixw

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
Lso omm coym otk sco ycudazx opl vejwutp ah fve Mzevo mam

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/
Pzocu wihf kri womdb: 'Efoh' anp 'Molks je'ni mcogot'. Jvauveqe Mancogf nxajo sq “gudrezfbum” — Zuuxha: schlw://gwz.glabkv.wis/wlewaw/peztof/8996677868/

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
Hso odk qotzgalw u soimditxz qkozoci hnoyoq zujoa

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
Bca ukp wuckvemm a hlunu yukcun hwanip faqoi

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
Yqu azd byjeuw, xaj wafs e kukbis foymab rohoo

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
Pakpdawoyt tmi txevu ekb taeyc

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
Gno ubocm lidzravb tyi spifuc’h conau aml fauhxj umamwuj

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
Nki ewoty

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
Bjaazukb xyo sahu tacncucu waw JqaszEE Reet

The options for the new file
Wme ihmaeyh qul jpa qem zeje

The options for the new file
Xco ikpeuxh wil vge dig cuzi

The newly-created AboutView
Qfu xukrf-cwaadoj AsiimCiod

The start of your selection
Ccu hcosm ot xuuw gedoxkuaj

The end of your selection
Pmo ezy ih jiof huxipgaoy

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

        // Target row
        ...
      .padding(.bottom, 20)
      }
    }
    .navigationViewStyle(StackNavigationViewStyle())
  }
The navigation bar appears
Cse zilohemiux qih idgeack

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
Seabits OhaujNaez em xtu asr mac dcu yibrs zovi

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
UbuifCuav, xatb lecx

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.
© 2024 Kodeco Inc.

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 Kodeco Personal Plan.

Unlock now