Month: August 2020
Setting up the TabView
Setting up the TabView ↔️
In our ContentView, we can now replace the existing “Hello, world” Text with a TabView.
But first, we have to declare a State property that tells our TabView which subview to display. By default, we want to display the .meditating Tab.
@State var selectedTab: Tab = .meditating
Now we can insert a TabView into our ContentView and bind it to the selectedTab State.
struct ContentView: View {
@State var selectedTab: Tab = .meditating
var body: some View {
TabView(selection: $selectedTab) {
}
}
}
SwiftUI now starts to prepare a tab bar for our ContentView. Don’t worry, we will convert it into a swipeable page view in a moment.

Our TabView should cycle through each SubviewModel instance in our subviewData array and initialize a corresponding Subview. For this purpose, we use a ForEach statement.
TabView(selection: $selectedTab) {
ForEach(subviewData) { entry in
Subview(subviewModel: entry)
}
}
In order for our TabView to correctly assign and arrange the initialized Subviews we have to append a .tag to each Subview. For this purpose, we use the tag property of our SubviewModel.
TabView(selection: $selectedTab) {
ForEach(subviewData) { entry in
Subview(subviewModel: entry)
.tag(entry.tag)
}
}
As said before our TabView just created a tab bar for us. To add different tab bar items we could use the .tabItem modifier at this point. For example, as follows:
Subview(subviewModel: entry)
.tag(entry.tag)
.tabItem {
Text("More")
Image(systemName: "star")
}

But instead of a tab bar, we need a swipeable page view. To do this, we simply apply the .tabViewStyle modifier to our TabView and use the PageViewTabStyle.
TabView(selection: $selectedTab) {
//...
}
.tabViewStyle(PageTabViewStyle())
The previously generated tab bar has disappeared. If we now start a Live preview, we see that we can easily swipe through the different subviews.
Let’s embed our TabView into a NavigationView and add a .navigationTitle to it.
NavigationView {
TabView(selection: $selectedTab) {
//...
}
.tabViewStyle(PageTabViewStyle())
.navigationTitle("Calmify")
}
Your ContentView preview should now look like this:

But where is the dotted page indicator that tells the user which page he is on? In fact, it is already there, but unfortunately, it is almost invisible when the “Light Mode” is activated. We can easily check this by adding another Preview simulator with “Dark Mode” enabled:
We can fix this “bug” by adjusting the color scheme of the indicator.
struct ContentView: View {
//...
init() {
UIPageControl.appearance().currentPageIndicatorTintColor = .orange
UIPageControl.appearance().pageIndicatorTintColor = UIColor.gray.withAlphaComponent(0.5)
}
var body: some View {
//...
}
}
The Page view indicator is now visible even with “LightMode” enabled.

Swiping through the page view manually ↔️
We also want to allow the user to manually go to the next Subview by tapping a corresponding Button.
To do this, insert the following struct below your ContentView struct:
struct NavigatorView: View {
var body: some View {
HStack {
Spacer()
Button(action: {
print("Go to the next subview")
}) {
Image(systemName: "arrow.right")
.resizable()
.foregroundColor(.white)
.frame(width: 30, height: 30)
.padding()
.background(Color.orange)
.cornerRadius(30)
}
}
.padding()
}
}
Next, we add a NavigatorView to our ContentView. This NavigatorView should be stacked on top of the ContentView. To do this, we wrap the TabView in a ZStack and select the .bottomTrailing alignment mode.
NavigationView {
ZStack(alignment: .bottomTrailing) {
TabView(selection: $selectedTab) {
//...
}
.tabViewStyle(PageTabViewStyle())
.navigationTitle("Calmify")
NavigatorView()
}
}
Your ContentView preview should now look like this:

