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.
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.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.
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 ObservableObject
s 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 ObservableObject
s.
/* 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?