SwiftUI ForEach over #State dynamic array [duplicate] - swift

SwiftUI seems to have a rather annoying limitation that makes it hard to create a List or a ForEach while getting a binding to each element to pass to child views.
The most often suggested approach I've seen is to iterate over indices, and get the binding with $arr[index] (in fact, something similar was suggested by Apple when they removed Binding's conformance to Collection):
#State var arr: [Bool] = [true, true, false]
var body: some View {
List(arr.indices, id: \.self) { index in
Toggle(isOn: self.$arr[index], label: { Text("\(idx)") } )
}
}
That works until the array changes in size, and then it crashes with index out of range run-time error.
Here's an example that will crash:
class ViewModel: ObservableObject {
#Published var arr: [Bool] = [true, true, false]
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.arr = []
}
}
}
struct ContentView: View {
#ObservedObject var vm: ViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self) { idx in
Toggle(isOn: self.$vm.arr[idx], label: { Text("\(idx)") } )
}
}
}
What's the right way to handle deletion from a List, while still maintaining the ability to modify elements of it with a Binding?

Using insights from #pawello2222 and #Asperi, I came up with an approach that I think works well, without being overly nasty (still kinda hacky).
I wanted to make the approach more general than just for the simplified example in the question, and also not one that breaks separation of concerns.
So, I created a new wrapper view that creates a binding to an array element inside itself (which seems to fix the state invalidation/update ordering as per #pawello2222's observation), and passes the binding as a parameter to the content closure.
I initially expected to be needing to do safety checks on the index, but turns out it wasn't required for this problem.
struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
typealias BoundElement = Binding<T.Element>
private let binding: BoundElement
private let content: (BoundElement) -> C
init(_ binding: Binding<T>, index: T.Index, #ViewBuilder content: #escaping (BoundElement) -> C) {
self.content = content
self.binding = .init(get: { binding.wrappedValue[index] },
set: { binding.wrappedValue[index] = $0 })
}
var body: some View {
content(binding)
}
}
Usage is:
#ObservedObject var vm: ViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self) { index in
Safe(self.$vm.arr, index: index) { binding in
Toggle("", isOn: binding)
Divider()
Text(binding.wrappedValue ? "on" : "off")
}
}
}

It looks like your Toggle is refreshed before the List (possibly a bug, fixed in SwiftUI 2.0).
You can extract your row to another view and check if the index still exists.
struct ContentView: View {
#ObservedObject var vm: ViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self) { index in
ToggleView(vm: self.vm, index: index)
}
}
}
struct ToggleView: View {
#ObservedObject var vm: ViewModel
let index: Int
#ViewBuilder
var body: some View {
if index < vm.arr.count {
Toggle(isOn: $vm.arr[index], label: { Text("\(vm.arr[index].description)") })
}
}
}
This way the ToggleView will be refreshed after the List.
If you do the same but inside the ContentView it will still crash:
ContentView {
...
#ViewBuilder
func toggleView(forIndex index: Int) -> some View {
if index < vm.arr.count {
Toggle(isOn: $vm.arr[index], label: { Text("\(vm.arr[index].description)") })
}
}
}