When we click on the Button in our NavigatorView, we want to navigate to the next Subview. For this, we need a Binding to the selectedTab State of the ContentView.
struct NavigatorView: View {
@Binding var selectedTab: Tab
var body: some View {
//...
}
}
Let’s initialize this Binding in our ContentView.
NavigatorView(selectedTab: $selectedTab)
By inserting a switch statement into the action closure of our Button we can specify that the next Subview should be shown each time the user taps the Button. If we now embed this switch statement in a withAnimation statement, the transition to the next tab view is animated as if we were swiping.
Button(action: {
withAnimation {
switch selectedTab {
case .meditating:
selectedTab = .running
case .running:
selectedTab = .walking
case .walking:
return
}
}
}) {
//...
}
If we now run our app, we can navigate manually using our NavigatorView.
Conclusion 🎊
Great, we finished our “Onboarding” app! You’ve learned how to introduce the user to a newly installed app using a modified TabView. We also learned more about working with view hierarchies in SwiftUI.
In the next chapter, we will learn how to store data persistently in SwiftUI and create our own To-Do app.
Preparing the subviews
Preparing the subviews 🖌
We start by preparing the subviews that our TabView should contain later. Create a new SwiftUI view file and name it “Subview”. Delete the “Hello, world!” Text view.
Each Subview should consist of an Image view and two Text views. Since these views should be arranged vertically, we use a VStack. Let’s insert our Image view and use – for example – the “meditating” image file. In order to scale our Image view properly, we must first apply the .resizable modifier.
struct Subview: View {
var body: some View {
VStack {
Image("meditating")
.resizable()
}
}
}
Our Image view should always be half as high as the screen of the device our app is running on. But how can we find out how wide and high this screen is? After all, the screen of an iPhone 13 Pro Max has completely different dimensions than that of an iPhone SE.
This is the perfect case for using a GeometryReader. We can embed views into such a GeometryReader. The GeometryReader has access to the dimensions of the outer view. The embedded view can then access this information through the GeometryReader.
This should become more comprehensible when you see how we use a GeometryReader for our purposes. Our Image view should always be half as high as the entire Subview. Therefore, we have to access the body of our Subview. To do this, wrap the VStack below it in a GeometryReader as follows:
struct Subview: View {
var body: some View {
GeometryReader { geometry in
VStack {
Image("meditating")
.resizable()
}
}
}
}
The views embedded into the GeometryReader can now access the dimensions of the overall Subview by using the GeometryReader’s geometry property.
Now we can easily frame our Image view as follows:
GeometryReader { geometry in
VStack {
Image("meditating")
.resizable()
.frame(height: geometry.size.height/2)
}
}
Great! We have now ensured that our Image view is always half the height of the entire Subview. Let’s apply the common modifiers for framing our Image view properly and add some padding to it.
Image("meditating")
.resizable()
.frame(height: geometry.size.height/2)
.aspectRatio(contentMode: .fit)
.clipped()
.padding(.top, 70)
.padding()
Your preview simulator should now look like this:

Now we have to insert the two Text views below our Image view.
VStack {
//...
Text("Take some time out")
.font(.title)
.padding()
Text("Take your time out and bring awareness into your everyday life")
.font(.subheadline)
.foregroundColor(.gray)
.padding()
}
Next, we make sure that all views are aligned on the left. For this purpose, we change the alignment mode of our VStack. Furthermore, we push all views to the top by adding a Spacer view.
VStack(alignment: .leading) {
//...
Spacer()
}
We are finished with preparing our Subview!

Defining the data model for our subviews ⚙️
Each Subview of our onboarding app should show a different Image and Text. Therefore, we create a suitable data model that we can use later on. Create a new Swift file named “Helper” and declare a “SubviewModel” struct that conforms to the Identifiable protocol.
struct SubviewModel: Identifiable {
}
For each Subview, we need properties for the corresponding Image asset, the title and caption Text. We also need an id property to satisfy the Identifiable protocol.
struct SubviewModel: Identifiable {
let imageString: String
let title: String
let caption: String
let id = UUID()
}
In order for our TabView to be able to distinguish between the different Subview instances, we need another property. But first, we create a new enum below our SubviewModel struct called “Tab” that describes the different subview cases.
enum Tab: Hashable {
case meditating
case running
case walking
}
Now we can extend our SubviewModel with a “tag” property:
struct SubviewModel: Identifiable {
let imageString: String
let title: String
let caption: String
let id = UUID()
let tag: Tab
}
Next, we create some SubviewModel instances which we can use later on to initialize the corresponding Subview instances in the TabView. For this purpose, you can add the following array to your Helper.swift file.
let subviewData = [
SubviewModel(imageString: "meditating", title: "Take some time out", caption: "Take your time out and bring awareness into your everyday life", tag: .meditating),
SubviewModel(imageString: "running", title: "Conquer personal hindrances", caption: "Meditating helps you dealing with anxiety and bringing calmness into your life", tag: .running),
SubviewModel(imageString: "walking", title: "Create a peaceful mind", caption: "Regular meditation sessions create a peaceful inner mind", tag: .walking)
]
Updating our Subview
Instead of fixed values, our Subview should use the information from a given SubviewModel instance. Therefore, we change our Subview accordingly:
struct Subview: View {
let subviewModel: SubviewModel
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
Image(subviewModel.imageString)
//...
Text(subviewModel.title)
//...
Text(subviewModel.caption)
//...
Spacer()
}
}
}
}
Our Subview_Previews struct now requires a corresponding instance. For example, we can use the second element in our subviewData array.
struct Subview_Previews: PreviewProvider {
static var previews: some View {
Subview(subviewModel: subviewData[1])
}
}

