SwiftUI ForEach based on State int - swift

I am storing a Int value as State in my View. When I press a button the Int increase by one. This is working fine when I print my int value.
I have now a ForEach loop, which iterates based on this Int. When I set my State on 2 by default it works fine at the beginning. However, when I increase that Int my ForEach is not called again.
I understand that State will reload my actual view. Does it only load specific parts?
Here I declare my State:
#State var s_countVenues : Int = 2
This is the ForEach I use. It works at the beginning, however changing s_countVenues does NOT update the view.
ForEach(0..<self.s_countVenues)
{_ in
HStack(spacing: 0)
{
//here comes my view
}
}
If necessary, here I am increasing my value by one. It works, I printed the changes and if I use it inside a Label, the Label gets updated.
self.s_countVenues += 1
TL:DR:
My Int State is working. I can increase and print it inside a label. However, using it as Statement in ForEach does not call that loop again after changing.

from apple docs
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a *constant*
/// range.
///
/// This instance only reads the initial value of `data` and so it does not
/// need to identify views across updates.
///
/// To compute views on demand over a dynamic range use
/// `ForEach(_:id:content:)`.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
So, you have to use (as suggested by Apple)
struct ContentView: View {
#State var counter = 0
var body: some View {
VStack {
ForEach(0 ..< counter, id:\.self) { i in
Text("row: \(i.description)")
}
Button(action: {
self.counter += 1
}, label: {
Text("counter \(counter.description)")
})
}
}
}

It is not due to state, it is because you use ForEach constructor with constant Range, it is documented feature, so not supposed to update, so it is not updated. The simplest solution is as following - to use identifier joined to your state. It just indicates for rendering engine that ForEach is new so refresh (Tested with Xcode 11.2 / iOS 13.2)
ForEach(0..<self.s_countVenues)
{_ in
HStack(spacing: 0)
{
//here comes my view
}
}.id(s_countVenues)

I have silenced it with ', id: .self'

Related

How to perform `ForEach` on a computed property in SwiftUI

Intro
Imagine we have an infinite range like the range of all possible integers and we can NOT just store them in memory. So we need to calculate them chunk by chunk like:
func numbers(around number: Int, distance: Int) -> [Int] {
((number - distance)...(number + distance))
.map { $0 } // Using `map` ONLY for making the question clear that we do NOT want to use a range
}
so now we can build our view like:
struct ContentView: View {
#State var lastVisibleNumber: Int = 0
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(numbers(around: lastVisibleNumber, distance: 10), id:\.self) { number in
Text("\(number)")
.padding()
.foregroundColor(.white)
.background(Circle())
// .onAppear { lastVisibleNumber = number }
}
}
}
}
}
Describing the issue and what tried
In order to load more numbers when needed, I have tried to update the lastVisibleNumber on the appearance of last visible Number by adding the following modifier on the Text:
.onAppear { lastVisibleNumber = number }
Although there is a safe range that is visible and should prevent the infinite loop (theoretically), adding this modifier will freeze the view for calculating all numbers!
So how can we achieve a scroll view with some kind of infinite data source?
Considerations
The range of Int is just a sample and the question can be about an infinite range of anything. (In a form of an Array!)
we don't want unexpected stops (like showing a dummy spinner at the leading or trailing of the list) in the middle of the scrolling.
For this, you should try using table view and data source. Let's assume you have an array of integers. You may create a buffer with an arbitrary number of instances. Let say 100. In that case, with a similar logic, your distance would become a 50. When you scroll, and close enough to the limits, you create another array and reload the table view data source so that you can pretend like you have an infinite array. Be aware that reusable cell and table view implementation is very consistent and optimized.
Don’t refresh view when lastVisibleNumber changes - it just gets into endless loop. You can create Observableobject, store it and update it only on-demand (I provided a button but you can obviously load it onAppear eith some criteria - e.g. last of the array was shown):
class NumberViewModel: ObservableObject {
var lastVisibleNumber = 0
#Published var lastRefreshedNumber = 0
func numbers(distance: Int) -> [Int] {
((lastRefreshedNumber - distance)...(lastRefreshedNumber + distance))
.map { $0 } // Using `map` ONLY for making the question clear that we do NOT want to use a range
}
}
struct ContentView: View {
#StateObject var viewModel = NumberViewModel()
var body: some View {
VStack {
Button("Refresh view", action: {
viewModel.lastRefreshedNumber = viewModel.lastVisibleNumber
})
ScrollView(.horizontal) {
LazyHStack {
ForEach(viewModel.numbers(distance: 10), id:\.self) { number in
Text("\(number)")
.padding()
.foregroundColor(.white)
.background(Circle())
.onAppear { viewModel.lastVisibleNumber = number }
}
}
}
}
}
}

is possible to change #State property's value through its structure's method?

