The goal is to change the title of three buttons via .onChange event.
There is a main ContentView which contains three ButtonView. Each button view is initialized with a enum as an identifier. There is a 4th Button "Change" that should change the titles of the three buttons.
enum ButtonTitleEnum {
case button0
case button1
case button2
}
struct ContentView: View {
@Environment(ButtonViewModel.self) var bvm
var body: some View {
VStack {
ButtonView(aButtonId: .button0)
ButtonView(aButtonId: .button1)
ButtonView(aButtonId: .button2)
}
Button("Change") {
bvm.b0Title = "Button 0"
bvm.buttonToChange = .button0
bvm.b1Title = "Button 1"
bvm.buttonToChange = .button1
bvm.b2Title = "Button 2"
bvm.buttonToChange = .button2
}
}
}
bvm
is a ButtonViewModel which contains the updated button titles as well as which buttonToChange.
@Observable
class ButtonViewModel {
var buttonToChange: ButtonTitleEnum?
var b0Title = ""
var b1Title = ""
var b2Title = ""
func titleFor(aButtonId buttonId: ButtonTitleEnum) -> String {
switch buttonId {
case .button0:
return b0Title
case .button1:
return b1Title
case .button2:
return b2Title
}
}
}
The ButtonView looks like this, with an .onChange
watching for changes to the bvm.buttonToChange var so when it changes, the button compares the change to itself and updates it's title accordingly.
struct ButtonView: View {
@Environment(ButtonViewModel.self) var bvm
@State private var title = "Default"
var buttonId: ButtonTitleEnum
var body: some View {
Button(title) {
print("\(title) button click")
}
.onChange(of: bvm.buttonToChange) { oldValue, newValue in
print("got button to change event")
if newValue == buttonId {
title = bvm.titleFor(aButtonId: buttonId)
}
}
}
init(aButtonId buttonId: ButtonTitleEnum) {
self.buttonId = buttonId
}
}
However, upon running the code, only the last button title changes.
I am confident the issue is asynchronous or perhaps the update is 'too fast' - if I add a small delay, the issue is corrected but that doesn't seem like the best solution.
Button("Change") {
Task {
bvm.b0Title = "Button 0"
bvm.buttonToChange = .button0
try await Task.sleep(for: .seconds(1))
bvm.b1Title = "Button 1"
bvm.buttonToChange = .button1
try await Task.sleep(for: .seconds(1))
bvm.b2Title = "Button 2"
bvm.buttonToChange = .button2
}
}
How can .onChange be utilized async? as in conceptually await bvm.buttonToChange = .button0
. Just not sure where to put @MainActor or some kind of async call?
As an Edit, the title itself of each button could simply be derived from a property within the ViewModel. However, when the title changes additional code needs to run along with it hence the implementation of the .onChange
. Which is why the question revolves around that event firing when the var changes.
The goal is to change the title of three buttons via .onChange event.
There is a main ContentView which contains three ButtonView. Each button view is initialized with a enum as an identifier. There is a 4th Button "Change" that should change the titles of the three buttons.
enum ButtonTitleEnum {
case button0
case button1
case button2
}
struct ContentView: View {
@Environment(ButtonViewModel.self) var bvm
var body: some View {
VStack {
ButtonView(aButtonId: .button0)
ButtonView(aButtonId: .button1)
ButtonView(aButtonId: .button2)
}
Button("Change") {
bvm.b0Title = "Button 0"
bvm.buttonToChange = .button0
bvm.b1Title = "Button 1"
bvm.buttonToChange = .button1
bvm.b2Title = "Button 2"
bvm.buttonToChange = .button2
}
}
}
bvm
is a ButtonViewModel which contains the updated button titles as well as which buttonToChange.
@Observable
class ButtonViewModel {
var buttonToChange: ButtonTitleEnum?
var b0Title = ""
var b1Title = ""
var b2Title = ""
func titleFor(aButtonId buttonId: ButtonTitleEnum) -> String {
switch buttonId {
case .button0:
return b0Title
case .button1:
return b1Title
case .button2:
return b2Title
}
}
}
The ButtonView looks like this, with an .onChange
watching for changes to the bvm.buttonToChange var so when it changes, the button compares the change to itself and updates it's title accordingly.
struct ButtonView: View {
@Environment(ButtonViewModel.self) var bvm
@State private var title = "Default"
var buttonId: ButtonTitleEnum
var body: some View {
Button(title) {
print("\(title) button click")
}
.onChange(of: bvm.buttonToChange) { oldValue, newValue in
print("got button to change event")
if newValue == buttonId {
title = bvm.titleFor(aButtonId: buttonId)
}
}
}
init(aButtonId buttonId: ButtonTitleEnum) {
self.buttonId = buttonId
}
}
However, upon running the code, only the last button title changes.
I am confident the issue is asynchronous or perhaps the update is 'too fast' - if I add a small delay, the issue is corrected but that doesn't seem like the best solution.
Button("Change") {
Task {
bvm.b0Title = "Button 0"
bvm.buttonToChange = .button0
try await Task.sleep(for: .seconds(1))
bvm.b1Title = "Button 1"
bvm.buttonToChange = .button1
try await Task.sleep(for: .seconds(1))
bvm.b2Title = "Button 2"
bvm.buttonToChange = .button2
}
}
How can .onChange be utilized async? as in conceptually await bvm.buttonToChange = .button0
. Just not sure where to put @MainActor or some kind of async call?
As an Edit, the title itself of each button could simply be derived from a property within the ViewModel. However, when the title changes additional code needs to run along with it hence the implementation of the .onChange
. Which is why the question revolves around that event firing when the var changes.
1 Answer
Reset to default 1As others mentioned in comments, the approach is simply overkill.
Because your button view model is @Observable
, any changes to its properties will be reflected immediately, without the need of .onChange
. All you need to do is simply point to them whatever needs to be updated.
So to have your buttons reflect the values in model, all you need is to:
- Set the values in the model
- Have your button titles use those values (from the model)
To set the values, there is no need for all the bvm.buttonToChange = .button0
lines. Just set the values:
//Button that updates the model title properties
Button("Change") {
bvm.b0Title = "Button 0"
bvm.b1Title = "Button 1"
bvm.b2Title = "Button 2"
}
In your button view, add a computed property that will use the model titles if not empty, or the default title:
//Computed property that uses the default title if the model titles are empty
private var buttonTitle: String {
let newTitle = bvm.titleFor(aButtonId: buttonId)
return newTitle.isEmpty ? defaultTitle : newTitle
}
And update your button to use the computed property:
Button(buttonTitle) {
print("\(buttonTitle) button click")
}
That's it. All the other code for onChange
, buttonToChange
, etc. is not needed. Even your default button title can simply be a constant.
Here's the complete code:
import SwiftUI
//Enum
enum ButtonTitleEnum {
case button0
case button1
case button2
}
//Main view
struct ButtonChangeDemoView: View {
//Environment values
@Environment(AnotherButtonViewModel.self) var bvm
//Body
var body: some View {
VStack {
AnotherButtonView(buttonId: .button0)
AnotherButtonView(buttonId: .button1)
AnotherButtonView(buttonId: .button2)
}
.buttonStyle(.bordered)
.padding()
Divider()
//Change button that updates the model title properties
Button("Change") {
bvm.b0Title = "Button 0"
bvm.b1Title = "Button 1"
bvm.b2Title = "Button 2"
}
.padding()
.buttonStyle(.borderedProminent)
//Reset button
Button {
bvm.b0Title = ""
bvm.b1Title = ""
bvm.b2Title = ""
} label : {
Image(systemName: "arrow.trianglehead.counterclockwise")
}
.tint(.yellow)
}
}
//Observable
@Observable
class AnotherButtonViewModel {
//Properties
var b0Title = ""
var b1Title = ""
var b2Title = ""
//Function that returns the corresponding value based on an enum parameter
func titleFor(aButtonId buttonId: ButtonTitleEnum) -> String {
switch buttonId {
case .button0:
return b0Title
case .button1:
return b1Title
case .button2:
return b2Title
}
}
}
//Button view
struct AnotherButtonView: View {
//Parameters
var buttonId: ButtonTitleEnum
//Environment values
@Environment(AnotherButtonViewModel.self) var bvm
private let defaultTitle = "Default"
//Computed property that uses the default title if the model titles are empty
private var buttonTitle: String {
let newTitle = bvm.titleFor(aButtonId: buttonId)
return newTitle.isEmpty ? defaultTitle : newTitle
}
//Body
var body: some View {
Button(buttonTitle) {
print("\(buttonTitle) button click")
}
}
}
#Preview {
ButtonChangeDemoView()
.environment(AnotherButtonViewModel())
}
Update:
I am updating this answer with an additional code example and to address some questions in the comments.
The .onChange event is not firing; Is SwiftUI having difficulty keeping up?
The .onChange
is firing fine. If you add the following to the button in AnotherButtonView
of my code above, it will work properly:
.onChange(of: bvm.titleFor(aButtonId: buttonId)) {
print("Change detected for: \(buttonId)")
}
The reason it doesn't work in your original code basically boils down to improper usage and expectations.
What happens in your original button code is that you're setting the same model property to different values in succession and expecting that the view you're calling it from (ContentView
), or even the model, will somehow know to only set the value of buttonToChange
again if the view(s) that may be observing it (if any) has finished whatever it needs to do if it detects a change (like setting its own state value).
If this happened all in the same view that had all the states and properties, where it could be ensured that one step can run only after the previous one completed, it would be a very different scenario.
But when it comes to passing values from views to observable models to potentially other views that may update states and run code without providing any updates as to the completion of it (what if your onChange has an async function that makes a network call and then has to do some heavy processing) and back to views again (the one originally calling), it doesn't work like that.
Attempting to use it in this fashion won't get you anywhere. The solution is not to figure out how long to wait/sleep before setting the value again, but to build it in such way that these racing conditions can't happen.
I am attaching below an updated example where the button updates two model properties to target a specific button. The button view will react if the model changes and matches its own ID and use the model color value (if it exists at the time the observation is made) to update its internal state which will change the button color.
import SwiftUI
//Enum
enum ButtonTitleEnum: String, CaseIterable {
case button0
case button1
case button2
var keyPath: WritableKeyPath<AnotherButtonViewModel, String> {
switch self {
case .button0: return \AnotherButtonViewModel.b0Title
case .button1: return \AnotherButtonViewModel.b1Title
case .button2: return \AnotherButtonViewModel.b2Title
}
}
}
//Main view
struct ButtonChangeDemoView: View {
//Environment values
@Environment(AnotherButtonViewModel.self) var bvm
//State values
@State private var inputTitle = ""
@State private var selectedButton: ButtonTitleEnum?
@State private var alternateButtonSelection: ButtonTitleEnum?
@State private var buttonColor: Color = .orange
//Body
var body: some View {
Form {
Section("Buttons") {
VStack(alignment: .leading) {
AnotherButtonView(buttonId: .button0)
AnotherButtonView(buttonId: .button1)
AnotherButtonView(buttonId: .button2)
//Change button that updates the model title properties
Button("Change all buttons") {
bvm.b0Title = "Button 0"
bvm.b1Title = "Button 1"
bvm.b2Title = "Button 2"
}
// .padding()
.buttonStyle(.borderedProminent)
//Reset button
HStack {
Button {
bvm.b0Title = ""
bvm.b1Title = ""
bvm.b2Title = ""
} label : {
HStack {
Image(systemName: "arrow.trianglehead.counterclockwise")
.imageScale(.small)
Text("Reset")
}
}
.tint(.yellow)
.buttonStyle(.borderless)
}
}
.buttonStyle(.bordered)
}
Section("Update button title") {
//Picker
Picker("Select button", selection: $selectedButton) {
ForEach(ButtonTitleEnum.allCases, id: \.self) { button in
Text(String(describing: button).capitalized).tag(button)
}
}
//Input field
TextField("Enter new button title", text: $inputTitle)
//Change button that updates a specific button title
Button("Change selected button") {
if let button = selectedButton {
var buttonModel = bvm // Strange workaround of using a local mutable copy in order to call the model with the keypath parameter
buttonModel[keyPath: button.keyPath] = inputTitle
}
}
.disabled(selectedButton == nil || inputTitle.isEmpty)
.buttonStyle(.borderedProminent)
}
Section("Update button color") {
//Picker
Picker("Select button", selection: $alternateButtonSelection) {
ForEach(ButtonTitleEnum.allCases, id: \.self) { button in
Text(String(describing: button).capitalized).tag(button)
}
}
//Color picker
ColorPicker("Select color", selection: $buttonColor)
//Change button that updates a specific button title
Button("Change selected button") {
bvm.buttonToChange = alternateButtonSelection
bvm.buttonColor = buttonColor
}
.disabled(alternateButtonSelection == nil)
.buttonStyle(.borderedProminent)
}
}
}
}
//Observable
@Observable
class AnotherButtonViewModel {
//Properties
var b0Title = ""
var b1Title = ""
var b2Title = ""
var buttonToChange: ButtonTitleEnum?
var buttonColor: Color?
//Function that returns the corresponding value based on an enum parameter
func titleFor(aButtonId buttonId: ButtonTitleEnum) -> String {
switch buttonId {
case .button0:
return b0Title
case .button1:
return b1Title
case .button2:
return b2Title
}
}
}
//Button view
struct AnotherButtonView: View {
//Parameters
var buttonId: ButtonTitleEnum
//Environment values
@Environment(AnotherButtonViewModel.self) var bvm
//State values
@State private var buttonColor = Color.blue
//Default values
private let defaultTitle = "Default"
//Computed property that uses the default title if the model titles are empty
private var buttonTitle: String {
let newTitle = bvm.titleFor(aButtonId: buttonId)
return newTitle.isEmpty ? defaultTitle : newTitle
}
//Body
var body: some View {
Button(buttonTitle) {
print("\(buttonTitle) button click")
}
.onChange(of: bvm.titleFor(aButtonId: buttonId)) {
print("Change detected for: \(buttonId)")
}
.onChange(of: bvm.buttonToChange) { _, button in
if let button = button, button == buttonId, let color = bvm.buttonColor {
//Update button's color state
buttonColor = color
//Reset model property so it can observed again
bvm.buttonToChange = nil
}
}
.tint(buttonColor)
}
}
#Preview {
ButtonChangeDemoView()
.environment(AnotherButtonViewModel())
}
buttonToChange
to change. This sounds like an XY Problem. – Sweeper Commented Feb 16 at 17:34