4.
Prototyping Supplementary Views
Written by Audrey Tam
Your app still needs three more full-screen views:
- Welcome
- History
- Success
In the previous chapter, you laid out the Exercise view and created an Exercise structure. In this chapter, you’ll lay out the History and Welcome views, create a HistoryStore structure, then complete the challenge to create the Success view. And your app’s prototype will be complete.
Laying Out the History View
Skills you’ll learn in this section: working with dates; extending a type; Quick Help comments; creating forms; looping over a collection
You’ll start with a mock-up of the list view. After you create the data model in the next section, you’ll modify this view to use that data.
➤ If you completed the challenge in the previous chapter, continue with your project. Or open the project in this chapter’s starter folder.
➤ In the Views group, create a new SwiftUI View file named HistoryView.swift. For this mock-up, add some sample history data to HistoryView, above body:
let today = Date()
let yesterday = Date().addingTimeInterval(-86400)
let exercises1 = ["Squat", "Step Up", "Burpee", "Sun Salute"]
let exercises2 = ["Squat", "Step Up", "Burpee"]
You’ll display exercises completed over two days.
➤ Replace Text("Hello, World!") with this code:
VStack {
Text("History")
.font(.title)
.padding()
// Exercise history
}
You’ve created the title for this view with some padding around it.
Creating a Form
SwiftUI has a container view that automatically formats its contents to look organized.
➤ Inside the VStack, replace // Exercise history with this code:
Form {
Section(
header:
Text(today.formatted(as: "MMM d"))
.font(.headline)) {
// Section content
}
Section(
header:
Text(yesterday.formatted(as: "MMM d"))
.font(.headline)) {
// Section content
}
}
Inside the Form container view, you create two sections. Each Section has a header with the date, using headline font size.
This code takes yesterday and today’s date as the section headers, so your view will have different dates from the one below:
Extending the Date Type
When you created the timer view, you had a quick look at the Swift Date type and used one of its methods. It’s now time to learn a little more about it.
Swift Tip: A
Dateobject is just some number of seconds relative to January 1, 2001 00:00:00 UTC. To display it as a calendar date in a particular time zone, you must use aDateFormatter. This class has a few built-in styles namedshort,medium,longandfull, described in links from the developer documentation page forDateFormatter.Style. You can also specify your own format as aString.
➤ Open DateExtension.swift. The first method shows how to use a DateFormatter.
func formatted(as format: String) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = format
return dateFormatter.string(from: self)
}
DateFormatter has only the default empty initializer. You create an instance, then configure it by setting the properties you care about. This method uses its format argument to set the dateFormat property.
In HistoryView, you pass "MMM d" as format. This specifies three characters for the month — so you get SEP or OCT — and one character for the day — so you get a number. If the number is a single digit, that’s what you see. If you specify "MM dd", you get numbers for both month and day, with leading 0 if the number is single digit: 09 02 instead of SEP 2.
Once you’ve configured dateFormatter, its string(from:) method returns the date string.
You don’t have to worry about time zones if you simply want the user’s current time zone. That’s the default setting.
Formatting Quick Help Comments
Extending the Date class with formatted(as:) makes it easy to get a Date in the format you want: today.formatted(as: "MMM d").
Swift Tip: You can add methods to extend any type, including those built into the software development kit, like
ImageandDate. Then, you can use them the same way you use the built-in methods.
➤ Look at the comment above the formatted(as:) method:
/// Format a date using the specified format.
/// - parameters:
/// - format: A date pattern string like "MM dd".
This is a special kind of comment. It appears in Xcode’s Quick Help when you Option-click the method name:
It looks just like all the built-in method summaries!
It’s good practice to document all the methods you write this way. See Apple’s Formatting Quick Help documentation for more details.
Looping Over a Collection
➤ Now, head back to HistoryView.swift to fill in the Section content.
To display the completed exercises for each day, you’ll use ForEach to loop over the elements of exercises1 and exercises2.
➤ In the first Section, replace // Section content with this code:
ForEach(exercises1, id: \.self) { exercise in
Text(exercise)
}
In ContentView, you looped over a number range. Here, you’re using the third ForEach initializer for Creating a collection from data:
init(Data, id: KeyPath<Data.Element, ID>, content: (Data.Element) -> Content)
exercises1 is the Data and \.self is the key path to each array element’s identifier. \.self means each element of the array is its own unique identifier.
As the loop visits each array element, you assign it to the local variable exercise, which you display in a Text view.
➤ In the second Section, replace // Section content with the almost identical code:
ForEach(exercises2, id: \.self) { exercise in
Text(exercise)
}
This time, you display exercises2.
➤ Refresh the preview to admire your exercise history:
Of course, HistoryView needs to display any number of days, with any collection of exercises on each day. You need a data structure that enables you to loop over a collection of days. For each day, you’ll create a Section, where you loop over that day’s exercises.
Structuring HistoryView Data
Skills you’ll learn in this section:
Identifiable;mutating func; initializer; compiler directive / conditional compilation; debug/release build configuration; Preview Content;ForEachwith an array ofIdentifiablevalues
In this section, you’ll create a data structure to store the user’s activity, to replace the hard-coded dates and exercise lists in your mock-up.
Creating HistoryStore
➤ Outside the Views group, create a new Swift file and name it HistoryStore.swift. Group it with Exercise.swift and name the group folder Model:
➤ In HistoryStore.swift, add the following code below import Foundation:
struct ExerciseDay: Identifiable {
let id = UUID()
let date: Date
var exercises: [String] = []
}
struct HistoryStore {
var exerciseDays: [ExerciseDay] = []
}
An ExerciseDay has properties for the date and a list of exercise names completed by the user on that date.
ExerciseDay conforms to Identifiable. This protocol is useful for named types that you plan to use as elements of a collection, because you usually want to loop over these elements or display them in a list.
When you loop over a collection with ForEach, it must have a way to uniquely identify each of the collection’s elements. The easiest way is to make the element’s type conform to Identifiable and include id: UUID as a property. UUID is a basic Foundation type, and UUID() is the easiest way to create a unique identifier whenever you create an ExerciseDay instance.
The only property in HistoryStore is the array of ExerciseDay values you’ll loop over in HistoryView.
In Chapter 8, “Saving History Data”, you’ll extend HistoryStore with a method to save the user’s history to persistent storage and another method to load the history. Soon, you’ll add a HistoryStore property to HistoryView, which will initialize it.
In the meantime, you need some sample history data and an initializer to create it.
➤ Below HistoryStore, completely outside its braces, add this code:
extension HistoryStore {
mutating func createDevData() {
// Development data
exerciseDays = [
ExerciseDay(
date: Date().addingTimeInterval(-86400),
exercises: [
Exercise.exercises[0].exerciseName,
Exercise.exercises[1].exerciseName,
Exercise.exercises[2].exerciseName
]),
ExerciseDay(
date: Date().addingTimeInterval(-86400 * 2),
exercises: [
Exercise.exercises[1].exerciseName,
Exercise.exercises[0].exerciseName
])
]
}
}
The exercise lists are slightly different to the mock-up data in HistoryView, and they’re stored in your new Exercise and ExerciseDay structures. In Chapter 6, “Observing Objects”, you’ll add a new ExerciseDay item, so I’ve set the development data to yesterday and the day before yesterday.
You create this sample data in a method named createDevData(). This method changes, or mutates, exerciseDays, so you must mark it with the mutating keyword. And you create this method in an extension because it’s not part of the core definition. But there’s another reason, too — coming up soon!
➤ Now, in the main HistoryStore, create an initializer for HistoryStore that calls createDevData():
init() {
#if DEBUG
createDevData()
#endif
}
You don’t want to call createDevData() in the release version of your app, so you use a compiler directive to check whether the current Build Configuration is Debug:
Note: To see this window, click the Scheme menu button (next to the run destination menu). Select Edit Scheme…, then select the Info tab.
Moving Development Code Into Preview Content
In fact, you don’t want createDevData() to ship in your release version at all. Xcode provides a place for development code and data: Preview Content. Anything you put into this group will not be included in your release version. So handy!
➤ In the Preview Content group, create a new Swift file named HistoryStoreDevData.swift and move the HistoryStore extension into it:
And this is the other reason createDevData() is in an extension: You can store extensions in separate files. This means you never have to scroll through very long files.
Using HistoryStore in HistoryView
➤ Now, in HistoryView.swift, delete the Date properties and the exercise arrays, then add this property:
let history = HistoryStore()
HistoryStore now encapsulates all the information in the stored properties today, yesterday and the exercises arrays.
The Form closure currently displays each day in a Section. Now that you have an exerciseDays array, you should loop over it.
➤ Replace the Form closure with the following:
Form {
ForEach(history.exerciseDays) { day in
Section(
header:
Text(day.date.formatted(as: "MMM d"))
.font(.headline)) {
ForEach(day.exercises, id: \.self) { exercise in
Text(exercise)
}
}
}
}
Instead of today and yesterday, you use day.date. And, instead of the named exercises arrays, you use day.exercises.
The code you just replaced looped over exercises1 and exercises2, which were arrays of String. The id: \.self argument told ForEach to use the instance itself as the unique identifier. The exercises array also contains String instances, so you still need to specify this id value.
➤ Check out the preview:
Congratulations, your data structure works in your view. Just one finishing touch remains.
Dismissing HistoryView
Skills you’ll learn in this section: layering views with
ZStack; stack alignment values
Creating a Button in Another Layer
In the next chapter, you’ll make HistoryView appear as a modal sheet, so it needs a button to dismiss it. You’ll often see a dismiss button in the upper right corner of a modal sheet. The easiest way to place it there, without disturbing the layout of the rest of HistoryView, is to put it in its own layer.
ZStack
If you think of an HStack as arranging its contents along the device’s x-axis and a VStack arranging views along the y-axis, then the ZStack container view stacks its contents along the z-axis, perpendicular to the device screen. Think of it as a depth stack, displaying views in layers.
➤ Command-click VStack to embed it in a ZStack, then add this code at the top of ZStack above the VStack:
Button(action: {}) {
Image(systemName: "xmark.circle")
}
➤ Switch the preview to Selectable mode to see the outline of the button:
The button is centered in the view, because the default stack alignment is center. Because you added the Button code above the VStack in the source code, it’s underneath the VStack on screen, so you see only its outline.
The arrangement is a little counter-intuitive unless you think of it as placing the first view down on a flat surface, then layering the next view on top of that, and so on. So declaring the button as the first view places it on the bottom of the stack. If you want the button in the top layer, declare it last in the ZStack.
It doesn’t matter in this case, because you’re about to move the button into the top right corner of the view, where there’s nothing in the VStack to cover it.
Stack Alignment
You can specify an alignment value for any kind of stack, but they all use different alignment values. VStack alignment values are horizontal: leading, center or trailing. HStack alignment values are vertical: top, center, bottom, firstTextBaseline or lastTextBaseline.
To specify the alignment of a ZStack, you must set both horizontal and vertical alignment values. You can either specify separate horizontal and vertical values, or a combined value like topTrailing.
➤ Replace ZStack { with this:
ZStack(alignment: .topTrailing) {
You set the ZStack alignment parameter to position the button in the top right corner of the view. Other views in the ZStack have their own alignment values, so the ZStack alignment value doesn’t affect them.
The button is now visible, but it’s small and a little too close to the corner edges.
➤ Add these modifiers to the Button to adjust its size and position:
.font(.title)
.padding(.trailing)
➤ Check out the preview:
You’re finished with HistoryView for now. Next up: WelcomeView.
Laying Out the Welcome View
Skills you’ll learn in this section: refactoring/renaming a parameter; modifying images; using a custom modifier;
Buttonlabel with text and image
➤ Open WelcomeView.swift.
WelcomeView is the first page in your app’s page-style TabView, so it should have the same header as ExerciseView.
➤ Replace Text("Hello, World!") with this line:
HeaderView(exerciseName: "Welcome")
You want the title of this page to be “Welcome”, so you pass this as the value of the exerciseName parameter. HeaderView also displays the page numbers of the four exercises:
Refactoring HeaderView
Using HeaderView here raises two issues:
- There’s no page number for the Welcome page.
- The parameter name
exerciseNameisn’t a good description of “Welcome”.
The first issue is easy to resolve. The app has only one non-exercise page, so you just need to add another page “number” in HeaderView.
➤ In HeaderView.swift, duplicate the first Image, then change the now-first Image to display a hand wave:
Image(systemName: "hand.wave")
That’ll do nicely.
Now you need to rename the exerciseName property. Its purpose is really to be the title of the page, so titleText is a better name for it.
You could search for all occurrences of exerciseName in your app, then decide for each whether to change it to titleText. In a more complex app, this approach almost guarantees you’ll forget one or change one that shouldn’t change.
Xcode has a safer way! You’ve already used it to rename ExtractedView.
➤ Command-click the first occurrence of exerciseName and select Rename… from the menu:
Note: If you Command-click
exerciseNameinText(exerciseName), you’ll see the longer menu that includes Embed in HStack etc. Rename… is at the bottom of this menu.
Xcode displays the code statements in three files that need to change:
➤ The first instance is highlighted differently. Type titleText, and all the instances change:
➤ Click the Rename button in the upper right corner to confirm these changes, then head back to WelcomeView.swift to see the results:
That’s better! The user sees a page icon, and the programmer sees a descriptive parameter.
More Layering With ZStack
So far, so good, but the header should be at the top of the page. A History button should be at the bottom of the page. The main content should be centered in the view, independent of the heights of the header and button.
In HistoryView, you used a ZStack to position the dismiss button in the upper right corner (topTrailing), without affecting the layout of the other content.
In this view, you’ll use a ZStack to put the header and History button in one layer, to push them apart. Then you’ll create the main content in another layer, centered by default.
➤ First, embed HeaderView in a VStack, then embed that VStack in a ZStack.
ZStack {
VStack {
HeaderView(titleText: "Welcome")
}
}
➤ In the VStack, below HeaderView, add this code:
Spacer()
Button("History") { }
.padding(.bottom)
You have the header and the History button in a VStack, with a Spacer to push them apart and some padding so the button isn’t too close to the bottom edge:
➤ Now to fill in the middle space. Add this layer to the ZStack:
VStack {
HStack {
VStack(alignment: .leading) {
Text("Get fit")
.font(.largeTitle)
Text("with high intensity interval training")
.font(.headline)
}
}
}
Note: You can add this
VStackeither above or below the existingVStack. It doesn’t matter because there’s no overlapping content in the two layers.
The inner VStack contains two Text views with different font sizes. You set its alignment to leading to left-justify the two Text views.
This VStack is in an HStack because you’re going to place an Image to the right of the text. And the HStack is in an outer VStack because you’ll add a Button below the text and image.
Using an Image
➤ Look in Assets.xcassets for the step-up image:
➤ Back in WelcomeView.swift, open the Library with Shift-Command-L (or click the + toolbar button) and select the media tab:
➤ To insert step-up in the correct place, it’s easiest to drag it into the code editor. Hold onto it while nudging the code with the cursor, until a line opens, just below the VStack of two Text views. Let go of the image, and it appears in your code:
HStack {
VStack(alignment: .leading) {
Text("Get fit")
.font(.largeTitle)
Text("with high intensity interval training")
.font(.headline)
}
Image("step-up") // your new code appears here
}
➤ You usually have to add several modifiers to an Image, so open the Attributes inspector in the inspectors panel:
Note: If you don’t see Image with a value of step-up, select the image or select another inspector then re-select Attributes.
Modifying an Image
➤ First, you must add a modifier that lets you resize the image. In the Add Modifier field, type res then select Resizable.
Don’t worry if the image stretches. You’ll fix that with the next modifier.
➤ When resizing an image, you usually want to preserve the aspect ratio. So search for an aspect modifier and select Aspect Ratio:
➤ The suggested contentMode value is fill, which is what you usually want, so accept it.
➤ Now the image looks more normal, but it’s too big. In the Frame section, set the Width and Height to 240:
That’s looking pretty good! How about clipping it to a circle?
➤ Search for a clip modifier and select Clip Shape:
➤ Again, the suggestion Circle() is what you want, so accept it.
Your HStack code and preview now look like this:
➤ You need just one more tweak: The text would look better if you align it with the bottom of the image. Just change the alignment of the enclosing HStack:
HStack(alignment: .bottom)
And here’s your Welcome page:
You’ve done enough to make it look welcoming. :] In Chapter 9, “Refining Your App”, you’ll add a few more images.
Using a Custom Modifier
You’ll use this triplet of Image modifiers all the time:
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 240.0, height: 240.0)
Everyone does, although the frame dimensions won’t always be 240. In ImageExtension.swift, you’ll find resizedToFill(width:height:) which encapsulates these three modifiers:
func resizedToFill(width: CGFloat, height: CGFloat)
-> some View {
return self
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
}
It extends the Image view, so self is the Image you’re modifying with resizedToFill(width:height:).
➤ To use this custom modifier, head back to WelcomeView.swift. Comment out (Command-/) or delete the first three modifiers of Image("step-up"), then add this custom modifier:
.resizedToFill(width: 240, height: 240)
And the view looks the same, but there’s a little less code.
Labeling a Button With Text & Image
The final detail is a Button. The user can tap this to move to the first exercise page, but the label also has an arrow image to indicate they can swipe to the next page. The other buttons you’ve created have only text labels. But it’s easy to label a Button with text and an image.
➤ In the center view VStack, below the HStack with the image, add this code:
Button(action: { }) {
Text("Get Started")
Image(systemName: "arrow.right.circle")
}
.font(.title2)
.padding()
This code is quite different from the other buttons you’ve created and requires some explanation. SwiftUI uses a lot of syntactic sugar: Instead of using the official method calls, SwiftUI lets you write code that’s much simpler and more readable.
(action, label) vs. (String, action)
The official Button signature is:
Button(action: () -> Void, label: () -> Label)
-
actionis a method or a closure containing executable code. -
labelis a view describing the button’saction.
Both parameter values can be closures, so action can be more than one executable statement, and label can be more than one view.
The buttons you’ve created so far use the simplest Button syntax: The button’s label is simply a String, and the button’s action is in a trailing closure. For example:
Button("History") { }
Swift Tip: You can move the last closure argument of a function call outside the parentheses into a trailing closure.
This simple Button syntax reverses the official signature, and it’s only for the case where label is a string.
If you want more than a string in your label, its content must be in a closure. It’s the last closure argument of this function call, so it can be a trailing closure:
Button(action: {} ) {
<Content>
}
This is the syntax used in the “Get Started” Button above, with the Text and Image views in an implicit HStack.
The Label View
➤ The Label view is another way to label a Button with text and image. Comment out (Command-/) the Text and Image lines, then write this line in the label closure:
Label("Get Started", systemImage: "arrow.right.circle")
Look closely: Do you see what changed?
Note: You can modify a
LabelwithlabelStyleto show only the text or only the image.
The image is on the left side of the text. This looks wrong to me: An arrow pointing right should appear after the text. Unfortunately for this particular Button, there’s no way to make the image appear to the right of the text, unless you’re using a language like Arabic that’s written right-to-left. Label is ideal for icon-text lists, where you want the icons nicely aligned on the leading edge.
➤ Delete the Label and uncomment the Text and Image.
A Border For Your Button
➤ Just for fun, give this button a border. Add this modifier below padding():
.background(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.gray, lineWidth: 2))
You put a rounded rectangle around the padded button, specifying the corner radius, line color and line width.
Challenge
When your users tap Done on the last exercise page, your app will show a modal sheet to congratulate them on their success. Your challenge is to create this SuccessView:
Challenge: Creating the Success View
- Create a new SwiftUI View file named SuccessView.swift.
- Replace its
Textview with aVStackcontaining thehand.raised.fillsymbol and the text in the screenshot. - The symbol is in a 75 by 75 frame and colored purple. Hint: Use the custom
Imagemodifier. - For the large “High Five!” title, you can use the
fontWeightmodifier to emphasize it more. - For the three small lines of text, you could use three
Textviews. Or refer to our Swift Style Guide to see how to create a multi-line string.Texthas amultilineTextAlignmentmodifier. This text is colored gray. - Like
HistoryView,SuccessViewneeds a button to dismiss it. Center a Continue button at the bottom of the screen. Hint: Use aZStackso the “High Five!” view remains vertically centered.
Here’s a close-up of the “High Five!” view:
You’ll find the solution to this challenge in the challenge folder for this chapter.
Key Points
- The
Datetype has many built-in properties and methods. You need to configure aDateFormatterto create meaningful text to show your users. - Use the
Formcontainer view to quickly lay out table data. -
ForEachlets you loop over the items in a collection. - To use a collection in a
ForEachloop, it needs to have a way to uniquely identify each of its elements. The easiest way is to make it conform toIdentifiableand includeid: UUIDas a property. - Use compiler directives to create development data only while you’re developing and not in the release version of your app.
- Preview Content is a convenient place to store code and data you use only while developing. Its contents won’t be included in the release version of your app.
-
ZStackis useful for keeping views in one layer centered while pushing views in another layer to the edges. - You can specify vertical alignment values for
HStack, horizontal alignment values forVStackand combination alignment values forZStack. - Xcode helps you to refactor the name of a parameter quickly and safely.
-
Imageoften needs the same three modifiers. You can create a custom modifier so you Don’t Repeat Yourself. - A
Buttonhas a label and an action. You can define aButtona few different ways.
Where to Go From Here?
Your views are all laid out. You’re eager to implement all the button actions. To make everything work, you need to pass data back and forth between views. You already know how to pass data to a view. But some of your views need to change values and send them back. Excitement awaits!