Adapting #State variables and UI to user actions in SwiftUI - class

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:
generate an array of Binding with the number of elements equal to Items.Component.EndIndex and
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!

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

iOS16 SwiftUI app crashes when trying to create view using loop

I've defined a view in SwiftUI which takes an Int array and is supposed to display the elements of the array in a VStack such that every "full row" contains three elements of the array and then the last "row" contains the remainder of elements. When running the app on iOS16 I get "Fatal error: Can't remove first element from an empty collection" for the call let die = dice.removeFirst() (also when passing in a non-empty array of course). I've tried following the debugger but I don't understand the way it jumps around through the loops.
On iOS15 this code worked fine. In the actual program I don't display the array content as Text but I have images associated with each Int between 1 and 6 which I display. I replaced this with Text for simplicity's sake.
Thanks for any help!
struct DiceOnTableView: View {
let diceArray: [Int]
var body: some View {
let fullRows: Int = diceArray.count / 3
let diceInLastRow: Int = diceArray.count % 3
var dice: [Int] = diceArray
VStack {
ForEach(0..<fullRows, id: \.self) { row in
HStack {
ForEach(0..<3) { column in
let die = dice.removeFirst()
Text("\(die)")
}
}
}
HStack {
ForEach(0..<diceInLastRow, id: \.self) { column in
let die = dice.removeFirst()
Text("\(die)")
}
}
}
}
}
This does kind of work on iOS 15 (but strangely - the order of the dice is unexpected), and crashes on iOS 16. In general, you should not be using vars in SwiftUI view building code.
Your code can be modified to compute the index into the original diceArray from the row, fullRows, and column values:
struct DiceOnTableView: View {
let diceArray: [Int]
var body: some View {
let fullRows: Int = diceArray.count / 3
let diceInLastRow: Int = diceArray.count % 3
VStack {
ForEach(0..<fullRows, id: \.self) { row in
HStack {
ForEach(0..<3) { column in
Text("\(diceArray[row * 3 + column])")
}
}
}
HStack {
ForEach(0..<diceInLastRow, id: \.self) { column in
Text("\(diceArray[fullRows * 3 + column])")
}
}
}
}
}
The ForEach View will crash if you use it like a for loop with dynamic number of items. You need to supply it an array of item structs with ids, e.g. one that is Identifiable, e.g.
struct DiceItem: Identifable {
let id = UUID()
var number: Int
}
#State var diceItems: [DiceItem] = []
ForEach(diceItems) { diceItem in
And perhaps use a Grid instead of stacks.

How to update interface elements from asynchronous stuff from a singleton

I am developing my first app in SwiftUI and my brain have not wrapped around certain things yet.
I have to display prices, titles and descriptions of inapp purchases on the interface.
I have a singleton model like this, that loads as the app starts.
class Packages:ObservableObject {
enum Package:String, CaseIterable {
typealias RawValue = String
case package1 = "com.example.package1"
case package2 = "com.example.package2"
case package3 = "com.example.package3"
}
struct PurchasedItem {
var productID:String?
var localizedTitle:String = ""
var localizedDescription:String = ""
var localizedPrice:String = ""
}
static let sharedInstance = Packages()
#Published var purchasedItems:[PurchasedItem] = []
func getItem(_ productID:String) -> PurchasedItem? {
return status.filter( {$0.productID == productID } ).first
}
func getItemsAnync() {
// this will fill `purchasedItems` asynchronously
}
purchasedItems array will be filled with PurchasedItems, asynchronous, as the values of price, title and description come from the App Store.
Meanwhile, at another part of the interface, I am displaying buttons on a view, like this:
var buttonPackage1String:String {
let item = Packages.sharedInstance.getItem(Packages.Package.package1.rawValue)!
let string = """
\(umItem.localizedTitle) ( \(umItem.localizedPrice) ) \
\(umItem.localizedDescription) )
"""
return string
}
// inside var Body
Button(action: {
// purchase selected package
}) {
Text(buttonPackage1String)
.padding()
.background(Color.green)
.foregroundColor(.white)
.padding()
}
See my problem?
buttonPackage1String is built with title, description and price from an array called purchasedItems that is stored inside a singleton and that array is filled asynchronously, being initially empty.
So, at the time the view is displayed all values may be not retrieved yet but will, eventually.
Is there a way for the button is updated after the values are retrieved?
Calculable properties are not observed by SwiftUI. We have to introduce explicit state for this and update it once dependent dynamic data changed.
Something like,
#ObservedObject var packages: Packages
...
#State private var title: String = ""
...
Button(action: {
// purchase selected package
}) {
Text(title)
}
.onChange(of: packages.purchasedItems) { _ in
self.title = buttonPackage1String // << here !!
}
Below is a small pseudo code, I'm not sure it's suitable for your project but might give you some hint.
Packages.sharedInstance
.$purchasedItems //add $ to get the `Combine` Subject of variable
.compactMap { $0.firstWhere { $0.id == Packages.Package.package1.rawValue } } //Find the correct item and skip nil value
.map { "\($0.localizedTitle) ( \($0.localizedPrice) ) \ \($0.localizedDescription) )" }
.receive(on: RunLoop.main) // UI updates
.assign(to: \.buttonTitleString, on: self) //buttonTitleString is a #Published variable of your View model
.store(in: &cancellables) //Your cancellable collector
Combine framework knowledge is a must to start with SwiftUI.

How to build NumberField in SwiftUI?

I'm working on MacOS and I try to make NumberField — something like TextField, but for numbers. In rather big tree of views at the top I had:
...
VStack {
ForEach(instances.indices, id:\.self) {index in
TextField("",
text: Binding(
get: {"\(String(format: "%.1f", instances[index].values[valueIndex]))"},
set: {setValueForInstance(index, valueIndex, $0)})
)
}
}
...
And it worked well, but not nice:
✔︎ when I changed value, all View structure was redrawn – good
✔︎ values was updated if they were changed by another part of Views structure – good
✖︎ it was updated after each keypresses, which was annoying, when I tried to input 1.2, just after pressing 1 view was updated to 1.0. Possible to input every number but inconvenient – bad
So, I tried to build NumberField.
var format = "%.1f"
struct NumberField : View {
#Binding var number: Double {
didSet {
stringNumber = String(format: format, number)
}
}
#State var stringNumber: String
var body: some View {
TextField("" , text: $stringNumber, onCommit: {
print ("Commiting")
if let v = Double(stringNumber) {
number = v
} else {
stringNumber = String(format:format, number)
}
print ("\(stringNumber) , \(number)" )
})
}
init (number: Binding<Double>) {
self._number = number
self._stringNumber = State(wrappedValue: String(format:format, number.wrappedValue))
}
}
And It's called from the same place as before:
...
VStack {
ForEach(instances.indices, id:\.self) {index in
NumberField($instances[index].values[valueIndex])
}
}
...
But in this case it never updates NumberField View if values was changed by another part of View. Whats's wrong? Where is a trick?

DispatchQueue triggering foreach loop twice with SwiftUI

I'm having a weird behavior with my code.
There is View with text that is getting its text values from the User object.
If I take the reference out and leave it with just String everything is good, but with it the foreach loop that is getting this value runs twice.
Also, if I put the assignment out from DispatchQueue it runs once but the view isn't updating itself (because its a #Published var).
Even if I remove #Published it still run twice.
I'm sure there is a simple explanation for this behavior but I really don't know what could make this happen.
Better explanation in code with comments:
The view:
struct MenuGroupBoxList: View {
#EnvironmentObject var session: SessionStore
var columns = [
GridItem(.adaptive(minimum: 100, maximum: 200), spacing: 8),
GridItem(.adaptive(minimum: 100, maximum: 200), spacing: 8)
]
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(MenuTitles.allCases, id:\.self) { item in
if item == .sentShifts{
GroupColumn(menuItem: item, unit: Text(session.user?.totalShiftConsts.description ?? "0"))
}else if item == .weekdays{
//Deleting this line: session.user?.totalShifts.description. will run the loop once.
GroupColumn(menuItem: item, unit: Text(session.user?.totalShifts.description ?? "0"))
}else{
GroupColumn(menuItem: item, unit: Text("0"))
}
}
}.background(Color(UIColor.systemGroupedBackground))
}
}
Where the foreach loop:
enum ChooseWeekdays{
case current, next
}
class WeekdaysModel: Identifiable, ObservableObject{
private var session = SessionStore.shared
private var date = Date()
#Published var week = [Day]()
#Published var isFetching = false
init(_ weekdays: ChooseWeekdays){
switch weekdays {
case .current:
currentWeekdaysFetch()
case .next:
nextWeekdaysFetch()
}
}
private func currentWeekdaysFetch(){
DispatchQueue.main.async {
self.isFetching = true
var count = 0
for (index, symbol) in self.date.weekDaySymbols.enumerated(){
let day = Day(date: self.date.currentWeek[index], isSelected: false, hasShifts: false, daySymbol: symbol, dayNumber: self.date.currentWeekNumbers[index])
day.setShifts(morning: self.session.employeesList, middle: self.session.employeesList, evening: self.session.employeesList)
if index == Date().getCurrentDayIndex(){
day.isSelected = true
}
count = count + day.shifts.compactMap({$0.employees.contains(where: {$0.uid == self.session.user?.uid})}).count
self.week.append(day)
self.isFetching = false
//Printing to see if loop run twice.
print("Index:", index)
}
//OR - take this line out from DispatchQueue, will run once but wouldn't update the UI because totalShifts is a #Published var
self.session.user?.totalShifts = count
}
}
}
The results I'm getting in print is Index: 0...6 twice.
Where WeekdaysModel created:
struct WeekdaysView: View {
#ObservedObject var viewModel = WeekdaysModel(.current)
var body: some View {
ScrollView{
WeekRow(model: viewModel)
ForEach(viewModel.week){ day in
ShiftTypesView(day: day)
}
}
.navigationTitle(LocalizedStringKey("Weekdays"))
.background(Color(UIColor.systemGroupedBackground))
.edgesIgnoringSafeArea(.bottom)
}
}
There is another initiation in a different file but different enum (WeekdaysModel(.next))
seems a common case on SwiftUI.
I did experiment similar behaviour filling cells of a tableview being called twice for every cell: it seems swiftUI fills every viewed elements twice (I assume to satisfy rotation)
As suggested fill a data structure ONCE out of swiftUI "building of views"
(your data will be read anyway twice, but no double expensive calls to DB or network)
This behaviour is very different from std UiKit and can be frustrating if network is involved (calls will be issued twice... double network completions, errors on servers (for example double login and so on...)

Save value on stepper SwiftUI

I have a stepper:
Stepper(value: $timeBetweenMeals, in: 1...10, step: 1) {
Text("Time Between Meals: \(timeBetweenMeals, specifier: "%.0f")h")
self.settingsUserDefaults = $timeBetweenMeals
}
That gives me error because I'm trying to set: self.settingsUserDefaults = $timeBetweenMeals inside that view. How can I run that logic? I don't have a button, I just want to save that #Published value as the user changes it
Use a custom Binding, and inside your set you can store your value:
Stepper(value: Binding<Int>(get: {
self.timeBetweenMeals
}, set: {
print($0) // here you can access your value with $0, store it here
self.timeBetweenMeals = $0
}), in: 1...10, step: 1) {
Text("Time Between Meals: \(timeBetweenMeals)h")
}
You need to perform other actions when Stepper value changes in onIncrement and onDecrement method not inside of the label parameter which is meant to return the View of the Stepper. Here is an example (Note: this is just an example, you can achieve this in many easier ways too):
struct ContentView: View {
#State var timeBetweenMeals: Int
var body: some View {
Stepper(value: Binding<Int>(get: {
self.timeBetweenMeals
}, set: {
print($0)
self.timeBetweenMeals = $0
}), in: 1...10, step: 1) {
Text("Time Between Meals: \(timeBetweenMeals)h")
}
}
}
Note: I've updated to a much better approach. Credits to davidev. Also, your specifier seems to be incorrect.