SwiftUI – Nested arrays Binding values through the hierarchy

I am implementing an app for stocktaking in SwiftUI, currently I have a three level deep model. Level 1/view1 is a list of [StockTake]. Level 2/view2 is a list of [StockCount] and finally level 3/view3 displays all products from the [StockProduct] array. Simplified structures:

class StockTakeViewModel: ObservableObject {
    @Published var stockTakes = [StockTake]()
}

struct StockTake {
    var stockCounts = [StockCount]()
}

struct StockCount {
    var stockProducts = [StockProduct]()
}

struct StockProduct {}

I need to add items to the stockProducts array in View3. This should be reflected in all three views. However, since I am using Binding in View2 and View3, I do not see new additions to the stockProducts array on the screen (View3 does not update when adding to the stockProducts array). Until I navigate back to View1 and then down to View2 and finally View3.

So question is how can I keep the single source of truth on level one and at the same time be able to update data and the view three levels deep? Is it possible to force View2 and View3 to re-render. Don´t like that idea, what am I missing?

This image showcases how the code behaves now

Full source code can be downloaded here: https://github.com/igunther/Levels

Models:

 class StockTakeViewModel: ObservableObject {
    @Published var stockTakes = [StockTake]()
        
    init() {
        let stockTake1 = StockTake(name: "Stocktake1",
                                   stockCounts: [.init(employee: "John Doe",
                                                       stockProducts: [.init(productName: "Initial Product1", counted: 1),
                                                                       .init(productName: "Initial Product2", counted: 1),
                                                                       .init(productName: "Initial Product3", counted: 1),
                                                                       .init(productName: "Initial Product4", counted: 1)])])
        
        self.stockTakes.append(stockTake1)
    }
}

struct StockTake: Identifiable, Hashable {
    var id = UUID()
    var name: String
        
    var counted: Int32 {
        get {
            return stockCounts.reduce(0) { $0 + $1.counted }
        }
    }
    
    var stockCounts = [StockCount]()
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

struct StockCount: Identifiable, Hashable {
    var id = UUID()
    var employee: String
    
    var counted: Int32 {
        get {
            return stockProducts.reduce(0) { $0 + $1.counted }
        }
    }
    
    var stockProducts = [StockProduct]()
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

struct StockProduct: Identifiable, Hashable {
    var id = UUID()
    var productName: String
    var counted: Int32 = 0
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

Views

struct ContentView: View {
    @ObservedObject var stockTakeViewModel = StockTakeViewModel()
    
    var body: some View {
        NavigationStack {
            StockTakesView(stockTakes: $stockTakeViewModel.stockTakes)
        }
    }
}   

/// View 1
struct StockTakesView: View {
    @Binding var stockTakes: [StockTake]
    var body: some View {
        VStack {
            Text("StockTakesView: View 1")
                .font(.title2)
            
            LazyVStack {
                ForEach($stockTakes, id: \.self) { $stockTake in
                    NavigationLink {
                        StockTakeView(stockTake: $stockTake)
                    } label: {
                        Text("\(stockTake.name) - Counted: \(stockTake.counted) ")
                    }
                }
            }
        }
    }
}

/// View 2
struct StockTakeView: View {
    @Binding var stockTake: StockTake
    var body: some View {
        VStack {
            Text("StockTakeView: View 2")
                .font(.title)
         
            Text("\(stockTake.name) - Counted: \(stockTake.counted) ")
            
            LazyVStack {
                ForEach($stockTake.stockCounts, id: \.self) { $stockCount in
                    NavigationLink {
                        StockCountsView(stockCount: $stockCount)
                    } label: {
                        Text(stockCount.employee)
                    }
                }
            }
        }
    }
}

/// View 3
struct StockCountsView: View {
    @Binding var stockCount: StockCount
    @State private var productNumber: Int = 0
    var body: some View {
        VStack {
            Text("StockCountsView: View 3")
                .font(.title)
            
            Text("Counted: \(stockCount.counted)")
            
            Button {
                productNumber += 1
                let stockProduct = StockProduct(productName: "New Product \(productNumber)", counted: 1)
                stockCount.stockProducts.append(stockProduct)
                
            } label: {
                Text("Add product")
            }
            
            LazyVStack {
                ForEach(stockCount.stockProducts) { stockProduct in
                    Text(stockProduct.productName)
                }
            }
        }
    }
}

>Solution :

The problem doesn’t lie in your model but instead in your ForEach calls. By using id: \.self you’re inadvertently defining the range as a fixed hash value.

You can read more about \.self on Hacking With Swift.

Thankfully, because your model is Identifiable the fix is super easy—just delete id: \.self from both ForEach and instead call just ForEach($stockTakes) {... and ForEach($stockTakes. stockCounts) {...

Leave a Reply