How can I have a NavigationLink with a programmatically defined destination? - swiftui

So I am working on a list view, where tapping an item on the list opens the detail view for that item.
I also want to have a button which adds an item to the list, and immediately opens the detail view.
Something like this:
struct Item: Identifiable {
let id: UUID
init() {
self.id = UUID()
}
}
struct DetailView: View {
let item: UUID
}
struct ContainerView: View {
#State var items: [Item] = []
var body: some View {
VStack {
List {
ForEach(items) { item in
NavigationLink(
"Item: \(item.id)",
destination: DetailView(item:item)
)
}
}
Button("New Item") {
let newItem = Item()
items += [newItem]
// now I want to go to DetailView(item:newItem)
// how do I set the navigation link target here?
}
}
}
}
How can I achieve this?
I see there is this method for programmatic navigation:
NavigationItem.init<S, V>(S, tag: V, selection: Binding<V?>, destination: () -> Destination)
But I think this will not work as the tag is not known ahead of time.

You almost got it, remember that a NavigationLink can only "navigate" inside NavigationView
import Foundation
import SwiftUI
struct Item: Identifiable {
let id: UUID
init() {
self.id = UUID()
}
}
struct DetailView: View {
let item: UUID
var body: some View {
Text("I'm the item \(item)")
}
}
struct ContainerView: View {
#State var items: [Item] = []
#State var activeItem: UUID?
var body: some View {
NavigationView{
VStack {
List {
ForEach(items) { item in
NavigationLink(
destination: DetailView(item: item.id),
tag: item.id,
selection: $activeItem
){
Text("Item: \(item.id)")
}
}
}
Button("New Item") {
let newItem = Item()
items += [newItem]
self.activeItem = newItem.id
// now I want to go to DetailView(item:newItem)
// how do I set the navigation link target here?
}
}
.navigationTitle("Main View")
}
}
}

Related

Default navigation link in Swiftui

