swiftuiobservableobject

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.


Solution

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