top of page

Router Pattern for SwiftUI Navigation: Sheets and Full Screen Covers

In our previous introductory post, we explored the challenges of coupling navigation logic with views in SwiftUI and introduced the Router pattern as a solution. Now it’s time to dive in deeper and see how we can extend our Router so that it can present screens using a sheet or full screen cover, rather than only relying on a navigation stack.

In order to get a fundamental understanding of the Router pattern, I recommend you first read this introductory post before moving forward. It goes over the basic idea of a Router.

You can view my open sourced implementation of the Routing pattern that I cover in this blog series below!

Updating our RouterView to present Sheets and Full Screen Covers

Previously, for simplicity we only concentrated on the Router's management of a NavigationStack for push navigation. As a result our RouterView only included APIs for navigation stacks.

Now, since we are expanding our Router to handle diverse navigation methods like sheets and full screen covers, we need to add the corresponding navigation APIs to our RouterView. Below is our updated code:

  1. We have added the .sheet modifier to support presenting views in a sheet. It contains a binding to a new property in our Router called presentingSheet. We will learn more about this property in the following section.

  2. We have added the .fullScreenCover modifier to support presenting views using a full screen cover. It too has a binding to a new property called presentingFullScreenCover. More on this property in the following section.

Just like that, with these minor additions in place, our RouterView can now support two other types of navigation in addition to a NavigationStack. We are halfway to our goal of supporting sheets and full screen covers. Now we just need to update our Router object.

Updating our Router to support Sheet and Full Screen Cover presentation

The majority of the updates we need in order to support the additional types of navigation are located in our Router object. It needs the following additions:

  1. A new Enum representing the navigation types it supports.

  2. Observable properties used to trigger each navigation type.

  3. Methods to expose the new functionality to our views.

We define a simple Enum called NavigationType to represent the different navigation types supported by our Router.

Second, we add three new @Published properties used for managing our navigation state. In total our Router now has four properties.

Remember that path, presentingSheet, and presentingFullScreenCover are all used to trigger the corresponding navigation apis in our RouterView.

isPresented is used by presented Router instances to dismiss themselves. It simply holds a binding to either presentingSheet or presentingFullScreenCover. We will see exactly how this works in the next section.

Next, we add two new methods that our views can use to present other views via sheet or full screen cover:

Dismissing presented screens using a Router

One critical piece we are still missing is the ability to dismiss presented screens using our Router instance. Both the presenting screen and the presented screen should be able to trigger a dismiss. What we need is a Binding to the appropriate property for toggling the presentation of screens using sheet/fullScreenCover.

In the illustration below, we can see that View A presents View B using a sheet. When this occurs, both View A and View B contain a Router instance, each with a reference to presentingSheet. This way either Router can trigger a dismissal.

SwiftUI Binding in the Router Pattern

To achieve this we need to change how our views get a Router instance. Previously, when we only used push navigation via a NavigationStack, we simply placed our Router instance in the view hierarchy’s environment so that child views could access it by using @EnvironmentObject.

This strategy no longer works, instead, we need to inject screens with an instance of the Router object when they are presented. Below is a new method that returns an appropriate Router instance depending on navigation type.

Remember the isPresented property? This is where it is set. When a screen presents another screen using either the presentSheet() or presentFullScreen() methods, in order for the screen to be dismissible programmatically via the Router, it must contain a Binding to the corresponding property in the presenting Router. In this way, both the presenting view and presented view can use the dismiss() method while still keeping all navigation logic in the Router.

Lastly, here is the new dismiss method that can be used to dismiss screens. Notice that it can handle dismissal for any navigation type.

Putting it all together

With all our updates in place, it is time to see how it is all tied together to allow views to navigate with more than just a navigation stack. The first thing we do is wrap our root view in our RouterView:

With that done, now our view code can easily navigate to different screens in several ways:

Here's a demonstration of the code above in action:

To dismiss themselves the presented screens can call the dismiss() method on their Router instance. Here is an example from ViewB :


In this article we learned how we could build upon our initial simple router to support more than just push navigation. I personally learned a ton by trying to figure out how to extend the initial Router object to support sheets and full screen covers, which are two other important navigation APIs in SwiftUI. However, there is still more work to do.

Some of my readers pointed out that in a modularized approach, there is a circular dependency because the Router builds views (so depends on views), and at the same time the views depend on the Router. So in light of this great feedback, in the following articles we will explore how to resolve this and improve our Router so that it works with modularized code. Subscribe to our newsletter below in order to be amongst the first to know when our next article is available.

bottom of page