最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

swiftui - Change UI Element Title via .onChange - Stack Overflow

programmeradmin3浏览0评论

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.

Share Improve this question edited Feb 17 at 18:43 Jay asked Feb 16 at 17:25 JayJay 35.7k19 gold badges58 silver badges86 bronze badges 9
  • This is a very weird thing to do. What is the point of such a design? The "Change" button needs to cause 3 separate view updates. Why do it in 3 when you can do it in one? Just have each button access their own titles from the view model directly, rather than waiting for some buttonToChange to change. This sounds like an XY Problem. – Sweeper Commented Feb 16 at 17:34
  • onChange is for external actions. Try Binding instead. Also always start with struct and only use class when you need reference semantics. – malhal Commented Feb 16 at 17:44
  • stackoverflow/questions/78282542/… – lorem ipsum Commented Feb 16 at 20:23
  • @Sweeper Thank you for the feedback. I agree it seems 'weird' but it was the simplest way to ask the question about propagating the three updates. The bigger picture is thousands of lines of code. I think my question persists though; why does only the last button title update? Is SwiftUI not able to handle quick UI updates in succession without a delay? – Jay Commented Feb 17 at 18:28
  • @malhal Yes, I am aware of struct vs class. There's a SwiftData component involved at a higher level, hence the class. – Jay Commented Feb 17 at 18:29
 |  Show 4 more comments

1 Answer 1

Reset to default 1

As 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:

  1. Set the values in the model
  2. 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())
}

发布评论

评论列表(0)

  1. 暂无评论