SwiftUI 2.0
As tested with Xcode 12 / iOS 14 - crash not reproducible
SwiftUI 1.0+
Crash happens due to dangling bindings to removed elements (presumably `cause of bad invalidation/update order).
Here is a safe workaround. Tested with Xcode 11.4 / iOS 13.4
struct ContentView: View {
#ObservedObject var vm: ToggleViewModel = .init()
var body: some View {
List(vm.arr.indices, id: \.self, rowContent: row(for:))
}
// helper function to have possibility to generate & inject proxy binding
private func row(for idx: Int) -> some View {
let isOn = Binding(
get: {
// safe getter with bounds validation
idx < self.vm.arr.count ? self.vm.arr[idx] : false
},
set: { self.vm.arr[idx] = $0 }
)
return Toggle(isOn: isOn, label: { Text("\(idx)") } )
}
}

If anyone interested I combined the Safe solution by New dev with a ForEach:
struct ForEachSafe<T: RandomAccessCollection & MutableCollection, C: View>: View where T.Index: Hashable {
private let bindingArray: Binding<T>
private let array: T
private let content: (Binding<T.Element>) -> C
init(_ bindingArray: Binding<T>, _ array: T, #ViewBuilder content: #escaping (Binding<T.Element>) -> C) {
self.bindingArray = bindingArray
self.array = array
self.content = content
}
var body: some View {
ForEach(array.indices, id: \.self) { index in
Safe(bindingArray, index: index) {
content($0)
}
}
}
}

Related

SwiftUI: How to update element in ForEach without necessity to update all elements?

Imagine that you have some parent view that generate some number of child views:
struct CustomParent: View {
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index)
}
}
}
}
struct CustomChild: View {
#State var index: Int
#State private var text: String = ""
var body: some View {
Button(action: {
// Here should be some update of background/text/opacity or whatever.
// So how can I update background/text/opacity or whatever for button with index for example 3 from button with index for example 1?
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Question is included in the code as comment.
Thanks!
UPDATE:
First of all really thanks for all of your answers, but now imagine that you use mentioned advanced approach.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
If I use let _ = Self._printChanges() method in CustomChildView, to catch UI updates/changes, it'll print that every element in ForEach was updated/changed on button action.
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
let _ = Self._printChanges() // This have been added to code
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}
And now imagine that you have for example 1000 custom elements which have some background, opacity, shadow, texts, fonts and so on. Now I change text in any of the elements.
Based on log from let _ = Self._printChanges() method, it goes through all elements, and all elements are updated/changed what can cause delay.
Q1: Why did update/change all elements, if I change text in only one element?
Q2: How can I prevent update/change all elements, if I change only one?
Q3: How to update element in ForEach without necessity to update all elements?
Simpler Approach:
Although child views cannot access things that the host views have, it's possible to declare the child states in the host view and pass that state as a binding variable to the child view. In the code below, I have passed the childTexts variable to the child view, and (for your convenience) initialized the text so that it binds to the original element in the array (so that your onAppear works properly). Every change performed on the text and childTexts variable inside the child view reflects on the host view.
I strongly suggest not to do this though, as more elegant approaches exist.
struct CustomParent: View {
#State var childTexts = [String](repeating: "", count: 10)
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, childTexts: $childTexts)
}
}
}
}
struct CustomChild: View {
let index: Int
#Binding private var text: String
#Binding private var childTexts: [String]
init(index: Int, childTexts: Binding<[String]>) {
self.index = index
self._childTexts = childTexts
self._text = childTexts[index]
}
var body: some View {
Button(action: {
//button behaviors goes here
//for example
childTexts[index + 1] = "A"
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Advanced Approach:
By using the Combine framework, all your logics can be moved into an ObservableObject view model. This is much better as the button logic is no longer inside the view. In simplest terms, the #Published variable in the ObservableObject will publish a change when it senses its own mutation, while the #StateObjectand the #ObservedObject will listen and recalculate the view for you.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}

SwiftUI Reorder list dynamic sections from another view

I have a simple List with sections that are stored inside an ObservableObject. I'd like to reorder them from another view.
This is my code:
class ViewModel: ObservableObject {
#Published var sections = ["S1", "S2", "S3", "S4"]
func move(from source: IndexSet, to destination: Int) {
sections.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var showOrderingView = false
var body: some View {
VStack {
Button("Reorder sections") {
self.showOrderingView = true
}
list
}
.sheet(isPresented: $showOrderingView) {
OrderingView(viewModel: self.viewModel)
}
}
var list: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section)) {
ForEach(0 ..< 3, id: \.self) { _ in
Text("Item")
}
}
}
}
}
}
struct OrderingView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.sections, id: \.self) { section in
Text(section)
}
.onMove(perform: viewModel.move)
}
.navigationBarItems(trailing: EditButton())
}
}
}
But in the OrderingView when trying to move sections I'm getting this error: "Attempt to create two animations for cell". Likely it's because the order of the sections has changed.
How can I change the order of the sections?
The problem of this scenario is recreated many times ViewModel, so modifications made in sheet just lost. (The strange thing is that in SwiftUI 2.0 with StateObject these changes also lost and EditButton does not work at all.)
Anyway. It looks like here is a found workaround. The idea is to break interview dependency (binding) and work with pure data passing them explicitly into sheet and return them back explicitly from it.
Tested & worked with Xcode 12 / iOS 14, but I tried to avoid using SwiftUI 2.0 features.
class ViewModel: ObservableObject {
#Published var sections = ["S1", "S2", "S3", "S4"]
func move(from source: IndexSet, to destination: Int) {
sections.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var showOrderingView = false
var body: some View {
VStack {
Button("Reorder sections") {
self.showOrderingView = true
}
list
}
.sheet(isPresented: $showOrderingView) {
OrderingView(sections: viewModel.sections) {
self.viewModel.sections = $0
}
}
}
var list: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section)) {
ForEach(0 ..< 3, id: \.self) { _ in
Text("Item")
}
}
}
}
}
}
struct OrderingView: View {
#State private var sections: [String]
let callback: ([String]) -> ()
init(sections: [String], callback: #escaping ([String]) -> ())
{
self._sections = State(initialValue: sections)
self.callback = callback
}
var body: some View {
NavigationView {
List {
ForEach(sections, id: \.self) { section in
Text(section)
}
.onMove {
self.sections.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
.onDisappear {
self.callback(self.sections)
}
}
}
A possible workaround solution for SwiftUI 1.0
I found a workaround to disable animations for the List by adding .id(UUID()):
var list: some View {
List {
...
}
.id(UUID())
}
This, however, messes the transition animations for NavigationLinks created with NavigationLink(destination:tag:selection:): Transition animation gone when presenting a NavigationLink in SwiftUI.
And all other animations (like onDelete) are missing as well.
The even more hacky solution is to disable list animations conditionally:
class ViewModel: ObservableObject {
...
#Published var isReorderingSections = false
...
}
struct OrderingView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
...
}
.onAppear {
self.viewModel.isReorderingSections = true
}
.onDisappear {
self.viewModel.isReorderingSections = false
}
}
}
struct ContentView: View {
...
var list: some View {
List {
...
}
.id(viewModel.isReorderingSections ? UUID().hashValue : 1)
}
}

SwiftUI SceneDelegate - contentView Missing argument for parameter 'index' in call

I am trying to create a list using ForEach and NavigationLink of an array of data.
I believe my code (see the end of the post) is correct but my build fails due to
"Missing argument for parameter 'index' in call" and takes me to SceneDelegate.swift a place I haven't had to venture before.
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
I can get the code to run if I amend to;
let contentView = ContentView(habits: HabitsList(), index: 1)
but then all my links hold the same data, which makes sense since I am naming the index position.
I have tried, index: self.index (which is what I am using in my NavigationLink) and get a different error message - Cannot convert value of type '(Any) -> Int' to expected argument type 'Int'
Below are snippets of my code for reference;
struct HabitItem: Identifiable, Codable {
let id = UUID()
let name: String
let description: String
let amount: Int
}
class HabitsList: ObservableObject {
#Published var items = [HabitItem]()
}
struct ContentView: View {
#ObservedObject var habits = HabitsList()
#State private var showingAddHabit = false
var index: Int
var body: some View {
NavigationView {
List {
ForEach(habits.items) { item in
NavigationLink(destination: HabitDetail(habits: self.habits, index: self.index)) {
HStack {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.description)
}
}
}
}
}
}
}
}
struct HabitDetail: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var habits: HabitsList
var index: Int
var body: some View {
NavigationView {
Form {
Text(self.habits.items[index].name)
}
}
}
}
You probably don't need to pass the whole ObservedObject to the HabitDetail.
Passing just a HabitItem should be enough:
struct HabitDetail: View {
#Environment(\.presentationMode) var presentationMode
let item: HabitItem
var body: some View {
// remove `NavigationView` form the detail view
Form {
Text(item.name)
}
}
}
Then you can modify your ContentView:
struct ContentView: View {
#ObservedObject var habits = HabitsList()
#State private var showingAddHabit = false
var body: some View {
NavigationView {
List {
// for every item in habits create a `linkView`
ForEach(habits.items, id:\.id) { item in
self.linkView(item: item)
}
}
}
}
// extract to another function for clarity
func linkView(item: HabitItem) -> some View {
// pass just a `HabitItem` to the `HabitDetail`
NavigationLink(destination: HabitDetail(item: item)) {
HStack {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.description)
}
}
}
}
}

SwiftUI out of index when deleting an array element in ForEach

I looked through different questions here, but unfortunately I couldn't find an answer. This is my code:
SceneDelegate.swift
...
let contentView = ContentView(elementHolder: ElementHolder(elements: ["abc", "cde", "efg"]))
...
window.rootViewController = UIHostingController(rootView: contentView)
ContentView.swift
class ElementHolder: ObservableObject {
#Published var elements: [String]
init(elements: [String]) {
self.elements = elements
}
}
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(elementHolder: self.elementHolder, index: index)
}
}
}
}
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("...", text: self.$elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
When pressing on the delete button, the app is crashing with a Index out of bounds error.
There are two strange things, the app is running when
1) you remove the VStack and just put the ForEach into the body of the ContentView.swift or
2) you put the code of the SecondView directly to the ForEach
Just one thing: I really need to have the ObservableObject, this code is just a simplification of another code.
UPDATE
I updated my code and changed Text to a TextField, because I cannot pass just a string, I need a connection in both directions.
The issue arises from the order in which updates are performed when clicking the delete button.
On button press, the following will happen:
The elements property of the element holder is changed
This sends a notification through the objectWillChange publisher that is part of the ElementHolder and that is declared by the ObservableObject protocol.
The views, that are subscribed to this publisher receive a message and will update their content.
The SecondView receives the notification and updates its view by executing the body getter.
The ContentView receives the notification and updates its view by executing the body getter.
To have the code not crash, 3.1 would have to be executed after 3.2. Though it is (to my knowledge) not possible to control this order.
The most elegant solution would be to create an onDelete closure in the SecondView, which would be passed as an argument.
This would also solve the architectural anti-pattern that the element view has access to all elements, not only the one it is showing.
Integrating all of this would result in the following code:
struct ContentView: View {
#ObservedObject var elementHolder: ElementHolder
var body: some View {
VStack {
ForEach(self.elementHolder.elements.indices, id: \.self) { index in
SecondView(
element: self.elementHolder.elements[index],
onDelete: {self.elementHolder.elements.remove(at: index)}
)
}
}
}
}
struct SecondView: View {
var element: String
var onDelete: () -> ()
var body: some View {
HStack {
Text(element)
Button(action: onDelete) {
Text("delete")
}
}
}
}
With this, it would even be possible to remove ElementHolder and just have a #State var elements: [String] variable.
Here is possible solution - make body of SecondView undependable of ObservableObject.
Tested with Xcode 11.4 / iOS 13.4 - no crash
struct SecondView: View {
#ObservedObject var elementHolder: ElementHolder
var index: Int
let value: String
init(elementHolder: ElementHolder, index: Int) {
self.elementHolder = elementHolder
self.index = index
self.value = elementHolder.elements[index]
}
var body: some View {
HStack {
Text(value) // not refreshed on delete
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Another possible solution is do not observe ElementHolder in SecondView... for presenting and deleting it is not needed - also no crash
struct SecondView: View {
var elementHolder: ElementHolder // just reference
var index: Int
var body: some View {
HStack {
Text(self.elementHolder.elements[self.index])
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}
Update: variant of SecondView for text field (only changed is text field itself)
struct SecondViewA: View {
var elementHolder: ElementHolder
var index: Int
var body: some View {
HStack {
TextField("", text: Binding(get: { self.elementHolder.elements[self.index] },
set: { self.elementHolder.elements[self.index] = $0 } ))
Button(action: {
self.elementHolder.elements.remove(at: self.index)
}) {
Text("delete")
}
}
}
}

Deletable Table with TextField on SwiftUI

Environment
Xcode 11.2.1 (11B500)
Problem
In order to implement editable teble with TextField on SwiftUI, I used ForEach(0..<items.count) to handle index.
import SwiftUI
struct DummyView: View {
#State var animals: [String] = ["🐶", "🐱"]
var body: some View {
List {
EditButton()
ForEach(0..<animals.count) { i in
TextField("", text: self.$animals[i])
}
}
}
}
However, problems arise if the table is changed to be deleteable.
import SwiftUI
struct DummyView: View {
#State var animals: [String] = ["🐶", "🐱"]
var body: some View {
List {
EditButton()
ForEach(0..<animals.count) { i in
TextField("", text: self.$animals[i]) // Thread 1: Fatal error: Index out of range
}
.onDelete { indexSet in
self.animals.remove(atOffsets: indexSet) // Delete "🐶" from animals
}
}
}
}
Thread 1: Fatal error: Index out of range when delete item
🐶 has been removed from animals and the ForEach loop seems to be running twice, even though animals.count is 1.
(lldb) po animals.count
1
(lldb) po animals
▿ 1 element
- 0 : "🐱"
Please give me advice on the combination of Foreach and TextField.
Thanks.
Ok, the reason is in documentation for used ForEach constructor (as you see range is constant, so ForEach grabs initial range and holds it):
/// 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 the solution would be to provide dynamic container. Below you can find a demo of possible approach.
Full module code
import SwiftUI
struct DummyView: View {
#State var animals: [String] = ["🐶", "🐱"]
var body: some View {
VStack {
HStack {
EditButton()
Button(action: { self.animals.append("Animal \(self.animals.count + 1)") }, label: {Text("Add")})
}
List {
ForEach(animals, id: \.self) { item in
EditorView(container: self.$animals, index: self.animals.firstIndex(of: item)!, text: item)
}
.onDelete { indexSet in
self.animals.remove(atOffsets: indexSet) // Delete "🐶" from animals
}
}
}
}
}
struct EditorView : View {
var container: Binding<[String]>
var index: Int
#State var text: String
var body: some View {
TextField("", text: self.$text, onCommit: {
self.container.wrappedValue[self.index] = self.text
})
}
}
it is because editbutton is IN your list. place it ouside or better in navigationbar.