at Xcode's play ground I run code below:
import SwiftUI
struct Test {
#State var count: Int = 0 //to avoid "mutating" keyword, I use "#State" property wrapper
func countUp() {
self.count += 1
}
}
var test = Test()
test.countUp() // I want "count" property to be count up. but it did not
print(test.count) // print 0
Q) why test.count is still 0??
This may not be the intent of the question, but I will explain why the code in question does not work.(I believe this is the same thing the comment.)
You are using SwiftUI in a playground and not View.
The #State conforms to DynamicProperty.
https://developer.apple.com/documentation/swiftui/state
https://developer.apple.com/documentation/swiftui/dynamicproperty
The Documents say:
You should only access a state property from inside the view’s body, or from methods called by it.
An interface for a stored variable that updates an external property of a view.
and
The view gives values to these properties prior to recomputing the view’s body.
It has been suggested to use it with View and view’s body.
As mentioned above, your code does not use View or view’s body.
Since #State can only be used in a struct, if you want to do something similar, you can use a class and use #Published as shown below(simple use Combine, not SwiftUI):
import Combine
class Test {
#Published var count: Int = 0
func countUp() {
self.count += 1
}
}
var test = Test()
test.countUp()
print(test.count) // 1
However, your code has no one to subscribe to the changes, so this is just an experimental code.
Expressions are not allowed at the top level.
To test your case I created a View struct called Test1, and within its initialiser I created object for Test view. Check code below.
import SwiftUI
struct Test1:View {
init() {
let test = Test() // concentrate on this
test.countUp()
}
var body: some View {
Test() // Ignore for now
}
}
Test View-:
import SwiftUI
#propertyWrapper struct TestCount:DynamicProperty {
#State private var count = 0
var wrappedValue: Int {
get {
count
}
nonmutating set {
count = newValue
}
}
init(_ count: Int) {
self.count = count
}
}
struct Test: View {
#TestCount var count:Int
init(count:Int) {
_count = TestCount(count)
print(_count)
}
var body: some View {
Button {
countUp()
} label: {
Text("\(count)")
}
}
func countUp() {
count += 1
print(count)
}
}
For better understanding I created a custom propertyWrapper and we will initialise that inside Test view.
Now, when you run this code in simulator Test1 view is called first, and in it’s initialiser it will create object for Test() view.
Test view upon initialisation will create object for it’s property wrapper called TestCount, once created it will print description for TestCount instance, and notice the print output now-:
TestCount(_count: SwiftUI.State<Swift.Int>(_value: 0, _location: nil))
You can clearly see State variable count inside TestCount wrapper was assigned default value 0, but wasn’t allocated any SwiftUI.StoredLocation yet.
Now, this line is executed next test.countUp() in Test1 view, and because we are calling it from outside of body property and Test1 is also initialised outside of body property , compiler is smart enough to detect that and it will not provide SwiftUI storage location for this change to count variable, because there is no view update required for this change, hence you always see Output as 0 (default saved value).
To prove my point, now make following change in Test1 view.
import SwiftUI
struct Test1:View {
// init() {
// let test = Test(count: 0)
// test.countUp() // I want "count" property to be count up. but it did not
// }
var body: some View {
Test(count: 0)
}
}
Now again the initial print statement inside Test view is same -:
TestCount(_count: SwiftUI.State<Swift.Int>(_value: 0, _location: nil))
But, now you have a view with Button to change the count variable value on tap.
When you tap the button, again countUp() will be called, and this time check the output of print statement again, SwiftUI storage location is no more nil.
TestCount(_count: SwiftUI.State<Swift.Int>(_value: 0, _location:
Optional(SwiftUI.StoredLocation<Swift.Int>))).
You can debug the code further to get more better understanding, and I would also suggest you to read about PropertyWrappers in more detail, and how they work internally.

SwiftUI Add to ForEach array without causing the entire view to reload

