SwiftUI View not updating with binding to computed property on ObservableObject - swift

I have a very simple SwiftUI view that only shows a TextField. The text field's text is bound to the string property of my viewModel that I instantiate as a #StateObject:
struct ContentView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
TextField("Placeholder", text: $viewModel.string)
.padding()
}
}
The one thing that's "out of the ordinary" is that my string property is not a #Published property, but a simple computed property. In its setter, it sets the displayedString property to a fixed string (for testing purposes) which is a #Published property:
class ViewModel: ObservableObject {
#Published var displayedString: String = ""
var string: String {
get { displayedString }
set { displayedString = "OVERRIDE" }
}
}
So when I type a string into the text field, I would expect the setter to trigger a view update (as it updates a #Published property) and then in the view, the text field should be updated with the displayedString. So in other words: No matter what I type, the text field should always show the string OVERRIDE.
But that's not the case!
It works for the very first letter I type, but then I can freely type anything into the text field.
For example, if I launch the app with only this view and type the string
123456789
this is what is displayed on the text field:
OVERRIDE23456789
Why is that?
And how can I get the text field to always be up-to-date to what's set on the viewModel? (I know, I can just hook the text field to a #Published property directly, but I'm doing this computed property stuff for a reason and want to understand why it doesn't work as expected.)
Additional Observation:
When I add a Text label above the TextField and just make it show the viewModel.string, it shows the correct (expected) string:
var body: some View {
Text(viewModel.string)
TextField("Placeholder", text: $viewModel.string)
.padding()
}

I’m not sure but I think a custom binding might work for you
In the ViewModel use
var string: Binding<String> { Binding(
get: { displayedString },
set: { displayedString = “OVERRIDE” } )}
Then in body use: viewModel.string instead of $viewModel.string in the TextField and use: viewModel.string.wrappedValue in the text view
Stuf

Related

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

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

Swift binding to a computed property

Have the following situation. I have a view model that is an observable object with a computed property of type Bool. I want to be able to enable/disable a navigation link based on the computed property, but I need a binding to do so. Here a simplified example:
struct Project {
var name: String
var duration: Int
}
class MyViewModel: Observable Object {
#Published var project: Project
var isProjectValid: Bool {
return project.name != "" && project.duration > 0
}
}
struct MyView: View {
#EnvironmentObject var myVM: MyViewModel
var body: some View {
...
NavigationLink("Click Link", isActive: ?????, destination: NextView())
...
}
}
Since isActive expects a binding I am not able to access computed property such as myVM.isProjectValid. Tried also with the computed property in project class, still same problem.
Also considered creating custom binding related to the computed property, but not quite sure if/how to go about it.
First question I have posted, so if I am missing some details please be kind :)
Make it a #Published property and update it when project is changed
class MyViewModel: ObservableObject {
#Published var project: Project {
didSet {
isProjectValid = project.name != "" && project.duration > 0
}
}
#Published var isProjectValid: Bool
//...
}
The use of the computed property suggests some type of design where the user is not supposed to trigger the NavigationLink directly. But instead the NavigationLink is expected to be triggered programatically as a side-effect of some other mechanism elsewhere in the code. Such as might be done at the completion of a form or similar process by the user.
Not 100% if this is what's being aimed for, but if it is, then one option would be to pass a constant Binding to the NavigationLink, e.g.
NavigationLink("Click Link", isActive: .constant(myVM.isProjectValid), destination: NextView())`

Cannot convert value of type 'Published<[StepsEntity]>.Publisher' to expected argument type 'Binding<String>'

StepsEntity is a core data entity
Receiving the following error when attempting to display a string value in a TextField: "Cannot convert value of type 'Published<[StepsEntity]>.Publisher' to expected argument type 'Binding'"
I know this is because StepsEntity in my core data model is #Published. #Published works great here as it allows all the data to be updated neatly. How can I display an #Published in a TextField?
Below is the piece where I am receiving the error:
List {
VStack(alignment: .center) {
if let recipeSteps = (vm.recipes[vm.getRecordsCount() - 1].steps?.allObjects as? [StepsEntity])?.sorted { $0.stepNumber < $1.stepNumber } {
if (textFieldCount == 1) {
//do nothing
} else if (textFieldCount > 1) {
ForEach(recipeSteps, id: \.stepNumber) { index in
HStack {
Text(String(index.stepNumber) + ".").bold()
TextField("", text: vm.$recipeSteps) //This is where the error is seen
}
}.onDelete(perform: { index in
self.vm.deleteRecipeSteps(at: index, from: vm.recipes[vm.getRecordsCount() - 1])
})
}
}
}
vm.recipeSteps refers to my CoreDataRelationshipViewModel, which is where all core data functions are handled. this is declared in the view struct as:
#StateObject var vm = CoreDataRelationshipViewModel()
Here is a snippet from the CoreDataRelationshipViewModel class:
class CoreDataRelationshipViewModel: ObservableObject {
let manager = CoreDataManager.instance
#Published var recipes: [RecipeEntity] = []
#Published var recipeSteps: [StepsEntity] = []
init() {
getRecipes()
}
func getRecipes() { ////more functions for retrieving, deleting, etc. in this section
I have tried converting the Published var to a binding but no luck:
TextField("", text: Binding(vm.$recipeSteps)!)
I have also tried referencing the recipeSteps declared in the if let statement within the list, but that does not work either.
I have been at it for a few days, and I think I have exhausted all options. Open to all ideas here. Maybe I need to rebuild my model?
Thoughts?
--Edits--
Upper portion of struct, where variables are created:
struct AddItemView: View {
#StateObject var viewModel = ViewModel()
#State var frameDimensions: CGFloat = 0
#State var imageButtonText: String = "Click To Add Image"
#State var imageToUpload: Data
#StateObject var vm = CoreDataRelationshipViewModel()
#Environment(\.presentationMode) var presentationMode
#State var stepInfo: String = ""
#State var textFieldCount: Int = 1
#State var stepNumber: [Int]
#State var recordsCount = 1
#State var errorText = ""
#State var stepErrorColor = Color.white.opacity(0)
#State var nameErrorColor = Color.white.opacity(0)
var body: some View {
ZStack {
HStack {
Your problem is that
TextField("", text: vm.$recipeSteps)
should actually be
TextField("", text: $vm.recipeSteps)
as you need to pass the view model with Binding rather than recipeSteps (which, when using $ to pass it beyond vm's dot accessor, passes a generic Publisher).
Also, this would only work if the #Published property in your view model conforms to StringProtocol (is a string). Are you sure recipeSteps is a string that can be edited via TextField?
I ended up restructuring the way my core data save works. Instead of saving item by item, I am now looping through a for in loop to save data all at the end. This allows me to display the data locally via some state variables, but then save the full array to my core data entity.

SwiftUI change on multilevel children Published object change

I have an ObservedObject AppStatus class that has multiple Published classes inside itself. If I have only level in terms of children, everything is working great.
The problem comes when I have a class RecordingTimeManager that has another variable inside (2-levels of children). The variable maxRecordingTime is changing properly when I press the button, it prints '15 fifteenSeconds' but the foregroundColor is not triggering the change. I am not sure if this is a SwiftUI bug or I should structure the relationships in another way:
// AppStatus
// Recording
#Published var recordingTimeManager: RecordingTimeManager = RecordingTimeManager()
// RecordingTimeManager
class RecordingTimeManager {
#Published var maxRecordingTime: TimeSeletedTime = .sixteenSeconds
...
// SwiftUI component that would need to change the opacity based on a maxRecordingTime change (.foregroundColor is not changing)
Button {
appStatus.recordingTimeManager.maxRecordingTime = .fifteenSeconds
print("15 \(appStatus.recordingTimeManager.maxRecordingTime)")
} label: {
Text("15")
.font(Font.custom("BwGradual-Bold", size: 15))
.foregroundColor(appStatus.recordingTimeManager.maxRecordingTime == .fifteenSeconds ? CLAPSOFFWHITE : TRIBESGREY)
}
Many Thanks,
Observable objects don't just work when you have multiple levels of classes. The #Published property wrapper notifies the view when the property has changed, but because this property is a class - a reference type - it doesn't actually change when you change one of its properties. In other words, the reference remains the same.
And the inner #Published doesn't do anything because there's nothing directly observing it (even if RecordingTimeManager conformed to an ObservableObject)
So, you either need to make RecordingTimeManager a value-type - a struct:
struct RecordingTimeManager {
var maxRecordingTime: TimeSeletedTime = .sixteenSeconds
}
Or, if it must be a class (perhaps because it has some internal state and life cycle), then you could create an inner view that directly observes it.
First, it needs to be an ObservableObject:
class RecordingTimeManager: ObservableObject {
#Published var maxRecordingTime: TimeSeletedTime = .sixteenSeconds
and then create a view (could be a private internal view) that observes it:
struct MainView: View {
#StateObject var appStatus: AppStatus = .init()
private struct InnerView: View {
#ObservedObject var recordingManager: RecordingTimeManager
var body: some View {
Button {
recordingManager.maxRecordingTime = .fifteenSeconds
} label: {
Text("15")
.font(Font.custom("BwGradual-Bold", size: 15))
.foregroundColor(
recordingManager.maxRecordingTime == .fifteenSeconds
? CLAPSOFFWHITE : TRIBESGREY)
}
}
}
var body: some View {
InnerView(recordingManager: appStatus.recordingTimeManager)
}
}

How to bind an array and List if the array is a member of ObservableObject?

I want to create MyViewModel which gets data from network and then updates the arrray of results. MyView should subscribe to the $model.results and show List filled with the results.
Unfortunately I get an error about "Type of expression is ambiguous without more context".
How to properly use ForEach for this case?
import SwiftUI
import Combine
class MyViewModel: ObservableObject {
#Published var results: [String] = []
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.results = ["Hello", "World", "!!!"]
}
}
}
struct MyView: View {
#ObservedObject var model: MyViewModel
var body: some View {
VStack {
List {
ForEach($model.results) { text in
Text(text)
// ^--- Type of expression is ambiguous without more context
}
}
}
}
}
struct MyView_Previews: PreviewProvider {
static var previews: some View {
MyView(model: MyViewModel())
}
}
P.S. If I replace the model with #State var results: [String] all works fine, but I need have separate class MyViewModel: ObservableObject for my purposes
The fix
Change your ForEach block to
ForEach(model.results, id: \.self) { text in
Text(text)
}
Explanation
SwiftUI's error messages aren't doing you any favors here. The real error message (which you will see if you change Text(text) to Text(text as String) and remove the $ before model.results), is "Generic parameter 'ID' could not be inferred".
In other words, to use ForEach, the elements that you are iterating over need to be uniquely identified in one of two ways.
If the element is a struct or class, you can make it conform to the Identifiable protocol by adding a property var id: Hashable. You don't need the id parameter in this case.
The other option is to specifically tell ForEach what to use as a unique identifier using the id parameter. Update: It is up to you to guarentee that your collection does not have duplicate elements. If two elements have the same ID, any change made to one view (like an offset) will happen to both views.
In this case, we chose option 2 and told ForEach to use the String element itself as the identifier (\.self). We can do this since String conforms to the Hashable protocol.
What about the $?
Most views in SwiftUI only take your app's state and lay out their appearance based on it. In this example, the Text views simply take the information stored in the model and display it. But some views need to be able to reach back and modify your app's state in response to the user:
A Toggle needs to update a Bool value in response to a switch
A Slider needs to update a Double value in response to a slide
A TextField needs to update a String value in response to typing
The way we identify that there should be this two-way communication between app state and a view is by using a Binding<SomeType>. So a Toggle requires you to pass it a Binding<Bool>, a Slider requires a Binding<Double>, and a TextField requires a Binding<String>.
This is where the #State property wrapper (or #Published inside of an #ObservedObject) come in. That property wrapper "wraps" the value it contains in a Binding (along with some other stuff to guarantee SwiftUI knows to update the views when the value changes). If we need to get the value, we can simply refer to myVariable, but if we need the binding, we can use the shorthand $myVariable.
So, in this case, your original code contained ForEach($model.results). In other words, you were telling the compiler, "Iterate over this Binding<[String]>", but Binding is not a collection you can iterate over. Removing the $ says, "Iterate over this [String]," and Array is a collection you can iterate over.