I'm working on an app the uses traditional sidebar navigation with a detail view. I've synthesized the app to illustrate two issues.
when the app starts, the detail view is empty. How can I programmatically select an entry in the sidebar to show in the detail view?
The sidebar allows swipe to delete. If the selected row (the one showing in the detail view) is deleted, it still shows in the detail view. How can update the detail view with, for example, an empty view?
Here's the source code for the app illustrating the issues:
import SwiftUI
class Model: ObservableObject {
var items = [Item("")]
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
class Item: Identifiable, Hashable {
static func == (lhs: Item, rhs: Item) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
let id = UUID()
#Published var name: String
init(_ name: String) {
self.name = name
}
}
#main
struct IBTSimulatorApp: App {
#StateObject var model = Model.loadData
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach($model.items, id: \.self) { $item in
NavigationLink(item.name, destination: Text(item.name))
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
}
private func addItem() {
withAnimation {
model.items.append(Item("New item (\(model.items.count))"))
model.objectWillChange.send()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
model.items.remove(atOffsets: offsets)
model.objectWillChange.send()
}
}
}
For 1. you can use the NavigationLink version with tag and selection, and save the active selection in a persisted AppStoragevar.
For 2. I expected you can set the selection to nil, but that does not work for some reason. But you can set it to the first item in the sidebar list.
As a general note you should make Item a struct instead of a class. Only the published Model should be a class.
class Model: ObservableObject {
var items: [Item] = []
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
struct Item: Identifiable { // Change from class to struct!
let id = UUID()
var name: String
init(_ name: String) {
self.name = name
}
}
struct ContentView: View {
#StateObject var model = Model.loadData
#AppStorage("selectemItem") var selected: String? // bind to persisted var here
var body: some View {
NavigationView {
List {
ForEach(model.items) { item in //no .id needed as Item is identifiable
NavigationLink(tag: item.id.uuidString, selection: $selected) { // use link with selection here
Text(item.name)
} label: {
Text(item.name)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Nothing selected")
}
}
private func addItem() {
withAnimation {
model.objectWillChange.send()
model.items.append(Item("New item (\(model.items.count))"))
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
// model.objectWillChange.send() // not necessary if Item is struct
self.selected = nil // for some reaseon this does not work
self.selected = model.items.first?.id.uuidString // selects first item
model.items.remove(atOffsets: offsets)
}
}
}

What's the best way to achieve parameterized "on tap"/"on click" behavior for a list row?

So let's say I have a list component in SwiftUI:
struct MyListView: View {
var body: some View {
List(...) { rec in
Row(rec)
}
}
}
Now let's say I want to make this reusable, and I want the "caller" of this view to determine what happens when I tap on each row view. What would be the correct way to insert that behavior?
Here is some other Buttons in ListView example that you can run and play with it yourself
import SwiftUI
struct TestTableView: View {
#State private var item: MyItem?
var body: some View {
NavigationView {
List {
// Cell as Button that display Sheet
ForEach(1...3, id:\.self) { i in
Button(action: { item = MyItem(number: i) }) {
TestTableViewCell(number: i)
}
}
// Cell as NavigationLink
ForEach(4...6, id:\.self) { i in
NavigationLink(destination: TestTableViewCell(number: i)) {
TestTableViewCell(number: i)
}
}
// If you want a button inside cell which doesn't trigger the whole cell when being touched
HStack {
TestTableViewCell(number: 7)
Spacer()
Button(action: { item = MyItem(number: 7) }) {
Text("Button").foregroundColor(.accentColor)
}.buttonStyle(PlainButtonStyle())
}
}
}.sheet(item: $item) { myItem in
TestTableViewCell(number: myItem.number)
}
}
struct MyItem: Identifiable {
var number: Int
var id: Int { number }
}
}
struct TestTableViewCell: View {
var number: Int
var body: some View {
Text("View Number \(number)")
}
}
Make it like Button and takes an action param that is a closure.
From my understanding you're looking for a reusable generic List view with tap on delete functionality. If I'm guessing right my approach then would be like this:
struct MyArray: Identifiable {
let id = UUID()
var title = ""
}
struct ContentView: View {
#State private var myArray = [
MyArray(title: "One"),
MyArray(title: "Two"),
MyArray(title: "Three"),
MyArray(title: "Four"),
MyArray(title: "Five"),
]
var body: some View {
MyListView(array: myArray) { item in
Text(item.title) // row view
} onDelete: { item in
myArray.removeAll(where: {$0.id == item.id}) // delete func
}
}
}
struct MyListView<Items, Label>: View
where Items: RandomAccessCollection, Items.Element: Identifiable, Label: View {
var array: Items
var row: (Items.Element) -> Label
var onDelete: (Items.Element) -> ()
var body : some View {
List(array) { item in
Button {
onDelete(item)
} label: {
row(item)
}
}
}
}

SwiftUI onDelete List with Toggle and NavigationLink

I refer to two questions that I already asked and have been answered very well by Asperi: SwiftUI ForEach with .indices() does not update after onDelete,
SwiftUI onDelete List with Toggle
Now I tried to modify the closure in ForEach with a NavigationLink and suddenly the App crashes again with
Thread 1: Fatal error: Index out of range
when I try to swipe-delete.
Code:
class Model: ObservableObject {
#Published var name: String
#Published var items: [Item]
init(name: String, items: [Item]) {
self.name = name
self.items = items
}
}
struct Item: Identifiable {
var id = UUID()
var isOn: Bool
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach(model.items) {item in
NavigationLink(destination: DetailView(item: self.makeBinding(id: item.id))) {
Toggle(isOn: self.makeBinding(id: item.id).isOn)
{Text("Toggle-Text")}
}
}.onDelete(perform: delete)
}
}
}
func delete(at offsets: IndexSet) {
self.model.items.remove(atOffsets: offsets)
}
func makeBinding(id: UUID) -> Binding<Item> {
guard let index = self.model.items.firstIndex(where: {$0.id == id}) else {
fatalError("This person does not exist")
}
return Binding(get: {self.model.items[index]}, set: {self.model.items[index] = $0})
}
}
struct DetailView: View {
#Binding var item: Item
var body: some View {
Toggle(isOn: $item.isOn) {
Text("Toggle-Text")
}
}
}
It works without NavigationLink OR without the Toggle. So it seems for me that I only can use the makeBinding-Function once in this closure.
Thanks for help
Your code was crashing for me with and even without Navigation Link. Sometimes only if I deleted the last object in the Array. It looks like it was still trying to access an index out of the array. The difference to your example you linked above, is that they didn't used EnvironmentObject to access the array. The stored the array directly in the #State.
I came up with a little different approach, by declaring Item as ObservedObject and then simply pass it to the subview where you can use their values as Binding, without any function.
I changed Item to..
class Item: ObservableObject {
var id = UUID()
var isOn: Bool
init(id: UUID, isOn: Bool)
{
self.id = id
self.isOn = isOn
}
}
Change the ContentView to this..
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach(model.items, id:\.id) {item in
NavigationLink(destination: DetailView(item: item)) {
Toggler(item: item)
}
}.onDelete(perform: delete)
}
}
}
I outsourced the Toggle to a different view, where we pass the ObservedObject to, same for the DetailView.
struct Toggler: View {
#ObservedObject var item : Item
var body : some View
{
Toggle(isOn: $item.isOn)
{Text("Toggle-Text")}
}
}
struct DetailView: View {
#ObservedObject var item: Item
var body: some View {
Toggle(isOn: $item.isOn) {
Text("Toggle-Text")
}
}
}
They both take an Item as ObservedObject and use it as Binding for the Toggle.

