In the last few months, many developers have reported NavigationLinks to unexpectedly pop out and some workarounds have been published, including adding another empty link and adding .navigationViewStyle(StackNavigationViewStyle()) to the navigation view.
Here, I would like to demonstrate another situation under which a NavigationLink unexpectedly pops out:
When there are two levels of child views, i.e. parentView > childLevel1 > childLevel2, and childLevel2 modifies childLevel1, then, after going back from level 2 to level 1, level 1 pops out and parentView is shown.
I have filed a bug report but not heard from apple since. None of the known workarounds seem to work. Does someone have an idea what to make of this? Just wait for iOS 15.1?
Below is my code (iPhone app). In the parent view, there is a list of persons from which orders are taken. In childLevel1, all orders from a particular person are shown. Each order can be modified by clicking on it, which leads to childLevel2. In childLevel2, several options are available (here only one is shown for the sake of brevity), which is the reason why the user is supposed to leave childLevel2 via "< Back".
import SwiftUI
struct Person: Identifiable, Hashable {
let id: Int
let name: String
var orders: [Order]
}
struct Pastry: Identifiable, Hashable {
let id: Int
let name: String
}
struct Order: Hashable {
var paId: Int
var n: Int // used only in the real code
}
class Data : ObservableObject {
init() {
pastries = [
Pastry(id: 0, name: "Prezel"),
Pastry(id: 1, name: "Donut"),
Pastry(id: 2, name: "bagel"),
Pastry(id: 3, name: "cheese cake"),
]
persons = [
Person(id: 0, name: "Alice", orders: [Order(paId: 1, n: 1)]),
Person(id: 1, name: "Bob", orders: [Order(paId: 2, n: 1), Order(paId: 3, n: 1)])
]
activePersonsIds = [0, 1]
}
#Published var activePersonsIds: [Int] = []
#Published var persons: [Person] = []
#Published var pastries: [Pastry]
#Published var latestOrder = Order(paId: 0, n: 1)
lazy var pastryName: (Int) -> String = { (paId: Int) -> String in
if self.pastries.first(where: { $0.id == paId }) == nil {
return "undefined pastryId " + String(paId)
}
return self.pastries.first(where: { $0.id == paId })!.name
}
var activePersons : [Person] {
return activePersonsIds.compactMap {id in persons.first(where: {$0.id == id})}
}
}
#main
struct Bretzel_ProApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#Environment(\.colorScheme) var colorScheme
#StateObject var data = Data()
var body: some View {
TabView1(data: data)
// in the real code, there are more tabs
}
}
struct TabView1: View {
#StateObject var data: Data
var body: some View {
NavigationView {
List {
ForEach(data.activePersons, id: \.self) { person in
NavigationLink(
destination: EditPerson(data: data, psId: person.id),
label: {
VStack (alignment: .leading) {
Text(person.name)
}
}
)
}
}
.listStyle(PlainListStyle())
.navigationTitle("Orders")
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct EditPerson: View {
#ObservedObject var data: Data
var psId: Int
var body: some View {
let pindex: Int = data.persons.firstIndex(where: { $0.id == psId })!
let p: Person = data.persons[pindex]
List() {
ForEach (0...p.orders.count-1, id: \.self) { loop in
Section(header:
HStack() {
Text("BESTELLUNG " + String(loop+1))
}
) {
EPSubview1(data: data, psId: psId, loop: loop)
}
}
}.navigationTitle(p.name)
.listStyle(InsetGroupedListStyle())
}
}
struct EPSubview1: View {
#ObservedObject var data: Data
var psId: Int
var loop: Int
var body: some View {
let pindex: Int = data.persons.firstIndex(where: { $0.id == psId })!
let p: Person = data.persons[pindex]
let o1: Order = p.orders[loop]
NavigationLink(
destination: SelectPastry(data: data)
.onAppear() {
data.latestOrder.paId = o1.paId
}
.onDisappear() {
data.persons[pindex].orders[loop].paId = data.latestOrder.paId
},
label: {
VStack(alignment: .leading) {
Text(String(o1.n) + " x " + data.pastryName(o1.paId))
}
}
)
}
}
struct SelectPastry: View {
#ObservedObject var data : Data
var body: some View {
VStack {
List {
ForEach(data.pastries, id: \.self) {pastry in
Button(action: {
data.latestOrder.paId = pastry.id
}) {
Text(pastry.name)
.foregroundColor(data.latestOrder.paId == pastry.id ? .primary : .secondary)
}
}
}.listStyle(PlainListStyle())
}
}
}
The problem is your ForEach. Despite that fact that Person conforms to Identifiable, you're using \.self to identify the data. Because of that, every time an aspect of the Person changes, so does the value of self.
Instead, just use this form, which uses the id vended by Identifiable:
ForEach(data.activePersons) { person in
Which is equivalent to:
ForEach(data.activePersons, id: \.id) { person in
Related
I have a List, with custom Stepper inside each row. Therefore, when I scroll my stepper is reset. (The increment and decrement works when is visible. When it disappear, it's reset. Don't keep the state. It's alway's reset).
Xcode: v14.2 / Simulator iOS: 16.2
struct Product: Codable, Hashable, Identifiable {
let id: String
let name: String
let step: Int
let quantity: Int
let priceHT: Double
}
class ProductViewModel: ObservableObject {
#Published var products = [Product]()
...
}
struct ProductListView: View {
#EnvironmentObject var productViewModel: ProductViewModel
var body: some View {
List(productViewModel.products) { product in
ProductRowView(product: product)
}
}
}
My List row:
I tried to modify #State with #binding, but without success.
struct ProductRowView: View {
#State var product: Product
var body: some View {
HStack {
VStack {
Text(product.name)
Text(String(format: "%.2f", product.priceHT) + "€ HT")
}
Spacer()
MyStepper(product: $product, value: product.quantity)
.font(.title)
}
}
}
My Custom stepper:
struct MyStepper: View {
#Binding var product: Product
#State var value: Int = 0
var body: some View {
HStack {
VStack() {
HStack {
Button(action: {
value -= product.step
if let row = orderViewModel.productsOrder.firstIndex(where: { $0.name == product.name }) {
let order = Product(id: product.id, name: product.name, step: product.step, quantity: value, priceHT: product.priceHT)
if (value == 0) {
orderViewModel.productsOrder.remove(at: row)
} else {
orderViewModel.productsOrder[row] = order
}
}
}, label: {
Image(systemName: "minus.square.fill")
})
Text(value.formatted())
Button(action: {
value += product.step
let order = Product(id: product.id, name: product.name, step: product.step, quantity: value, priceHT: product.priceHT)
if let row = orderViewModel.productsOrder.firstIndex(where: { $0.name == product.name }) {
orderViewModel.productsOrder[row] = order
} else {
orderViewModel.productsOrder.append(order)
}
}, label: {
Image(systemName: "plus.app.fill")
})
}
Text(product.unit)
}
}
}
}
Thks
EDIT / RESOLVED
Here is the solution for my case :
Change type of quantity. let to var
struct Product: Codable, Hashable, Identifiable {
...
var quantity: Int
...
}
Delete #State in MyStepper and replace value by product.quantity
Use bindings for that, e.g.
struct ProductListView: View {
#EnvironmentObject var model: Model
var body: some View {
List($model.products) { $product in
ProductRowView(product: $product)
}
}
}
struct ProductRowView: View {
#Binding var product: Product // now you have write access to the Product struct
...
However, to make the View reusable and to help with previewing, it's best to pass in only the simple types the View needs, e.g.
struct TitlePriceView: View {
let title: String
#Binding var price: Double
// etc.
TitlePriceView(title: product.title, price: $product.price)
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)
}
}
}
}
OK, I nailed down the problem to the dynamic list with sections, so I changed the title and I'm going to shorten my post...
In my project, there is a complex view that contains a dynamic list with sections that depend on many state variables.
Problem: after a few user interactions and subsequent changes to the list, I see strange behaviour:
sometimes a section doesn't show up although the condition of the if-clause in which it is embedded is definitely met
sometimes, the section header label is replaced with the label of a list item
sometimes, it crashes
when the view is broken and I enforce a refresh of the view without changing any of the state variables, everything looks good again
The code below is taken from my project, however, greatly simplified by removing parts that are not necessary to produce the error. The state variable altered by user interaction is pIdStatus. From pIdStatus, the function data.analyze() calculates the arrays pastriesWithStatus1, pastriesWithStatus2 and pastriesWithStatus3.
Interestingly, when I remove the tab-view in which everything is embedded, everything works fine.
Any help is greatly appreciated.
import SwiftUI
struct ContentView: View {
#StateObject var data = Data()
var body: some View {
TabView { // needed in my project and needed for crash.
List {
// for debugging purpose: create a button that triggers a refresh
Button {
data.counter += 1
} label: {
HStack() {
Text("Refresh")
.font(.system(size: 16, weight: .regular))
}
}
// Section with status3-items
if data.pastriesWithStatus3.count > 0 {
Section(header:
HStack() {
Text("STATUS 3")
}
){
ForEach(data.pastriesWithStatus3, id: \.self) { i in
subview21(data: data, label: data.pastries.first(where: { $0.id == i })!.name, id: i, status: 3)
}
}
}
// section with other items
Section(header:
HStack() {
Text("OTHER ITEMS")
}
){
ForEach(data.pastriesWithStatus2, id: \.self) { i in
subview21(data: data, label: data.pastries.first(where: { $0.id == i })!.name, id: i, status: 2)
}
ForEach(data.pastriesWithStatus1, id: \.self) { i in
subview21(data: data, label: data.pastries.first(where: { $0.id == i })!.name, id: i, status: 1)
}
}
}
.listStyle(InsetGroupedListStyle())
.tabItem {
Image(systemName: "cart")
Text("shopping")
}.onAppear {
data.analyze()
}
}
}
}
struct subview21: View {
#ObservedObject var data : Data
let hspacing: CGFloat = 20
let tab1: CGFloat = 150
var label: String
var id: Int
var status: Int // 1=red, 2=unchecked, 3=green
var body: some View {
if data.testcondition { // this line is required for crash. Although it is always true.
HStack (spacing: hspacing) {
Text(data.pastries.first(where: { $0.id == id })!.name)
.frame(width: tab1, alignment: .leading)
Button {
data.pIdStatus[id] = 1
data.analyze()
} label: {
Image(systemName: (status == 1 ? "checkmark.square.fill" : "square"))
.foregroundColor(.red)
}
.buttonStyle(BorderlessButtonStyle())
Button {
data.pIdStatus[id] = 3
data.analyze()
} label: {
Image(systemName: (status == 3 ? "checkmark.square.fill" : "square"))
.foregroundColor(.green)
}
.buttonStyle(BorderlessButtonStyle())
}
}
}
}
let maxPId = 99
struct Person: Identifiable, Hashable {
let id: Int
let name: String
var orders: [Order]
}
struct Pastry: Identifiable, Hashable {
let id: Int
let name: String
}
struct Order: Hashable {
var pastryId: Int
var n: Int
var otherwise: OrderL2?
}
struct OrderL2: Hashable {
let level: Int = 2
var pastryId: Int
var n: Int
}
class Data : ObservableObject {
#Published var persons: [Person] = [
Person(id: 1, name: "John", orders: [Order(pastryId: 0, n:1, otherwise:OrderL2(pastryId: 1, n:2))]),
]
#Published var pastries: [Pastry] = [Pastry(id: 0, name: "Donut"),
Pastry(id: 1, name: "prezel")]
#Published var counter: Int = 0 // for debugging
#Published var pIdStatus: [Int] = Array(repeating: 2, count: maxPId)
#Published var testcondition: Bool = true // needed in my project, for simplicity, here, always true
#Published var pastriesWithStatus1: [Int] = []
#Published var pastriesWithStatus2: [Int] = []
#Published var pastriesWithStatus3: [Int] = []
func analyze() {
var localPastriesWithStatus1: [Int] = []
var localPastriesWithStatus2: [Int] = []
var localPastriesWithStatus3: [Int] = []
for p in pastries { // there may be more elegant ways to code, however, in real life, there's a lot more functionality here
if p.id < maxPId {
if pIdStatus[p.id] == 1 {
localPastriesWithStatus1.append(p.id)
}
if pIdStatus[p.id] == 2 {
localPastriesWithStatus2.append(p.id)
}
if pIdStatus[p.id] == 3 {
localPastriesWithStatus3.append(p.id)
}
}
}
pastriesWithStatus1 = localPastriesWithStatus1
pastriesWithStatus2 = localPastriesWithStatus2
pastriesWithStatus3 = localPastriesWithStatus3
}
}
Apparently, swiftui List is not made for Lists that are built from scratch each time the view is refreshed. Instead, Lists must be linked to arrays to/from which items are added/removed one by one.
I solved the problem by avoiding List altogether, instead using Stack, Text etc.
Currently I am trying to update the count in a SingleDay struct inside a Days class from from the TestScreen view.The SingleDay struct is also in an array in Days. The change in count should be reflected in the UpdatingArrayElements view. So far I am running into this error:
Left side of mutating operator isn't mutable: 'day' is a 'let' constant"
and I have absolutely no idea on how to resolve this issue. I would appreciate any help given that I am still a beginner in iOS development and still trying to get the hang of building more complex iOS apps, thanks!
import SwiftUI
struct SingleDay: Identifiable {
let id: String = UUID().uuidString
let day: Int
var count: Int
}
class Days: ObservableObject {
#Published var daysArray: [SingleDay] = []
init() {
daysArray.append(SingleDay(day: 1, count: 0))
}
}
struct UpdatingArrayElements: View {
#StateObject var days: Days = Days()
var body: some View {
NavigationView {
List {
ForEach(days.daysArray) { day in
HStack{
Text("Day: \(day.day)")
Text("Count: \(day.count)")
}
}
}
.navigationBarItems(trailing:
NavigationLink(destination: TestScreen(dayViewModel: days), label: {
Image(systemName: "arrow.right")
.font(.title)
})
)
}
}
}
struct TestScreen: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var dayViewModel: Days
var body: some View {
ZStack {
Color.green.ignoresSafeArea()
VStack {
ForEach(dayViewModel.daysArray) { day in
Text(String(day.day))
Button(action: {
day.count += 1
}, label: {
Text("Add count")
})
}
}
}
}
}
struct UpdatingArrayElements_Previews: PreviewProvider {
static var previews: some View {
UpdatingArrayElements()
}
}
here you go. now we are not using the identifiable so if you want you can also remove it.
so the real problem was that the day which is of type SingleDay is a struct not a class. so foreach give us let not vars , so in case of class object we can easily update properties because they are reference type, but in case of struct we can not do that, so it means day is another copy. so even if we can update the day of struct it will still not update the daysArray element.
struct TestScreen: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var dayViewModel: Days
var body: some View {
ZStack {
Color.green.ignoresSafeArea()
VStack {
ForEach(dayViewModel.daysArray.indices ,id : \.self) { index in
let day = dayViewModel.daysArray[index]
Text("\(day.count)")
Button(action: {
dayViewModel.daysArray[index].count = day.count + 1
print( day.count)
}, label: {
Text("Add count")
})
}
}
}
}
}
A simple way to express this would be to go back to UITableView and have a didSelectRowAt(indexPath) function that behaved like this:
if (indexPath.row == 0) { ... } else { ... }
Where based upon the indexPath.row value, I can call a unique view controller (ex: the first one is a TableView and the others are CollectionViews.
Currently, based upon the two answers thus far, I can produce the following code:
import SwiftUI
struct MenuItem {
let title: String
let isEnabled: Bool
}
struct HomeList: View {
let menuItems = [
MenuItem(title: "ABC", isEnabled: true),
MenuItem(title: "DEF", isEnabled: false),
MenuItem(title: "GHI", isEnabled: true)
]
var body: some View {
NavigationView {
List {
ForEach(menuItems.indices, id: \.self) { index in
NavigationLink(destination: menuItems[index].title == "ABC" ?
FirstList() :
SecondView(menuItem: menuItems[index])) {
HomeRow(menuItem: menuItems[index])
}
}
}
}
}
}
struct HomeRow: View {
var menuItem: MenuItem
var body: some View {
HStack {
Text(verbatim: menuItem.title)
}
}
}
struct FirstList: View {
var body: some View {
List(1 ..< 5) { index in
Text("Row \(index)")
}
.listStyle(GroupedListStyle())
}
}
struct SecondView: View {
var menuItem: MenuItem
var body: some View {
Text(menuItem.title)
}
}
However, I get the following error with my NavigationLink:
Result values in '? :' expression have mismatching types 'FirstList'
and 'SecondView'
Since my goal here is to have two different views I point to based upon the title, I'd like to find some way to make that work.
The answer posted by superpuccio seems to be pretty close to what I want, but with the expected complexity of the target views, I do not think it would be feasible to compose them entirely within NavigationLink.
Since you have a dynamic List I suggest you use a ForEach inside a List this way:
import SwiftUI
struct MenuItem {
let title: String
let isEnabled: Bool
}
struct HomeList: View {
let menuItems = [
MenuItem(title: "ABC", isEnabled: true),
MenuItem(title: "DEF", isEnabled: false),
MenuItem(title: "GHI", isEnabled: true)
]
var body: some View {
let firstRowModel = menuItems[0]
let actualModel = menuItems[1...menuItems.count-1]
return NavigationView {
List {
NavigationLink(destination: FirstList()) {
HomeRow(menuItem: firstRowModel)
}
ForEach(actualModel.indices, id: \.self) { index in
NavigationLink(destination: SecondView(menuItem: actualModel[index])) {
HomeRow(menuItem: actualModel[index])
}
}
}
}
}
}
struct HomeRow: View {
var menuItem: MenuItem
var body: some View {
HStack {
Text(verbatim: menuItem.title)
}
}
}
struct FirstList: View {
var body: some View {
List(1 ..< 5) { index in
Text("Row \(index)")
}
.listStyle(GroupedListStyle())
}
}
struct SecondView: View {
var menuItem: MenuItem
var body: some View {
Text(menuItem.title)
}
}
I would include the condition in the destination.
var body: some View {
NavigationView {
List(1 ..< 5) { idx in
NavigationLink(destination:
idx < 3 ? Text("1111") : Text("2222") ) {
Text("Row \(idx)")
}
}
.listStyle(GroupedListStyle())
}
}