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

Share on facebook
Share on twitter
Share on pinterest

Hello and welcome to this tutorial! In this article, 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.

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

Here is what we are going to achieve :


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 with a grumpy dog and a Button saying “Next”. The other view, called ContentViewB shows an Image with a happy dog and a Button saying “Back”.

You can download the starter project here:

Starter Project

Now we want to connect these two views in a way that when clicking on the buttons we navigate between the views. We could kinda accomplish this using a NavigationView, but for this one, we don’t want to create a Navigation View Hierarchy but 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 holds both Content Views as its subviews. For this purpose, create a new File-New-File-SwiftUI View and call it MotherView. This is the place where we want to show the ContentViewA or ContentViewB depending on which page was selected by the user.

Important: Since our MotherView will “contain” the ContentViews, it must be the default view when the app launches. To set the MotherView as the root view, go into the SceneDelegate.swift file and update the scene function:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: MotherView())
self.window = window
            window.makeKeyAndVisible()
        }
    }

Back to our MotherView: To keep track of the page selection, we first have to declare a State property. At default, we want to see the first page so let’s assign an according string to the State

import SwiftUI

struct MotherView : View {
    @State var 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 current State, we want to either display the ContentViewA or ContentViewB, so let’s implement this logic by inserting an If-Statement inside a VStack.

var body: some View {
        VStack {
            if page == "page1" {
                ContentViewA()
            } else if page == "page2" {
                ContentViewB()
            }
        }
    }

Hint: We have to wrap it into a VStack (or any other Stack) because conditional statements can’t be wrapped directly into the body of a view.

Let’s run our preview simulator and take a look at it. Since our State is currently assigned to “page1”, our first condition is met and the ContentViewA gets displayed to us. Let’s change the page State to “page2” and see what happens. Here we go! Because the State changed, our whole MotherView gets rerendered and the else-if block gets executed, this time showing us ContentViewB. So far, so good.


But we want to enable the user to change this selection by clicking 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 possibility for accessing the MotherView from the outside, meaning that when, for instance, clicking on the “Next” Button of ContentViewA we want to toggle the page State of the MotherView for eventually navigating to ContentViewB.

To achieve this we have to interject a thing called ObservableObject inside our MotherView – ContentViews hierarchy.

Observable Objects ?! 🤯

At this point, you are probably asking yourself: What the heck are Observable Objects?! Well, understanding this can be pretty complicated but don’t worry, we will explain it in detail.

ObservableObjects are similar to State properties which you should already know. But instead of (re)rendering a view depending on it’s assigned data, ObservableObjects are capable of the following things:

  • ObservableObjects can contain data, for example, a string assigned to a variable
  • We can bind views to the ObservableObject (or better said, we can make these views observe the object). 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 a State changes

So how could we use this functionality? Well, we can create a Observable 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. We then tell our MotherView to show the appropriate ContentView depending on the ObservableObject’s currentPage variable.

From the ContentViews we can update the Observable’s variable. This would trigger rerendering of all three views, including the mother view. With this functionality, we can achieve that the Mother View will always show the right Content View 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 our ObservableObject. To do this, create a new Swift file and call it ViewRouter.swift. Make sure you import the SwiftUI and Combine framework since we need them for implementing subscription/notification functionality. Then create a class called ViewRouter conforming to the ObservableObject protocol.

import Foundation
import Combine
import SwiftUI

class ViewRouter: ObservableObject {
    
}

As said, a ObservableObject notifies and updates all of its observing views when a change happens. For doing this we must declare a constant objectWillChange and assign it to a PasstroughObject.

class ViewRouter: ObservableObject {
    
    let objectWillChange = PassthroughSubject<ViewRouter,Never>()
    
}

Hint: The objectWillChange property is a part of the Combine framework and needs to be assigned to a Passthrough Subject. The Passthrough Subject literally passes its data to any view that’s observing (in our case that will be especially our MotherView) whenever the objectWillChange property gets called. The first parameter inside the “<>”s is the data that gets passed, in our case that should be the ViewRouter. The second parameter can be used for setting a rule when an error should be thrown, in our case that should be never the case. For more details, check out this great tutorial by HackingWithSwift!

But what 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 Content View) should be shown currently, whether it’s on the launch of the app or when the user’s taps on a Button. For this purpose, we declare a variable called currentPage and assign it to a string “page1” as the default value. This value represents that the first page is selected (we will refer our ContentViewA to the page1 string as you will see later).

class ViewRouter: ObservableObject {
    
    let objectWillChange = PassthroughSubject<ViewRouter,Never>()
    
    var currentPage: String = "page1" 
}

The observers of the ViewRouter, in our case especially the MotherView, should get notified and updated when this value gets changed. So let’s implement a didSet wrapper calling the send method of the objectWillChange property we declared before.

var currentPage: String = "page1" {
        didSet {
            objectWillChange.send(self)
        }
    }

Next, we need to update our MotherView for displaying the right page every time the currentPage gets updated.

Update the MotherView 🔁

To make MotherView to observe the ViewRouter, we can declare a @ObservedObject property, which is used for binding to ObservableObjects. When doing this, we also need to update our MotherView_Previews struct with initialising an instance of the ViewRouter.

struct MotherView : View {
    
    //...
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
        //...
    }
}
struct MotherView_Previews : PreviewProvider {
    static var previews: some View {
        MotherView(viewRouter: ViewRouter())
    }
}

