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

Share on facebook
Share on twitter
Share on pinterest

Hello and welcome to this tutorial! In this article series, 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 the last part, we learned how to do this by using an @ObservableObject. 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:

Where we are 👓📚

So we just figured out how to navigate between different views using a ObservableObject. In short, we created a ViewRouter and bound our Mother View and the Content Views to it. We then manipulate the ViewRouter’s currentPage property when clicking on the Content Views buttons. After this, the MotherView gets updated, showing the correct Content View!

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 “NavigateInSwiftUIComplete” folder):

GitHub

Why using a ObservableObject is not the best solution⚠

You are probably asking yourself: Why should we do it any other way when our current solution works fine? Well, it should get clear when looking on our app’s hierarchy logic. Our MotherView is the root view which initialises the ViewRouter instance. In the MotherView we also initialise the ContentViewA and ContentViewB with passing the ViewRouter instance as the BindableObject to them.

You see, that we must follow a strict hierarchy which passes the initialised ObservableObject downwards to all subviews. This is currently not a big deal, but imagine a more complex app with a lot of views. We must always be aware to pass the initialised Observable of the root view down to all subviews and to all subviews of the subviews etc., which eventually can get pretty messy.

In one sentence: Using a pure ObservableObject can get problematic when it comes to 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 can be directly bound to this instance, or better said, are observing this instance, with no regard to the app’s hierarchy. The ViewRouter instance would then be like a cloud that flies above our app’s code where all Views have automatically access to, without taking care of a proper initialisation chain downwards the view’s hierarchy.

Doing this is the perfect job for an EnvironmentObject!

What is an EnvironmentObject? 🧐

An EnvironmentObject is a data model which, once initialised, can share data to all view’s of your app. The cool thing is, that an EnvironmentObject is created by supplying a ObservableObject, thus we can use our ViewRouter for creating an EnvironmentObject!

So, once we declared 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 a 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 MotherView as the root view will look into the ViewRouter‘s currentPage property we need to initialise the EnvironmentObject at the app’s launch. We can then automatically change the EnvironmentObject’s currentPage variable from the ContentView’s which then triggers the MotherView to rerender.

Implement the ViewRouter as an EnvironmentObject 🤓

So let’s update our app’s code!

First, change the viewRouter property wrapper inside the MotherView from an @ObservableObject to an @EnvironmentObject.

import SwiftUI

struct MotherView : View {
@EnvironmentObject var viewRouter: ViewRouter
var body: some View {
//...
    }
}

The viewRouter property now looks for a ViewRouter-EnvironmentObject. Thus, we need to provide our MotherView_Previews struct with an according instance:

#if DEBUG
struct MotherView_Previews : PreviewProvider {
    static var previews: some View {
        MotherView().environmentObject(ViewRouter())
    }
}
#endif

As said when launching our app, it must immediately be provided with a ViewRouter instance as the EnvironmentObject, because the MotherView as the root view now refers to such an EnvironmentObject. Therefore, update the scene function inside the SceneDelegage.swift file 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().environmentObject(ViewRouter()))
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Great, SwiftUI now creates a ViewRouter instance as an EnvironmentObject when the app launches, to which all views of our app can now be bound.

Next, let’s update our ContentViewA. Change the viewRouter property of it to an EnvironmentObject as well and also update the ContentViewA_Previews struct.

import SwiftUI

struct ContentViewA : View {
    
    @EnvironmentObject var viewRouter: ViewRouter
    
    var body: some View {
       //...
    }
}
#if DEBUG
struct ContentViewA_Previews : PreviewProvider {
    static var previews: some View {
        ContentViewA().environmentObject(ViewRouter())
    }
}
#endif

Hint: Again, the ContentViewsA_Previews struct has an own instance of the ViewRouter, but the ContentViewA is bound to the instance created at the app’s launch!

Let’s repeat this for the ContentViewB:

import SwiftUI

struct ContentViewB : View {
    
    @EnvironmentObject var viewRouter: ViewRouter
    
    var body: some View {
        //...
    }
}
#if DEBUG
struct ContentViewB_Previews : PreviewProvider {
    static var previews: some View {
        ContentViewB().environmentObject(ViewRouter())
    }
}
#endif

Since our ContentView’s viewRouter properties are now directly bound to/observing 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 {
        VStack {
            if viewRouter.currentPage == "page1" {
                ContentViewA()
            } else if viewRouter.currentPage == "page2" {
                ContentViewB()
            }
        }
    }
}

And that’s the cool thing: We no more need to initialize the ViewRouter inside our MotherView and pass this instance downwards to ContentView’s which 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!

