How to use SwiftUI and Combine to propagate state changes of child View to parent View?

Related searches

How can I, using SwiftUI and Combine, have a state of the uppermost View depend on a state of its contained SubView, determined by criteria among others dependent on its contained SubSubView?


The scenario

I have the following View hierarchy: V1 contains V2, which contains V3.

  1. V1 is a general, mostly decorative, 'wrapper' of a specific settings view V2 and holds a "Save" button. The button's disabled state of type Bool should depend on the save-ability state of V2.

  2. V2 is a specific settings view. Which type of V2, the specific settings shown, may differ depending on the rest of my program. It is guaranteed to be able to determine its save-ability. It contains a Toggle and V3, a MusicPicker. V2's save-ability is dependent on criteria processing V3's selection-state and its Toggle-state.

  3. V3 is a general 'MusicPicker' view with a selection-state of type Int?. It could be used with any parent, communicating bidirectionally its selection-state.

A Binding should normally be used to communicate back and forth between 2 views. As such, there could be a binding between V1 and V2 and V2 and V3. However, V2 cannot/should not react to a binding's value change of V3 and communicate this (along with other criteria) back to V1, as far as I know/understand. I may use ObservableObjects to share a save-ability with V1 and V2 and to share a selection-state with V2 and V3, but it is unclear to me how to integrate V3's ObservableObject changes with other criteria to set V1's ObservableObject.

The examples
Using @State and @Binding
/* V1 */
struct SettingsView: View {
    @State var saveable = false

    var body: some View {
        VStack {
            Button(action: saveAction){
                Text("Save")
            }.disabled(!saveable)
            getSpecificV2(saveable: $saveable)
        }
    }

    func getSpecificV2(saveable: Binding<Bool>) -> AnyView {
        // [Determining logic...]
        return AnyView(SpecificSettingsView(saveable: saveable))
    }

    func saveAction(){
        // More code...
    }
}

/* V2 */
struct SpecificSettingsView: View {
    @Binding var saveable: Bool

    @State var toggled = false
    @State var selectedValue: Int?

    var body: some View {
        Form {
            Toggle("Toggle me", isOn: $toggled)
            CustomPicker(selected: $selectedValue)
        }
    }

    func someCriteriaProcess() -> Bool {
        if let selected = selectedValue {
            return (selected == 5)
        } else {
            return toggled
        }
    }
}

/* V3 */
struct CustomPicker: View {
    @Binding var selected: Int?

    var body: some View {
        List {
            Text("None")
                .onTapGesture {
                    self.selected = nil
            }.foregroundColor(selected == nil ? .blue : .primary)
            Text("One")
                .onTapGesture {
                    self.selected = 1
            }.foregroundColor(selected == 1 ? .blue : .primary)
            Text("Two")
                .onTapGesture {
                    self.selected = 2
            }.foregroundColor(selected == 2 ? .blue : .primary)
        }
    }
}

In this example code, I would need to essentially have saveable be dependent on someCriteriaProcess().

Using ObservableObject

In response to Tobias' answer, a possible alternative would be to use ObservableObjects.

/* V1 */
class SettingsStore: ObservableObject {
  @Published var saveable = false
}

struct SettingsView: View {
    @ObservedObject var store = SettingsStore()

    var body: some View {
        VStack {
            Button(action: saveAction){
                Text("Save")
            }.disabled(!store.saveable)
            getSpecificV2()
        }.environmentObject(store)
    }

    func getSpecificV2() -> AnyView {
        // [Determining logic...]
        return AnyView(SpecificSettingsView())
    }

    func saveAction(){
        // More code...
    }
}

/* V2 */
struct SpecificSettingsView: View {
    @EnvironmentObject var settingsStore: SettingsStore
    @ObservedObject var pickerStore = PickerStore()

    @State var toggled = false
    @State var selectedValue: Int?

    var body: some View {
        Form {
            Toggle("Toggle me", isOn: $toggled)
            CustomPicker(store: pickerStore)
        }.onReceive(pickerStore.objectWillChange){ selected in
            print("Called for selected: \(selected ?? -1)")
            self.settingsStore.saveable = self.someCriteriaProcess()
        }
    }

    func someCriteriaProcess() -> Bool {
        if let selected = selectedValue {
            return (selected == 5)
        } else {
            return toggled
        }
    }
}

/* V3 */