Chapter 9: Onboarding App
What you’ll learn in this chapter:
- How to create an onboarding experience for a SwiftUI app
- How to utilise a TabView to let the user swipe through multiple subviews
- How to work with dark- and light mode in the Preview simulator
What we’ll create in this chapter 🚀
In this chapter, we will build an onboarding screen using SwiftUI. Usually, such onboarding screens are presented the first time an installed app is launched and provide the user with an introduction to the functionalities of the specific app.
We will create an onboarding experience for a hypothetical mediation app called “Calmify”. The onboarding screen will consist of three subviews. The user will be able to navigate through them by either swiping left or right or by tapping on a “Next” button.
Before we start, make sure you create a new Xcode project named “Onboarding” and copy the necessary images to your Assets folder.
Our app’s architecture 🏛
Before we get started, let’s briefly talk about how we are going to build up our app hierarchically.
At the top of the view hierarchy, there should be a NavigationView. This NavigationView should contain a custom “NavigatorView” (the area where the user can manually navigate back and forth by tapping on the corresponding Button). On the other hand, our NavigationView should contain a so-called TabView.
The TabView will contain our three subviews, each with one Image and two Text views. The TabView functionality will allow the user to navigate through these subviews by swiping left or right.

Why using an ObservableObject isn’t always the best solution⚠
Okay, we just learned how ObservableObjects and SwiftUI work and how we can utilize them for navigating between views.
Maybe you’re asking yourself: “Why should we change something when the existing solution is sufficient?”. Well, it should become clear when looking at our app’s hierarchy logic. The NavigatingInSwiftUIApp struct initializes a ViewRouter instance as a @StateObject and passes it to the root MotherView. In the MotherView, we initialize either ContentViewA or ContentViewB while passing down the ViewRouter instance down to them.
You see that we follow a strict hierarchy that passes the initialized ViewRouter as a @StateObject downwards to the “lowest” subviews. For our purposes, this is not a big deal, but imagine a more complex app with a lot of views containing subviews that in turn contain subviews and so on. Passing down the primary initialized @StateObject down to all subviews could get pretty messy.
In one sentence: Using ObservableObjects observed by using @StateObjects can become confusing when working with more complex app hierarchies.
So, what we could do instead, is to initialize the ViewRouter once at the app’s launch in a way that all views of our app hierarchy can be directly bound to this instance, or better said, are observing this instance, with no regard to the app’s hierarchy and no need to passing the ViewRouter downwards the hierarchy manually. The ViewRouter instance would then act like a cloud that flies above our app’s code where all views have access to, without taking care of a proper initialization chain downwards the app’s view hierarchy.
Doing this is the perfect job for an EnvironmentObject!
What is an EnvironmentObject? 🧐
An EnvironmentObject is a data model that, once initialized, can be used to share information across all views of your app. The cool thing is, that an EnvironmentObject is created by supplying an ObservableObject. Thus we can use our ViewRouter as it is for creating an EnvironmentObject!
So, once we defined our ViewRouter as an EnvironmentObject, all views can be bound to it in the same way as a regular ObservableObject but without the need of an initialization chain downwards the app’s hierarchy.
As said, an EnvironmentObject needs to already be initialized when referring to it the first time. Since our root MotherView will look into the ViewRouter‘s currentPage property first, we need to initialize the EnvironmentObject at the app’s launch. We can then automatically change the data assigned to the EnvironmentObject’s currentPage property from the ContentViews which then causes the MotherView to rerender its body.

