SwiftUI Navigation in List View: Exploring Available Options


To implement native iOS navigation in SwiftUI we need to use NavigationLink. There are a few different options for initializing navigation links. In this article we will look into when we can use which option to navigate from List to a detail view and what problems they currently have. This article is relevant for iOS version 13.4.1.


User-Driven Navigation


The simplest way to create a NavigationLink is to pass it a destination and a label. This type of link is suitable when we expect the navigation to always be triggered by the user tapping the link.


struct UserDrivenNavigation: View {
    
    let items = [Item(name: "Item 1"), Item(name: "Item 2"), Item(name: "Item 3")]
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink(
                        destination: DetailView(item: item)
                    ) {
                        Text(item.name)
                    }
                }
            }
            .navigationBarTitle("Items")
        }
    }
}


The problem with this type of NavigationLink is that there is no way for us to control the navigation programmatically.



Programmatic Navigation


For example, we want to select a newly added item when we are in regular horizontal size class (some iPhones in landscape and iPads) and have the list of items on the left and the detail view on the right.


There are two ways to control the NavigationLink with a binding. One is to pass it a boolean binding isActive (see docs) and the other one is to give it a tag and a selection binding (see docs).

Because in our example we have a list of links and we want to control the selection, we will need to use the second option. Ideally, when the tag matches the selection, the navigation should be triggered and when the selection changes, the detail view should be dismissed.


struct ProgrammaticNavigation: View {
    @State private var items = [
        Item(name: "Item 1"),
        Item(name: "Item 2"),
        Item(name: "Item 3")
    ]
    
    @State private var selectedItemId: UUID?
    
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink(
                        destination: DetailView(item: item),
                        tag: item.id,
                        selection: self.$selectedItemId) {
                            
                        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")
                }
            )
            .navigationBarTitle("Items")
        }
    }
}


From the first glance, this option works well. We press the Add button on the iPad and the new item gets displayed in the detail view. But unfortunately, there are a few problems that we can observe.



Programmatic Navigation Issues


Selected Background


The programmatically selected item doesn't get the selected background color. There is no indication in the list that it's selected.


As a way around this problem, we can try to set a custom background on the selected item.


NavigationLink(
    destination: DetailView(item: item), tag: item.id, selection: self.$selectedItemId) {
    Text(item.name)
}
.listRowBackground(
    item.id == self.selectedItemId ? Color.blue : Color(UIColor.systemBackground)
)


But this won't work quite like we expect. Only the programmatically selected item will get our custom color. The items that the user selects by tapping on them, will still get the selected background that the NavigationLink sets. Looks like there is currently no way to override that color.


Moreover, programmatically selecting an item, doesn't unset the selected system color from the other items. So if the user selects Item 3 then taps the Add button, Item 3 will still have the system selected background and the newly added Item 4 will have our custom background.


Ipad Screenshot



Runtime Issues


If current selection is not nil and we programmatically select an item, then we start getting runtime issues: Modifying state during view update, this will cause undefined behavior, that are coming from the SwiftUI framework.


Ipad Screenshot



Selecting an Item Currently Off-Screen


When we have a long list of items and programmatically select an item that is currently off-screen, the detail view for that item won't appear, unless we scroll the item into visible range. There is no way of doing it in SwiftUI at the moment without reaching to the underlying UIScrollView.



If you want to get the code for this article and try it out yourself, you can get it from our GitHub.


Because of the issues with programmatic navigation, we are not using any of the described methods in our projects. After some experimentation, we found a workaround that is more reliable at the moment. You can read our article SwiftUI Navigation in List View: Programmatic Navigation that describes our solution.