Transforming the ViewRouter into an @EnvironmentObject

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.

6 replies on “Transforming the ViewRouter into an @EnvironmentObject”

This was an interesting project. Many steps involved. My main problem is where to place the { } and ( ). I get a little confused and then a lot of errors appear, until I realize it is a missing symbol.

Any hints on how to better understand how to place these correctly?
Thanks

It seems a concept of swift language. You’d better read the closure part of swift.
The Button has a constructor Button(action: {}, label: {}). Hence, you can use
Button(action: {}, label: {})
or
Button(action: {}) {}
It is convenient if you have a large body of codes for the closure.

I currently have a project, which uses a NavigationView for navigation. Since I need to be able to cancel my progress at any view and go back to the root view or even to a view in between, I find it very laborious to implement. Therefore I really like the idea of being able to freely go to any view from any view.
There is only one thing missing. Is there a way to add a navigationBar even when I am not using a NavigationView anymore?
Thanks and have a great day.

I think the best way to achieve this is by still embedding your views into a NavigationView and applying a navigationTitle or navigationBarTitle modifier to it. As long as you don’t implement any NavigationLink this shouldn’t affect the routing logic described in this chapter.

Leave a Reply

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