Search code examples
iosswiftuiswiftui-navigationlinkswiftui-navigationview

SwiftUI's NavigationLink `tag` and `selection` in NavigationView stops working if not all NavigationLinks are displayed


I have a list of items in a Form in a NavigationView, each having a detail-view that can be reached with NavigationLink. When I add a new element to the list, I want to show its detail-view. For that I use a @State var currentSelection that the NavigationLink receives as selection, and each element has functions as the tag:

NavigationLink(
  destination: DetailView(entry: entry),
  tag: entry,
  selection: $currentSelection,
  label: { Text("The number \(entry)") })

This works, and it follows the Apple docs and the best practises.

The surprise is, that it stops working when the list has more elements than fit on screen (plus ~2). Question: Why? And how can I work around it?


I made a minimal example to replicate the behaviour:

import SwiftUI

struct ContentView: View {
    @State var entries = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
    @State var currentSelection: Int? = nil

    var body: some View {
        NavigationView {
            Form {
                ForEach(entries.sorted(), id: \.self) { entry in
                    NavigationLink(
                        destination: DetailView(entry: entry),
                        tag: entry,
                        selection: $currentSelection,
                        label: { Text("The number \(entry)") })
                }
            }
            .toolbar {
                ToolbarItem(placement: ToolbarItemPlacement.navigationBarLeading) { Button("Add low") {
                    let newEntry = (entries.min() ?? 1) - 1
                    entries.insert(newEntry, at: 1)
                    currentSelection = newEntry
                } }
                ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) { Button("Add high") {
                    let newEntry = (entries.max() ?? 50) + 1
                    entries.append(newEntry)
                    currentSelection = newEntry
                } }
                ToolbarItem(placement: ToolbarItemPlacement.bottomBar) {
                    Text("The current selection is \(String(describing: currentSelection))")
                }
            }
        }
    }
}

struct DetailView: View {
    let entry: Int
    var body: some View {
        Text("It's a \(entry)!")
    }
}

(I ruled out that the number of elements is the core problem by reducing the list to 5 items and setting a padding on the label: label: { Text("The number \(entry).padding(30)") }))

As you can see in the screen-recordings, after reaching the critical number of elements (either by prepending or appending to the list), the bottom sheet still shows that the currentSelection is being updated, but no navigation is happening.

I used iOS 14.7.1, Xcode 12.5.1 and Swift 5.

adding to the start of the list adding to the end of the list


Solution

  • This happens because lower items are not rendered, so in the hierarchy there's no NavigationLink with such tag

    I suggest you using an ZStack + EmptyView NavigationLink "hack".

    Also I'm using LazyView here, thanks to @autoclosure it lets me pass upwrapped currentSelection: this will only be called when NavigationLink is active, and this is happens when currentSelection != nil

    struct ContentView: View {
        @State var entries = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
        @State var currentSelection: Int? = nil
    
        var body: some View {
            NavigationView {
                ZStack {
                    EmptyNavigationLink(
                        destination: { DetailView(entry: $0) },
                        selection: $currentSelection
                    )
                    Form {
                        ForEach(entries.sorted(), id: \.self) { entry in
                            NavigationLink(
                                destination: DetailView(entry: entry),
                                label: { Text("The number \(entry)") })
                        }
                    }
                    .toolbar {
                        ToolbarItem(placement: ToolbarItemPlacement.navigationBarLeading) { Button("Add low") {
                            let newEntry = (entries.min() ?? 1) - 1
                            entries.insert(newEntry, at: 1)
                            currentSelection = newEntry
                        } }
                        ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) { Button("Add high") {
                            let newEntry = (entries.max() ?? 50) + 1
                            entries.append(newEntry)
                            currentSelection = newEntry
                        } }
                        ToolbarItem(placement: ToolbarItemPlacement.bottomBar) {
                            Text("The current selection is \(String(describing: currentSelection))")
                        }
                    }
                }
            }
        }
    }
    
    struct DetailView: View {
        let entry: Int
        var body: some View {
            Text("It's a \(entry)!")
        }
    }
    
    public struct LazyView<Content: View>: View {
        private let build: () -> Content
        public init(_ build: @autoclosure @escaping () -> Content) {
            self.build = build
        }
        public var body: Content {
            build()
        }
    }
    
    struct EmptyNavigationLink<Destination: View>: View {
        let lazyDestination: LazyView<Destination>
        let isActive: Binding<Bool>
        
        init<T>(
            @ViewBuilder destination: @escaping (T) -> Destination,
            selection: Binding<T?>
        )  {
            lazyDestination = LazyView(destination(selection.wrappedValue!))
            isActive = .init(
                get: { selection.wrappedValue != nil },
                set: { isActive in
                    if !isActive {
                        selection.wrappedValue = nil
                    }
                }
            )
        }
        
        var body: some View {
            NavigationLink(
                destination: lazyDestination,
                isActive: isActive,
                label: { EmptyView() }
            )
        }
    }
    

    Check out more about LazyView, it helps often with NavigationLink: in real apps destination may be a huge screen, and when you have a NavigationLink in each cell SwiftUI will process all of them which may lead to lags