Reputation: 555
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.
Upvotes: 8
Views: 9263
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
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 : NavigationLink
s) 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
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