Why #Binding Property causes not releated init every time? - swiftui

It's a bug confused me for all day, I make a demo for my case, all is just a list and a detail.
Room.swift
struct Room: Identifiable {
var id: Int
var name: String
}
RoomListModel.swift
class RoomListModel: ObservableObject {
#Published var rooms: [Room] = [
Room(id: 1, name: "Room A"),
Room(id: 2, name: "Room B"),
Room(id: 3, name: "Room C"),
]
}
RoomList.swift
struct RoomList: View {
#ObservedObject var model = RoomListModel()
var body: some View {
NavigationView {
List {
ForEach(model.rooms.indices) { index in
RoomRow(room: self.$model.rooms[index])
}
}
.listStyle( GroupedListStyle() )
}
}
}
RoomRow.swift
struct RoomRow: View {
#Binding var room: Room
init(room: Binding<Room>) {
print("initing RoomRow: " + room.name.wrappedValue)
self._room = room
}
var body: some View {
NavigationLink(
destination: RoomDetail(room: $room)
) {
Text(room.name)
}
}
}
RoomDetail.swift
struct RoomDetail: View {
#Binding var room: Room
init(room: Binding<Room>) {
print("initing RoomDetail: " + room.name.wrappedValue)
self._room = room
}
var body: some View {
Text(room.name)
}
}
For convenience, I add print in init.
When List load, all the RoomRow and RoomDetail are inited, that is OK.
initing RoomRow: Room A
initing RoomDetail: Room A
initing RoomRow: Room B
initing RoomDetail: Room B
initing RoomRow: Room C
But when I trigger to one of RowDetail, all the RoomDetails is inited again, But I just go to one, not all of them, WHY all the RoomDetails are inited, and AGAIN?
initing RoomDetail: Room C
initing RoomDetail: Room A
initing RoomDetail: Room B
I don't know why, but found one solution, I removed all #Binding property, and It's OK, but WHY #Binding will cause unreleated init?

Related

List row reset value

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)

SwiftUI NavigationLink issue with multi selections

I have an issue with SwiftUI navigation. To show the issue I made a simple example of a list of cars and if the user clicks on it, it shows the car details:
struct ContentView: View {
var cars = [Car(name: "A"), Car(name: "B"), Car(name: "C"), Car(name: "D")]
var body: some View {
NavigationView {
LazyVStack(spacing: 10) {
ForEach(cars, id: \.self) { car in
NavigationLink(destination: {
CarDetailsView(viewModel: CarDetailsViewModel(car: car))
},
label: {
CarRowView(car: car)
})
}
}
}
}
}
struct CarDetailsView: View {
#StateObject var viewModel: CarDetailsViewModel
#Environment(\.presentationMode) private var presentationMode
var body: some View {
Button("Back") {
presentationMode.wrappedValue.dismiss()
}
}
}
class CarDetailsViewModel: ObservableObject {
#Published var car: Car
init(car: Car) {
self.car = car
}
}
struct Car: Hashable {
var name: String
}
struct CarRowView: View {
var car: Car
var body: some View {
Text(car.name)
}
}
This works well when you select one car at the time. Unfortunatly with swiftUI I cannot disabled multi selection. If the user select multiple cars at the same time, and then go back to the car list sometime I get an error log:
SwiftUI encountered an issue when pushing aNavigationLink. Please file a bug.
At that moment, the car list is no longer responsive. It takes few attempt but it does eventually happen. I can only replicate this on iOS 15 so far. I also tried to do this without the viewModel and it still happen.
Ideally I want to keep the NavigationLink inside the VStack because it makes the row dimming when the user selects it.
I can reproduce your issue, and I think this is a bug.
However using the following NavigationLink setup with selection and tag seems to works for me.
struct ContentView: View {
#State var cars = [Car(name: "A"), Car(name: "B"), Car(name: "C"), Car(name: "D")]
#State private var showCar: UUID?
var body: some View {
NavigationView {
LazyVStack(spacing: 30) {
ForEach(cars) { car in
NavigationLink(
destination: CarDetailsView(car: car),
tag: car.id,
selection: $showCar,
label: { CarRowView(car: car) }
)
}
}
}
}
}
struct CarDetailsView: View {
#Environment(\.dismiss) private var dismiss
#State var car: Car
var body: some View {
Button("Back \(car.name)") {
dismiss()
}
}
}
struct Car: Identifiable, Hashable {
let id = UUID()
var name: String
}
struct CarRowView: View {
var car: Car
var body: some View {
Text(car.name)
}
}

iOS 15: Navigation link popping out, again

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

Bidirectional binding with SwiftUI and Combine

