-2

Update:

This question is already solved (see responses below). The correct way to do this is to get your Binding by projecting the ObservableObject For example, $options.refreshRate.

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 an ObservableObject.
  • 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 the Options 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.)

Justin Reusch
  • 624
  • 7
  • 14
  • 1
    Have a look at this link, it gives you some good examples of how to manage data in your app : https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app Declare your `Options` as `class Options: ObservableObject { @Published var name ....}` and use this `options` model directly in your `Picker`, like this: `Picker(selection: $options.name, label: Text("...")) {...}` – workingdog support Ukraine Jan 29 '23 at 07:49
  • 1
    Just use “$options.refreshRate” why recreate the wheel? – lorem ipsum Jan 29 '23 at 12:58
  • We use `@AppStorage` for options not `ObservableObject`. `ObservableObject` is used for holding the array of model structs in `@Published` properties. – malhal Jan 29 '23 at 17:03
  • Thanks! `$options.refreshRate` was exactly what I was looking for. I just wasn't aware `ObservableObject` had a projected value that returned a `Binding`. I had tried to get this working with no luck, but I think I was trying to project the property vs. the entire `ObservedObject`.. in other words, `options.$refreshRate` vs. `$options.refreshRate`. The local bindings were a workaround since I hadn't found a way to get a `Binding` from an `ObservedObject` till now. Thanks! – Justin Reusch Jan 30 '23 at 06:46

2 Answers2

2

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())

It's also worth noting that if you want to create a custom Binding, the code you wrote almost works. Just change it to be a computed property instead of a stored property:

var refreshRate: Binding<RefreshRate> {
    .init(
        get: { self.options.refreshRate },
        set: { self.options.refreshRate = $0 }
    )
} 
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
1

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
         }
     }
 }
  • Perfect... This is exactly the way I wanted to do it, but isn't obvious from any docs I've come across that `ObservableObject` had a projected value that returned a `Binding`. I think I was trying to project the published value vs. the entire `ObservedObject`.. in other words, `optionModel.$name` vs. `$optionModel.name`. Thanks! This is exactly the way I wanted it to work but had never found an actual example of. – Justin Reusch Jan 30 '23 at 06:39
  • This is covered in [WWDC 2020: Data Essentials in SwiftUI](https://developer.apple.com/wwdc20/10040?time=1046). At 17m25s, Luca talks about it and shows an example. This video is part of the [“Getting started with SwiftUI”](https://developer.apple.com/news/?id=6ka7o7z9) collection. – rob mayoff Jan 30 '23 at 21:55
  • how to be when Its required that optionModel should be ObservedObject ??? – swift2geek Feb 14 '23 at 20:41