Implementing the ViewRouter as an EnvironmentObject 🤓
Let’s update our app’s code!
First, change the viewRouter‘s property wrapper inside the MotherView from an @StateObject to an @EnvironmentObject.
@EnvironmentObject var viewRouter: ViewRouter
Now, the viewRouter property looks for a ViewRouter as an EnvironmentObject instance. Thus, we need to provide our MotherView_Previews struct with such an instance:
struct MotherView_Previews: PreviewProvider {
static var previews: some View {
MotherView().environmentObject(ViewRouter())
}
}
When launching our app, the first and most high view in the app hierarchy must immediately be provided with a ViewRouter instance as an EnvironmentObject. Therefore, we need to pass the @StateObject we initialized in our NavigatingInSwiftUIApp struct to the MotherView as an injected EnvironmentObject like this.
@main
struct NavigatingInSwiftUIApp: App {
@StateObject var viewRouter = ViewRouter()
var body: some Scene {
WindowGroup {
MotherView().environmentObject(viewRouter)
}
}
}
Great! SwiftUI now creates a ViewRouter instance and injects it into the whole view hierarchy as an EnvironmentObject when the app launches. Now, all views of our app can be bound to this EnvironmentObject.
Next, let’s update our ContentViewA. Change the viewRouter property to an EnvironmentObject …
@EnvironmentObject var viewRouter: ViewRouter
… and update the ContentViewA_Previews struct:
struct ContentViewA_Previews: PreviewProvider {
static var previews: some View {
ContentViewA().environmentObject(ViewRouter())
}
}
Hint: Again, only the ContentViewsA_Previews struct has its own instance of the ViewRouter, but the ContentViewA itself is bound to the instance created at the app’s launch!
Let’s repeat this for ContentViewB…
@EnvironmentObject var viewRouter: ViewRouter
…and its previews struct:
struct ContentViewB_Previews: PreviewProvider {
static var previews: some View {
ContentViewB().environmentObject(ViewRouter())
}
}
Since the viewRouter properties of our ContentViews are now directly bound to the initial ViewRouter instance as an EnvironmentObject, we don’t need to initialize them inside our MotherView anymore. So, let’s update our MotherView:
struct MotherView: View {
@EnvironmentObject var viewRouter: ViewRouter
var body: some View {
switch viewRouter.currentPage {
case .page1:
ContentViewA()
case .page2:
ContentViewB()
}
}
}
And that’s the cool thing about EnvironmentObjects. We don’t need to pass down the viewRouter of our MotherView downwards to ContentViews anymore. This can be very efficient, especially for more complex hierarchies.
Great! Let’s run our app and see if that works … perfect, we are still able to navigate between our different views but with a more clean code.
Adding a transition animation 🚀
Before ending this chapter, let’s take a look at how to add a transition animation when navigating from .page1 to .page2.
Doing this in SwiftUI is pretty straightforward.
Take a look at the ViewRouter’s currentPage property that we manipulate when we tap on the Next/Back Button. As you learned, due to the @Published property wrapper’s functionality, this triggers the bound MotherView to rerender its body with eventually showing another ContentView. We can simply animate this navigation process by wrapping the code that changes the Page assigned to the currentPage into a “withAnimation” statement. Let’s do this for the Button of ContentViewA …
Button(action: {
withAnimation {
viewRouter.currentPage = .page2
}
}) {
NextButtonContent()
}
… and ContentViewB:
Button(action: {
withAnimation {
viewRouter.currentPage = .page1
}
}) {
BackButtonContent()
}
Now, we present a transition animation when navigating to another ContentView.
By default, the “withAnimation” statement uses a fade transition style. But instead, we want to show a “pop up” transition when navigating from ContentViewA to ContentViewB. To do this, go into your MotherView.swift file and add a transition modifier when calling the ContentViewB. You can choose between several preset transition styles or create even a custom one (but that’s a topic for another tutorial). For adding a “pop up” transition, we choose the .scale transition type.
switch viewRouter.currentPage {
case .page1:
ContentViewA()
case .page2:
ContentViewB()
.transition(.scale)
}
Hint: Most animations don’t work within the Preview simulator. Try running your app in the standard simulator instead.
Awesome! With just a few lines of code, we added a nice transition animation to our app.
Conclusion 🎊
That’s it! We learned when and how to use EnvironmentObjects in SwiftUI. We also learned how to add a transition animation to view showing up.
You are now capable of navigating between views in SwiftUI by using two ways – either you put your views into a navigation view hierarchy or you create an external view router as an Observable-/EnvironmentObject.
In the next chapter, we are going to create an onboarding screen you often see when you’re launching an app the first time.
Creating a Mother View 👩👧👦
The first step is to create a mother view that hosts both ContentViews as its subviews. For this purpose, create a new File-New-File-SwiftUI View and name it MotherView. In this view, we want to show either ContentViewA or ContentViewB depending on where the user navigated to.
Important: Since our MotherView will “contain” the ContentViews, it must be the root view when the app launches. To set the MotherView as the root view, open the NavigatinInSwiftUIApp.swift file and replace the ContentViewA inside the WindowGroup with an instance of our MotherView.
@main
struct NavigatingInSwiftUIApp: App {
var body: some Scene {
WindowGroup {
MotherView()
}
}
}
Back to our MotherView.swift file: To keep track of the selected main view, we need to declare a State property first. By default, we want to present ContentViewA as the first “page”. For this purpose, create a new Swift file named “Helper” and insert the following enum.
enum Page {
case page1
case page2
}
Now, we can declare the State property in our MotherView and assign it to the page1 option of our Page enum.
struct MotherView: View {
@State var currentPage: Page = .page1
var body: some View {
Text("Hello World")
}
}
Depending on the Page assigned to the currentPage State, we want to either host ContentViewA or ContentViewB. Let’s implement this logic by inserting a switch-statement inside a VStack.
var body: some View {
switch currentPage {
case .page1:
ContentViewA()
case .page2:
ContentViewB()
}
}
Let’s run a preview and take a look at it. Since our State is currently assigned to .page1, our first switch case is met and the ContentViewA gets hosted. Let’s change the page State to .page2 and see what happens.
Here we go! When our State property changes, the whole MotherView gets updated and the switch block gets executed again, this time showing us ContentViewB. So far, so good.
But we want to let the user change the currentPage State by tapping on the Buttons inside of ContentViewA and ContentViewB, respectively. Note that the Buttons are not part of the MotherView itself, so we need to create a “bridge” for accessing the MotherView’s currentPage State from the outside; meaning that when, for instance, tapping on the “Next” Button of ContentViewA, we alter the Page assigned to the currentPage State of the MotherView for eventually navigating to ContentViewB.
We can achieve this by interjecting something called an ObservableObject into our MotherView – ContentViews hierarchy.
Observable Objects ?! 🤯
At this point, you are probably asking yourself: “What the heck are ObservableObjects?!”. Well, understanding this can be pretty tough but don’t worry, we will explain it to you in a simple way.
ObservableObjects are similar to State properties which you should already know. But instead of just refreshing the body of the related view when the data assigned to the State changes, ObservableObjects are capable of the following things:
- Instead of variables, ObservableObjects are classes that can contain data, for example, a String assigned to a variable
- We can bind multiple views to the ObservableObject (in other words: we can make these views observe the ObservableObject).
- Because an ObservableObject is just a class, the observing views can access and manipulate the data inside the ObservableObject
- When a change happens to the ObservableObject’s data, all observing views get automatically notified and refreshed similar to when the value assigned to a State changes
So, how can we utilize this functionality? Well, we can create an ObservableObject class that contains a variable indicating the current Page that should be displayed. Then we can bind our MotherView, our ContentViewA, and our ContentViewB to it. Then, we can tell our MotherView to show the corresponding ContentView depending on the Page assigned to the ObservableObject’s variable.
From the Buttons inside the ContentViews, we can update the Page assigned to the ObservableObject’s variable. This would cause all three observing views to update their bodies, including the MotherView. With this functionality, we can achieve that the MotherView will present show the correct ContentView depending on the selected Page!

