The Correct Way to Dismiss Screen in SwiftUI

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,

  1. A landing screen (LandingScreen )
  2. 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 a HomeScreen
  3. The landing screen will have one more button to pop the navigation stack and navigate back to the landing screen
  4. HomeScreen will have a button called "Present", which will present another screen called PromotionsScreen.
  5. A PromotionsScreen will have one more button to dismiss the presented screen and go back to the HomeScreen

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")
        }
    }
}
0:00
/

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.

💡
The updated code is annotated with the red rectangle
LandingScreen.swift
HomeScreen.swift
PromotionsScreen.swift
0:00
/

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.

💡
Why?

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.

💡
In short, if you're using global environment variables like 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 app

Let'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.

0:00
/

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

LandingScreen.swift

HomeScreen.swift

HomeScreen.swift

PromotionsScreen.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.

0:00
/

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,

  1. Sparingly use the global environment variables which are responsible for the  global state change
  2. 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
  3. To handle the simple case of navigation, it's ok to use environment variables such as presentationMode and dismiss, but when you have long navigation link, alternate approaches must be explored
  4. In order to present/push or dismiss/pop view controllers on the screen, SwiftUI provides an easy mechanism via State/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 a binding variable which toggles its value to dismiss or pop the current screen out
  5. 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