Jannik Arndt
Jannik Arndt

Reputation: 555

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

Upvotes: 8

Views: 9263

Answers (3)

Phil Dukhov
Phil Dukhov

Reputation: 87894

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

Upvotes: 9

Adrien
Adrien

Reputation: 1927

The problem comes from the programmatic navigation (launched by the buttons in the toolbar) Your NavigationLink needs to exist in order to be triggered.

But if a List is used (or a Form, or a LazyVStack), children view (here : NavigationLinks) that are not supposed to appear on the screen are not "created". Therefore when you try to fire the NavigationLink (modifying the currentSelection), this NavigationLink may not exist

If we replace the List / Form with a ScrollView the problem should disappear.

But the best approach is certainly to separate the two ways to navigate :

1- programatic navigation, triggered by the Buttons. For that we'll use the NavigationLink(_:destination:isActive:) initializer.

2- navigation via the List (tappable cells, aka NavigationLink(destination:label:)

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
    @State var linkIsActive = false
    var body: some View {
        NavigationView {
            Form {
                ForEach(entries.sorted(), id: \.self) { entry in
                    // 2- Clickable label
                    NavigationLink(
                        destination: DetailView(entry: entry),
                        label: { Text("The number \(entry)") })
                }
            }
            .background(
                // 1- Programmatic navigation
                NavigationLink("", destination: DetailView(entry: currentSelection ?? -99), isActive: $linkIsActive)
            )
            .onChange(of: currentSelection, perform: { value in
                if value != nil {
                    linkIsActive = true
                }
            })
            .onChange(of: linkIsActive, perform: { value in
                if !value {
                    currentSelection = nil
                }
            })
   //...
}

Upvotes: 4

CouchDeveloper
CouchDeveloper

Reputation: 19116

As it may be desired to see which row has been added to the form. I would like to show a solution which first scrolls to the newly inserted item, then shows the detail view.

It's not my favourite animation or flow, though.

The solution adds an additional ScrollViewReader which seems to work flawlessly with Forms, too. The view where we want to scroll-to, is the NavigationLink (the Label won't work, since it is not instantiated when not shown).

Caution

I occasionally encountered some glitches, which may be Playgrounds. Also, on Xcode 13 beta 4, there's warning issued in the console indicating that there is an issue with NavigationLink, and we should file a bug report. Well ...

My suspicion is that there might be an issue when performing the scroll animation and subsequently the push navigation. Needs more investigation.

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 {
            ScrollViewReader { proxy in
                Form {
                    ForEach(entries.sorted(), id: \.self) { entry in
                        NavigationLink(
                            destination: DetailView(entry: entry),
                            tag: entry,
                            selection: $currentSelection,
                            label: { Text("The number \(entry)") }
                        )
                        .id("-\(entry)-")
                    }
                }
                .onChange(of: currentSelection) { newValue in
                    withAnimation {
                        if let currentSelection = self.currentSelection {
                            proxy.scrollTo("-\(currentSelection)-")
                        }
                    }
                }
                .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)!")
    }
}

Upvotes: 0

Related Questions