Let’s create such an ObservableObject. To do this, create a new Swift file and name it “ViewRouter”. Make sure you import the SwiftUI framework. Then create a class called “ViewRouter” conforming to the ObservableObject protocol.
import SwiftUI
class ViewRouter: ObservableObject {
}
As said, an ObservableObject notifies and causes all of its observing views to update themselves when a change happens. But what exactly do we mean by “when a change happens”? As said, the main task of our ViewRouter should be to stay tracked on which Page (meaning which ContentView) should be currently shown – whether it’s on the launch of the app or when the user taps on a specific Button. For this purpose, we declare a variable called currentPage inside our ViewRouter class and assign .page1 to it as its default value.
class ViewRouter: ObservableObject {
var currentPage: Page = .page1
}
The views that will observe the ViewRouter, especially the MotherView, should get notified and updated when the Page assigned to the currentPage changes.
To do this, we use the @Published property wrapper.
@Published var currentPage: Page = .page1
The @Published property wrapper works very similarly to the @State property wrapper. Every time the value assigned to the wrapped property changes, every observing view rerenders. In our case, we want our MotherView to observe the ViewRouter and to navigate to the right Page depending on the currentPage’s updated value.
Updating the MotherView 🔁
To make the MotherView observe the ViewRouter, we need to declare a @StateObject property, which is used for binding views to ObservableObjects.
struct MotherView: View {
@State var currentPage: Page = .page1
@StateObject var viewRouter: ViewRouter
var body: some View {
//...
}
}
When doing this, we also need to provide our MotherView_Previews struct with an instance of the ViewRouter.
struct MotherView_Previews: PreviewProvider {
static var previews: some View {
MotherView(viewRouter: ViewRouter())
}
}
In the NavigatingInSwiftUIApp.swift file, we defined the MotherView as the root view when the app launches. Thus, not only does our Preview simulator need to be provided with a ViewRouter instance, but also the actual App hierarchy when the app gets executed on a real device or in the standard simulator.
Therefore, let’s go to the NavigatingInSwiftUIApp file and declare a @StateObject property. Then, pass the initialized @StateObject to the viewRouter of our MotherView.
@main
struct NavigatingInSwiftUIApp: App {
@StateObject var viewRouter = ViewRouter()
var body: some Scene {
WindowGroup {
MotherView(viewRouter: viewRouter)
}
}
}
Our MotherView router is now able to observe and access the viewRouter‘s OberservableObject. So, let’s show the corresponding ContentView depending on the Page assigned to the viewRouter‘s currentPage property.
var body: some View {
switch viewRouter.currentPage {
case .page1:
ContentViewA()
case .page2:
ContentViewB()
}
}
You can delete the currentPage State of the MotherView, since we won’t need it anymore.
Let’s take a look at the simulator of our MotherView: The MotherView reads the value of the ViewRouter’s currentPage variable and hosts the corresponding ContentView. You can check this by changing the default value assigned to the ViewRouter’s currentPage property to .page2. Go back to the MotherView Preview simulator and see what happens! The @Published property of our ObservableObject told the MotherView to update its body.
Because we want ContentViewA to be the default view, assign .page1 to the currentPage property again.
Great! We accomplished a lot so far! We initialized a ViewRouter instance and bound it to the MotherView by using a @StateObject. Every time the values assigned to the currentPage property of the ViewRouter instance gets updated, the MotherView refreshes its body with eventually showing the right ContentView!
Bind the ContentViews to the ViewRouter ⛓
Our MotherView is now able to show the correct ContentView depending on the Page assigned to the currentPage property of the ViewRouter. But until now, the user is not able to change this value by tapping on the respective Button of ContentViewA and ContentViewB.
Let’s start with ContentViewA. To let it access the currentPage and manipulate its value we have to bind it to the ViewRouter. So, let’s create an @StateObject again.
struct ContentViewA: View {
@StateObject var viewRouter: ViewRouter
var body: some View {
//...
}
}
We need to update the ContentViewA_Previews struct again.
struct ContentViewA_Previews: PreviewProvider {
static var previews: some View {
ContentViewA(viewRouter: ViewRouter())
}
}
ContentViewA should observe the ViewRouter instance we created inside the NavigatingInSwiftUI struct and passed to the MotherView. So, let’s assign our new @StateObject to this instance when initializing ContentViewA in our MotherView.
switch viewRouter.currentPage {
case .page1:
ContentViewA(viewRouter: viewRouter)
case .page2:
ContentViewB()
}
Great! Now we have access to the currentPage property of our viewRouter. Use the Button’s action closure to assign .page2 to it when tapping on the “Next”-Button.
Button(action: {
viewRouter.currentPage = .page2
}) {
NextButtonContent()
}
Okay, let’s see if that works: Run the app in the regular simulator or start a Live preview of the MotherView and tap on the “Next”-Button. Great, we successfully navigate to ContentViewB!
Here’s what happens when the user taps on the “Next”-Button of ContentViewA: ContentViewA changes the Page assigned to the currentPage property of the viewRouter to .page2. Therefore, the viewRouter tells all bound views to rerender their bodies, including the MotherView. The MotherView updates its body and checks the currentPage‘s value. Because it’s .page2 now, the case for showing ContentViewB is met and we eventually navigate to it!
To be able to navigate back to ContentViewA, repeat this implementation process for ContentViewB:
Declare a @StateObject property as a ViewRouter instance…
struct ContentViewB: View {
@StateObject var viewRouter: ViewRouter
var body: some View {
//...
}
}
…and update the related previews struct:
struct ContentViewB_Previews: PreviewProvider {
static var previews: some View {
ContentViewB(viewRouter: ViewRouter())
}
}
Assign this viewRouter property to the initial ViewRouter instance passed by the NavigatingInSwiftUIApp struct to the MotherView.
switch viewRouter.currentPage {
case .page1:
ContentViewA(viewRouter: viewRouter)
case .page2:
ContentViewB(viewRouter: viewRouter)
}
Then, update the Button’s action parameter for showing the first page again:
Button(action: {
viewRouter.currentPage = .page1
}) {
BackButtonContent()
}
We can now navigate independently between our ContentViews!
Recap 🎊
We just figured out how to navigate between different views using an @ObservableObject. We created a ViewRouter and bound our MotherView and the ContentViews to it. Then, we achieved to manipulate the ViewRouter’s currentPage property when clicking on the ContentViews “Next”-Buttons. Due to the @Published property wrapper’s functionality, this causes the MotherView to rebuild its body with eventually hosting the right ContentView!
But often, there is an alternative, more efficient way to do this: Using an @EnvironmentObject. EnvironmentObjects provide us with more freedom and independence within our app’s view hierarchy. You will see what we mean by that when reading on.
By the way: we are also going to learn how to apply some nice transition animations to our app!
What you’ll learn in this chapter:
- How to navigate between views without relying on a NavigationView hierarchy
- More advanced data flow techniques including @ObservableObjects, @StateObjects and @EnvironmentObjects
- First look at using animations in SwiftUI
What we’ll create 🚀
In this chapter, we will learn how to navigate between views in SwiftUI without relying on view hierarchies such as navigation and modal views. A concept that may seem trivial, but by understanding it deeply, we can learn a lot about the data flow concepts used in SwiftUI.
For learning how to navigate independently between different views in SwiftUI, it’s appropriate to start with an example that’s not too complex. Supposed we have an app with two different main views. ContentViewA contains an Image with a grumpy dog and a Button reading “Next”. The other main view, called ContentViewB contains an Image with a happy dog and a Button reading “Back”.
I encourage you to practice your SwiftUI skills by building up those views by yourself!
However, you can also download the starter project from here.
We want to connect those views in a way that when we tap on the buttons we navigate back and forth. We could accomplish this using a NavigationView, but in this chapter, we don’t want to use such a NavigationView hierarchy. Instead, we want both views to be independent of each other. So let’s get started!
Download: FoodDeliveryPt2
Presenting modal views
Adding the “Place Order” Button
Great! What’s left is to insert one last Section containing a Button for the user to submit his order.
So, let’s create another Section containing a Button view (performing a temporary dummy action).
Section {
Button(action: {
print("Order placed.")
}) {
Text("Place Order")
}
}
However, we want to disable the button until the user has filled in all address fields.
Button(action: {
print("Order placed.")
}) {
Text("Place Order")
}
.disabled(!firstName.isEmpty && !lastName.isEmpty && !street.isEmpty && !city.isEmpty && !zip.isEmpty ? false : true)

