Update text with Slider value in List from an array in SwiftUI

Viewed 455

I have a list of sliders, but I have a problem updating the text that shows the slider value.

The app workflow is like this:

  1. User taps to add a new slider to the list.
  2. An object that defines the slider is created and stored in an array.
  3. The class that has the array as a property (Db) is an ObservableObject and triggers a View update for each new item.
  4. The list is updated with a new row.

So far, so good. Each row has a slider whose value is stored in a property in an object in an array. However, the value text doesn't update as soon as the slider is moved, but when a new item is added. Please see the GIF below:

The Slider doesn't update the text value when moved

How can I bind the slider movements to the text value? I thought that by defining

@ObservedObject var slider_value: SliderVal = SliderVal()

and binding that variable to the slider, the value would be updated simultaneously but that is not the case. Thanks a lot for any help.

import SwiftUI
import Combine

struct ContentView: View {
    
    @ObservedObject var db: Db
    
    var body: some View {
        NavigationView{
            List(db.criteria_db){criteria in
                VStack {
                    HStack{
                        Text(criteria.name).bold()
                        Spacer()
                        Text(String(criteria.slider_value.value)) //<-- Problem here
                    }
                    Slider(value: criteria.$slider_value.value, in:0...100, step: 1)
                }
            }
            .navigationBarTitle("Criteria")
            .navigationBarItems(trailing:
                                    Button(action: {
                                        Criteria.count += 1
                                        db.criteria_db.append(Criteria(name: "Criteria\(Criteria.count)"))
                                        dump(db.criteria_db)
                                    }, label: {
                                        Text("Add Criteria")
                                    })
            )
        }
        .listStyle(InsetGroupedListStyle())
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(db: Db())
    }
}

struct Criteria: Identifiable {
    var id = UUID()
    var name: String
    @ObservedObject var slider_value: SliderVal = SliderVal()
    static var count: Int = 0
    
    init(name: String) {
        self.name = name
    }
}

class Db: ObservableObject {
    @Published var criteria_db: [Criteria] = []
}

class SliderVal: ObservableObject {
    @Published var value:Double = 50
}
2 Answers

The @ObservableObject won't work within a struct like that -- it's only useful inside a SwiftUI View or a DynamicProperty. With your use case, because the class is a reference type, the @Published property has no way of knowing that the SliderVal was changed, so the owner View never gets updated.

You can fix this by turning your model into a struct:

struct Criteria: Identifiable {
    var id = UUID()
    var name: String
    var slider_value: SliderVal = SliderVal()
    static var count: Int = 0
    
    init(name: String) {
        self.name = name
    }
}

struct SliderVal {
    var value:Double = 50
}

The problem, once you do this, is you don't have a Binding to use in your List. If you're lucky enough to be on SwiftUI 3.0 (iOS 15 or macOS 12), you can use $criteria within your list to get a binding to the element being currently iterated over.

If you're on an earlier version, you'll need to either use indexes to iterate over the items, or, my favorite, create a custom binding that is tied to the id of the item. It looks like this:

struct ContentView: View {
    
    @ObservedObject var db: Db = Db()
    
    private func bindingForId(id: UUID) -> Binding<Criteria> {
        .init {
            db.criteria_db.first { $0.id == id } ?? Criteria(name: "")
        } set: { newValue in
            db.criteria_db = db.criteria_db.map {
                $0.id == id ? newValue : $0
            }
        }
    }
    
    var body: some View {
        NavigationView{
            List(db.criteria_db){criteria in
                VStack {
                    HStack{
                        Text(criteria.name).bold()
                        Spacer()
                        Text(String(criteria.slider_value.value))
                    }
                    Slider(value: bindingForId(id: criteria.id).slider_value.value, in:0...100, step: 1)
                }
            }
            .navigationBarTitle("Criteria")
            .navigationBarItems(trailing:
                                    Button(action: {
                                        Criteria.count += 1
                                        db.criteria_db.append(Criteria(name: "Criteria\(Criteria.count)"))
                                        dump(db.criteria_db)
                                    }, label: {
                                        Text("Add Criteria")
                                    })
            )
        }
        .listStyle(InsetGroupedListStyle())
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(db: Db())
    }
}

class Db: ObservableObject {
    @Published var criteria_db: [Criteria] = []
}

Now, because the models are all value types (structs), the View and @Published know when to update and your sliders work as expected.

try something like this:

                Slider(value: criteria.$slider_value.value, in:0...100, step: 1)
                    .onChange(of: criteria.slider_value.value) { newVal in
                        DispatchQueue.main.async {
                            criteria.slider_value.value = newVal
                        }
                    }
Related