TLDR version:
How do I get a SwiftUI Picker
(or other API that relies on a local Binding
) to immediately update my ObservedObject
/EnvironmentObject
. Here is more context...
The scenario:
Here is something I consistently need to do in every SwiftUI app I create...
- I always make some class that stores any user preference (let's call this class
Options
and I make it anObservableObject
. - Any setting that needs to be consumed is marked with
@Published
- Any view that consumes this brings it in as a
@ObservedObject
or@EnvironmentObject
and subscribes to changes.
This all works quite nicely. The trouble I always face is how to set this from the UI. From the UI, here is usually what I'm doing (and this should all sound quite normal):
- I have some SwiftUI view like
OptionsPanel
that drives theOptions
class above and allows the user to choose their options. - Let's say we have some option defined by an enum:
enum RefreshRate {
case low, medium, high
}
Naturally, I'd choose a Picker
in SwiftUI to set this... and the Picker
API requires that my selection param be a Binding
. This is where I find the issue...
The issue:
To make the Picker
work, I usually have some local Binding
that is used for this purpose. But, ultimately, I don't care about that local value. What I care about is immediately and instantaneously broadcasting that new value to the rest of the app. The moment I select a new refresh rate, I'd like immediately know that instant about the change. The ObservableObject
(the Options
class) object does this quite nicely. But, I'm just updating a local Binding
. What I need to figure out is how to immediately translate the Picker
's state to the ObservableObject
every time it's changed.
I have a solution that works... but I don't like it. Here is my non-ideal solution:
The non-ideal solution:
The first part of the solution is quite actually fine, but runs into a snag...
Within my SwiftUI view, rather than do the simplest way to set a Binding
with @State
I can use an alternate initializer...
// Rather than this...
@ObservedObject var options: Options
@State var refreshRate: RefreshRate = .medium
// Do this...
@ObservedObject var options: Options
var refreshRate: Binding<RefreshRate>(
get: { self.options.refreshRate },
set: { self.options.refreshRate = $0 }
)
So far, this is great (in theory)! Now, my local Binding
is directly linked to the ObservableObject
. All changes to the Picker
are immediately broadcast to the entire app.
But this doesn't actually work. And this is where I have to do something very messy and non-ideal to get it to work.
The code above produces the following error:
Cannot use instance member 'options' within property initializer; property initializers run before 'self' is available
Here my my (bad) workaround. It works, but it's awful...
The Options
class provides a shared
instance as a static property. So, in my options panel view, I do this:
@ObservedObject var options: Options = .shared // <-- This is still needed to tell SwiftUI to listen for updates
var refreshRate: Binding<RefreshRate>(
get: { Options.shared.refreshRate },
set: { Options.shared.refreshRate = $0 }
)
In practice, this actually kinda works in this case. I don't really need to have multiple instances... just that one. So, as long as I always reference that shared instance, everything works. But it doesn't feel well architected.
So... does anyone have a better solution? This seems like a scenario EVERY app on the face of the planet has to tackle, so it seems like someone must have a better way.
(I am aware some use an .onDisapear
to sync local state to the ObservedObject
but this isn't ideal either. This is non-ideal because I value having immediate updates for the rest of the app.)
CodePudding user response:
If I understand your question correctly, you want
to Set a Published value in an ObservableObject from the UI (Picker, etc.)
in SwiftUI.
There are many ways to do that, I suggest you use a ObservableObject
class, and use it directly wherever you need a binding in a view, such as in a Picker
.
The following example code shows one way of setting up your code to do that:
import Foundation
import SwiftUI
// declare your ObservableObject class
class Options: ObservableObject {
@Published var name = "Mickey"
}
struct ContentView: View {
@StateObject var optionModel = Options() // <-- initialise the model
let selectionSet = ["Mickey", "Mouse", "Goofy", "Donald"]
@State var showSheet = false
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.red)
Picker("names", selection: $optionModel.name) { // <-- use the model directly as a $binding
ForEach (selectionSet, id: \.self) { value in
Text(value).tag(value)
}
}
Button("Show other view") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
SheetView(optionModel: optionModel) // <-- pass the model to other view, see also @EnvironmentObject
}
}
}
struct SheetView: View {
@ObservedObject var optionModel: Options // <-- receive the model
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.green) // <-- show updated value
}
}
}
If you really want to have a "useless" intermediate local variable, then use this approach:
struct ContentView: View {
@StateObject var optionModel = Options() // <-- initialise the model
let selectionSet = ["Mickey", "Mouse", "Goofy", "Donald"]
@State var showSheet = false
@State var localVar = "" // <-- the local var
var body: some View {
VStack {
Text(optionModel.name).foregroundColor(.red)
Picker("names", selection: $localVar) { // <-- using the localVar
ForEach (selectionSet, id: \.self) { value in
Text(value).tag(value)
}
}
.onChange(of: localVar) { newValue in
optionModel.name = newValue // <-- update the model
}
Button("Show other view") { showSheet = true }
}
.sheet(isPresented: $showSheet) {
SheetView(optionModel: optionModel) // <-- pass the model to other view, see also @EnvironmentObject
}
}
}
CodePudding user response:
The good news is you're trying way, way, way too hard.
The ObservedObject
property wrapper can create this Binding
for you. All you need to say is $options.refreshRate
.
Here's a test playground for you to try out:
import SwiftUI
enum RefreshRate {
case low, medium, high
}
class Options: ObservableObject {
@Published var refreshRate = RefreshRate.medium
}
struct RefreshRateEditor: View {
@ObservedObject var options: Options
var body: some View {
// vvvvvvvvvvvvvvvvvvvv
Picker("Refresh Rate", selection: $options.refreshRate) {
// ^^^^^^^^^^^^^^^^^^^^
Text("Low").tag(RefreshRate.low)
Text("Medium").tag(RefreshRate.medium)
Text("High").tag(RefreshRate.high)
}
.pickerStyle(.segmented)
}
}
struct ContentView: View {
@StateObject var options = Options()
var body: some View {
VStack {
RefreshRateEditor(options: options)
Text("Refresh rate: \(options.refreshRate)" as String)
}
.padding()
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())