Once the user taps the “Order”-Button, we would like to ask him again explicitly whether he wants to place the order bindingly.
For this purpose, we can use a so-called Confirmation dialog, which is displayed to the user as an alert sheet.
For this we add another State “showConfirmationDialog” to our OrderForm.
@State var showConfirmationDialog = false
We want to show the Confirmation dialog as soon as the user taps the “Order”-Button.
Button(action: {
showConfirmationDialog = true
}) {
Text("Place Order")
}
Now we add the .confirmationDialog modifier to our “Order”-Button and specify the displayed content.
.confirmationDialog("Are you sure?", isPresented: $showConfirmationDialog, titleVisibility: Visibility.visible) {
Button(action: {print("Order placed")}) {
Text("Place Order Now!")
}
}
Let’s see if this works by running a Live preview:

Displaying the Form as a modal view
We want to show the OrderForm we created whenever the user selects a certain food from the DetailView of our Food Delivery App and taps on the “Order”-Button. When that happens, we want to present our OrderForm as a modal view. A modal view is a temporary view that prevents the user from interacting with the overlaid UI until a certain action is done to exit the modal view. In our case, the DetailView should be overlayed until the user places an order or cancels the process.
To present the OrderForm when the user taps the “Order”-Button, we have to go to our DetailView and add a State property for keeping track of when the modal view should be shown.
@State var showOrderSheet = false
To add a modal view, we have to append the .sheet modifier that presents the wrapped OrderForm when the bound State gets assigned to true.⠀
List(filterData(foodDataSet: foodData, by: currentCategory)) { food in
DetailRow(food: food)
}
//...
.sheet(isPresented: $showOrderSheet, content: {
OrderForm()
})
We want to toggle our showOrderSheet State from the DetailRow view where our “Order”-Button is. Therefore, we have to declare a Binding inside the DetailRow struct.
@Binding var showOrderSheet: Bool
Our DetailRow_Previews struct needs to know what value should be assigned to the Binding. We can use the .constant method for this. Only for the DetailView‘s preview, we want to simulate that the showOrderSheet Binding is false, regardless of the related State of the DetailView.
struct DetailRow_Previews: PreviewProvider {
static var previews: some View {
DetailRow(food: foodData[0], showOrderSheet: .constant(false))
.previewLayout(.sizeThatFits)
}
}
But inside our DetailView’s List, we have to initialize the DetailRow while binding it to the showOrderSheet State.
List(filterData(foodDataSet: foodData, by: currentCategory)) { food in
DetailRow(food: food, showOrderSheet: $showOrderSheet)
}
Now, we can toggle the showOrderSheet State through this Binding by using the Order Button’s action closure.
Button(action: {
showOrderSheet = true
}) {
Text("ORDER")
.foregroundColor(.white)
}
Okay, let’s start a Live preview of our DetailView and see if that works. When we tap on the “Order”-Button, the DetailRow’s Button assigns the showOrderSheet State of the DetailView through its Binding to true. This causes the DetailView’s body to rerender, which leads to presenting our OrderForm as a modal view.
When we swipe down the modal view, SwiftUI automatically assigns the showOrderSheet State to false again which causes the OrderForm to dismiss!
We also want to add a cancel button for dismissing the OrderForm.
Adding a navigation bar item
We want to present this cancel button as a navigation bar item. Therefore, we have to wrap our OrderForm’s Form into a NavigationView.
NavigationView {
Form {
//...
}
}
Next, we add a navigation bar by using the .navigationTitle modifier.
NavigationView {
Form {
//...
}
.navigationTitle("Your Order")
}
To add a Button to our navigation bar, we have to append another modifier below our .navigationTitle called .navigationBarItems. By using the “leading” argument of this modifier, we can add a view to the left side. By using the “trailing” argument we can add a view to the right side of the navigation bar.
We want our cancel button to be on the left side of the navigation bar, so we use the “leading” argument.
.navigationBarItems(leading:
//Button goes here
)
But before we add the cancel button, we need to be able to access the showSheetModifier State of the DetailView for manually dismissing the OrderForm modal view. We can do this by adding a Binding to the OrderForm.
@Binding var showOrderSheet: Bool
Again, we use the .constant method inside our OrderForm_Previews struct for telling the preview of this particular view that it should always simulate the case that the showOrderSheet property is assigned false.
struct OrderForm_Previews: PreviewProvider {
static var previews: some View {
OrderForm(showOrderSheet: .constant(false))
}
}
But in our DetailView, we initialize the OrderForm by binding it to the showOrderSheet State of the DetailView!
.sheet(isPresented: $showOrderSheet, content: {
OrderForm(showOrderSheet: $showOrderSheet)
})
Now, we can use the Binding in our OrderForm to add a Button as the leading navigation bar item that assigns the showSheetModifier to false when tapping on it, which causes the OrderForm to dismiss.
.navigationBarItems(leading:
Button(action: {
showOrderSheet = false
}) {
Text("Cancel")
})
Great! Let’s try running the app in the regular simulator or run the DetailView in a Live preview.
When we click on the “Order”-Button, the showOrderSheet State of the DetailView gets assigned to true which causes the OrderForm to be displayed as an overlaid modal view.
We can now interact with the Form, fill in the information, and work with the controls. When we are finished, we can tap on the “Place Order” button.
We can cancel the order process by tapping on the cancel navigation bar button, which assigns the DetailView’s showOrderSheet State to false again through the OrderForm’s binding. Alternatively, we can swipe down the modal view, which automatically resets the showOrderSheet State and therefore dismisses the OrderForm.
Sections, Sliders and SFSymbols
Working with Sections
Now that we have implemented our Toggle and Stepper, we want to create several TextFields where the user can enter his delivery address.
The first TextField should contain the first name of the user. Therefore, we start with creating a State property for holding the user’s first name as a String:
@State var firstName = ""
Then, we insert a TextField view between the Stepper and the Toggle by binding it to this State and providing it with a placeholder String.
TextField("First name", text: $firstName)
Take a look at the Preview simulator. You see that the TextField is placed in the same visual group as the Stepper and Toggle. But instead, we want to create three different sections that are visually separated from each other – one including our Toggle and its related TextField, one including the TextFields for the delivery address information and one including the Stepper.
To do this, group these views by wrapping them into three Sections:
Form {
Section {
Stepper(value: $orderAmount, in: 1...10) {
Text("Quantity: \(orderAmount)")
}
}
Section {
TextField("First name", text: $firstName)
}
Section {
Toggle(isOn: $specialRequest) {
Text("Any special requests?")
}
.toggleStyle(SwitchToggleStyle(tint: .orange))
if specialRequest {
TextField("Enter your request", text: $specialRequestInput)
}
}
}
Great! Our Preview simulator shows three different sections that are visually separated from each other.