How to edit an item in a list using NavigationLink?

I am looking for some guidance with SwiftUI please.
I have a view showing a simple list with each row displaying a "name" string. You can add items to the array/list by clicking on the trailing navigation bar button. This works fine. I would now like to use NavigationLink to present a new "DetailView" in which I can edit the row's "name" string. I'm struggling with how to use a binding in the detailview to update the name.
I've found plenty of tutorials online on how to present data in the new view, but nothing on how to edit the data.
Thanks in advance.
ContentView:
struct ListItem: Identifiable {
let id = UUID()
let name: String
}
class MyListClass: ObservableObject {
#Published var items = [ListItem]()
}
struct ContentView: View {
#ObservedObject var myList = MyListClass()
var body: some View {
NavigationView {
List {
ForEach(myList.items) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
}
.navigationBarItems(trailing:
Button(action: {
let item = ListItem(name: "Test")
self.myList.items.append(item)
}) {
Image(systemName: "plus")
}
)
}
}
}
DetailView
struct DetailView: View {
var item: ListItem
var body: some View {
TextField("", text: item.name)
}
}
The main idea that you pass in DetailsView not item, which is copied, because it is a value, but binding to the corresponding item in your view model.
Here is a demo with your code snapshot modified to fulfil the requested behavior:
struct ListItem: Identifiable, Equatable {
var id = UUID()
var name: String
}
class MyListClass: ObservableObject {
#Published var items = [ListItem]()
}
struct ContentView: View {
#ObservedObject var myList = MyListClass()
var body: some View {
NavigationView {
List {
ForEach(myList.items) { item in
// Pass binding to item into DetailsView
NavigationLink(destination: DetailView(item: self.$myList.items[self.myList.items.firstIndex(of: item)!])) {
Text(item.name)
}
}
}
.navigationBarItems(trailing:
Button(action: {
let item = ListItem(name: "Test")
self.myList.items.append(item)
}) {
Image(systemName: "plus")
}
)
}
}
}
struct DetailView: View {
#Binding var item: ListItem
var body: some View {
TextField("", text: self.$item.name)
}
}

Trigger Navigation from Context Menu in SwiftUI

I have a List that contains NavigationLink inside a NavigationView.
I know want to extend the view with a ContextMenu that contains an element that shows another view inside my navigation stack.
struct MainView: View {
#State var elements = ["Hello", "World"]
var body: some View {
NavigationView {
List(elements, id: \.self, rowContent: { element in
NavigationLink(destination: PresentView(element: element)) {
Text(element)
.contextMenu {
NavigationLink(
"Edit",
destination: EditView(element: element)
)
}
}
})
}
}
}
The navigation for a normal tap on my item works fine. The context menu however stopped working in Xcode 11 Beta 5. I get the following error: `[WindowServer] display_timer_callback: unexpected state.
How would I push a new view on my navigation stack from a context menu?
One approach is to use NavigationLink(destination: Destination, isActive: Binding<Bool>, #ViewBuilder label: () -> Label), the label as an EmptyView hidden inside a ZStack. You would then select the element to navigate to and toggling the NavigationLink inside the contextMenu. Here is a full example:
struct PresentView: View {
let element: String
var body: some View {
Text(element)
}
}
struct EditView: View {
let element: String
var body: some View {
Text("EditView for \(element)")
}
}
struct MainView: View {
#State var elements = ["Hello", "World"]
#State var elementToEdit: String?
#State var isPresentedEditView = false
var body: some View {
NavigationView {
ZStack {
NavigationLink(
destination: elementToEdit == nil ? AnyView(EmptyView()) : AnyView(EditView(element: elementToEdit!)),
isActive: $isPresentedEditView) {
EmptyView()
}
List(elements, id: \.self) { element in
NavigationLink(destination: PresentView(element: element)) {
Text(element)
.contextMenu {
Button("Edit") {
elementToEdit = element
isPresentedEditView.toggle()
}
}
}
}
}
}
}
}
struct ContentView: View {
var body: some View {
MainView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}