Binding an item within a collection - swiftui

Looking at the following code
struct FruitDetailView: View {
#EnvironmentObject var merchant: Merchant
#State var item: Fruit
var body: some View {
VStack {
Text(item.name)
Button("Press Me") {
item.name = "Watermelon"
merchant.updateFruits(with: item)
}
}
}
}
A list of fruits was previously displayed to the user and when the user selected a fruit, that selected fruit was passed to this detail view. Where it now exists as a #State controlled property.
The merchant is also passed along because we want to keep the original array in sync. Updating both the fruit and the merchant is a pain. Is there no way to bind the fruit so that any modifications to it, also modifies the entry in the array of fruits on the merchant?
This post leads on from my previous post here.
In the world of Objective-C the following three items could be updated by just passing a pointer through the view hierarchy.
#implementation Merchant
- (id)init {
self = [super init];
if (self) {
Fruit *apple = [[Fruit alloc] initWithName: #"Apple"];
Fruit *orange = [[Fruit alloc] initWithName: #"Orange"];
Fruit *pear = [[Fruit alloc] initWithName: #"Pear"];
self.fruits = #[apple, orange, pear];
}
return self;
}
#end
#interface FruitDetailViewController : UIViewController
#property (nonatomic, strong) Fruit * fruit;
#end
#implementation FruitDetailViewController
- (IBAction)buttonAction:(UIButton *)sender {
self.fruit.name = #"Cherry";
}

Like I said in your previous question, there are many ways to do things.
Here is yet another way, where only a Binding Fruit
is passed to the FruitDetailView. It really depends what you want to do.
struct ContentView: View {
#StateObject var merchant = Merchant()
var body: some View {
FruitsView().environmentObject(merchant)
}
}
struct Fruit: Identifiable, Hashable {
let id = UUID()
var name: String
}
final class Merchant: ObservableObject {
#Published var fruits = [Fruit(name: "Banana"), Fruit(name: "Apple")]
}
struct Selector: Identifiable {
let id = UUID()
var index: Int
}
struct FruitsView: View {
#EnvironmentObject var merchant: Merchant
#State var selection: Selector?
var body: some View {
VStack {
ForEach(merchant.fruits.indices, id: \.self) { index in
Button(merchant.fruits[index].name) {
selection = Selector(index: index)
}.buttonStyle(.borderedProminent)
}
}
.sheet(item: $selection) { selector in
FruitDetailView(fruit: $merchant.fruits[selector.index])
}
}
}
struct FruitDetailView: View {
#Binding var fruit: Fruit
var body: some View {
VStack {
Text(fruit.name)
Button("Press Me") {
fruit.name = "Watermelon"
}
}
}
}
EDIT-1:
Here is another way using a NavigationLink to goto the FruitDetailView destination view,
instead of a sheet, using only bindings:
struct FruitsView: View {
#EnvironmentObject var merchant: Merchant
var body: some View {
NavigationStack {
ForEach($merchant.fruits, id: \.id) { $fruit in
NavigationLink(destination: FruitDetailView(fruit: $fruit)) {
Text(fruit.name)
}
}
}
}
}

Related

Keeping the original datasource in sync with downstream bindings

If I have a collection of fruits, and I pass one of them to a detail view, how do I edit that item so that both the item and it's original datasource are updated?
final class Merchant: ObservableObject {
#Published
var selection: Fruit?
#Published
var fruits = [
Fruit(name: "Banana"),
Fruit(name: "Apple")
]
}
struct FruitsView: View {
#EnvironmentObject var merchant: Merchant
var body: some View {
VStack {
ForEach(merchant.fruits) { fruit in
Button {
merchant.selection = fruit
} label: {
Text(fruit.name)
}
.buttonStyle(.borderedProminent)
}
}
.sheet(item: $merchant.selection, content: {
FruitDetailView(item: $0)
})
}
}
struct FruitDetailView: View {
let item: Fruit
init(item: Fruit) {
self.item = item
}
var body: some View {
VStack {
Text(item.name)
Button("Press Me") {
item.name = "Watermelon" // error
}
}
}
}
Changing the item on FruitDetailView to a binding doesn't change the original datasource.
There are a number of ways to achieve what you ask. This is one simple way using the model constructs you already have. It uses the Merchant selection to update the Merchant fruits data.
struct ContentView: View {
#StateObject var merchant = Merchant()
var body: some View {
FruitsView().environmentObject(merchant)
}
}
struct Fruit: Identifiable {
let id = UUID()
var name: String
}
final class Merchant: ObservableObject {
#Published var selection: Fruit? = nil {
didSet {
if selection != nil,
let index = fruits.firstIndex(where: {$0.id == selection!.id}) {
fruits[index].name = selection!.name
}
}
}
#Published var fruits = [Fruit(name: "Banana"), Fruit(name: "Apple")]
}
struct FruitsView: View {
#EnvironmentObject var merchant: Merchant
var body: some View {
VStack {
ForEach(merchant.fruits) { fruit in
Button {
merchant.selection = fruit
} label: {
Text(fruit.name)
}.buttonStyle(.borderedProminent)
}
}
.sheet(item: $merchant.selection) { _ in
FruitDetailView().environmentObject(merchant)
}
}
}
struct FruitDetailView: View {
#EnvironmentObject var merchant: Merchant
var body: some View {
VStack {
Text(merchant.selection?.name ?? "no selection name")
Button("Press Me") {
merchant.selection?.name = "Watermelon"
}
}
}
}
EDIT-1:
This is another way of keeping the model in sync. It uses a function updateFruits in the Merchant ObservableObject class, to update the model's data.
It separates the UI interaction part using a local #State var selection: Fruit? from the main data in the Merchant model.
final class Merchant: ObservableObject {
#Published var fruits = [Fruit(name: "Banana"), Fruit(name: "Apple")]
func updateFruits(with item: Fruit) {
if let index = fruits.firstIndex(where: {$0.id == item.id}) {
fruits[index].name = item.name
}
}
}
struct FruitsView: View {
#EnvironmentObject var merchant: Merchant
#State var selection: Fruit?
var body: some View {
VStack {
ForEach(merchant.fruits) { fruit in
Button {
selection = fruit
} label: {
Text(fruit.name)
}.buttonStyle(.borderedProminent)
}
}
.sheet(item: $selection) { item in
FruitDetailView(item: item).environmentObject(merchant)
}
}
}
struct FruitDetailView: View {
#EnvironmentObject var merchant: Merchant
#State var item: Fruit
var body: some View {
VStack {
Text(item.name)
Button("Press Me") {
item.name = "Watermelon"
merchant.updateFruits(with: item)
}
}
}
}

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)
}
}

