P. Protas
P. Protas

Reputation: 502

How to pass binding to child view in the new NavigationStack.navigationDestination

I am trying to pass a binding from a parent list view into a child detail view. The child detail view contains logic to edit the child. I want these changes to be reflected in the parent list view:

import SwiftUI

struct ParentListView: View {
    var body: some View {
        NavigationStack {
            List {
                ForEach(0 ..< 5) { number in
                    NavigationLink(value: number) {
                        Text("\(number)")
                    }
                }
            }
            .navigationDestination(for: Int.self) { number in
                ChildDetailView(number: number) //Cannot convert value of type 'Int' to expected argument type 'Binding<Int>'

            }
        }
    }
}

struct ChildDetailView: View {
    @Binding var number: Int
    var body: some View {
        VStack {
            Text("\(number)")
            Button {
                number += 10
            } label: {
                Text("Add 10")
            }
        }
    }
}

But as you can see, I cannot pass number into ChildDetailView because it expects a binding. I have tried putting $ before number but that doesn't work either. Is there a way to make this work, or am I using the new NavigationStack completly wrong?

Upvotes: 27

Views: 7275

Answers (5)

malhal
malhal

Reputation: 30746

Here is a working solution, it uses the binding(for:) func from Apple's ScrumDinger sample before they updated it to use the newer ForEach($scrums) { $scrum. There is something similar in the newer Food Truck sample in orderBinding(for id: Order.ID) in but that is more verbose than the old ScrumDinger way.

Note: A strange thing is the navigationDestination block is called 3 times when navigating instead of once as we would expect (Edit: its because self was captured by the closure so the closure is when any property changes).

struct NumberItem: Identifiable {
    let id = UUID()
    var number: Int
}

struct ParentListView: View {
    
    @State private var numberItems = [NumberItem(number: 1), NumberItem(number: 2), NumberItem(number: 3), NumberItem(number: 4), NumberItem(number: 5)]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(numberItems) { numberItem in
                    NavigationLink(value: numberItem.id) {
                        Text("\(numberItem.number)")
                    }
                }
            }
            .navigationDestination(for: UUID.self) { numberItemID in
                ChildDetailView(numberItems: $numberItems, numberItemID: numberItemID)
            }
        }
    }
}

struct ChildDetailView: View {
    @Binding var numberItems: [NumberItem]
    let numberItemID: UUID
    
    var body: some View {
        ChildDetailView2(numberItem: binding(for: numberItemID))
    }
    
    private func binding(for numberItemID: UUID) -> Binding<NumberItem> {
        guard let index = numberItems.firstIndex(where: { $0.id == numberItemID }) else {
            fatalError("Can't find item in array")
        }
        return $numberItems[index]
    }
}

struct ChildDetailView2: View {
    
    @Binding var numberItem: NumberItem
    
    var body: some View {
        VStack {
            Text("\(numberItem.number)")
            Button {
                numberItem.number += 10
            } label: {
                Text("Add 10")
            }
        }
    }
}

Upvotes: 2

Asperi
Asperi

Reputation: 258345

Well, actually it is possible, but at first it is needed source of truth, i.e. state with data to bind to, and but in this case Binding will update only source, but not destination. It is more appropriate is to use ObservableObject view model in such case.

Tested with Xcode 14 / iOS 16

Note: binding do not refresh ChildDetailView in such case - in can be used only for actions, but source is updated !!

Here is main part:

@State private var numbers = [1, 2, 3, 4, 5]  // << data !!
var body: some View {
    NavigationStack {
        List {
            ForEach($numbers) { number in      // << binding !!
                NavigationLink(value: number) {
                    Text("\(number.wrappedValue)")
                }
            }
        }
        .navigationDestination(for: Binding<Int>.self) { number in // << !!
            ChildDetailView(number: number)  // << binding !!
        }
    }
}

and a couple of extensions needed for Binding to transfer via navigation link value.

Complete code on GitHub

Upvotes: 12

thiezn
thiezn

Reputation: 2034

The Foodtruck sample app from Apple has an example on how to tackle bindings in a navigationDestination. Have a look at the following line in their github repo

        .navigationDestination(for: Donut.ID.self) { donutID in
            DonutEditor(donut: model.donutBinding(id: donutID))
        }

They use the Donut model ID for navigation. To pass on the binding, they added a getter/setter on the FoodTruck model that contains a list of donuts to generate a binding by Donut.ID.

    public func donutBinding(id: Donut.ID) -> Binding<Donut> {
        Binding<Donut> {
            self.donuts[id]
        } set: { newValue in
            self.donuts[id] = newValue
        }
    }

Upvotes: 11

P. Protas
P. Protas

Reputation: 502

We did it! Thank you guys so much for your answers, it helped soo much! I was struggling all day with this

class NumbersService: ObservableObject {
    @Published public var numbers = [1, 2, 3, 4, 5]
    
    func incrementByTen(index: Int) {
        numbers[index] += 10
    }
}

struct ParentListView: View {
    @StateObject var numbersService = NumbersService()
    var body: some View {
        NavigationStack {
            List {
                ForEach(numbersService.numbers.indices, id: \.self) { index in
                    NavigationLink(value: index) {
                        Text("\(numbersService.numbers[index])")
                    }
                }
            }
            .navigationDestination(for: Int.self) { index in
                ChildDetailView(index: index)
            }
        }
        .environmentObject(numbersService)
    }
}

struct ChildDetailView: View {
    @EnvironmentObject var numbersService: NumbersService
    var index: Int

    var body: some View {
        Group {
            Text("\(numbersService.numbers[index])")
            Button {
                numbersService.incrementByTen(index: index)
            } label: {
                Text("Add 10")
            }
        }
    }
}

Basically I just used a shared NumbersService, and identified the selected number by index. This way both views share the same reference to the correct number, and the value correctly updates in BOTH the ParentView and ChildView! I'm not sure if this is way too complicated code, but it works for me :P

Upvotes: 0

Steven-Carrot
Steven-Carrot

Reputation: 3101

This might be the solution that you are looking for. When you change the data in the ChildView, the data in the ParentView will be changed too. I used a different NavigationView in ParentView due to not having your package, but it's the same logic.

When you want to pass data between ChildView and ParentView (for small work), you can use @Binding type in ChildView and @State type in ParentView. Code:

import SwiftUI

struct ParentListView: View {

//Try this
@State var bindingData: Int = 0

var body: some View {
    NavigationView {
        List {
            ForEach(0 ..< 5, id: \.self) { _ in
                NavigationLink {
                    ChildDetailView(number: $bindingData) //Try this, and don't forget $ sign
                } label: {
                    Text("\(bindingData)") //Try this
                }
            }
        }
    }
}
}

struct ChildDetailView: View {

@Binding var number: Int //Try this

var body: some View {
    VStack {
        Text("\(number)")
        Button {
            number += 10
        } label: {
            Text("Add 10")
        }
    }
}
}

Upvotes: 0

Related Questions