class PickerStore: ObservableObject {
    public let objectWillChange = PassthroughSubject<Int?, Never>()
    var selected: Int? {
        willSet {
            objectWillChange.send(newValue)
        }
    }
}

struct CustomPicker: View {
    @ObservedObject var store: PickerStore

    var body: some View {
        List {
            Text("None")
                .onTapGesture {
                    self.store.selected = nil
            }.foregroundColor(store.selected == nil ? .blue : .primary)
            Text("One")
                .onTapGesture {
                    self.store.selected = 1
            }.foregroundColor(store.selected == 1 ? .blue : .primary)
            Text("Two")
                .onTapGesture {
                    self.store.selected = 2
            }.foregroundColor(store.selected == 2 ? .blue : .primary)
        }
    }
}

Using the onReceive() attachment, I try to react to any changes of the PickerStore. Although the action fires and the debug prints correctly, no UI change is shown.


The question

What is (in this scenario) the most appropriate approach to react to a change in V3, process this with other states in V2, and correspondingly change a state of V1, using SwiftUI and Combine?

Posting this answer on the premise of the approach with ObservableObject that is added on your question itself.

Look carefully. As soon as the code:

.onReceive(pickerStore.objectWillChange){ selected in
    print("Called for selected: \(selected ?? -1)")
    self.settingsStore.saveable = self.someCriteriaProcess()
}

runs in the SpecificSettingsView the settingsStore is about to change which triggers the parent SettingsView to refresh its associated view components. That means the func getSpecificV2() -> AnyView will return SpecificSettingsView object that in turns will instantiate the PickerStore again. Because,

SwiftUI views, being value type (as they are struct), will not retain your objects within their view scope if the view is recreated by a parent view, for example. So it’s best to pass those observable objects by reference and have a sort of container view, or holder class, which will instantiate and reference those objects. If the view is the only owner of this object, and that view is recreated because its parent view is updated by SwiftUI, you’ll lose the current state of your ObservedObject.

(Read More on the above)


If you just push the instantiation of the PickerStore higher in the view hierarchy (probably the ultimate parent) you will get the expected behavior.

struct SettingsView: View {
    @ObservedObject var store = SettingsStore()
    @ObservedObject var pickerStore = PickerStore()

    . . .

    func getSpecificV2() -> AnyView {
        // [Determining logic...]
        return AnyView(SpecificSettingsView(pickerStore: pickerStore))
    }

    . . .

}

struct SpecificSettingsView: View {
    @EnvironmentObject var settingsStore: SettingsStore
    @ObservedObject var pickerStore: PickerStore

    . . .

}

Note: I uploaded the project at remote repository here

How do I pass data from a child view to a parent view to another , 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� @Binding. A single view in SwiftUI may be composed of multiple child views. Sometimes you want to allow the child to change the state of the parent view.

Because SwiftUI doesn't support refreshing Views on changes inside a nested ObservableObject, you need to do this manually. I posted a solution here on how to do this:

https://stackoverflow.com/a/58996712/12378791 (e.g. with ObservedObject)

https://stackoverflow.com/a/58878219/12378791 (e.g. with EnvironmentObject)