Why does the TextField in the List stop updating?

I started studying SwiftUI and wanted to make a prototype of standard reminders, like in an iPhone. It seems nothing complicated, there is a List, in each cell a TextField.
But I ran into a problem: when we change the text in the TextField using onChange, then we accordingly tell the view model to update our objects.
And when the objects are updated, the entire List is redrawn and the editing of the current TextField is reset (you can neither remove more than one character, nor add). You have to click on the text again to continue editing.
Does anyone know how to treat this?
This is my code:
import SwiftUI
struct Fruit: Identifiable {
let id = UUID()
let name: String
func updateName(newName: String) -> Fruit {
return Fruit(name: newName)
}
}
class ViewModel: ObservableObject {
#Published var fruits: [Fruit] = [Fruit(name: "apple"), Fruit(name: "banana"), Fruit(name: "orange")]
func updateName(newName: String, fruit: Fruit) {
if let index = fruits.firstIndex(where: { $0.id == fruit.id }) {
fruits[index] = fruit.updateName(newName: newName)
}
}
}
struct ListView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
List {
ForEach(viewModel.fruits) { fruit in
ListViewRow(fruit: fruit)
}
}
.environmentObject(viewModel)
}
}
struct ListViewRow: View {
#EnvironmentObject var viewModel: ViewModel
#State var fruitTextField: String
let fruit: Fruit
init(fruit: Fruit) {
self.fruit = fruit
_fruitTextField = State(initialValue: fruit.name)
}
var body: some View {
TextField("", text: $fruitTextField)
.onChange(of: fruitTextField) { newValue in
viewModel.updateName(newName: newValue, fruit: fruit)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ListView()
}
}
You can simplify it even more with:
struct Fruit: Identifiable {
let id = UUID()
var name: String
}
class ViewModel: ObservableObject {
#Published var fruits: [Fruit] = [Fruit(name: "apple"), Fruit(name: "banana"), Fruit(name: "orange")]
}
struct ListView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
List {
ForEach($viewModel.fruits) { $fruit in
ListViewRow(fruit: $fruit)
}
}
}
}
struct ListViewRow: View {
#Binding var fruit: Fruit
var body: some View {
TextField("", text: $fruit.name)
}
}
That being said, you really need to view the Apple Swift Tutorials that were linked in the comments.
Edit: Full Project Code for Lorem Ipsum:
//
// ContentView.swift
// FruitApp
//
// Created by Developer on 11/27/21.
//
import SwiftUI
struct Fruit: Identifiable {
let id = UUID()
var name: String
}
class ViewModel: ObservableObject {
#Published var fruits: [Fruit] = [Fruit(name: "apple"), Fruit(name: "banana"), Fruit(name: "orange")]
}
struct ListView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
List {
ForEach($viewModel.fruits) { $fruit in
ListViewRow(fruit: $fruit)
}
}
}
}
struct ListViewRow: View {
#Binding var fruit: Fruit
var body: some View {
TextField("", text: $fruit.name)
}
}
struct ContentView: View {
var body: some View {
ListView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

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.