Adapting @State variables and UI to user actions in SwiftUI

Viewed 74

I am a novice at programming and exploring SwiftUI. I've been tackling a challenge for too long, and hoping that someone can guide me to the right direction!

I want a list of interlinked sliders (as in Interlinked Multiple Sliders in SwiftUI), but with the number of sliders that change dynamically, depending on actions taken by a user.

For example, a user can choose various items, and later on adjust the percentage variable with sliders (and where these percentages are interdependent as in the linked example).

class Items: ObservableObject {
    
    @Published var components = [ItemComponent]()
    
    func add(component: itemComponent){
        components.append(component)
        }

}

struct ItemComponent: Hashable, Equatable, Identifiable {
       
    var id = UUID().uuidString
    var name: String = ""
    var percentage: Double
}

Conceptually, it seems I need to do two things to adapt the linked code:

  1. generate an array of Binding with the number of elements equal to Items.Component.EndIndex and
  2. assign each Binding to the percentage of each ItemComponent.

I am fumbling on both. For 1., I can easily manually create any number of variables, e.g.

@State var value1 = 100
@State var value2 = 100
@State var value3 = 100

let allBindings = [$value1, $value2, $value3]

but how do I generate them automatically?

For 2., I can use ForEach() to call the components, or Index, but not both together:

ForEach(Items.components){ component in 
    Text("\(component.name)")
    Text("\(component.percentage)")
}

ForEach(Items.components.indices){ i in 
    synchronizedSlider(from: allBindings, index: i+1)
}

In broken code, what I want is something like:

ForEach(Items.component){component in
     HStack{
            Text("component.name")
Spacer()
synchronizedSlider(from: allBindings[$component.percentage], index: component.indexPosition)

      }


where allBindings[$component.percentage] is a binding array comprised of each itemComponent's percentage, and the index is an itemComponent's index.

I am happy to share more code if relevant. Any help would be greatly appreciated!

1 Answers

To adapt the existing code you linked, if you're going to have a dynamic number of sliders, you'll definitely want your @State to be an array, rather than individual @State variables, which would have to be hard coded.

Once you have that, there are some minor syntax issues changing the synchronizedBinding functions to accept Binding<[ItemComponent]> rather than [Binding<Double>], but they are pretty minor. Luckily, the existing code is pretty robust outside of the initial hard-coded states, so there isn't any additional math to do with the calculations.

I'm using ItemComponent rather than just Double because your sample code included it and having a model with a unique id makes the ForEach code I'm using for the sliders easier to deal with, since it expects uniquely-identifiable items.


struct ItemComponent: Hashable, Equatable, Identifiable {
    
    var id = UUID().uuidString
    var name: String = ""
    var percentage: Double
}

struct Sliders: View {
    
    @State var values : [ItemComponent] = [.init(name: "First", percentage: 100.0),.init(name: "Second", percentage: 0.0),.init(name: "Third", percentage: 0.0),.init(name:"Fourth", percentage: 0.0),]
    
    var body: some View {
        VStack {
            Button(action: {
                // Manually setting the values does not change the values such
                // that they sum to 100. Use separate algorithm for this
                self.values[0].percentage = 40
                self.values[1].percentage = 60
            }) {
                Text("Test")
            }
            
            Button(action: {
                self.values.append(ItemComponent(percentage: 0.0))
            }) {
                Text("Add slider")
            }
            
            Divider()
            
            ScrollView {
                ForEach(Array(values.enumerated()),id: \.1.id) { (index,value) in
                    Text(value.name)
                    Text("\(value.percentage)")
                    synchronizedSlider(from: $values, index: index)
                }
            }
            
        }.padding()
    }
    
    
    func synchronizedSlider(from bindings: Binding<[ItemComponent]>, index: Int) -> some View {
        return Slider(value: synchronizedBinding(from: bindings, index: index),
                      in: 0...100)
    }
    
    func synchronizedBinding(from bindings: Binding<[ItemComponent]>, index: Int) -> Binding<Double> {
        
        return Binding(get: {
            return bindings[index].wrappedValue.percentage
        }, set: { newValue in
            
            let sum = bindings.wrappedValue.indices.lazy.filter{ $0 != index }.map{ bindings[$0].wrappedValue.percentage }.reduce(0.0, +)
            // Use the 'sum' below if you initially provide values which sum to 100
            // and if you do not set the state in code (e.g. click the button)
            //let sum = 100.0 - bindings[index].wrappedValue
            
            let remaining = 100.0 - newValue
            
            if sum != 0.0 {
                for i in bindings.wrappedValue.indices {
                    if i != index {
                        bindings.wrappedValue[i].percentage = bindings.wrappedValue[i].percentage * remaining / sum
                    }
                }
            } else {
                // handle 0 sum
                let newOtherValue = remaining / Double(bindings.wrappedValue.count - 1)
                for i in bindings.wrappedValue.indices {
                    if i != index {
                        bindings[i].wrappedValue.percentage = newOtherValue
                    }
                }
            }
            bindings[index].wrappedValue.percentage = newValue
        })
    }
}
Related