if I have something like this:
struct ContentView: View {
var results = [Result(score: 8), Result(score: 5), Result(score: 10)]
var body: some View {
VStack {
ForEach(results, id: \.id) { result in
Text("Result: \(result.score)")
}
}
}
}
And then I have a button that appends sometihng to the results array, the entire ForEach loop will reload. This makes sense, but I'm wondering if there is some way to prevent this. The reason I'm asking is because I have a ForEach loop with a few items, each of which plays an animation. If another item is appended to the array, however, the new item appears at the top of the ForEach, but, since the entire view is reload, the other animations playing in the items stop.
Is there any way to prevent this? Like to add an item to a ForEach array, and have it appear, but not reload the entire ForEach loop?
I assume not, but I would wonder how to get around such an issue.
Create separate view for iterating ForEach content, then SwiftUI rendering engine will update container (by adding new item), but not refresh existed rows
ForEach(results, id: \.id) {
ResultCellView(result: $0)
}
and
struct ResultCellView: View {
let result: Result
var body: some View {
Text("Result: \(result.score)")
}
}
Note: I don't see your model, so there might be needed to confirm it to Hashable, Equatable.
In general not providing an id makes it impossible for the ForEach to know what changed (as it has no track of the items) and therefore does not re-render the view.
E.g.
struct ContentView: View {
#State var myData: Array<String> = ["first", "second"]
var body: some View {
VStack() {
ForEach(0..<self.myData.count) { item in
Text(self.myData[item])
}
Button(action: {
self.myData.append("third")
}){
Text("Add third")
}
}
}
}
This throws an console output (that you can ignore) where it tells you about what I just wrote above:
ForEach<Range<Int>, Int, Text> count (3) != its initial count (2).
`ForEach(_:content:)` should only be used for *constant* data.
Instead conform data to `Identifiable` or use `ForEach(_:id:content:)`
and provide an explicit `id`!
For your code try this:
Tested on iOS 13.5.
struct ContentView: View {
#State var results = [Result(score: 8), Result(score: 5), Result(score: 10)]
var body: some View {
VStack {
ForEach(0..<self.results.count) { item in
// please use some index test on production
Text("Result: \(self.results[item].score)")
}
Button(action: {
self.results.append(Result(score: 11))
}) {
Text("Add 11")
}
}
}
}
class Result {
var score: Int
init(score: Int) {
self.score = score
}
}
Please note that this is a "hacky" solution and ForEach was not intended to be used for such cases. (See the console output)

Change the options in a Picker dynamically using distinct arrays

I'm trying to get a Picker to update dynamically depending on the selection of the prior Picker. In order to achieve this, I'm using a multidimensional array. Unfortunately this seems to confuse my ForEach loop and I noticed the following message in the logs:
ForEach<Range<Int>, Int, Text> count (3) != its initial count (5).ForEach(:content:)should only be used for *constant* data. Instead conform data toIdentifiableor useForEach(:id:content:)and provide an explicitid!
This kinda makes sense, I'm guessing what is happening is that I'm passing it one array and it keeps referring to it, so as far as it is concerned, it keeps changing constantly whenever I pass it another array. I believe the way to resolve this is to use the id parameter that can be passed to ForEach, although I'm not sure this would actually solve it and I'm not sure what I would use. The other solution would be to somehow destroy the Picker and recreate it? Any ideas?
My code follows. If you run it, you'll notice that moving around the first picker can result in an out of bounds exception.
import SwiftUI
struct ContentView: View {
#State private var baseNumber = ""
#State private var dimensionSelection = 1
#State private var baseUnitSelection = 0
#State private var convertedUnitSelection = 0
let temperatureUnits = ["Celsius", "Fahrenheit", "Kelvin"]
let lengthUnits = ["meters", "kilometers", "feet", "yards", "miles"]
let timeUnits = ["seconds", "minutes", "hours", "days"]
let volumeUnits = ["milliliters", "liters", "cups", "pints", "gallons"]
let dimensionChoices = ["Temperature", "Length", "Time", "Volume"]
let dimensions: [[String]]
init () {
dimensions = [temperatureUnits, lengthUnits, timeUnits, volumeUnits]
}
var convertedValue: Double {
var result: Double = 0
let base = Double(baseNumber) ?? 0
if temperatureUnits[baseUnitSelection] == "Celsius" {
if convertedUnitSelection == 0 {
result = base
} else if convertedUnitSelection == 1 {
result = base * 9/5 + 32
} else if convertedUnitSelection == 2 {
result = base + 273.15
}
}
return result
}
var body: some View {
NavigationView {
Form {
Section {
TextField("Enter a number", text: $baseNumber)
.keyboardType(.decimalPad)
}
Section(header: Text("Select the type of conversion")) {
Picker("Dimension", selection: $dimensionSelection) {
ForEach(0 ..< dimensionChoices.count) {
Text(self.dimensionChoices[$0])
}
}.pickerStyle(SegmentedPickerStyle())
}
Group {
Section(header: Text("Select the base unit")) {
Picker("Base Unit", selection: $baseUnitSelection) {
ForEach(0 ..< self.dimensions[self.dimensionSelection].count) {
Text(self.dimensions[self.dimensionSelection][$0])
}
}.pickerStyle(SegmentedPickerStyle())
}
Section(header: Text("Select the unit to convert to")) {
Picker("Converted Unit", selection: $convertedUnitSelection) {
ForEach(0 ..< self.dimensions[self.dimensionSelection].count) {
Text(self.dimensions[self.dimensionSelection][$0])
}
}.pickerStyle(SegmentedPickerStyle())
}
}
Section(header: Text("The converted value is")) {
Text("\(convertedValue) \(dimensions[dimensionSelection][convertedUnitSelection])")
}
}.navigationBarTitle("Unit Converter")
}
}
}
I hate to answer my own question, but after spending some time on it, I think it's worth summarizing my findings in case it helps somebody. To summarize, I was trying to set the second Picker depending on what the selection of the first Picker was.
If you run the code that I pasted as is, you will get an out of bounds. This is only the case if I set #State private var dimensionSelection = 1 and the second array is larger than the first array. If you start with smaller array, you will be fine which you can observe by setting #State private var dimensionSelection = 0. There are a few ways to solve this.
Always start with the smallest array (Not great)
Instead of using an array of String, use an array of objects implementing Identifiable. this is the solution proposed by fuzz above. This got past the out of bound array exception. In my case though, I needed to specify the id parameter in the ForEach parameters.
Extend String to implement Identifiable as long as your strings are all different (which works in my trivial example). This is the solution proposed by gujci and his proposed solution looks much more elegant than mine, so I encourage you to take a look. Note that this to work in my own example. I suspect it might be due to how we built the arrays differently.
HOWEVER, once you get past these issues, it will still not work, You will hit an issue that appears be some kind of bug where the Picker keep adding new elements. My impression is that to get around this, one would have to destroy the Picker every time, but since I'm still learning Swift and SwiftUI, I haven't gotten round doing this.
So you'll want to make sure according to Apple's documentation that the array elements are Identifiable as you've mentioned.
Then you'll want to use ForEach like this:
struct Dimension: Identifiable {
let id: Int
let name: String
}
var temperatureUnits = [
Dimension(id: 0, name: "Celsius"),
Dimension(id: 1, name: "Fahrenheit"),
Dimension(id: 2, name: "Kelvin")
]
ForEach(temperatureUnits) { dimension in
Text(dimension.name)
}

