SwiftUI can be a bit... eager

When writing an application using AppKit or UIKit you feel can confident as to when views are created and released. Your code takes explicit actions for that to happen.

The full code for all three versions of the app are available on GitHub.

SwiftUI is a big more nebulous. Let's look at a typical example. Below is a simple SwiftUI application that shows a partial list of SFSymbols and allows you to tap on one to see a larger version.

If you're familiar with AppKit and UIKit, you'd expect that the detail view would be created when you tap on a row in the symbol list.

But when dealing with SwiftUI, that's not the case. In fact, there are some cases where every view that a NavigationLink references is created even before the row is tapped on.

We're going to examine three different means of creating this same interface, and see how the small changes we make affect the execution. You may be surprised by some of the results.

The Underlying Models

First, lets over over the underlying models as they are identical amongst the implementations demonstrated here.

The model object is the SFSymbol.swift class, shown below.

import Foundation
import SwiftUI

struct SFSymbol: Identifiable, Hashable {
    var id = UUID()
    var symbolName: String

    init(symbolName: String) {
        self.symbolName = symbolName
    }

    var image: Image {
        return Image(systemName: symbolName)
    }
}

SFSymbol.swift model object

This provides the model content to the HostView.swift file as well as the SymbolVideo.swift.

The HostViewModel.swift is also quite straightforward. It iterates over an array of SFSymbol names and generates SFSymbol models for each.

import Foundation

@Observable
class HomeViewModel: Identifiable {
    var id = UUID()
    var symbolsArray: [SFSymbol] = []


    init() {
        for symbol in sfSymbols {
            self.symbolsArray.append(SFSymbol(symbolName: symbol))
        }
        
    }

    private let sfSymbols = [
        "house", "bell", "bookmark",
        "calendar", "camera", "cart",
        "clock", "cloud", "folder", "gear",
        "globe", "heart", "lightbulb", "link",
        "lock", "mail", "map", "megaphone",
        "music.note", "paperclip", "person",
        "phone", "photo", "pin", "printer", "scissors",
        "star", "trash", "umbrella", "video",
    ]
}

HostViewModel.swift

Cases to Examine

There really three cases we'll examine:

  • using NavigationLink(_:destination) and the unexpected pitfalls,
  • using NavigationLink(_:destination) and a means of avoiding those pitfalls,
  • using NavigationLink(value:) which was added in iOS 16 that eliminates these issues entirely.

Case 1: NavigationLink(_:destination)

If you're using NavigationLink inside of a List and a ForEach, you just might be surprised at the behaviour.

The code for this version is available in the navigationlink-Destintation-onappear branch

There is the HomeView.swift file for case 1

import SwiftUI

struct HomeView: View {
    var viewModel: HomeViewModel

    init() {
        self.viewModel = HomeViewModel()
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.symbolsArray) { sfsymbol in
                    NavigationLink {
                        SymbolView(sfsymbol: sfsymbol)
                    } label: {
                        Label(sfsymbol.symbolName, 
                              systemImage: sfsymbol.symbolName)
                    }
                }
            }
            .navigationTitle("Symbols")
            .listStyle(.plain)
            .toolbarTitleDisplayMode(.inlineLarge)
            
        }
    }
}

HomeView.swift (main)

This is a very simple list that loops through ForEach of the symbols and creates a NavigationLink with a label and designation for each one.

However, if insert a print in the init statement of the SymbolView.swift file, you'll be surprised to see this:

0:00
/0:05

Before you even tap on a row, every one of the SymbolViews have already ready been initialized, twice! If you were doing anything computationally heavy in your initial method, it would drastically slow down the process.

So, how can we work around this?

Case 2: NavigationLink(_:destination) and the .onAppear Modifier.

The .onAppear modifier is called when a view is first displayed. This allows you to delay creation of your view modifier and any complex work it may need to do, till it is required.

The only change required to adopt .onAppear is in the SymbolView.swift file.

import Foundation
import SwiftUI

struct SymbolView: View {
    var id = UUID()
    var sfsymbol: SFSymbol

    init(sfsymbol: SFSymbol) {
        self.sfsymbol = sfsymbol
    }

    var body: some View {
        VStack(spacing: 30) {
            sfsymbol.image
                .resizable()
                .foregroundColor(Color.blue)
                .aspectRatio(contentMode: .fill)
                .frame(width: 100, height: 100)
            Text(sfsymbol.symbolName)
                .font(.largeTitle)
        }
        .onAppear {
            print(".onAppear \(sfsymbol.symbolName)")
        }

    }
}

SwiftView.swift with .onAppear

Adding .onAppear requires no change to the HomeView.swift class. And if you're unable to target iOS 16 as a minimum, it's a great solution to the problem. As the video shows, when you run the application the symbol name is only printed once per tap on the row.

0:00
/0:10

Case 3: Using NavigationLink(value:) iOS 16 only.

The code for this version is available in the NavigationLink-value branch

iOS 16 introduced the concept of a NavigationLink(value). It is used similar to the existing versions, however rather than specifying a destination you provide a value. You then add .navigationDestination modifier that determines which view is shown.

Below is the implementation of the HomeView.swift file using the new NavigationLink(value).

import SwiftUI

struct HomeView: View {
    let viewModel: HomeViewModel

    init() {
        self.viewModel = HomeViewModel()
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.symbolsArray) { sfsymbol in
                    NavigationLink(value: sfsymbol) {
                        Label(sfsymbol.symbolName, 
                              systemImage: sfsymbol.symbolName)
                    }
                }
            }
            .navigationDestination(for: SFSymbol.self) { sfsymbol in
                SymbolView(sfsymbol: sfsymbol)
            }
            .navigationTitle("Symbols")
            .listStyle(.plain)
            .toolbarTitleDisplayMode(.inlineLarge)
        }
    }
}

HomeView.swift using NavigationLink(value)

As you can see, the ForEach loops over the sfsymbolsArray, and passes each sfsymbol to the NavigationLink(value: sfsymbol) the label for the NavigationLink is passed as the trailing label closure Label(sfsymbol.symbolName, systemImage: sfsymbol.symbolName).

The actual navigation takes place in the .navigationDestination modifier. The .navigationDestination(for:) accepts a class type. For our needs we're using .navigationDestination(for: Symbols.self) and then the sfsymbol is passed to our SymbolView to display.

This allows you to have more than one type of class in a NavigationLink(value:) and then you would provide additional .navigationDestination(for:) modifiers for each of the class types.

But, what about .task

I've purposefully left out of discussion of the .task modifier because it's intended to perform an asynchronous task before a view appears. Fetching data from a JSON back end, or an image from the web, for example.

It's related to .onAppear, but only tangentially. They both are fired when the views are first shown, but have different uses.

I'd love to hear your comments. Feel free to reach out to me at @sanguish on Mastodon.

sanguish (@sanguish@iosdev.space)
6.27K Posts, 159 Following, 1K Followers · Searching for a macOS or iOS engineering position Ex-Apple DevPubs (12+yrs, remote). I’d go back. macOS/iOS engineer, & author. OCD-sufferer. Like any rational adult, I dislike mimes.