In the scenedelegate.swift file we set the MotherView as our start view when the app launches. Thus, not only our preview simulator needs to be provided with a ViewRouter instance but also the “normal” simulator. So switch to the scenedelegate.swift file and update the scene function as follows:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: MotherView(viewRouter: ViewRouter()))
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Because we initialised the ViewRouter within our MotherView we can read the currentPage value of the ViewRouter. So let’s show the appropriate Content View depending on the variable’s value!

var body: some View {
        VStack {
            if viewRouter.currentPage == "page1" {
                ContentViewA()
            } else if viewRouter.currentPage == "page2" {
                ContentViewB()
            }
        }
    }

At this point, you can delete the page State of the MotherView, since we won’t need it anymore.

Let’s take a look at the simulator: The MotherView reads the value of the ViewRouter’s currentPage variable and displays the appropriate view. You can proof this by changing the default value of the ViewRouter’s currentPage property to “page2”. Switch back to the MotherView preview simulator and see what happens! The didSet block got executed and the ObservableObject told the MotherView to update its view. (After doing this change it back to page1, since we want ContentViewA to be the default view).

Great, we accomplished a lot so far! We initialised a ViewRouter instance and bound it to the MotherView. Every time the currentPage value of the ViewRouter instance gets updated the MotherView will rerender (since all observing views of a ObservableObject get automatically updated when a change occurs) with showing the right view!

Bind the Content Views to the ViewRouter ⛓

Our MotherView is now able to show the correct Content View depending on the currentPage value of the ViewRouter. But until now, the user is not able to change this value by clicking on the button of the ContentViewA and the ContentViewB, respectively.

Let’s start with our ContentViewA. To enable it to access the currentPage data and manipulate it we have to bind it to the ViewRouter. So let’s create an @ObservedObject property for this.

struct ContentViewA : View {
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
        //...
    }
}
struct ContentViewA_Previews : PreviewProvider {
    static var previews: some View {
        ContentViewA(viewRouter: ViewRouter())
    }
}

The ContentViewA should observe ViewRouter instance we created inside the MotherView. So let’s assign this instance to this ObservedObject property inside the MotherView.swift file

struct MotherView : View {
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
        VStack {
            if viewRouter.currentPage == "page1" {
                ContentViewA(viewRouter: viewRouter)
            } else if viewRouter.currentPage == "page2" {
                ContentViewB()
            }
        }
    }
}

Great, we now have access to the instance’s currentPage variable. Let’s change this variable’s data to “page2” inside the Button’s action.

import SwiftUI

struct ContentViewA : View {
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
        VStack {
            GrumpyDog()
            Button(action: {self.viewRouter.currentPage = "page2"}) {
                NextButtonContent()
            }
        }
    }
}

Okay, let’s try out and see if that works: Run the app in the simulator and click on the “Next” button. Great, we navigate to the ContentViewB!

Here is what happens now when the user taps the Button of the ContentViewA. The ContentViewA changes the currentPage property of the ViewRouter instance to “page2”. Therefore the ViewRouter tells all bound views to rerender, including the MotherView. The MotherView rerenders and checks the currentPage data. Since it’s “page2” the condition for showing the 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 @ObservedObject property as an ViewRouter instance.

struct ContentViewB : View {
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
       //...
    }
}
struct ContentViewB_Previews : PreviewProvider {
    static var previews: some View {
        ContentViewB(viewRouter: ViewRouter())
    }
}

Assign this binding to the initial ViewRouter instance inside the ViewRouter.swift file.

struct MotherView : View {
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
        VStack {
            if viewRouter.currentPage == "page1" {
                ContentViewA(viewRouter: viewRouter)
            } else if viewRouter.currentPage == "page2" {
                ContentViewB(viewRouter: viewRouter)
            }
        }
    }
}

And update the Button’s action parameter for showing the first page again:

struct ContentViewB : View {
    
    @ObservedObject var viewRouter: ViewRouter
    
    var body: some View {
        VStack {
            HappyDog()
            Button(action: {self.viewRouter.currentPage = "page1"}) {
                BackButtonContent()
            }
        }
    }
}

We can now navigate forward and backward between our ContentView’s! Try it out in the simulator!


You can download the whole source code here!

Conclusion 🎊

Great, we just learned how to navigate between different views using a ObservableObject. But there exists an alternative, a bit more efficient way, to do this: Using an @EnvironmentObject. EnvironmentObjects provide us with more freedom and independence between 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!

8 replies on “How to navigate between views in SwiftUI by using an @ObservableObject”

I downloaded the application from your github and ran the starter application. The next button does not navigate to the next screen. Coming across the same problem in my own application. Any idea why that’s happening?

Hey there! We just checked the repository and everything seems to work fine. Did you make sure you opened the project in the “NavigateInSwiftUIComplete” folder? Also note that navigating only works when running the app in the regular simulator and not in the SwiftUI preview

That’s not even near to navigating between views, this is actually swapping between the views. For navigation, you need to use NavigationView, which stacks up the view in a hierarchy. I think you should rename this article.

Hello user832, thanks for your comment. In the first paragraph we make clear that the tutorial is not about navigating within a stack hierarchy using a NavigationView.

Thank you, that is a very good example without navigation. If I don’t use @ObservedObject as a router, I only use button to open another view(there are no data to be transferred), but there is an error ,like Result of ‘anotherView’ initializer is unused, how to initialize anotherView?

code likes: Button(action: {self.viewRouter.currentPage = “page1”}) {BackButtonContent()}

Leave a Reply

Your email address will not be published. Required fields are marked *

small_c_popup.png

Covid-19 Forces you into quarantine?

Start Mastering swiftUI Today save your 33% discount

small_c_popup.png

Are you ready for a new era of iOS development?

Start learning swiftUI today - download our free e-book