Create the remaining TextFields by declaring corresponding State properties …
@State var lastName = ""
@State var street = ""
@State var city = ""
@State var zip = ""
…and add the remaining TextFields to the second Section:
Section {
TextField("First name", text: $firstName)
TextField("Last name", text: $lastName)
TextField("Street", text: $street)
TextField("City", text: $city)
TextField("ZIP code", text: $zip)
}
Great, we’re finished with setting up the TextFields containing the delivery address information.

Now, only two more components are left: a Slider that can be used to provide feedback and a Button that can be used to submit the order.
Creating a custom Slider

Before we implement the Slider you see above, create a new Section below the one containing the Toggle and its related TextField.
Section {
}
The Slider in this Section should be surrounded by two icons. We also want to insert a Text view above the Slider. Let’s start by creating the Slider control.
To do this, declare another State property for keeping track of the current “progress” value (1.0 represents 100%) of the Slider. Make sure, it’s a float-type value by entering a decimal value.
@State var userFeedback = 0.0
Now we are ready to initialize the Slider by binding it to this State and providing it with a range of values within which the user can move the Slider.
Section {
Slider(value: $userFeedback, in: 0.0...10.0)
}
What’s left is to place one icon before and one icon behind the Slider. Because the Slider will be arranged horizontally with the icons, we have to wrap it into a HStack first.
Section {
HStack {
Slider(value: $userFeedback, in: 0.0...10.0)
}
}
At this point, we could initialize Image views by accessing custom icon files we would need to import into our Assets folder first. Fortunately, there is a much easier way: Using system symbols! Apple provides us with a bunch of native icons we can use for our SwiftUI apps. We can initialize such icons by using an Image view and providing it with the “systemName” parameter.
HStack {
Image(systemName: "hand.thumbsdown")
Slider(value: $userFeedback, in: 0.0...10.0)
Image(systemName: "hand.thumbsup")
}
If you want, you can adjust the size and appearance of the icons. You can do this using the usual modifiers we have used for Image views in the previous chapters.
Your Slider should now look as follows:

Excursus: Working with SF Symbols
At this point, you are probably asking yourself: How I am supposed to know what system icons are available and how can I find their names so I can use them within my app?
Well, that’s super easy! Just use the free Apple SF Symbols app. This macOS app provides you with a set of over 3100 symbols you can use in your app.
You can download the app here.
Once you have opened the SF Symbols app, you can scroll through the icons or search for a certain keyword to find matching symbols for your app.

You can simply search for any type of icon, for instance, “house” or “star” and then get a list of icons related to the entered search term! You can then use the name of this icon for inserting it into your app. In our example, I searched for “thumb”. SF Symbols then provided me with different icons I can use by using the corresponding name as the Image view’s “systemName” argument!
Okay, back to our Slider. What’s left is to insert another Text view above the Slider. Therefore, we wrap the existing HStack into a VStack and insert a Text view (with some padding) above the HStack.
Finally, we also want to change the accent color of the Slider.
VStack {
Text("How do you like our Food Delivery App?")
.padding(.top, 10)
HStack {
Image(systemName: "hand.thumbsdown")
Slider(value: $userFeedback, in: 0.0...10.0)
.accentColor(.orange)
Image(systemName: "hand.thumbsup")
}
}
Your Slider should now look as follows:
