The Correct Way to Dismiss Screen in SwiftUI
In today's blog post, I am going to talk about the most commonly used API to dismiss presented or pushed view controllers on the screen in SwiftUI.
However, when I say "talk", I am going to write about how that approach may not be suitable everywhere, what's the Gotcha behind it, and what's the alternative and reliable way to dismiss the presented or pushed view in your app.
Let's build a simple app with a typical navigation flow having the following components,
- A landing screen (
LandingScreen
) - This screen will be a container in the
NavigationView
and will have a button to navigate to the next screen. Let's call this next screen aHomeScreen
- The landing screen will have one more button to pop the navigation stack and navigate back to the landing screen
HomeScreen
will have a button called "Present", which will present another screen calledPromotionsScreen
.- A
PromotionsScreen
will have one more button to dismiss the presented screen and go back to theHomeScreen
Let's build an app and see what it looks like. For now, I am not going to attach actions to any of the buttons. Let's finish it in the next steps.
// LandingScreen.swift
import SwiftUI
struct LandingScreen: View {
var body: some View {
NavigationView {
NavigationLink(destination: HomeScreen()) {
VStack(spacing: 16) {
Button("Exit") {
}
Text("Go to Home Screen")
}
.navigationTitle("Landing Screen")
}
}
}
}
// HomeScreen.swift
import SwiftUI
struct HomeScreen: View {
@State private var sheetPresented = false
var body: some View {
VStack(spacing: 16) {
Button("Close") {
}
Button("Go to Promotions Screen") {
sheetPresented.toggle()
}.sheet(isPresented: $sheetPresented) {
PromotionsScreen()
}
}.navigationTitle("Home Screen")
}
}
// PromotionsScreen.swift
import SwiftUI
struct PromotionsScreen: View {
var body: some View {
VStack(spacing: 16) {
Button("Dismiss") {
}
Text("Welcome to Promotions Screen")
}
}
}
Adding Dismiss Actions for Presented Screens
Now that our view layout is ready, let's add some code to dismiss the presented screen so that the user can navigate back to the previous screen.
The easiest way to do it is to use an environment variable called presentationMode
.(Which has since been deprecated in iOS 15). From iOS 15 onwards, you can either use isPresented
or dismiss
environment variables to dismiss the presented views. Let's add that code to the above navigation structure and see what happens.
Problem
Now that our concepts around the current dismissal flow are out of the way, let's talk about the problem. We're using environment variables - Either dismiss
or presentationMode
mode across all the screens to dismiss the presented view. Every time we dismiss any screen in the app using these APIs, other screens using the same environment variables are going to recompute their body
. Meaning any time any view in your app dismisses the view, every other view using these environment variables is going to re-execute the code inside their body.
This is sub-optimal and can have adverse effects on the performance of your app as well as memory and power consumption. Not so good when the app is doing it for a long time and they see the phone's resource usage spiking while your app is running.
Another visible issue is, if you're using any of these environment variables on the top level, it's going to re-compute its body which involves recomputing bodies of all of its child views. If any child views are making an expensive network request, the reloading will also force it to fire it again every time the presented view somewhere else in the app is dismissed.
In our example, suppose the home page performs an initial loading operation to download home page data the first time it is loaded. Users can tap the "Go to Promotions Screen" button on the home page to navigate to the promotions page and as soon as they dismiss this view, the home page initiates loading again.
This is because we're using environment variables - Either presentationMode
or dismiss
in the root controller LandingScreen
. So as soon as the view is dismissed, the state of these environment variables changes and forces LandingScreen
and all its subcomponents to re-create its state which forces HomeScreen
to restart loading.
The same is true when you present a new screen inside a view using any of these environment variables. This is not a bug per se, but a side-effect associated with using a global state.
presentationMode
or dismiss
in your app, you have to be cognizant of possible implications when the state of these global variables changes anywhere within the app and how it's affecting your appLet's see how view states change as we dismiss a couple of presented screens - HomeScreen
and PromotionsScreen
We will use a static variable on each screen to indicate how many times the view is loaded while using these global variables. Then we will fix the code to prevent the use of these globals and then compare these loading counts to see if it has any impact on loading counts. Since view updates recreate the full view, we need to maintain this state somewhere agnostic of recreating and redrawing the view any number of times.
We will use the new singleton class LoadCounterViewModel
for this purpose which will store load count on a per-screen basis.
import Foundation
enum Screen: String, CaseIterable {
case landingScreen
case homeScreen
case promotionsScreen
}
class LoadCounterViewModel {
static let loadCounterViewModel = LoadCounterViewModel()
func increaseAndGetCount(for screen: Screen) -> Int {
var count = UserDefaults.standard.integer(forKey: screen.rawValue)
if count != 0 {
count += 1
UserDefaults.standard.set(count, forKey: screen.rawValue)
return count
} else {
UserDefaults.standard.set(1, forKey: screen.rawValue)
return 1
}
}
func resetLoadCount() {
for screen in Screen.allCases {
UserDefaults.standard.set(0, forKey: screen.rawValue)
}
}
private init() {
resetLoadCount()
}
}
We have defined an enum that captures all 3 screens and every time view's body is recomputed, we increase the load count by 1 and display it on the view.
var body: some View {
VStack(spacing: 16) {
Button("Dismiss") {
promotionsScreenPresented.toggle()
// presentationMode.wrappedValue.dismiss()
// If you're running iOS 15+, you can also use following line
// dismiss()
}
Text("Welcome to Promotions Screen")
let loadCount = LoadCounterViewModel.loadCounterViewModel.increaseAndGetCount(for: .promotionsScreen)
Text("Load count \(loadCount)")
}
}
Measuring the Load count for screens
Now the measuring is in place, let's run the app with this metric and count how many times each screen loads due to the usage of global variables.
Wow! That's too much loading counter. As you can see, in the span of 20 seconds we ran the app, Landing screen loaded 10 times, the Home screen loaded 32 times and the Promotions screen loaded 13 times. This happened despite no explicit state change.
Things look even more serious if you're making network requests from any of these screens, and they're fired multiple times putting strain on the network and related resources which includes power usage.
How to Fix Excessive Reloading in SwiftUI
The easiest answer is, don't use any of these global environment variables that keep updating on the fly forcing the full view to reload. But you may ask, how else am I going to dismiss my view?
The alternate way to dismiss the screen is to use State/Binding
variables. You present the screen on the current view using a State
variable. Pass that state variable as a Binding
variable to the next screen and attach the action that will toggle this binding variable. Since the state variable on the previous screen is connected with the variable on this next screen through binding interaction, as soon as we toggle the value of a bound variable, it will also toggle the state variable value on the previous screen and dismiss the view.
With this concept, let's clean up our app of global states.
Cleaning Out the Global States
Since the landing screen is the first screen in the app, it doesn't need presentationMode
or dismiss
to dismiss the screen and we will also get rid of Exit
button since there is no way to go back. If there is a UIKit
entry point, you can provide a dismiss
closure which will get executed to dismiss the presented SwiftUI
view.
We need a state variable on LandingScreen
for to maintain the existence of the screen presented by NavigationLink
. This state variable, in turn, is passed to the Home screen which will toggle its value when the user taps on the "Close" button.
Similarly, for our HomeScreen
setup, we can get rid of both global environment variables. If we need to pop back to LandingScreen
, we will toggle the binding variable passed by the last step. To present the PromotionsScreen
, we will keep using the sheetPresented
state variable but we will also pass it over to the next screen PromotionsScreen
which will toggle it when the user taps on the "Dismiss" button. The presence of this binding variable will also get rid of presentationMode
and dismiss
variables from this component.
With these changes in mind, let's take a look at how we refactored our code. The deleted part is marked in a red rectangle with X
mark and a newly added code are annotated with a green rectangle for a better reference.
LandingScreen.swift
HomeScreen.swift
PromotionsScreen.swift
Running the Load Counter after refactoring
Now that our refactor is done and the code is free from any global state, let's re-run the app and count how many times these screens are loaded as the user interacts with the app.
As you can see load counter for each screen only increments when there is a state change. It reloads the body of the component the first time it appears on the screen and every time its state variable(s) change(s) - Either directly through the view or from another component via binding interaction.
Summary
To summarize this post, I am highlighting some major takeaways when it comes to state management for handling screen dismissals in SwiftUI,
- Sparingly use the global environment variables which are responsible for the global state change
- Any changes to global environment variables also result in re-computing the body of views when we use them to dismiss or present other views. Thus, care must be taken to detect and avoid such unnecessary reloads
- To handle the simple case of navigation, it's ok to use environment variables such as
presentationMode
anddismiss
, but when you have long navigation link, alternate approaches must be explored - In order to present/push or dismiss/pop view controllers on the screen,
SwiftUI
provides an easy mechanism viaState/Binding
mechanism. State variables control whether to show the screen or not. This value is passed down to the next screen in the form of abinding
variable which toggles its value to dismiss or pop the current screen out - Since state/binding variables are explicitly connected to components, they do not affect states of other screens which aren't using them preventing unnecessary re-computation of
view body