Home > front end >  SwiftUI: Set a Published value in an ObservableObject from the UI (Picker, etc.)
SwiftUI: Set a Published value in an ObservableObject from the UI (Picker, etc.)

Time:01-30

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

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