I'm trying to work out how I can correctly pass an object or a set of values between two ViewModels in a parent-child relationship so that when the child ViewModel is updated the change bubbles back up to the parent.
This is pretty simple when just using SwiftUI views and binding directly to the stores but I wanted to keep my business logic for field validation and so on separate from the SwiftUI views.
The code below shows the child updating (as expected) when the parent gets updated, but I need to somehow pass the changed values in the child back up to the parent. I'm very new to mobile app development and still learning so I'm sure I'm missing something quite simple.
import SwiftUI
import Combine
struct Person: Hashable {
var givenName: String
var familyName: String
}
// my person store - in the real app it's backed by coredata
class PersonStore: ObservableObject {
#Published var people: [Person] = [
Person(
givenName: "Test",
familyName: "Person"
)
]
static let shared = PersonStore()
}
// app entrypoint
struct PersonView: View {
#ObservedObject var viewModel: PersonView_ViewModel = PersonView_ViewModel()
var body: some View {
NavigationView {
VStack {
List(viewModel.people.indices, id: \.self) { idx in
NavigationLink(destination: PersonDetailView(viewModel: PersonDetailView_ViewModel(personIndex: idx))) {
Text(self.viewModel.people[idx].givenName)
}
}
}
}
}
}
class PersonView_ViewModel: ObservableObject {
#Published var people: [Person] = PersonStore.shared.people
}
// this is the detail view
struct PersonDetailView: View {
#ObservedObject var viewModel: PersonDetailView_ViewModel
var body: some View {
Form {
Section(header: Text("Parent View")) {
VStack {
TextField("Given Name", text: self.$viewModel.person.givenName)
Divider()
TextField("Family Name", text: self.$viewModel.person.familyName)
}
}
PersonBasicDetails(viewModel: PersonBasicDetails_ViewModel(person: viewModel.person))
}
}
}
// viewmodel associated with detail view
class PersonDetailView_ViewModel: ObservableObject {
#Published var person: Person
init(personIndex: Int) {
self.person = PersonStore.shared.people[personIndex]
}
}
// this is the child view - in the real app there are multiple sections which are conditionally rendered
struct PersonBasicDetails: View {
#ObservedObject var viewModel: PersonBasicDetails_ViewModel
var body: some View {
Section(header: Text("Child View")) {
VStack {
TextField("Given Name", text: self.$viewModel.person.givenName)
Divider()
TextField("Family Name", text: self.$viewModel.person.familyName)
}
}
}
}
class PersonBasicDetails_ViewModel: ObservableObject {
#Published var person: Person
init(person: Person) {
self.person = person
}
}
struct PersonView_Previews: PreviewProvider {
static var previews: some View {
PersonView()
}
}
In most SwiftUI TextField examples around the web the binding is provided by utilizing a #State variable which creates an instance of Binding for you.
However, you can also create a custom binding using the Binding constructor. Here's an example of what that looks like:
TextField(
"Given Name",
text: Binding(
get: { self.$viewModel.person.givenName },
set: { self.$viewModel.person.givenName = $0 }))
If you want two way works, not only you need to publish, also you have to use binding for upward.
struct Person: Hashable {
var givenName: String
var familyName: String
}
// my person store - in the real app it's backed by coredata
class PersonStore: ObservableObject {
#Published var people: [Person] = [
Person(givenName: "Test",familyName: "Person")
]
static let shared = PersonStore()
}
// app entrypoint
struct PersonView: View {
#ObservedObject var viewModel: PersonView_ViewModel = PersonView_ViewModel()
var body: some View {
NavigationView {
VStack {
List(viewModel.people.indices, id: \.self) { idx in
NavigationLink(destination: PersonDetailView(viewModel: PersonDetailView_ViewModel(person: self.$viewModel.people , index: idx ))) {
Text(self.viewModel.people[idx].givenName)
}
}
}
}
}
}
class PersonView_ViewModel: ObservableObject {
#Published var people: [Person] = PersonStore.shared.people
}
// this is the detail view
struct PersonDetailView: View {
#ObservedObject var viewModel: PersonDetailView_ViewModel
var body: some View {
Form {
Section(header: Text("Parent View")) {
VStack {
TextField("Given Name", text: self.viewModel.person.givenName)
Divider()
TextField("Family Name", text: self.viewModel.person.familyName)
}
}
PersonBasicDetails(viewModel: PersonBasicDetails_ViewModel(person: viewModel.person))
}
}
}
// viewmodel associated with detail view
class PersonDetailView_ViewModel: ObservableObject {
#Published var person: Binding<Person>
init(person: Binding<[Person]> ,index: Int) {
self.person = person[index]
}
}
// this is the child view - in the real app there are multiple sections which are conditionally rendered
struct PersonBasicDetails: View {
#ObservedObject var viewModel: PersonBasicDetails_ViewModel
var body: some View {
Section(header: Text("Child View")) {
VStack {
TextField("Given Name", text: self.viewModel.person.givenName)
Divider()
TextField("Family Name", text: self.viewModel.person.familyName)
}
}
}
}
class PersonBasicDetails_ViewModel: ObservableObject {
#Published var person: Binding<Person>
init(person: Binding<Person>) {
self.person = person //person
}
}
struct PersonView_Previews: PreviewProvider {
static var previews: some View {
PersonView()
}
}

Changes to array objects not saving to main ObservableObject

I'm starting with SwiftUI and I'm running into a roadblock with array items of an ObservableObject not saving to the main object.
Main object:
class Batch: Codable, Identifiable, ObservableObject {
let id: String
var items = [Item]()
}
Item object:
class Item: Codable, Identifiable, ObservableObject {
let id: String
var description: String
}
I have a BatchView which I pass a batch into:
struct BatchView: View {
#ObservedObject var batch: Batch
var body: some View {
List {
ForEach(batch.items) { item in
ItemView(item: item)
}
}
.navigationBarTitle(batch.items.reduce("", { $0 + $1.description }))
}
}
In the ItemView I change the description:
struct ItemView: View {
#ObservedObject var item: Item
#State private var descr = ""
var body: some View {
VStack(alignment: .leading) {
Text("MANUFACTURED")
TextField("", text: $descr) {
self.updateDescr(descr: self.descr)
}
}
}
private func updateDescr(descr: String) {
item.description = descr
}
}
But when I update the description for a batch item, the title of BatchView doesn't change, so the changes to the Item isn't coming back to the root Batch.
How do I make the above work?
This answer helped me. I had to explicitly add #Published in front of the variable I was changing.