3

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?

Isaiah
  • 1,852
  • 4
  • 23
  • 48

3 Answers3

3

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

nayem
  • 7,285
  • 1
  • 33
  • 51
  • 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. – Isaiah Dec 19 '19 at 08:48
  • 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!! – sp_conway Jun 10 '20 at 02:06
0

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)

Tobias Hesselink
  • 1,487
  • 9
  • 17
  • Thank you for your suggestion! Hmm, I see SwiftUI doesn't support refreshing `View`s on changes of nested `ObservableObject`s 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? – Isaiah Dec 18 '19 at 14:39
  • 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 – Tobias Hesselink Dec 18 '19 at 14:54
  • 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). – Isaiah Dec 18 '19 at 17:04
0

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.

Isaiah
  • 1,852
  • 4
  • 23
  • 48