How do I pass data from a child view to a parent view to another child view in SwiftUI?

10,630

Solution 1

You can use EnvironmentObject for stuff like this...

The nice thing about EnvironmentObject is that whenever and wherever you change one of it's variables, it will always make sure that every where that variable is used, it's updated.

Note: To get every variable to update, you have to make sure that you pass through the BindableObject class / EnvironmentObject on everyView you wish to have it updated in...

SourceRectBindings:

class SourceRectBindings: BindableObject {

    /* PROTOCOL PROPERTIES */
    var willChange = PassthroughSubject<Application, Never>()

    /* Seems Like you missed the 'self' (send(self)) */
    var sourceRect: CGRect = .zero { willSet { willChange.send(self) }
}

Parent:

struct ParentView: view {
    @EnvironmentObject var sourceRectBindings: SourceRectBindings

    var body: some View {
        childViewA()
            .environmentObject(sourceRectBindings)
        childViewB()
            .environmentObject(sourceRectBindings)
    }
}

ChildViewA:

struct ChildViewA: view {
    @EnvironmentObject var sourceRectBindings: SourceRectBindings

    var body: some View {

        // Update your frame and the environment...
        // ...will update the variable everywhere it was used

        self.sourceRectBindings.sourceRect = CGRect(x: 0, y: 0, width: 300, height: 400)
        return Text("From ChildViewA : \(self.sourceRectBindings.sourceRect)")
    }
}

ChildViewB:

struct ChildViewB: view {
    @EnvironmentObject var sourceRectBindings: SourceRectBindings

    var body: some View {
        // This variable will always be up to date
        return Text("From ChildViewB : \(self.sourceRectBindings.sourceRect)")
    }
}

LAST STEPS TO MAKE IT WORK

• Go into your highest view you want the variable to update which is your parent view but i usually prefer my SceneDelegate ... so this is how i usually do it:

let sourceRectBindings = SourceRectBindings()

• Then in the same class 'SceneDelegate' is go to where i specify my root view and pass through my EnviromentObject

window.rootViewController = UIHostingController(
                rootView: ContentView()
                    .environmentObject(sourceRectBindings)
            )

• Then for the last step, I pass it through to may Parent view

struct ContentView : View {

    @EnvironmentObject var sourceRectBindings: SourceRectBindings

    var body: some View {
        ParentView()
            .environmentObject(sourceRectBindings)
    }
}

If you run the code just like this, you'll see that your preview throws errors. Thats because it didn't get passed the environment variable. To do so just start a dummy like instance of your environment:

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(SourceRectBindings())
    }
}
#endif

Now you can use that variable how ever you like and if you want to pass anything else other that the rect ... you can just add the variable to the EnvironmentObject class and you can access and update it

Solution 2

You did not mention how ChildViewB obtains its rect. That is, does it use a GeometryReader, or any other source. Whatever method you use, you can pass information upwards the hierachy in two ways: through a binding, or through preferences. And if geometry is involved, even anchor preferences.

For the first case, without seeing your implementation, you may be missing a DispatchQueue.main.async {} when setting the rect. This is because the change must occur after the view finished updating itself. However, I consider that a dirty, dirty trick and only use it while testing, but never on production code.

The second alternative (using preferences), is a more robust approach (and is preferred ;-). I will include the code for both approaches, but you should definetely learn more about preferences. I recently wrote an article, that you can find here: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/

First Method: I do not recommend, passing geometry values upward the hierarchy, that will affect how other views draw. But if you insist, this is how you can make it work:

struct ContentView : View {
    @State var rectFrame: CGRect = .zero

    var body: some View {
        VStack {
            ChildViewA(rectFrame: $rectFrame)
            ChildViewB(rectFrame: rectFrame)
        }
    }
}

struct ChildViewA: View {
    @Binding var rectFrame: CGRect

    var body: some View {
        DispatchQueue.main.async {
            self.rectFrame = CGRect(x: 10, y: 20, width: 30, height: 40)
        }

        return Text("CHILD VIEW A")
    }
}

