Categories
Uncategorized

How to navigate between views in SwiftUI by using an @EnvironmentObject

Updated for Xcode 12 and SwiftUI 2.0 ✅

Hello and welcome to this tutorial! In this post, we will talk about how to navigate between views in SwiftUI (not using a navigation view!). A concept that may sounds trivial but by understanding it deeply we can learn a lot about the data flow concepts used in SwiftUI.

This tutorial is an excerpt from our Interactive Mastering SwiftUI Book

In the last part, we learned how to do this by using an ObservableObject and the @StateObject property wrapper. In this part, we look at how to accomplish the same but more efficiently using an @EnvironmentObject. We are also going to apply a nice transition animation.

Here is what we are going to achieve:

Our current progress 👓📚

So we just figured out how to navigate between different views using a ObservableObject. In a nutshell, we created a ViewRouter and bound our MotherView and the ContentViews to it. Then, we update the Page assigned to the ViewRouter’s currentPage property when clicking on the respective ContentViews Buttons. This causes the MotherView to update its body with eventually showing the correct ContentView!

But there is a second, more efficient way for achieving this functionality: using an @EnvironmentObject!

Hint: You can download the current progress here (it’s the “NavigatinInSwiftUIFinishedPart1” folder):

GitHub

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 do anything else when the existing solution is sufficient?”. Well, it should become clear when looking at our app’s hierarchy logic. The NavigatingInSwiftUIApp struct initialises a ViewRouter instance as a @StateObject and passes it to the root MotherView. In the MotherView, we initialise either ContentViewA or ContentViewB with passing down the ViewRouter instance down to them. 

You see that we follow a strict hierarchy which passes the initialised ViewRouter as a @StateObject downwards to the “lowest” subviews. For our purposes, this 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 initialised @StateObject down to all subviews could get pretty messy.

In one sentence: Using an ObservableObjects observed by using @StateObjects can become confusing when we work with more complex app hierarchies.

So, what we could do instead, is to initialise 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 initialisation 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 initialised, can be used to share information across all view’s of your app. The cool thing is, that an EnvironmentObject is created by supplying an ObservableObject. Thus we can use our ViewRouter as it its 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 initialisation chain downwards the app’s hierarchy.

As said, an EnvironmentObject needs to already be initialised when referring to it the first time. Since our root MotherView will look into the ViewRouter‘s currentPage property first, we need to initialise the EnvironmentObject at the app’s launch. We can then automatically change the data assigned to the EnvironmentObject’s currentPage property from the ContentView’s 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 hiearchy must immediately be provided with a ViewRouter instance as an EnvironmentObject. Therefore, we need to pass the @StateObject we initialised 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 to the whole view hierarchy as an EnvironmentObject when the app launches. Now, all views of our app can 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 an 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 initialise 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 ContentView’s 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 tutorial, let’s take a look at how to add a transition animation when navigating from .page1 to .page2.

Doing this in SwiftUI is pretty straight forward.

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. 

“withAnimation(_:_:) – Returns the result of recomputing the view’s body with the provided animation”

Apple

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 regular simulator instead.

Awesome! With just a few lines of code, we added a nice transition animation to our app.

You can download the whole source code here!

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.

You liked the tutorial and you want to learn more about developing iOS apps with SwiftUI? Then check out our Interactive Mastering SwiftUI Book!

Categories
Uncategorized

How to navigate between views in SwiftUI by using an @ObservableObject

Updated for Xcode 12 and SwiftUI 2.0 ✅

Hello and welcome to this tutorial! In this post, we will talk about how to navigate between views in SwiftUI (not using a NavigationView!). A concept that may sounds trivial but by understanding it deeply we can learn a lot about the data flow concepts used in SwiftUI.

This tutorial is an excerpt from our Interactive Mastering SwiftUI Book

In this tutorial, you will learn:

  • How to navigate between views without relying on a navigation view hierarchy
  • More advanced data flow techniques including ObservableObjects, @StateObjects and @EnvironmentObjects
  • Taking a first look at using animations in SwiftUI

In this part, we will learn how to navigate between views using a ObservableObject and @StateObject. In the next part, we look at how to accomplish the same behaviour more efficiently using an @EnvironmentObject and apply some nice navigation animations.

And here is what we are going to achieve at the end of this tutorial:

For learning how to navigate 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 views. ContentViewA shows an Image view with a grumpy dog and a Button reading “Next”. The other view called ContentViewB shows an Image view with a happy dog and a Button reading “Back”.

You can download the starter project here:

Starter Project

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 navigation view hierarchy. Instead, we want both views to be independent of each other.

So let’s get started!

Creating a Mother View 👩‍👧‍👦

The first step is to create a mother view that hosts both Content Views 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, go into 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. At default, we want to present ContentViewA as the first “page”. For this purpose, create a new Swift file called Helper.swift 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")
    }
}

State properties are used for displaying views depending on the State’s data. Every time the State gets updated it triggers the view to rerender. If you’re not familiar with the concept of States in SwiftUI, we strongly recommend you to read this tutorial first, since it’s crucial for understanding the following concepts!

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 our preview simulator 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! Because the State changed, our 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 Statefrom 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. 

ObservableObjects ?! 🤯

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 rerendering at 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). 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 rerendered similar to when the value assigned to a State changes

So, how could we use 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!

Honestly, this seems a little bit abstract, but it should become clearer when applying the concept to our app.

Let’s create such an ObservableObject. To do this, create a new Swift file and call it ViewRouter.swift. 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 with “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 NavigatinInSwiftUIApp.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 regular simulator.

So, go to the NavigatingInSwiftUIApp file and declare a @StateObject property. Then, pass the initialised @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 proof 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 initialised 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 will rerender its body with eventually showing the correct 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 initialising 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! 

This is 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!

Conclusion 🎊

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 Buttons. Due to the @Published property wrapper’s functionality, this causes the MotherView to rebuild its body with eventually hosting the correct ContentView! 

You can download the whole source code here!

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 the second part of this tutorial ! We will also talk about adding animated transitions, so make sure you check it out!