Add a transition animation 🚀

As a bonus, let’s look on how to add a transition animation when going from “page1” to “page2”.

Doing this in SwiftUI, is pretty straight forward.

Take a look at the willChange method we call within the ViewRouter.swift file when the currentPage get’s updated. As you learned, this triggers the bound MotherView to rerender its body, eventually showing another ContentView, which means navigating to another ContentView. We can simply add an animation functionally to this by wrapping the willChange method into a withAnimation function:

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

Now we can add a transition animation when showing another Content View.

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

Apple

We want to provide our app with 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 types or create even a custom one (but that’s a topic for another article). For adding a “pop up” transition we choose the .scale transition type.

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

To see if that’s works, run your app in the normal simulator:


Cool, 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 why it’s better to use an EnvironmentObject for navigating between views in SwiftUI and how to accomplish this. We also learned how to add a transition animation to the navigation. If you want to see more, make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!

17 replies on “How to navigate between views in SwiftUI by using an @EnvironmentObject”

I have tried both your examples, ObservedObject & EnvironmentObject. Both operate correctly from Grumpy to Happy. Both fail to go back showing instead a blank white screen. If I download you project file from github it works correctly in both directions. From researching I learn there is a bug in NavigationLink in iOS 13.3. Is this my issue, I would welcome any comment.

Hey Richard,

please send us your code so we can look through it & help you! Just upload your Project to GitHub and send us the link.

I needed to go from a login screen to a master-detail screen, and this was the best way that I found to do it, thanks.

Why “mother” view? Its pretty much standard to call that type of thing parent view. Should probably try sticking to standard terminology as to not confuse beginners – a big part of learning is knowing which keywords to google.

I’ve been through several tutorials on EnvironmentObject etc. and this one finally made me understand how to get my SwiftUI code to work with it. Many thanks!

Hello,

Stay healthy, stay home…

Thank you for this clear and concise tutorial.
Loved it !
I followed along using the [kinda]new @Published declaration of my variable in the ViewRouter class so as to simplify it to the minimalist :

class ViewRouter: ObservableObject
{ @Published var currentPage = “page1”}

Works like a charm until I try to animate the views !

Any idea how to implement the animation without reverting to the heavier code :

var currentPage: String = “page1” {
didSet {
withAnimation() {
willChange.send(self)
}
}
}

Best regards

Hey Serge, thanks for your comment. That’s the same problem I dealt with. This I why I decided to stick with the current approach ..

I researched for 3 hours on how to make this work, multiple articles. this one solved everything I needed! Amazing work and thanks! this is really good! the explanation fo what is happening is great

So, to keep with conventions, I have used buttons in the Navigation Bar to handle the “go back” without doing anything movement in the navigation hierarchy. However, I can’t make the last button disappear in the MotherView. Any thoughts?

For anyone who’s having issues with the animations, I found out sometimes they don’t work in the live preview; only when running in the simulator.

Great tutorial! Save me tons of time!

I did just run into one edit change though above when you say:

So let’s update our app’s code!

First, change the viewRouter property wrapper inside the MotherView from an @ObservableObject to an @EnvironmentObject.

import SwiftUI

struct MotherView : View {
@EnvironmentObject var viewRouter: ViewRouter
var body: some View {
//…
}
}

—-
In the MotherView, the previous reference was to an @ObservedObject and not a @ObservableObject.

My understanding is that when BindableObject became ObservableObject, the PassthroughSubject went from didChange to willChange, which should now be called in the willSet handler. Alas, I have code that seems only to work right when I use a didSet to call willChange, as you did. What is the sequence of state-updating events?

Thank you for this excellent tutorial. This scratched an itch and now I’m much happier with my implementation. I also, as others mentioned, had to restore animation since the @Published API changed. Here’s what I like for restoring animation.

public class Router: ObservableObject {
@Published var activeView = “first”
}

extension AnyTransition {
static var inOutLeading: AnyTransition {
AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
}

static var inOutTrailing: AnyTransition {
AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}
}

struct RouterView: View {
@EnvironmentObject private var router: Router
@State var activeView = “”

var body: some View {
VStack {
if activeView == “first” {
Text(“first”)
.transition(.inOutLeading)
} else if activeView == “second” {
Text(“second”)
.transition(.inOutTrailing)
}
}
.animation(.easeIn(duration: 0.7))
.onReceive(router.$activeView, perform: { activeView in
withAnimation {
self.activeView = activeView
}
})
}
}

Then add a view that sets Router.activeView as needed.

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