Using ForEach with a an array of Bindings (SwiftUI)

My objective is to dynamically generate a form from JSON. I've got everything put together except for generating the FormField views (TextField based) with bindings to a dynamically generated list of view models.
If I swap out the FormField views for just normal Text views it works fine (see screenshot):
ForEach(viewModel.viewModels) { vm in
Text(vm.placeholder)
}
for
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: $vm)
}
I've tried to make the viewModels property of ConfigurableFormViewModel an #State var, but it loses its codability. JSON > Binding<[FormFieldViewModel] naturally doesn't really work.
Here's the gist of my code:
The first thing that you can try is this:
ForEach(0 ..< numberOfItems) { index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
The problem with the previous approach is that if numberOfItems is some how dynamic and could change because of an action of a Button for example, it is not going to work and it is going to throw the following error: ForEach<Range<Int>, Int, HStack<TextField<Text>>> count (3) != its initial count (0). 'ForEach(_:content:)' should only be used for *constant* data. Instead conform data to 'Identifiable' or use 'ForEach(_:id:content:)' and provide an explicit 'id'!
If you have that use case, you can do something like this, it will work even if the items are increasing or decreasing during the lifecycle of the SwiftView:
ForEach(items.indices, id:\.self ){ index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
Trying a different approach. The FormField maintains it's own internal state and publishes (via completion) when its text is committed:
struct FormField : View {
#State private var output: String = ""
let viewModel: FormFieldViewModel
var didUpdateText: (String) -> ()
var body: some View {
VStack {
TextField($output, placeholder: Text(viewModel.placeholder), onCommit: {
self.didUpdateText(self.output)
})
Line(color: Color.lightGray)
}.padding()
}
}
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: vm) { (output) in
vm.output = output
}
}
Swift 5.5
From Swift 5.5 version, you can use binding array directly by passing in the bindable like this.
ForEach($viewModel.viewModels, id: \.self) { $vm in
FormField(viewModel: $vm)
}
A solution could be the following:
ForEach(viewModel.viewModels.indices, id: \.self) { idx in
FormField(viewModel: self.$viewModel.viewModels[idx])
}
Took some time to figure out a solution to this puzzle. IMHO, it's a major omission, especially with SwiftUI Apps proposing documents that has models in struct and using Binding to detect changes.
It's not cute, and it takes a lot of CPU time, so I would not use this for large arrays, but this actually has the intended result, and, unless someone points out an error, it follows the intent of the ForEach limitation, which is to only reuse if the Identifiable element is identical.
ForEach(viewModel.viewModels) { vm in
ViewBuilder.buildBlock(viewModel.viewModels.firstIndex(of: zone) == nil
? ViewBuilder.buildEither(first: Spacer())
: ViewBuilder.buildEither(second: FormField(viewModel: $viewModel.viewModels[viewModel.viewModels.firstIndex(of: vm)!])))
}
For reference, the ViewBuilder.buildBlock idiom can be done in the root of the body element, but if you prefer, you can put this with an if.