struct ChildViewB: View {
    var rectFrame: CGRect = CGRect.zero

    var body: some View {
        Text("CHILD VIEW B: RECT WIDTH = \(rectFrame.size.width)")
    }
}

Second Method: Using Preferences

import SwiftUI

struct MyPrefKey: PreferenceKey {
    typealias Value = CGRect

    static var defaultValue: CGRect = .zero

    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

struct ContentView : View {
    @State var rectFrame: CGRect = .zero

    var body: some View {
        VStack {
            ChildViewA()
            ChildViewB(rectFrame: rectFrame)
        }
        .onPreferenceChange(MyPrefKey.self) {
            self.rectFrame = $0
        }

    }
}

struct ChildViewA: View {
    var body: some View {
        return Text("CHILD VIEW A")
            .preference(key: MyPrefKey.self, value: CGRect(x: 10, y: 20, width: 30, height: 40))
    }
}

struct ChildViewB: View {
    var rectFrame: CGRect = CGRect.zero

    var body: some View {
        Text("CHILD VIEW B: RECT WIDTH = \(rectFrame.size.width)")
    }
}
Share:
10,630

Related videos on Youtube

cyril
Author by

cyril

MD Candidate, DL/RL Researcher, Software Engineer and iOS Developer

Updated on June 04, 2022

Comments

  • cyril
    cyril almost 2 years

    as the title suggests, I'm trying to pass data from one child view (A), to another child view (B) through the parent view (P).

    The parent view looks like this:

    @State var rectFrame: CGRect = .zero 
    
    var body: some View {
        childViewA(rectFrame: $rectFrame)
        childViewB()
    }
    

    where childViewA obtains a CGRect that childViewB needs.

    childViewA looks like this:

    @Binding var rectFrame: CGRect
    
    var body: some View {
        // Very long code where rectFrame is obtained and printed to console correctly
    }
    

    How do I pass rectFrame to childViewB? Everything I've tried so far returns CGRect.zero in childViewB despite printing the correct values in both the parentView and childViewA.

    In order to try and pass the value to childViewB, I've rewritten parentView like this:

    @State var rectFrame: CGRect = .zero 
    
    var body: some View {
        childViewA(rectFrame: $rectFrame)
        childViewB(rectFrame: $rectFrame.value)
    }
    

    with childViewB having the following structure:

    var rectFrame: CGRect = CGRect.zero
    
    var body: some View {
    
    }
    

    But that just prints CGRect.zero every time.

    I've recently tried @ObjectBinding but I've struggled with it, so if anyone could help me out with this specific example, I'd be very grateful.

    class SourceRectBindings: BindableObject {
        let willChange = PassthroughSubject<Void, Never>()
    
        var sourceRect: CGRect = .zero {
            willSet {
                willChange.send()
            }
        }
    }
    
  • kontiki
    kontiki almost 5 years
    If you need to get the size and position of a view, check the link I posted. It's plenty of examples.
  • cyril
    cyril almost 5 years
    I actually used your blog (so Preferences to answer your question) to obtain the CGRect info in childA which I successfully get in the parent view. Unfortunately it seems Preferences doesn't get the correct frame if the view is inside of a list?
  • kontiki
    kontiki almost 5 years
    @cyril Without looking at the code, I am unable to tell. Maybe it is a coordinate space issue? Or an alignment issue? If you update the question, I'll have a look.
  • kontiki
    kontiki almost 5 years
    Also note that using EnvironmentObject (like in the other answer) is a possible solution. I did not go that route, because I though you were intending to keep it encapsulated. But going through a more global approach is also a possibility.
  • cyril
    cyril almost 5 years
    Might be a coordinate space issue - if I recall Part 2 of your blog post uses another method that doesn't rely on coordinate spaces. I might give that a go eventually
  • kontiki
    kontiki almost 5 years
    Remember you can also name the coordinate space of a view, and when you use GeometryReader's frame(in:CoordinateSpace) method, you can use that name to create a rect in another view's coordinate space.
  • Sardorbek Ruzmatov
    Sardorbek Ruzmatov about 3 years
    Great explanation!