Using ForEach with a an array of Bindings (SwiftUI) - swift

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.

Related

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)

Managing SwiftUI state in a typical list -> details app

I'm building an app that's similar in structure to the Apple tutorial. My app has a ListView, which navigates to a DetailsView. The DetailsView is composed of a UIKit custom UIView, which I wrap with a UIViewRepresentable. So far, so good.
Now I have for now a list (let's say, of addresses) that I instantiate in memory, to be replaced with core data eventually. I'm able to bind (using #EnvironmentObject) the List<Address> to the ListView.
Where I'm stuck is binding the elements for each DetailsView. The Apple tutorial, referenced above, does something which I think isn't great - for some reason (that I can't figure out), it:
Binds the List to the details view (using #EnvironmentObject)
Passes the element (in the Apple tutorial case, landmark, in my case, an address) to the details view
During updating in response to a user gesture, it effectively searches the List for the element, to update the element in the list. This seems expensive especially if the list is large.
Here's the code for #3 which to me is suspect:
Button(action: {
self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()
})
In their code, self.landmarkIndex does a linear search:
var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
What I'm trying to do is to bind the element directly to the DetailsView and have updates to the element update the list. So far, I have been unable to achieve this.
Does anyone know the right way? It seems like the direction the tutorial is pointing to does not scale.
Instead of passing a Landmark object, you can pass a Binding<Landmark>.
LandmarkList.swift: Change the iteration from userData.landmark to their indices so you can get the binding. Then pass the bidding into LandmarkDetail and LandmarkRow
struct LandmarkList: View {
#EnvironmentObject private var userData: UserData
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}
ForEach(userData.landmarks.indices) { index in
if !self.userData.showFavoritesOnly || self.userData.landmarks[index].isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: self.$userData.landmarks[index])
.environmentObject(self.userData)
) {
LandmarkRow(landmark: self.$userData.landmarks[index])
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
LandmarkDetail.swift: Change landmark into Binding<Landmark> and toggle the favorite based on the binding
#Binding var landmark: Landmark
.
.
.
Button(action: {
self.landmark.isFavorite.toggle()
}) {
if self.landmark
.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(Color.yellow)
} else {
Image(systemName: "star")
.foregroundColor(Color.gray)
}
}
LandmarkRow.swift: Change landmark to a Binding
#Binding var landmark: Landmark
Here is an example of approach to use binding directly to model Address item
Assuming there is view model like, where Address is Identifiable struct
class AddressViewModel: ObservableObject {
#Published var addresses: [Address] = []
}
So somewhere in ListView u can use the following
ForEach (Array(vm.addresses.enumerated()), id: \.element.id) { (i, address) in
NavigationLink("\(address.title)",
destination: DetailsView(address: self.$vm.addresses[i])) // pass binding !!
}

SwiftUI ForEach based on State int

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'

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)
}

SwiftUI strange behavior when moving items between sections in a List

so I've been trying to make a component using swiftUI that allows you to move items in a List between sections.
I prepared an example with two sections: "First List" and "Second List". Whenever you tap on an item it swaps sections. Here's a screenshot:
When I tap on "First List: 1", it correctly moves to the second section:
However, its name should now be changed to "Second List: 1" because of the way I named the elements in the sections (see code below). So that's strange. But it gets stranger:
When I now tap on "First List: 1" in the second section this happens:
It doesn't properly swap back. It just gets duplicated, but this time the name of the duplicate is actually correct.
Considering the code below I don't understand how this is possible. It seems that swiftUI somehow reuses the item, even though it re-renders the view? It also seems to reuse the .onTapGesture closure, because the method that's supposed to put the item back into the first section is never actually called.
Any idea what's going on here? Below is a fully working example of the problem:
import SwiftUI
import Combine
struct TestView: View {
#ObservedObject var viewModel: ViewModel
class ViewModel: ObservableObject {
let objectWillChange = PassthroughSubject<ViewModel,Never>()
public enum List {
case first
case second
}
public var first: [Int] = []
public var second: [Int] = []
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
self.objectWillChange.send(self)
}
init(first: [Int]) {
self.first = first
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("First List")) {
ForEach(self.viewModel.first, id: \.self) { id in
Text("First List: \(id)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
}
Section(header: Text("First List")) {
ForEach(self.viewModel.second, id: \.self) { id in
Text("Second List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .second)
}
}
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Testing"))
}.environment(\.editMode, .constant(EditMode.active))
}
}
struct TestView_Preview: PreviewProvider {
static var previews: some View {
TestView(viewModel: TestView.ViewModel(first: [1, 2, 3, 4, 5]))
}
}
The only way I've solved this is to prevent diffing of the list by adding a random id to the list. This removes animations though, so looking for a better solution
List {
...
}
.id(UUID())
Removing the sections also fixes this, but isn't a valid solution either
I've found myself in a similar situation and have a found a more elegant workaround to this problem. I believe the issue lies with iOS13. In iOS14 the problem no longer exists. Below details a simple solution that works on both iOS13 and iOS14.
Try this:
extension Int {
var id:UUID {
return UUID()
}
}
and then in your ForEach reference \.id or \.self.id and not \.self i.e like so in both your Sections:
ForEach(self.viewModel.first, id: \.id) { id in
Text("First List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
This will make things work. However, when fiddling around I did find these issues:
Animations were almost none existent in iOS14. This can be fixed though.
In iOS13 the .listStyle(GroupedListStyle()) animation looks odd. Remove this and animations look a lot better.
I haven't tested this solution on large lists. So be warned around possible performance issues. For smallish lists it works.
Once again, this is a workaround but I think Apple is still working out the kinks in SwiftUI.
Update
PS if you use any onDelete or onMove modifiers in iOS14 this adds animations to the list which causes odd behaviour. I've found that using \.self works for iOS14 and \.self.id for iOS13. The code isn't pretty because you'll most likely have #available(iOS 14.0, *) checks in your code. But it works.
I don't know why, but it seems like your swap method does something weird on the first object you add, because if the second one works, maybe you've lost some instance.
By the way, do you need to removeAll every time you add a new object in each list?
public function interchange (identifier elementWithIdentifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll (where: {$ 0 == identifier})
self.second.append (identifier)
case .second:
print ("Called")
self.second.removeAll (where: {$ 0 == identifier})
self.first.append (identifier)
}
self.objectWillChange.send (self)
}
maybe your problem is in this function, everything looks great.
The fix is simple - use default ObservableObject publishers (which are correctly observed by ObservedObject wrapper) instead of Combine here, which is not valid for this case.
class ViewModel: ObservableObject {
public enum List {
case first
case second
}
#Published public var first: [Int] = [] // << here !!
#Published public var second: [Int] = [] // << here !!
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
}
init(first: [Int]) {
self.first = first
}
}
Tested with Xcode 13.3 / iOS 15.4
*and even with animation wrapping swap into withAnimation {}