7.
Introducing Stacks & Containers
Written by Antonio Bello
In the previous chapter, you learned about common SwiftUI controls, including TextField, Button, Slider and Toggle. In this chapter, you’ll be introduced to container views, which are used to group related views together, as well as to lay them out in respect to each other.
Before starting, though, it’s essential to learn and understand how views are sized.
Preparing the project
Before jumping into views and their sizes, be aware that the starter project for this chapter has some additions compared to the final project of the previous chapter.
If you want to keep working on your own copy, worry not! Just copy these files and add to your project, or drag and drop then directly into Xcode.
- Practice/ChallengeView.swift
- Practice/ChallengesViewModel.swift
- Practice/ChoicesRow.swift
- Practice/ChoicesView.swift
- Practice/CongratulationsView.swift
- Practice/PracticeView.swift
- Practice/QuestionView.swift
- StarterView.swift
- HistoryView.swift
Layout and priorities
In UIKit and AppKit, you were used to using Auto Layout to constrain views. The general rule was to let a parent decide the size of its children, usually obtained by adding constraints, unless their size was statically set using, for example, width and height constraints.
To make a comparison with a family model, Auto Layout is a conservative model, or patriarchal to both parents, if you prefer.
SwiftUI works oppositely instead: the children choose their size, in response to a size proposed by the parent. It’s more of a modern family model — if you have kids, you know what I mean!
If you have a Text, and you put it in a View, the Text is given a proposed size when the view is rendered, corresponding to the parent’s frame size. However, the Text will calculate the size of the text to display and will choose the size necessary to fit that text, plus additional padding, if any.
Layout for views with a single child
Open the starter project and go to Practice/ChallengeView.swift, which is a new view created out of the SwiftUI View template. You can see that it contains a single Text:
struct ChallengeView: View {
var body: some View {
Text("Hello World!")
}
}
If you reactivate the preview in Xcode, you’ll see the text displayed at the center of the screen.
Note: Every view is positioned, by default, at the center of its parent.
This screenshot doesn’t give any indication about the Text’s frame size. Try adding a red background:
Text("Hello World!")
.background(Color.red)
Now you can see that the Text sizes itself with the bare minimum to contain the text it renders. Change the text to A great and warm welcome to Kuchi:
Text("A great and warm welcome to Kuchi")
.background(Color.red)
You’ll see that the Text resizes its frame to accommodate the new content.
The rules that SwiftUI applies to determine the size of a parent view and a child view are:
- The parent view determines the available frame at its disposal.
- The parent view proposes a size to the child view.
- Based on the proposal from the parent, the child view chooses its size.
- The parent view sizes itself such that it contains its child view.
This process is recursive, starting at the root view, down to the last leaf view in the view hierarchy.
Note: Each modifier applied to a view creates a new view that embeds the original view. The set of rules described above applies to all the views, regardless of whether they are individual components, or views generated by modifiers.
To see this in action, try specifying a fixed frame for Text, plus a new background color:
Text("A great and warm welcome to Kuchi")
.background(Color.red)
// fixed frame size
.frame(width: 150, height: 50, alignment: .center)
.background(Color.yellow)
Interestingly, you can see that the Text has a size, which differs from the size of the view created by the .frame modifier. This shouldn’t surprise you, because the four rules described above are applied here:
- The frame view has a fixed size of 150×50 points.
- The frame view proposes that size to the
Text. - The
Textfinds a way to display the text within that size, but using the minimum without having to truncate (when possible).
Rule 4 is skipped, because the frame view already has a defined size. The Text automatically arranges the text to display in two lines, because it realizes that it doesn’t fit in a single line of maximum 150 points without truncation.
If you expand the frame size, you have an additional proof of how views determine their size. Try, for example, a larger 300×100 size:
.frame(width: 300, height: 100, alignment: .center)
Now Text has enough width at its disposal to render the text in a single line. However, it still occupies the exact space needed to render the text (in red background), whereas the frame view uses the fixed frame size (in yellow background).
Can you guess what happens if the size of the parent view is not enough to contain the child view? In the case of a Text, it will just truncate the text. Try reducing its frame size to 100x50:
.frame(width: 100, height: 50, alignment: .center)
This happens in absence of other conditions, such as using the .minimumScaleFactor modifier, which, if needed, causes the text to shrink to the scale factor passed as parameter, which is a value between 0 and 1:
Text("A great and warm welcome to Kuchi")
.background(Color.red)
.frame(width: 100, height: 50, alignment: .center)
.minimumScaleFactor(0.5)
.background(Color.yellow)
Generally speaking, the component will always try to fit the content within the size proposed by its parent. If the component can’t do that because it needs more space, it will apply rules appropriate to, and strictly dependent, from the component type.
This reinforces the concept that, in SwiftUI, each view chooses its own size. It considers proposals made by its parent, and it tries to adapt to that suggestion to the best of its ability, but that’s always dependent on what type of component the view is.
Take an image, for instance. In the absence of other constraints, it will be rendered at its original resolution, as you can see if you replace the Text component with an Image:
Image("welcome-background")
.background(Color.red)
.frame(width: 100, height: 50, alignment: .center)
.background(Color.yellow)
This is the same image you used in Chapter 5: “Intro to Controls: Text & Image”.
The red arrow highlights the 100×50 static frame, but you can see that the image has been rendered at its native resolution, completely ignoring the proposed size — at least in the absence of any other constraints, such as the .resizable modifier, which would enable the image to automatically scale up or down in order to occupy all the available space offered by its parent:
Image("welcome-background")
.resizable()
So, in the end, you realize that there’s no way for a parent to enforce a size on a child. What a parent can do is propose a size, and eventually constrain the child inside a frame of its choice, but that doesn’t affect the ability of the child to choose a size that’s smaller or larger.
Some components, like Text, will try to be adaptive, by choosing a size that best fits with the size proposed by the parent, but still with an eye to the size of the text to render. Other components, like Image, will instead simply disregard the proposed size.
In the middle, there are views which are more or less adaptive, but also neutral, meaning that they don’t have any reason to choose a size. They will just pass that decision to their own children, and size themselves to merely wrap their children.
An example is the .padding modifier, which does not have an intrinsic size — it simply takes the child’s size, adds the specified padding to each of the four edges (top, left, right, bottom), and uses that to create the view that embeds the child.
Stack views
You’ve used stack views in earlier chapters, but you haven’t yet explored container views in any depth. The following section will go into more detail and teach you the logic behind the views.
Layout for container views
In the case of a container view, i.e., a view that contains two or more children views, the rules that determine children’s sizes are:
- The container view determines the available frame at its disposal, which usually is the size proposed by the parent.
- The container view selects the child view with the most restrictive constraints or, in case of equivalent constraints, with the smallest size.
- The container view proposes a size to the child view. The proposed size is the available size divided equally by the number of (the remaining) children views.
- The child view, based on the proposal from the parent, chooses its size.
- The container view subtracts from the available frame the size chosen by the child view, and goes back to step no. 2, until all children views have been processed.
The differences between this and the case of views with a single child that you’ve seen in the previous section are highlighted in bold text.
Back to the code! Restore the Text as it was before you replaced with the image, and duplicate it inside an HStack:
HStack {
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.background(Color.red)
}
.background(Color.yellow)
You’ve already encountered HStack in the previous chapters, so you should know that it lays out its children views horizontally. Since the two children are equal, you might expect that they have the same size. But this is what you get instead:
Why is that? A step-by-step breakdown is necessary here:
- The stack receives a proposed size from its parent, and divides it in two equal parts.
- The stack proposes the first size to one of the children. They are equal, so it sends the proposal to the first child, the one to the left.
- The
Textfinds that it needs less than the proposed size, because it can display the text in two lines, and can format it such that the two lines have similar lengths. - The stack subtracts the size taken by the first
Textand proposes the resulting size to the secondText. - The
Textdecides to use all the proposed size.
Now try making the second Text slightly smaller, by replacing an m with an n, for example, in the word warm:
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warn welcome to Kuchi") // <- Replace `m` with
// `n` in `warm`
.background(Color.red)
Being smaller now, the second Text takes precedence; in fact, it’s the first one to be proposed a size. The resulting layout is this:
You can experiment with the difference between longer and stronger texts in the two Text controls if you like.
Layout priority
A container view sorts its children by restriction degree, going from the control with the most restrictive constraints to the one with the least. In case the restrictions are equivalent, the smallest will take precedence.
However, there are cases when you will want to alter this order. This can be achieved in two different ways, usually for different goals:
- Alter the view behavior via a modifier.
- Alter the view’s layout priority.
Modifier
You can use a modifier to make the view more or less adaptive. Examples include:
-
Imageis one of the least adaptive components, because it ignores the size proposed by its parent. But its behavior drastically changes after applying theresizablemodifier, which enables it to blindly accept any size proposed by the parent. -
Textis very adaptive, as it tries to format and wrap the text in order to best fit with the proposed size. But it becomes less adaptive when it’s forced to use a maximum number of lines, via thelineLimitmodifier.
Changes of the adaptivity degree directly affect a control’s weight in the sort order.
Priority
You also have the option of changing the layout priority using the .layoutPriority modifier. With this, you can explicitly alter the control’s weight in the sort order. It takes a Double value, which can be either positive or negative. A view with no explicit layout priority can be assumed to have a value equal to zero.
Go back to the ChallengeView.swift file, and replace the view content with a stack of three Text copies:
HStack {
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.background(Color.red)
}
.background(Color.yellow)
Now try some explicit priorities. You can use any scale when setting priorities; for example, limit to values in the [0, 1] or [-1, +1] range, or go for integer values only, and so forth.
What’s important is that Stack processes views starting from the absolute highest down to the absolute lowest. If the absolute lowest is below zero, views without an explicitly priority are processed before all the ones with negative value.
Add a layout priority of 1 to the second Text:
HStack {
Text("A great and warm welcome to Kuchi")
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.layoutPriority(1)
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.background(Color.red)
}
You can see that it is given the opportunity to use as much space as needed.
Now try adding a negative priority to the first Text:
HStack {
Text("A great and warm welcome to Kuchi")
.layoutPriority(-1)
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.layoutPriority(1)
.background(Color.red)
Text("A great and warm welcome to Kuchi")
.background(Color.red)
}
With this, you can expect it to be the last element to be processed.
And in fact, it is given a very small width. To counterbalance that, the control expands vertically.
There’s an important distinction between the two ways of altering the adaptive degree: manually setting the layout priority doesn’t just alter the sort order, but also the size that is proposed.
For views with the same priority, the parent view proposes a size that’s evenly proportional to the number of children. In the case of different priorities, the parent view uses a different algorithm: it subtracts the bare minimum size of all children with lower priorities, and proposes that resulting size to the child (or children, if more than one) having the highest layout priority.
Look again at the result of the previous example. HStack lays out controls horizontally, so width is the most constraining size, because children views compete for width, whereas they have virtually no constraints vertically.
So, let’s focus on width:
-
HStackcalculates the minimum width required by the child view with lower priority. This happens to be theTextat the left, which has priority -1, and whose width is determined by the text displayed vertically. It therefore occupies the minimum possible width, highlighted in blue in the following zoomed-in image:
-
HStackfinds the child view with highest priority, which is the middleText, having priority 1, the highest among its children.
-
HStackassigns a virtual minimum width to all children views having a priority lower than the maximum. The minimum width is the one calculated at step 1, and the number of children views having lower priority is two; theTexts at left with priority -1 and at right with priority 0.
- Given the width at its disposal, for each child view with lower priority,
HStacksubtracts its minimum width, which in this case is two times the minimum width calculated at step 1. The resulting width is proposed to the child view with the highest priority, theTextat center.
- The
Textat center decides to take the width necessary to display the text in one line.
At this point, the stack can process the next view, which is the Text with priority 0, at the right side. The algorithm is the same; what’s different is that the remaining width is now:
- The width at
HStack’s disposal. - Minus the size taken by the Text with priority 1.
- Minus the minimum size required the Text with priority -1.
You see that the Text with priority 0 makes best use of the size at its disposal, by wrapping its text across 4 lines. This leaves no size other components can compete for, besides the bare minimum computed at step 1 of the previous list. That’s a guaranteed size; it’s like having a guaranteed minimum salary, maybe extremely low, but still guaranteed regardless of how greedy your superiors are!
The HStack and the VStack
HStack and VStack are both container views, and they behave in the same way. The only difference is the orientation:
-
HStacklays subviews out horizontally -
VStacklays subviews out vertically
AppKit and UIKit have a similar component, UIStackView, which works in dual mode, having an axis property which determines in which direction its subviews are laid out.
You’ve already seen HStack and VStack in this and in previous chapters. In many cases, using the initializer that takes the content view only. In reality, it takes two additional parameters, which come with default values:
// HStack
init(
alignment: VerticalAlignment = .center,
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
)
// VStack
init(
alignment: HorizontalAlignment = .center,
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
)
-
alignment is the vertical and horizontal alignment respectively for HStack and VStack, it determines how subviews are aligned, defaulted to
.centerin both cases. -
spacing is the distance between children. When
nil, a default, platform-dependent distance is used. So if you want zero, you have to set it explicitly.
The content parameter is the usual closure that produces a child view. But containers can usually return more than one child, as you’ve seen in the example of this section where the HStack contains three Text components.
The @ViewBuilder attribute is what enables that: It enables a closure that returns a child view to provide multiple children views instead.
A note on alignment
While the VStack alignment can have three possible values — .center, .leading and .trailing — the HStack counterpart is a bit richer. Apart from center, bottom and top, it also has two very useful cases:
- firstTextBaseline: Aligns views based on the topmost text baseline view.
- lastTextBaseline: Aligns views based on the bottom-most text baseline view.
These come in handy when you have texts of different sizes and/or fonts, and you want them to be aligned in a visually appealing fashion.
An example is worth a thousands words so, still in ChallengeView, replace its body property with:
var body: some View {
HStack() {
Text("Welcome to Kuchi").font(.caption)
Text("Welcome to Kuchi").font(.title)
Button(action: {}, label: { Text("OK").font(.body) })
}
}
This renders as a simple HStack with two Texts and a Button, each having a different font size. If you preview it as-is, you see that the three children are centered vertically:
But that doesn’t look very good, does it? To make it look nicer, it would be better to have the text aligned at bottom, which you can do by specifying the HStack alignment in its initializer:
HStack(alignment: .bottom) {
But again, this isn’t very pleasing to the eye:
And this is where the two baseline cases can come to the rescue. Try using .firstTextBaseline:
HStack(alignment: .firstTextBaseline) {
The smaller text and the button are now moved up slightly to match the larger text’s baseline. That looks much better, right?
The ZStack
With no AppKit and UIKit counterpart, the third stack component is ZStack, which stacks children views one on top of the other.
In ZStack, children are sorted by the position in which they are declared, which means that the first subview is rendered at the bottom of the stack, and the last one is at the top.
Interestingly, .layoutPriority applied to children views doesn’t affect their Z-order, so it’s not possible to alter the order in which they are defined in the ZStack’s body.
As with the other container views, ZStack positions its children views at its center by default.
Speaking of size, if the HStack has its height determined by its tallest subview, and the VStack has its width determined by its widest subview, both the width and height of a ZStack are determined respectively by its widest and the tallest subviews.
You’ll use ZStack in a moment to build a portion of the congratulations view in the Kuchi app.
Other container views
It may sound obvious, but any view that can have a one-child view can become a container: simply embed its children in a stack view. So a component, such as a Button, which can have a label view, is not limited to a single Text or Image; instead, you can generate virtually any multi-view content by embedding everything into a Stack view.
Stack views can also be nested one inside another, and this is very useful for composing complex user interfaces. Remember, however, that if a view becomes too complex, it could (and should!) be split into smaller pieces.
Note: Rumor has it that
Stackcannot contain more than 10 children. This is not documented, but is, at the time of writing, easily verifiable by creating a stack with 11 children. The compiler will issue one of those cryptic error messages to tell you you’ve strayed too far.
Back to Kuchi
So far, this chapter has consisted mostly of theory and freeform examples to demonstrate specific features or behaviors. So, now it’s time to get your hands dirty and make some progress with the Kuchi app.
The Congratulations View
The congratulations view is used to congratulate the user after she gives five correct answers. Open CongratulationsView.swift and take a look at its content.
struct CongratulationsView: View {
let avatarSize: CGFloat = 120
let userName: String
init(userName: String) {
self.userName = userName
}
var body: some View {
EmptyView()
}
}
If this is the first time you encounter EmptyView, it’s just… an empty view. You can use it as a placeholder everywhere a view is expected, but you don’t yet have any view for it, either by design, or because you haven’t built it yet.
Content in this view will be laid out vertically — so a good kick-off is adding a VStack, replacing the empty view:
var body: some View {
VStack {
}
}
Next, add a static congratulations Text inside, using a large font size of gray color:
VStack {
Text("Congratulations!")
.font(.title)
.foregroundColor(.gray)
}
Right after that congratulations Text, add another smaller Text:
Text("You’re awesome!")
.fontWeight(.bold)
.foregroundColor(.gray)
The bottom of this view should contain a button to close the view and go back. Add the following to the bottom of the stack:
Button(action: {
self.challengesViewModel.restart()
}, label: {
Text("Play Again")
})
.padding(.top)
The button label shows a simple “Play Again” message, and the action is to reset the status of the challenge in the challengesViewModel property. But there’s a problem: This property doesn’t yet exist in the view. So, you’ll need to add it.
For now, you can add the property and initialize it inline, directly in CongratulationsView.
struct CongratulationsView: View {
// Add this property
@ObservedObject
var challengesViewModel = ChallengesViewModel()
...
In the next chapter, Chapter 8: “State & Data Flow — Part I”, you’ll see how you can make this property an environment object, similarly to how you did with UserManager in the previous chapter, Chapter 6: “Controls & User Input”.
This is how the congratulations view looks:
User avatar
But let’s not stop there — surely you can make this look even better! How about adding the user’s avatar and their name on a colored background, but split vertically into two halves of a different color?
Something like this:
It might look complicated at first glance, but it only consists of three layers:
- The background, split in two halves of different colors
- The user avatar
- The name of the user
You might already have figured out that you need a ZStack to implement it.
Between the two Texts in the VStack, add the following code:
// 1
ZStack {
// 2
VStack(spacing: 0) {
Rectangle()
// 3
.frame(height: 90)
.foregroundColor(
Color(red: 0.5, green: 0, blue: 0).opacity(0.2))
Rectangle()
// 3
.frame(height: 90)
.foregroundColor(
Color(red: 0.6, green: 0.1, blue: 0.1).opacity(0.4))
}
// 4
Image(systemName: "person.fill")
.resizable()
.padding()
.frame(width: avatarSize, height: avatarSize)
.background(Color.white.opacity(0.5))
.cornerRadius(avatarSize / 2, antialiased: true)
.shadow(radius: 4)
// 5
VStack() {
Spacer()
Text(userName)
.font(.largeTitle)
.foregroundColor(.white)
.fontWeight(.bold)
.shadow(radius: 7)
}
.padding()
}
// 6
.frame(height: 180)
Phew — that’s a lot of code! But don’t be intimidated — it’s familiar code that you’ve already used in the previous chapter. Here’s what’s happening:
- You use a
ZStackto layer content on top of one another - The bottom layer (the one added first) is the background, which is split into two halves.
- Each of the two halves has a fixed height of 90 points and different background colors. This tells the
VStackhow tall it should be. - This is the user avatar, configured with a predefined size, and with a semi-transparent background color, rounded corners and some shadow. Notice how easy it is to customize an image!
- The final
VStackcontains the name of the user, aligned to the bottom. TheSpaceris used to make sure that theTextis pushed to the bottom. More onSpacerin a moment. - This entire ZStack is set to a fixed height.
The resulting view should look like this:
Much nicer, right?
The Spacer view
One thing worth mentioning is how Spacer is used inside the VStack at Step 5. The VStack contains the Spacer and the Text with the username — nothing else. So you might wonder why it’s even necessary?
If you remove both the Spacer and the VStack, the user name would still be displayed, but it would be centered vertically:
In order to push it down, you use a VStack, containing a Spacer at top and the Text at bottom. The Spacer expands along the major axis of its containing stack (or in both directions, if not in a stack) — so, as a side effect, it pushes the Text down.
Following the layout rules described at the beginning of this chapter, this is how it works:
- The
VStackis proposed a size by its parent, theZStack. -
VStackfinds that the child view with less layout flexibility is theText, so it proposes a size. In the absence of layout priority, as in this case, the proposed size is half the size at its disposal. - The
Textcomputes the size it needs and sends the ticket back to theVStack. - The
VStacksubtracts the size claimed by theTextfrom the size at its disposal, and proposes that to theSpacer. - The
Spacer, being flexible and unpretentious, accepts the proposal.
Challenge: The view would look much better if the button were aligned to the bottom of the screen. How could you do that?
There are probably several ways of achieving that result, but it can be done with Spacers alone.
In order to push the button down, you need to add a Spacer between the button and the text above it:
Text("You're awesome!")
.fontWeight(.bold)
.foregroundColor(.gray)
Spacer() // <== The spacer goes here
Button(action: {
self.challengesViewModel.restart()
}, label: {
Text("Play Again")
})
However, although you’ve achieved the desired result, something’s not quite right:
The button is now anchored to the bottom, but everything else has been pushed toward the top. To fix that, all you have to do is add another Spacer before the first Text in the VStack:
VStack {
Spacer() // <== The spacer goes here
Text("Congratulations!")
...
Mission accomplished!
You’re done with the congratulations view for now. It delivers the message nicely, now you can take care of another view.
Completing the challenge view
Earlier you’ve used ChallengeView as a playground to test code shown throughout this chapter. Now you need to fill it with more useful code. The challenge view is designed to show a question and a list of answers.
Both use views defined in QuestionView.swift and ChoicesView.swift. The answers view, however, is hidden the first time the challenge view is shown, and it appears when the user taps anywhere on the screen.
First up, you need to add some properties that the view will need later. Open ChallengeView.swift and add the following two properties:
let challengeTest: ChallengeTest
@State var showAnswers = false
As with previous examples, the preview is complaining about something. In ChallengeView_Previews, replace its entire implementation, including previews, with:
// 1
static let challengeTest = ChallengeTest(
challenge: Challenge(
question: "おねがい します",
pronunciation: "Onegai shimasu",
answer: "Please"
),
answers: ["Thank you", "Hello", "Goodbye"]
)
static var previews: some View {
// 2
return ChallengeView(challengeTest: challengeTest)
}
Straightforward stuff here:
- You create a challenge test to use in preview mode.
- You pass that test to the view initializer.
ChallengeView is used inside PracticeView, and again, ChallengeView expects a parameter that you need to pass in. Open PracticeView.swift, and replace the ChallengeView() line with:
ChallengeView(challengeTest: challengeTest!)
Force unwrapping is fine in this instance, as you’re checking for nil on the line above.
With all that setup out of the way, you’re ready to build the actual challenge view. As previously mentioned, the view is designed to show a question and a list of answers. To achieve this, replace the body of ChallengeView.swift with:
var body: some View {
// 1
VStack {
// 2
Button(action: {
self.showAnswers.toggle()
}) {
// 3
QuestionView(question: challengeTest.challenge.question)
.frame(height: 300)
}
// 4
if showAnswers {
Divider()
// 5
ChoicesView(challengeTest: challengeTest)
.frame(height: 300)
.padding()
}
}
}
Here’s what’s going on:
- The two views are stacked vertically, so you use a
VStack. - This button wraps the
QuestionView, and on tap, it toggles the visibility of theChoicesView. - This is
QuestionViewwhich, as mentioned, is implemented in its own file. - There’s some conditional logic here to display
ChoicesViewonly whenshowAnswersistrue. - This is
ChoicesView, implemented in its own file too. It receives a challenge test as a parameter, which you provide via an instance property.
Reworking the App Launch
With the challenge view now completed, you still need to work on two other parts of the app in order to run:
- Change the initial view when the app starts.
- Amend
WelcomeView.
The first part is very simple, as you’ve already done it in the previous chapters. Open KuchiApp.swift, and replace RegisterView(keyboardHandler: KeyboardFollower()) with StarterView(), leaving everything else unaltered. This is what KuchiApp should look like:
@main
struct KuchiApp: App {
let userManager = UserManager()
init() {
userManager.load()
}
var body: some Scene {
WindowGroup {
StarterView()
.environmentObject(userManager)
}
}
}
If you open StarterView.swift, you see that it works as a proxy view, choosing which view to display depending on a flag in the user manager:
@ViewBuilder
var body: some View {
if self.userViewModel.isRegistered {
WelcomeView()
} else {
#if os(iOS)
RegisterView(keyboardHandler: KeyboardFollower())
#endif
#if os(macOS)
RegisterView()
#endif
}
}
If isRegistered is true, it shows WelcomeView, otherwise RegisterView, which was the view displayed at launch time, before you replaced it just a few moments ago.
Note: The
@ViewBuilderattribute applied tobodyindicates that the returned view can actually consist of more than one view. Although here one view only is returned, you need it because two views are declared, one in theifbranch and the other in theelse’s.
Now, time to take care of WelcomeView. You need to change it so that it shows a welcome message the first time it is displayed, and it goes to the practice view after.
Open WelcomeView.swift, and add these three properties:
@EnvironmentObject var userManager: UserManager
@ObservedObject var challengesViewModel = ChallengesViewModel()
@State var showPractice = false
You’ve already used userManager and challengesViewModel elsewhere, there’s nothing more to say here. showPractice is a state flag that you can use to determine which view to show.
Because you introduced an uninitialized property (userManager) to the view, you need to update WelcomeView_Previews to include this. In WelcomeView_Previews, do this by adding the .environmentObject(UserManager()) modifier where WelcomeView is instantiated. This is how it should look like:
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView()
.environmentObject(UserManager())
}
}
Next, replace the body of WelcomeView with this:
// 1
@ViewBuilder
var body: some View {
if showPractice {
// 2
PracticeView(
challengeTest: $challengesViewModel.currentChallenge,
userName: $userManager.profile.name
)
} else {
// 3
ZStack {
WelcomeBackgroundImage()
VStack {
Text(verbatim: "Hi, \(userManager.profile.name)")
WelcomeMessageView()
// 4
Button(action: {
self.showPractice = true
}, label: {
HStack {
Image(systemName: "play")
Text(verbatim: "Start")
}
})
}
}
}
}
The new logic is:
- Because
bodycontains an if-else pair you need to prepend@ViewBuilderto satisfy the compiler. Same as you did inStarterViewpreviously. - If the
showPracticeflag is true, you showPracticeView - Otherwise, go to the other path, showing a welcome message
- This button is used to acknowledge the welcome message and start practicing, by setting the
showPracticeflag when it is tapped.
With all this done, you can run the app.
Congratulations on the achievement! Here are a few screenshots of how the app looks.
The Lazy Stacks
Stacks are very useful to lay out views in one direction or another. In most cases, they are all you need for that purpose. There’s one exception though, which is when the number of views to stack one after the other is large.
If you have used either UITableView or NSTableView, you have probably figured out where the problem is. A large number of views means a lot of processing to create the views themselves, and a lot of memory to keep them all — if the user never scrolls down or right to the last element, it would be a huge waste of CPU cycles and memory.
So it’s better to load views on demand, as needed, starting with the bare minimum to keep the screen crowded, and keep loading other views as the user demands for more.
This is what lazy stacks do. And unlike their energetic counterparts, the lazy ones come in two flavors only, horizontal and vertical, respectively LazyHStack and LazyVStack — if you think for a moment you realize that only a fool would stack tens or hundreds of views one on top of another in the Z axis.
Although you can add views to stack up manually, lazy stacks really shine when you iterate over a data source, making the lazy stack an efficient data driven stack component.
Practice History
To see lazy stacks in action, you’re going to build a history view that displays all the recent challenges. Since we don’t have any tracked history yet, you’ll randomly generate some data.
Open HistoryView.swift and take a look at its content. It defines:
-
History: A data structure for the history, consisting of a date and a challenge. -
random()andrandom(count:): A couple methods to generate some random history. -
HistoryView: The view you’re going to implement now. It comes with a couple properties and a function:-
history: The history data source. -
dateFormatter: A formatter to convert dates into strings. -
header: a view used as the section header. -
getElement(_:): a function that returns a view for a history element.
-
All this content is stuff you should already be familiar with, so there’s no need for a step by step guide to get there from an empty file.
All that said, you can focus on the body property, which contains an EmptyView for now. Replace that with an empty lazy vertical stack:
var body: some View {
LazyVStack {
}
}
Now you need to iterate over all elements of the history property, which for now is randomly generated with a size of 2000 elements. To iterate, you might be tempted to use a for-in statement, but you can’t - free to try, but all you’ll get is a compilation error.
Instead you’ll use ForEach, which looks like a statement, but in reality it’s just a view that can generate content dynamically. Its initializer takes three parameters:
init(
_ data: Data,
id: KeyPath<Data.Element, ID>,
content: @escaping (Data.Element) -> Content
)
-
datais the collection to iterate over. -
idis a key path of the element type,Historyin your case, pointing to a property that can let each element of the collection to be uniquely identified - such property must conform toHashable -
contentis the view for each element - defined in the form of a closure that takes the element to display as parameter.
Inside the body of LazyVStack add the following:
ForEach(history, id: \.self) { element in
}
This loops through all elements of history, using the element itself as id - if you look at the declaration of History, you see that it implements the Hashable protocol.
To display the element, you can use the getElement(_:) method, which creates and returns a simple cell:
ForEach(history, id: \.self) { element in
getElement(element)
}
If you resume the preview, you’ll see this. Not bad at all!
If you enable the live preview, you notice that you cannot scroll — the content is fixed. No worries, all you have to do is embed the stack into a scroll view:
ScrollView {
LazyVStack {
ForEach(history, id: \.self) { element in
getElement(element)
}
}
}
Now the content is scrollable vertically. It would be nice to add a header - and that’s dead easy to achieve, simply embed ForEach into a Section:
Section(header: header) {
ForEach(history, id: \.self) { element in
getElement(element)
}
}
You pass the header property to Section, which defines a text view with a gray background.
If you run it through live preview, you notice that the header scrolls with the rest of the view — but it would be better if it would stay anchored to the top. For this you can use the pinnedViews parameter of LazyVStack’s initializer to specify that section headers must be pinned.
Add the pinnedViews parameter as follows:
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section(header: header) {
ForEach(history, id: \.self) { element in
getElement(element)
}
}
}
Note that you can also define a footer in sections, and pin them as well.
Key points
Another long chapter — but you did a great job of getting through it! A lot of concepts have been covered here, the most important ones being:
- SwiftUI handles layout differently and more easily (at least, from the developer’s point of view) than Auto Layout.
- Views choose their own size; their parents cannot impose size but only propose instead.
- Some views are more adaptive than others. For instance,
Texttries to adapt to the size suggested by its parent, whileImagesimply ignores that and displays the image at its native resolution. - There are three types of stack views;
VStackfor vertical layouts,HStackfor horizontal layouts, andZStackfor stacking content on top of another. - Stack views propose sizes to their children starting from the least adaptive to the most adaptive.
- Horizontal and Vertical stack views also have lazy counterparts, which load content on demand, as opposed to rendering everything upfront.
- The order in which children are processed by stack views can be altered by using the
layoutPrioritymodifier.
Where to go from here?
To know more about container views, the WWDC video that covers them is a must-watch:
- WWDC 2019: Session 237 “Building Custom Views with SwiftUI” apple.co/2lVpSSc
Also recommended is the official documentation, which currently is a bit lacking in the verbosity department, but hopefully, that will improve soon.
- Stack Views: Official documentation apple.co/2lXlbr1
There are a few other container views that have not been covered in this chapter:
FormGroupGroupBox
You can check out the documentation for more information on these. Good luck in your adventures with SwiftUI stack and container views!