3.
Prototyping the Main View
Written by Audrey Tam
Now for the fun part! In this chapter, you’ll start creating a prototype of your app, which has four full-screen views:
- Welcome
- Exercise
- History
- Success
Creating the Exercise View
You’ll start by laying out the Exercise view, because it contains the most subviews. Here’s the list of what your user sees in this view:
- A title and page numbers are at the top of the view and a History button is at the bottom.
- The page numbers indicate there are four numbered pages.
- The exercise view contains a video player, a timer, a Start/Done button and rating symbols.
And here’s the list rewritten as a list of subviews:
- Header with page numbers
- Video player
- Timer
- Start/Done button
- Rating
- History button
You could sketch your screens in an app like Sketch or Figma before translating the designs into SwiftUI views. But SwiftUI makes it easy to lay out views directly in your project, so that’s what you’ll do.
The beauty of SwiftUI is it’s declarative: You simply declare the views you want to display, in the order you want them to appear. If you’ve created web pages, it’s a similar experience.
Outlining the Exercise View
➤ Continue with your project from the previous chapter or open the project in this chapter’s starter folder.
Start by creating an outline with placeholder Text views.
➤ Open ExerciseView.swift.
➤ You’ll start by laying out the iPad version of HIITFit, so select an iPad simulator:
➤ Zoom to fit the iPad in the canvas:
➤ Start by embedding the Text view in a VStack — right-click Text and select Embed in VStack from the menu.
➤ Next, to create the other five Text subviews, duplicate the Text(exerciseNames[index]) view, edit the second view to Text("Video player"), then duplicate this line and edit the strings to create this list:
VStack {
Text(exerciseNames[index])
Text("Video player")
Text("Timer")
Text("Start/Done button")
Text("Rating")
Text("History button")
}
Xcode Tip: Embed in … only works on a single line of code and the preview canvas must be open.
Creating the Header View
Skills you’ll learn in this section: modifying views; method signatures; SF Symbols;
Imageview; extracting and configuring subviews; preview variants
The first Text view is the starting point for the Header view. You’ll add code to it, here in ExerciseView, then you’ll extract this code as a subview and move it to its own file.
➤ To prepare for later extraction, embed the first Text view in a VStack. Now, this Text view is in a VStack, nested in the top-level VStack:
VStack {
VStack {
Text(exerciseNames[index])
}
The Many Ways to Modify a View
➤ Open the Attributes inspector: Press Option-Command-4 or click the inspectors button in the toolbar, then select the Attributes inspector. In the canvas Selectable mode, select the “Squat” Text view:
This inspector has sections for the most commonly-used modifiers: Accessibility, Font, Padding and Frame. You could select a font size from the Font ▸ Font menu, but you’ll use the Library’s search field this time. This is a more general approach to adding modifiers.
➤ Close the inspectors panel.
➤ Show the Library (+ toolbar button or Shift-Command-L), search for font modifiers, then drag Font into the code editor, hover/nudge the Text line until a new line opens beneath it, then release it:
Note: I’ve hidden details in the Library so it doesn’t cover the canvas.
The font size of “Squat” changes in both the canvas and in code:
Text(exerciseNames[index])
.font(.title)
Note: Putting the modifier on its own line is a SwiftUI convention. A view often has several modifiers, each on its own line. This makes it easy to move a modifier up or down, because sometimes the order makes a difference.
➤ Xcode suggests the font size title, but this is only a placeholder. To accept this value, click .title, then press Return.
Note: Xcode and SwiftUI auto-suggestions and default options are often what you want.
➤ To see other options, Control-Option-click font or title. This opens the font modifier’s pop-up Attributes inspector. In the Font section, click the selected Font option Title to see the Font menu:
➤ Select Large Title from the menu: “Squat” is even bigger now!
➤ Here’s another way to see the font menu. Delete .largeTitle and type .:
Xcode auto-suggests the possible values.
➤ Select largeTitle from the menu — you might have to scroll down in the menu.
➤ Once you’re familiar with SwiftUI modifiers, you might prefer to just type. Delete .font(.largeTitle) and type .font. Press the right-arrow key to see the second font(_ font:) method:
➤ Select either method and Xcode auto-completes with a (font: Font?) placeholder. Change this to .largeTitle.
Swift Tip: The method signature
func font(_ font: Font?) -> Textindicates this method takes one parameter of typeFont?and returns aTextview. The “_” means there’s no external parameter name — you call it withfont(.title), not withfont(font: .title).
Creating Page Numbers With SF Symbols
In addition to the name of the exercise, the header should display the page numbers with the current page number highlighted.
You could just display Text("1"), Text("2") and so on, but Apple provides a wealth of configurable icons as SF Symbols.
➤ The SF Symbols app is the best way to browse and explore the collection. Download it from SF Symbols and install it. Some symbols must be used only for specific Apple products like FaceTime or AirPods. You can check symbols for restrictions at sfsymbols.com. Xcode and Apple SDKs Agreement 2.11 System-Provided Images covers the use of SF Symbols.
➤ After installing the SF Symbols app, open it, select the Indices category, then scroll more than halfway down to the numbers:
There are black numbers on a white background or the other way around, in a circle or a square. The fill version could represent the current page, with no-fill numbers for the other pages.
You can copy SF Symbol names from the app with Shift-Command-C. However, there’s an easier way, if you know part of the symbol name you want.
➤ In the code editor, add this line below the title Text view in the VStack:
Image(systemName: "")
Image is another built-in SwiftUI view, and it has an initializer that takes an SF Symbol name as a String.
➤ Position the cursor between the quotation marks, then open the Library. Select the Symbols tab, search for “1 circle” and scroll down to the “1” you want:
➤ Now, double-click 1.circle. The symbol name appears at the cursor location:
Image(systemName: "1.circle")
➤ Before adding more numbers, embed this Image in an HStack, so the numbers will appear side by side. Then duplicate and edit more Image views to create the other three numbers:
HStack {
Image(systemName: "1.circle")
Image(systemName: "2.circle")
Image(systemName: "3.circle")
Image(systemName: "4.circle")
}
And here’s your header:
The page numbers look too small. However, because SF Symbols are integrated into the San Francisco system font — that’s the “SF” in SF Symbols — you can treat them like text and use font to specify their size.
➤ You could add .font(.title2) to each Image, but it’s quicker and neater to add it to the HStack container:
HStack {
Image(systemName: "1.circle")
Image(systemName: "2.circle")
Image(systemName: "3.circle")
Image(systemName: "4.circle")
}
.font(.title2)
The font size applies to all views in the HStack:
➤ You can modify an Image to override the HStack modifier. For example, modify the first number to make it extra large:
Image(systemName: "1.circle")
.font(.largeTitle)
Now the first symbol is larger:
➤ Delete the Image font modifier, so all the numbers are the same size.
Your ExerciseView now has a header, which you’ll reuse in WelcomeView. So you’re about to extract the header code to create a HeaderView.
Extracting a Subview
➤ Select the VStack containing the title Text and the page numbers HStack, then Right-click and select Extract Subview from the menu:
Xcode moves the whole VStack into the body property of a new view with the placeholder name ExtractedView.
And ExtractedView() is where the VStack used to be:
➤ If the placeholders are highlighted, type HeaderView and press Return. If not, select ExtractedView and right-click. Choose Refactor ▸ Rename… from the menu:
➤ Type HeaderView to replace both placeholders at once, then click Rename:
Adding a Parameter
The error flag in HeaderView shows where you need a parameter. The index property is local to ExerciseView, so you can’t use it in HeaderView. You could pass index to HeaderView and ensure it can access the exerciseNames array. But it’s always better to pass just enough information. This makes it easier to set up the preview for HeaderView. Right now, HeaderView needs only the exercise name.
➤ Add this property to HeaderView, above the body property:
let exerciseName: String
➤ And replace exerciseNames[index] in Text:
Text(exerciseName)
➤ Scroll up to ExerciseView, where Xcode is complaining about a missing argument in HeaderView(). Click the error icon to click Fix, then complete the line to read:
HeaderView(exerciseName: exerciseNames[index])
Moving a Subview to a New File
➤ Now, press Command-N to create a new SwiftUI View file and name it HeaderView.swift. Because you were in ExerciseView.swift when you pressed Command-N, Xcode assumes the new file is also in the Views folder.
Your new file opens in the editor with two error flags:
- Invalid redeclaration of ‘HeaderView’.
- Missing argument for parameter ‘exerciseName’ in call.
➤ To fix the first, in ExerciseView.swift, select all 17 lines of your new HeaderView structure and press Command-X to cut it — copy it to the clipboard and delete it from ExerciseView.swift.
➤ Back in HeaderView.swift, replace the boilerplate HeaderView with what’s in the clipboard.
➤ To fix the second error, in previews, let Xcode add the missing parameter, then enter any exercise name for the argument:
HeaderView(exerciseName: "Squat")
Because you pass only the exercise name to HeaderView, the preview doesn’t need access to the exerciseNames array.
Working With Previews
SwiftUI previews can do a lot more than what you’ve seen so far.
Layout: Size That Fits
The preview still uses the iPad simulator, which takes up a lot of space. You can modify the preview to show only the header.
➤ In HeaderView.swift, change #Preview { to this:
#Preview(traits: .sizeThatFitsLayout) {
➤ Switch to Selectable mode and Zoom to 100% to see only the header, without the device frame:
A small view makes it easier to see some of the possibilities of previews.
Variants
➤ Click the Variants button (next to Selectable) and select Color Scheme Variants:
➤ See what you get with Dynamic Type Variants:
iOS device users can set a preferred text size in Settings ➤ Display & Brightness ➤ Text Size — from extra small (to fit more on the screen) up to XXX Large. Accessibility ➤ Display & Text Size ➤ Larger Text goes up to Accessibility size 5. Your app supports these settings by using semantic font sizes like title2 instead of a fixed value like 36 points.
Dynamic Type Variants show how this view appears for different text size settings, enabling you to adapt your layout so important elements remain readable.
➤ Return to ExerciseView.swift to see the Orientation Variants:
That’s how easy it is to see how your views appear on a device with these settings.
➤ Click the Live Preview or Selectable button to stop showing the variants.
It’s also easy to select specific variants:
Creating the Exercise Structure
Skills you’ll learn in this section: enumeration; computed property;
extension;staticproperty
Currently, your app uses two arrays of strings for the exercise and video file names. This simple approach helped you pass just enough data to the extracted HeaderView, keeping its preview manageable. But, if you add more videos, you must manually ensure the strings match up across the two arrays. It’s safer to encapsulate them as properties of a named type.
First, you’ll create an Exercise structure with the properties you need. Then, you’ll create an array of Exercise instances and loop over this array to create the ExerciseView pages of the TabView.
➤ In the Project navigator, select a file outside the Views folder, then create a new Swift file (Command-N):
Note: Up to now, you’ve created and used new SwiftUI views. This
Exercisestructure models your app’s data, encapsulating theexerciseNameandvideoNameproperties. It isn’t a view, so you create it in a new Swift file.
➤ Name the file Exercise.swift. Because you selected a file outside Views, Xcode puts it in the HIITFit folder and group:
If it does end up in the Views folder, just drag it out of the folder.
➤ In Exercise.swift, add the following code below import Foundation:
struct Exercise {
let exerciseName: String
let videoName: String
enum ExerciseEnum: String {
case squat = "Squat"
case stepUp = "Step Up"
case burpee = "Burpee"
case sunSalute = "Sun Salute"
}
}
Enumerating Exercise Names
enum is short for enumeration. A Swift enumeration is a named type and can have methods and computed properties. It’s useful for grouping related values so the compiler can help you avoid mistakes like misspelling a string.
Swift Tip: A stored property is one you declare with a type and/or an initial value, like
let name: Stringorlet name = "Audrey". You declare a computed property with a type and a closure where you compute its value, likevar body: some View { ... }.
Here, you create an enumeration for the four exercise names. The case names are camelCase: If you start typing ExerciseEnum.sunSalute, Xcode will suggest the auto-completion.
Because this enumeration has String type, you can specify a String as the raw value of each case. Here, you specify the title-case version of the exercise name, like “Sun Salute” for sunSalute. Then ExerciseEnum.sunSalute.rawValue is "Sun Salute".
Creating an Array of Exercise Instances
Use your enumeration to create your exercises array.
➤ Below Exercise, completely outside its braces, add this code:
extension Exercise {
static let exercises = [
Exercise(
exerciseName: ExerciseEnum.squat.rawValue,
videoName: "squat"),
Exercise(
exerciseName: ExerciseEnum.stepUp.rawValue,
videoName: "step-up"),
Exercise(
exerciseName: ExerciseEnum.burpee.rawValue,
videoName: "burpee"),
Exercise(
exerciseName: ExerciseEnum.sunSalute.rawValue,
videoName: "sun-salute")
]
}
Swift Tips: Type Property, Array Literal, Type Extension
In an extension to Exercise, you initialize the exercises array as a type property.
exerciseName and videoName are instance properties: Each Exercise instance has its own values for these properties. A type property belongs to the type, and you declare it with the static keyword. The exercises array doesn’t belong to an Exercise instance. There’s only one exercises no matter how many Exercise instances you create. You access it with the type name: Exercise.exercises.
You create exercises with an array literal: a comma-separated list of values, enclosed in square brackets. Each value is an instance of Exercise, supplying the raw value of an enumeration case and the corresponding video file name.
As the word suggests, an extension extends a named type. The starter project includes two extensions: DateExtension.swift and ImageExtension.swift. Date and Image are built-in SwiftUI types but, by creating an extension, you can add custom methods and computed or type properties.
Exercise is your own custom type, so why do you have an extension? It’s housekeeping: You’re keeping this task — initializing an array of Exercise values — separate from the core definition of your structure — stored properties and any custom initializers.
Developers also use extensions to encapsulate the requirements for protocols, one for each protocol. Organizing code like this makes it easy to see where to add features or look for bugs.
Refactoring ContentView & ExerciseView
Now, you’ll modify ContentView and ExerciseView to use your new Exercise.exercises array.
➤ In ContentView.swift, replace the ForEach line with this:
ForEach(Exercise.exercises.indices, id: \.self) { index in
Instead of 0 ..< 4, you use the exercises array’s built-in range. Because the range is no longer fixed, you must provide an id for each array element. \.self means each element is its own unique identifier.
➤ In ExerciseView.swift, delete videoNames and exerciseNames. The error flags tell you where you need to use Exercise.exercises.
You could replace exerciseNames[index] with Exercise.exercises[index].exerciseName, but you’ll need to use Exercise.exercises[index] several times in ExerciseView. This is a good reason to define a computed property.
➤ Add this near the top of ExerciseView, below let index: Int:
var exercise: Exercise {
Exercise.exercises[index]
}
➤ Then replace exerciseNames[index] with exercise.exerciseName:
HeaderView(exerciseName: exercise.exerciseName)
➤ Live Preview ContentView to check everything still works:
Playing a Video
Skills you’ll learn in this section:
AVPlayerandVideoPlayer; bundle files; optional types; make conditional;GeometryReader; adding padding
➤ In ExerciseView.swift, add this statement just below import SwiftUI:
import AVKit
Importing AVKit lets you use high-level types like AVPlayer to play videos with the usual playback controls.
➤ Now replace Text("Video player") with this line:
VideoPlayer(player: AVPlayer(url: url))
Xcode complains it “cannot find ‘url’ in scope”, so you’ll define this value next.
Getting the URL of a Bundle File
You need the URL of the video file for this exercise. The videoName property is the name part of the file. All the files have file extension .mp4.
These files are in the project folder, which you can access as Bundle.main. Its method url(forResource:withExtension:) gets you the URL of a file in the main app bundle if it exists. Otherwise, it returns nil which means no value. The return type of this method is an Optional type, URL?.
Swift Tip: Swift’s
Optionaltype helps you avoid many hard-to-find bugs that are common in other programming languages. It’s usually declared as a type likeIntorStringfollowed by a question mark:Int?orString?. If you declarevar index: Int?,indexcan contain anIntor no value at all. If you declarevar index: Int— with no?—indexmust always contain anInt. Useif let index {...}to check whether an optional has a value. The condition istrueifindexhas a value. You can also checkindex != nil.
Note: You’ll learn more about the app bundle in Chapter 7, “Saving Settings” and about optionals in Chapter 8, “Saving History Data”.
So you need to wrap an if let around the VideoPlayer. Yet another pair of braces! It can be hard to keep track of them all. But Xcode is here to help. ;]
➤ Right-click VideoPlayer and select Make Conditional:
if true {
VideoPlayer(player: AVPlayer(url: url))
} else {
EmptyView()
}
An if-else closure wraps VideoPlayer, with placeholders true and EmptyView().
Xcode Tip: Take advantage of features like Embed in HStack and Make Conditional to let Xcode keep your braces matched. To adjust what’s included in the closure, use Option-Command-[ or Option-Command-] to move the closing brace up or down.
➤ Now replace if true { with:
if let url = Bundle.main.url(
forResource: exercise.videoName,
withExtension: "mp4") {
➤ In the else closure, replace EmptyView() with:
Text("Couldn't find \(exercise.videoName).mp4")
.foregroundColor(.red)
Swift Tip: The string interpolation code
\(exercise.videoName)inserts this value into the string literal.
➤ In Live Preview, click above, on or below the video to show the play button.
Note: When you start the app from
ContentView, either in Live Preview or on a simulator, just click the center of the video to play or stop it.
Getting the Screen Dimensions
The video takes up a lot of space on the screen. You could set the width and height of its frame to some constant values that work on most devices, but it’s better if these measurements adapt to the size of the device.
➤ In body, Right-click VStack and select Embed…. Change the Container { placeholder to this line:
GeometryReader { geometry in
GeometryReader is a container view that provides you with the screen’s measurements for whatever device you’re previewing or running on.
➤ Add this modifier to VideoPlayer:
.frame(height: geometry.size.height * 0.45)
The video player now uses only 45% of the screen height:
Adding Padding
➤ The header looks a little squashed. Control-Option-click HeaderView to add padding to its bottom:
This gives you a new modifier padding(.bottom) and now there’s space between the header and the video:
Note: You could have added padding to the
VStackin HeaderView.swift, butHeaderViewis a little more reusable without padding. You can choose whether to add padding and how to customize it whenever you useHeaderViewin another view.
➤ Head back to ContentView.swift and Live Preview your app. Swipe from one page to the next to see the different exercise videos.
Creating Timer, Buttons & Rating
Skills you’ll learn in this section:
Textwith date and style parameters; types in Swift;Date();Button,Spacer,foregroundColor; repeating a view; unused closure parameter
Creating the Timer View
➤ Add this property to ExerciseView, above body:
let interval: TimeInterval = 30
These are high-intensity interval exercises, so the timer counts down from 30 seconds.
➤ Replace Text("Timer") with this code:
Text(Date().addingTimeInterval(interval), style: .timer)
.font(.system(size: geometry.size.height * 0.07))
The default initializer Date() creates a value with the current date and time. The Date method addingTimeInterval(_ timeInterval:) adds interval seconds to this value.
➤ The Swift Date type has a lot of methods for manipulating date and time values. Option-click Date and Open in Developer Documentation to scan what’s available. You’ll dive a little deeper into Date when you create the History view.
The timeInterval parameter’s type is TimeInterval. This is simply an alias for Double. If you say interval is of type Double, you won’t get an error, but TimeInterval describes the value’s purpose more accurately.
Swift Tip: Swift is a strongly typed language. This means that you must use the correct type. When using numbers, you can usually pass a value of a wrong type to the initializer of the correct type. For example,
Double(myIntValue)creates aDoublevalue from anIntandInt(myDoubleValue)truncates aDoublevalue to create anInt. If you write code in languages that allow automatic conversion, it’s easy to create a bug that’s very hard to find. Swift makes sure you, and people reading your code — including “future you”, know that you’re converting one type to another.
You’re using the Text view’s (_:style:) initializer for displaying dates and times. The timer and relative styles display the time interval between the current time and the date value, formatted as “mm:ss” or “mm min ss sec”, respectively. These two styles update the display every second.
You set the system font size to geometry.size.height * 0.07 to make a really big timer — around 95 points for a 12.9” iPad and 47 points for the much smaller iPhone 8.
➤ Click Live Preview to watch the timer count down from 30 seconds:
Because you set date to 30 seconds in the future, the displayed time interval decreases by 1 every second, as the current time approaches date. If you wait until it reaches 0 (change interval to 3 so you don’t have to wait so long), you’ll see it start counting up, as the current time moves away from date. Don’t worry, this Text timer is just for the prototype. You’ll replace it with a real timer in Chapter 6, “Observing Objects”.
Creating Buttons
Creating buttons is simple, so you’ll do both now.
➤ Replace Text("Start/Done button") with this code:
Button("Start/Done") { }
.font(.title3)
.padding()
Here, you gave the Button the label Start/Done and an empty action. You’ll add the action in Chapter 6, “Observing Objects”. Then, you enlarged the font of its label and added padding all around it.
➤ Replace Text("History button") with this code:
Spacer()
Button("History") { }
.padding(.bottom)
The Spacer pushes the History button to the bottom of the screen. The padding pushes it back up a little, so it doesn’t look squashed.
You’ll add this button’s action in Chapter 5, “Moving Data Between Views”.
Here’s what ExerciseView looks like now:
Creating the Rating View
➤ Create a new SwiftUI View file in the Views group named RatingView.swift. This will be a small view, so replace its #Preview { with this line:
#Preview(traits: .sizeThatFitsLayout) {
➤ Switch to Selectable mode and Zoom to 100%.
➤ Replace the boilerplate Text with this code, leaving the cursor between the double quotation marks:
Image(systemName: "")
.foregroundColor(.gray)
A rating view is usually five stars or hearts, but the rating for an exercise should reflect the user’s exertion. Something heart-related…
➤ Open the Library and search Symbols for “ecg”:
➤ The ECG wave form seems just right for rating high-intensity exercises! Double-click it to insert its name between the double quotation marks:
Image(systemName: "waveform.path.ecg")
.foregroundColor(.gray)
A rating view needs five of these symbols, arranged horizontally.
➤ In the editor, right-click Image and select Repeat from the menu:
Xcode gives you a loop, with placeholder range 0 ..< 5:
ForEach(0 ..< 5) { item in
Image(systemName: "waveform.path.ecg")
.foregroundColor(.gray)
}
➤ Click this range and press Return to accept it.
In the canvas, you see five images stacked vertically.
➤ Right-click ForEach and embed it in an HStack.
Now your code looks like this:
HStack {
ForEach(0 ..< 5) { item in
Image(systemName: "waveform.path.ecg")
.foregroundColor(.gray)
}
}
That’s better! And the symbols are all in a row. But they’re very small.
➤ Remember, you can use font to specify the size of SF Symbols. So add this modifier to the Image:
.font(.largeTitle)
Bigger is better!
One last detail: The code Xcode created for you contains an unused closure parameter item:
ForEach(0 ..< 5) { item in
➤ You don’t use item in the loop code, so replace item with _:
ForEach(0 ..< 5) { _ in
Swift Tip: It’s good programming practice to replace unused parameter names with
_. The alternative is to create a throwaway name, which takes a non-zero amount of time and focus and will confuse you and other programmers reading your code.
➤ Now head back to ExerciseView.swift to use your new view. Replace Text("Rating") with this code:
RatingView()
.padding()
Your ECG wave forms now march across the screen!
In Chapter 5, “Moving Data Between Views”, you’ll add code to let the user set a rating value and represent this value by setting the right number of symbols to red. And, in Chapter 7, “Saving Settings”, you’ll save the rating values so they persist across app launches.
Challenges
ExerciseView will be easier to understand if all its components are in separate view files.
Challenge: Create VideoPlayerView
➤ Move most of the VideoPlayer code to a separate SwiftUI view file named VideoPlayerView.swift, so you can call it in ExerciseView like this:
VideoPlayerView(videoName: exercise.videoName)
.frame(height: geometry.size.height * 0.45)
The solution to this challenge is in this chapter’s challenge folder.
Key Points
- Declare SwiftUI views in the order you want them to appear.
- Create separate views for your user interface elements. This makes your code easier to read and maintain.
- Put each view modifier on its own line. This makes it easy to move or delete a modifier.
- Xcode and SwiftUI auto-suggestions and default values are often what you want.
- Let Xcode help you avoid errors: Use the Command-menu to embed or extract views.
- The SF Symbols app and Xcode’s Symbols Library provide icon images you can configure like text.
- Preview variants make it easy to check your interface for different user settings.
- An enumeration is a named type, useful for grouping related values so the compiler can help you avoid mistakes like misspelling a string.
- Swift is a strongly typed programming language.
-
GeometryReaderenables you to set a view’s dimensions relative to the screen dimensions.
Where to Go From Here?
In the next chapter, you’ll lay out views for History, Welcome and Success.