View Communication Patterns in SwiftUI, When to use SwiftUI binding, Environment and PreferenceKey explained with Swift code examples? SwiftUI passes it automatically from a parent view to its children. by the parent and the child, effectively passing that change back and forth. struct TodoListView: View { let items: [TodoItem] @State var� This means you can update the parent view from the child view just by changing the value of the parent view’s @State variable in the child view using @Binding. Let’s see how to use the

I have figured out a working approach with the same end result, that may be useful to others. It does not, however, pass data in the way I requested in my question, but SwiftUI does not seem suitable to do so in any case.

As V2, the 'middle' view, can properly access both important states, that of the selection and save-ability, I realised I could make V2 the parent view and have V1, initially the 'parent' view, be a child view accepting @ViewBuilder content instead. This example would not be applicable to all cases, but it would to mine. A working example is as follows.

/* V2 */
struct SpecificSettingsView: View {
    @State var toggled = false
    @State var selected: Int?

    var saveable: Bool {
        return someCriteriaProcess()
    }

    var body: some View {
        SettingsView(isSaveable: self.saveable, onSave: saveAction){
            Form {
                Toggle("Toggle me", isOn: self.$toggled)
                CustomPicker(selected: self.$selected)
            }
        }
    }

    func someCriteriaProcess() -> Bool {
        if let selected = selected {
            return (selected == 2)
        } else {
            return toggled
        }
    }

    func saveAction(){
        guard saveable else { return }
        // More code...
    }
}

/* V1 */
struct SettingsView<Content>: View where Content: View {
    var content: () -> Content
    var saveAction: () -> Void
    var saveable: Bool

    init(isSaveable saveable: Bool, onSave saveAction: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content){
        self.saveable = saveable
        self.saveAction = saveAction
        self.content = content
    }

    var body: some View {
        VStack {
            // More decoration
            Button(action: saveAction){
                Text("Save")
            }.disabled(!saveable)
            content()
        }
    }
}


/* V3 */
struct CustomPicker: View {
    @Binding var selected: Int?

    var body: some View {
        List {
            Text("None")
                .onTapGesture {
                    self.selected = nil
            }.foregroundColor(selected == nil ? .blue : .primary)
            Text("One")
                .onTapGesture {
                    self.selected = 1
            }.foregroundColor(selected == 1 ? .blue : .primary)
            Text("Two")
                .onTapGesture {
                    self.selected = 2
            }.foregroundColor(selected == 2 ? .blue : .primary)
        }
    }
}

I hope this proves useful to others.

SwiftUI Architecture: Observable Objects, the Environment and , Sharing data with View children and stateless Views. First, let's take a look Using Binding for two-way data sharing between a view and its children. In families, just like In that case, you're the one passing information to your parents. SwiftUI uses Combine to track changes to state variables. While you� 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 = .zer

Online Preview: Thinking in SwiftUI � objc.io, Like all view updates in SwiftUI, animations are triggered by state changes. For example, we won't discuss how to use a navigation view on iOS, a split SwiftUI uses to propagate values down the view tree, i.e. from a parent view to its As such, the parent needs a way to combine all the preferences of the children into a � The way SwiftUI works in regard to state is this: Cleans the 'touched' state on all @State variables. Invokes body on a view. Looks at the 'touched' state on the @State variables. If a state property was accessed, it's marked, so that when that state is modified, it triggers another body call.

What Is @Binding in SwiftUI?. And how do you use it?, With @Binding , you can pass the @State variable value from parent view to child view and if child view alters, mutates, or changes the value of� You can do the same thing SwiftUI does by using Combine's ObservableObject to track changes to any value, whether it's in a View or an entirely different object. ObservableObject object tracks changes to its properties and publishes a Combine event whenever a change occurs.

The example is showcasing the fact that the child (e.g., SubView) can ignore what’s being offered by the parent. In the example, the yellow rectangle is the child view and the blue border is what has been offered. For values greater than 120 the child honors the parent’s wishes, but for values lower than 120, the child ignores it.

Comments
  • Thank you, an excellent answer! It explains well why SwiftUI's set-up isn't quite suitable for my approach. Your proposed example would work for my specific V2. However, the substates in V2 should be unknown to V1, as V2 can be any of several specific views that's saveable; they may not need a PickerStore, or instead rely on other states. Your explanation is correct, but the example does not meet my requirements. I suppose any 'substates' should then be somehow attached to SettingsView. I will mark your answer as correct, although for 'varying V2s', my own answer is more appropriate.
  • This helped me. Instead of creating a new instance of the ObservedObject in the child view, I passed the ObservedObject in the parent view down to the child view. Thanks!!
  • Thank you for your suggestion! Hmm, I see SwiftUI doesn't support refreshing Views on changes of nested ObservableObjects out-of-the-box. However, from your answers it remains unclear to me how to reflect a change of one ObservedObject 'onto' another. I have added another possible (but incomplete) implementation of my code to my answer; could you further point me towards what is missing here?
  • You used SettingsStore 2 different times. The first time as ObservableObject and the second time as EnvironmentObject. What you can do is to use 1 global EnvironmentObject and store your data over there, like the answer i mentioned. When using 1 global data object (the environmentObject), every View will be updated automatically when a @Published variable changes inside this EnvironmentObject. Hope this helps.. GL
  • The first SettingsStore instance is saved as ObservableObject in SettingsView/V1 and shared with child views (including SpecificSettingsView/V2) as EnvironmentObject yes. My problem is not that I cannot update V1 from V2 (through changing the SettingsStore @Published variable) but that I cannot / do not know how to do this, as a reaction to MusicStore's state changing (another ObservedObject's @Published vairable).