2

I’m using the @Observable, @Bindable in SwiftUI. I have a simple ViewModel with two properties:

@Observable
final class AlarmLevelSetupViewModel {
    var displayedValueLevel1: Decimal = 1
    var displayedValueLevel2: Decimal = 2
}

And a view that renders two rows:

struct TestAlarms: View {

    @Bindable var viewModel: AlarmLevelSetupViewModel
    
    let values: [Decimal] = [0, 1, 2, 3, 4, 5]
    
    var body: some View {
        AlarmLevelRowView(
            title: "level 2",
            color: .accentRed,
            radiationType: .radiationLevel(.countRate),
            values: values,
            selectedValue: $viewModel.displayedValueLevel2
        )

        AlarmLevelRowView(
            title: "level 1",
            color: .accentYellow,
            radiationType: .radiationLevel(.countRate),
            values: values,
            selectedValue: $viewModel.displayedValueLevel1
        )
    }
}

When I change displayedValueLevel1, both AlarmLevelRowView instances get invalidated and re-rendered, even though the second row only uses displayedValueLevel2.

I expected only the view whose property changed to be redrawn.

2
  • 1
    See my answer to a similar question here: https://stackoverflow.com/a/79819291/5499898 Commented Nov 19 at 16:24
  • Observable is for model not view model. Try using Binding. FYI SwiftUI doesn't render anything it just updates structs and diffs them. Commented Nov 19 at 19:17

2 Answers 2

1

The reason both rows are re-rendering is that when you pass the entire @Observable view model into each row, SwiftUI treats each row as depending on the whole object, not on a specific property. So when any property inside the view model changes, SwiftUI marks both rows as affected and updates them.

The simplest way to make each row update only when “its” value changes is to avoid passing the whole view model down. Instead, pass only the specific Binding that the row needs. That way, the row observes only that one value, and SwiftUI will only refresh that particular row when it changes.

struct AlarmLevelRowView: View {
    @Binding var value: Decimal
    let title: String
    let color: Color
    let values: [Decimal]

    var body: some View {
        Picker(title, selection: $value) {
            ForEach(values, id: \.self) { v in
                Text("\(v)")
            }
        }
        .tint(color)
    }
}

Parent view:

AlarmLevelRowView(
    value: $viewModel.displayedValueLevel1,
    title: "level 1",
    color: .accentYellow,
    values: values
)

AlarmLevelRowView(
    value: $viewModel.displayedValueLevel2,
    title: "level 2",
    color: .accentRed,
    values: values
)

Each row now receives only a single binding, so SwiftUI will track only that binding. When displayedValueLevel1 changes, only the first row updates; when displayedValueLevel2 changes, only the second one does.

Sign up to request clarification or add additional context in comments.

1 Comment

I'm not passing the entire object, I'm passing exactly a specific value. You wrote absolutely the same code
0
@Observable
final class AlarmLevelSetupViewModel {
    var displayedValueLevel1: Decimal = 1
    var displayedValueLevel2: Decimal = 2
}

struct TestAlarms: View {
    @Bindable var viewModel: AlarmLevelSetupViewModel
    let values: [Decimal] = [0, 1, 2, 3, 4, 5]

    var body: some View {
        AlarmLevelRowView(
            title: "level 2",
            color: .accentRed,
            values: values,
            viewModel: viewModel,
            keyPath: \.displayedValueLevel2
        )

        AlarmLevelRowView(
            title: "level 1",
            color: .accentYellow,
            values: values,
            viewModel: viewModel,
            keyPath: \.displayedValueLevel1
        )
    }
}

struct AlarmLevelRowView: View {
    @Bindable var viewModel: AlarmLevelSetupViewModel
    let keyPath: WritableKeyPath<AlarmLevelSetupViewModel, Decimal>

    let title: String
    let color: Color
    let values: [Decimal]

    var body: some View {
        // This view now only observes *one* property:
        Picker(title, selection: $viewModel[keyPath: keyPath]) {
            ForEach(values, id: \.self) { value in
                Text("\(value.description)")
            }
        }
        .tint(color)
    }
}

You need each row to be its own tracking region over the observable model, i.e. have the row, not the parent, read the observable property.

1 Comment

I also want to use AlarmLevelRowView in the other way, without viewModel

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.