SwiftUI Navigation in List View: Programmatic Navigation


In our previous article SwiftUI Navigation in List View: Exploring Available Options we discussed the off-the-shelf solution for implementing programmatic navigation in List view. We also outlined all the problems that it brings. While we hope that they will be fixed in the future updates to SwiftUI framework, we would like to share a solution that works with the current iOS version 13.4.1.


Let's say we have a list of items and an Add button. When the user taps the Add button, a new item is added to the list. Our goal is to keep regular user-driven navigation on the iPhone. But on the iPad, where we see the master and the detail view at the same time, we want to programmatically select the newly added item.


struct ContentView: View {
    @State private var items = [Item(name: "Item 1"), Item(name: "Item 2")]
    @State private var selectedItemId: UUID? = nil
    
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    Text(item.name)
                }
            }
            .navigationBarItems(trailing:
                Button(action: {
                    let newItem = Item(name: "Item \(self.items.count + 1)")
                    self.items.append(newItem)
                    
                    if self.horizontalSizeClass == .regular {
                        self.selectedItemId = newItem.id
                    }
                }) {
                    Text("Add")
                }
            )
            
            NoSelectionView()
        }
                    
    }
}


We will still use a NavigationLink. But instead of wrapping each row in a NavigationLink, we'll only have one link which will navigate to the correct DetailView depending on the selectedItemId.


First, we'll create this link in a computed property.


var navigationLink: NavigationLink<EmptyView, DetailView>? {
    guard let selectedItemId = selectedItemId,
        let selectedItem = items.first(where: {$0.id == selectedItemId}) else {
            return nil
    }
    
    return NavigationLink(
        destination: DetailView(item: selectedItem),
        tag:  selectedItemId,
        selection: $selectedItemId
    ) {
        EmptyView()
    }
}


Notice, that the label of our link is an EmptyView. This is because it's not going to be visible to the user. It will always be triggered programmatically when the selectedItemId changes.


Then, we are going to add our invisible link to the view by wrapping our List in a ZStack. We will also wrap our list row in a Button, so that when the user taps on the row, it triggers the navigation link.


ZStack {
    navigationLink
    List {
        ForEach(items) { item in
            Button(action: {
                self.selectedItemId = item.id
            }) {
                Text(item.name)
            }
        }
    }
}


Now we've implemented navigation that works well on the iPhone and in split view on the iPad. We have user-driven navigation: the user can navigate to DetailView by tapping on the row. And we have programmatic navigation: when a new item is added on a wide screen, it gets selected automatically.


We can improve the user experience on the iPad by giving the selected row a background color. It's easy to do, since a we are fully controlling the selection in code.


List {
    ForEach(items) { item in
        Button(action: {
            self.selectedItemId = item.id
        }) {
            Text(item.name)
        }
        .listRowBackground(
            self.selectedItemId == item.id ? Color.blue : Color(UIColor.systemBackground)
        )
    }
}


With this approach we don't have any of the errors that we described in SwiftUI Navigation in List View: Exploring Available Options. We can easily set a selected background color on the rows, we don't have any runtime warnings and even if the newly added item ends up off-screen, it will still get selected.


You can find the full code for this article on our GitHub.