How can I prevent re-Initializing ForEach in SwiftUI? - swiftui

I have a very simple codes and I want keep it as much as possible simple, I am using a ForEach to render some simple Text, for understanding what is happening undercover I made a TextView to get notified each time this View get called by SwiftUI, unfortunately each time I add new element to my array, SwiftUI is going to render all array elements from begging to end, which I want and expecting it call TextView just for new element, So there is a way to defining an array of View/Text which would solve the issue, but that is over kill for such a simple work, I mean me and you would defiantly use ForEach in our projects, and we could use a simple Text inside ForEach or any other custom View, how we could solve this issue to stop SwiftUI initializing same thing again and again, whith this in mind that I want just use a simple String array and not going to crazy and defining a View array.
My Goal is using an simple array of String to this work without being worry to re-initializing issue.
Maybe it is time to re-think about using ForEach in your App!
SwiftUI would fall to re-rendering trap even with updating an element of the array! which is funny. so make yourself ready if you got 50 or 100 or 1000 rows and you are just updating 1 single row, swiftUI would re render the all entire your array, it does not matter you are using simple Text or your CustomView. So I would wish SwiftUI would be smart to not rendering all array again, and just making necessary render in case.
import SwiftUI
struct ContentView: View {
#State private var arrayOfString: [String] = [String]()
var body: some View {
ForEach(arrayOfString.indices, id:\.self) { index in
TextView(stringOfText: arrayOfString[index])
}
Spacer()
Button("append new element") {
arrayOfString.append(Int.random(in: 1...1000).description)
}
.padding(.bottom)
Button("update first element") {
if arrayOfString.count > 0 {
arrayOfString[0] = "updated!"
}
}
.padding(.bottom)
}
}
struct TextView: View {
let stringOfText: String
init(stringOfText: String) {
self.stringOfText = stringOfText
print("initializing TextView for:", stringOfText)
}
var body: some View {
Text(stringOfText)
}
}

Initializing and rendering are not the same thing. The views get initialized, but not necessarily re-rendered.
Try this with your original ContentView:
struct TextView: View {
let stringOfText: String
init(stringOfText: String) {
self.stringOfText = stringOfText
print("initializing TextView for:", stringOfText)
}
var body: some View {
print("rendering TextView for:", stringOfText)
return Text(stringOfText)
}
}
You'll see that although the views get initialized, they do not in fact get re-rendered.
If you go back to your ContentView, and add dynamic IDs to each element:
TextView(stringOfText: arrayOfString[index]).id(UUID())
You'll see that in this case, they actually do get re-rendered.

You are always iterating from index 0, so that’s an expected outcome. If you want forEach should only execute for newly added item, you need to specify correct range. Check code below-:
import SwiftUI
struct ContentViewsss: View {
#State private var arrayOfString: [String] = [String]()
var body: some View {
if arrayOfString.count > 0 {
ForEach(arrayOfString.count...arrayOfString.count, id:\.self) { index in
TextView(stringOfText: arrayOfString[index - 1])
}
}
Spacer()
Button("append new element") {
arrayOfString.append(Int.random(in: 1...1000).description)
}
}
}
struct TextView: View {
let stringOfText: String
init(stringOfText: String) {
self.stringOfText = stringOfText
print("initializing TextView for:", stringOfText)
}
var body: some View {
Text(stringOfText)
}
}

You need to use LazyVStack here
LazyVStack {
ForEach(arrayOfString.indices, id:\.self) { index in
TextView(stringOfText: arrayOfString[index])
}
}
so it reuse view that goes out of visibility area.
Also note that SwiftUI creates view here and there very often because they are just value type and we just should not put anything heavy into custom view init.
The important thing is not to re-render view when it is not visible or not changed and exactly this thing is what you should think about. First is solved by Lazy* container, second is by Equatable protocol (see next for details https://stackoverflow.com/a/60483313/12299030)

Related

SwiftUI List with #FocusState and focus change handling

I want to use a List, #FocusState to track focus, and .onChanged(of: focus) to ensure the currently focused field is visible with ScrollViewReader. The problem is: when everything is setup together the List rebuilds constantly during scrolling making the scrolling not as smooth as it needs to be.
I found out that the List rebuilds on scrolling when I attach .onChanged(of: focus). The issue is gone if I replace List with ScrollView, but I like appearance of List, I need sections support, and I need editing capabilities (e.g. delete, move items), so I need to stick to List view.
I used Self._printChanges() in order to see what makes the body to rebuild itself when scrolling and the output was like:
ContentView: _focus changed.
ContentView: _focus changed.
ContentView: _focus changed.
ContentView: _focus changed.
...
And nothing was printed from the closure attached to .onChanged(of: focus). Below is the simplified example, the smoothness of scrolling is not a problem in this example, however, once the List content is more or less complex the smooth scrolling goes away and this is really due to .onChanged(of: focus) :(
Question: Are there any chances to listen for focus changes and not provoke the List to rebuild itself on scrolling?
struct ContentView: View {
enum Field: Hashable {
case fieldId(Int)
}
#FocusState var focus: Field?
#State var text: String = ""
var body: some View {
List {
let _ = Self._printChanges()
ForEach(0..<100) {
TextField("Enter the text for \($0)", text: $text)
.id(Field.fieldId($0))
.focused($focus, equals: .fieldId($0))
}
}
.onChange(of: focus) { _ in
print("Not printed unless focused manually")
}
}
}
if you add printChanges to the beginning of the body, you can monitor the views and see that they are being rendered by SwiftUI (all of them on each focus lost and focus gained)
...
var body: some View {
let _ = Self._printChanges() // <<< ADD THIS TO SEE RE-RENDER
...
so after allot of testing, it seams that the problem is with .onChange, once you add it SwiftUI will redraw all the Textfields,
the only BYPASS i found is to keep using the deprecated API as it works perfectly, and renders only the two textfields (the one that lost focus, and the one that gained the focus),
so the code should look this:
struct ContentView: View {
enum Field: Hashable {
case fieldId(Int)
}
// #FocusState var focus: Field? /// NO NEED
#State var text: String = ""
var body: some View {
List {
let _ = Self._printChanges()
ForEach(0..<100) {
TextField("Enter the text for \($0)", text: $text)
.id(Field.fieldId($0))
// .focused($focus, equals: .fieldId($0)) /// NO NEED
}
}
// .onChange(of: focus) { _ in /// NO NEED
// print("Not printed unless focused manually") /// NO NEED
// } /// NO NEED
.focusable(true, onFocusChange: { focusNewValue in
print("Only textfileds that lost/gained focus will print this")
})
}
}
I recommend to consider separation of list row content into standalone view and use something like focus "selection" approach. Having FocusState internal of each row prevents parent view from unneeded updates (something like pre-"set up" I assume).
Tested with Xcode 13.4 / iOS 15.5
struct ContentView: View {
enum Field: Hashable {
case fieldId(Int)
}
#State private var inFocus: Field?
var body: some View {
List {
let _ = Self._printChanges()
ForEach(0..<100, id: \.self) {
ExtractedView(i: $0, inFocus: $inFocus)
}
}
.onChange(of: inFocus) { _ in
print("Not printed unless focused manually")
}
}
struct ExtractedView: View {
let i: Int
#Binding var inFocus: Field?
#State private var text: String = ""
#FocusState private var focus: Bool // << internal !!
var body: some View {
TextField("Enter the text for \(i)", text: $text)
.focused($focus)
.id(Field.fieldId(i))
.onChange(of: focus) { _ in
inFocus = .fieldId(i) // << report selection outside
}
}
}
}

Why the List does not appear?

Why the presetsList does not appear? No errors were thrown though.
import SwiftUI
struct AddMessagePreset: View {
let presetsList = [
Preset(name: "preset text 1"),
Preset(name: "preset text 2"),
Preset(name: "preset text 3")
]
var body: some View {
List(presetsList) { singlePresetModel in
SinglePresetChild (presetModel: singlePresetModel)
}
}
}
import SwiftUI
struct Preset: Identifiable {
let id = UUID()
let name: String
}
struct SinglePresetChild: View {
var presetModel: Preset
var body: some View {
Text("Preset Name \(presetModel.name)")
}
}
UPDATE: To show a List inside another ScrollView (or List), you have to set a height on the inner list view:
struct Preview: View {
var body: some View {
ScrollView {
AddMessagePreset().frame(height: 200)
// more views ...
}
}
}
But let me advise against doing so. Having nested scroll areas can be very confusing for the user.
As discussed in the comments, your component code is fine. However, the way you integrate it into your app causes a problem. Apparently, nesting a List inside a ScrollView does not work properly (also see this thread).
List is already scrollable vertically, so you won't need the additional ScrollView:
struct Preview: View {
var body: some View {
VStack {
AddMessagePreset()
}
}
}
P.S.: If you only want to show AddMessagePreset and won't add another sibling view, you can remove the wrapping VStack; or even show AddMessagePreset as the main view, without any wrapper.

SwiftUI Toggle Switch without Binding

This may seem like an odd question but I have looked around and can't seem to find an answer for it.
I would like to create toggles in a view that are not binded to any variable. Imagine a list of toggle switches that are toggle-able but don't actually do anything.
I have tried using .constant butt as you would expect, that doesn't allow me to toggle the switch. Obviously leaving It blank throws an error.
//Can't be changed
Toggle(isOn: .constant(true)) {
Text("Checkbox")
}
//Throws an error
Toggle() {
Text("Checkbox")
}
Is there anything that can be passed in the isOn: parameter to allow for that?
Edit:
In theory I could just have a #State variable in my view and binding to the toggle and simple not used that variable anywhere else in my view. Only thing is, I do not know ahead of time how many toggles will be displayed in my view so I can't just declare a bunch of #State variables. And if I were too only create one #State variable and blind it to all of my toggles, they would all be in sync, which is not what I am looking for, I would like them to all be independent.
Below is a simplified example of the layout of my view
private var array: [String]
var body: some View {
ForEach((0..<self.array.count), id: \.self) {
Toggle("Show welcome message", isOn: *binding here*)
}
}
Thank you
You can simply create a single #State variable of type [Bool], an array containing all the toggle booleans.
Here is some example code:
struct ContentView: View {
var body: some View {
ToggleStack(count: 5)
}
}
struct ToggleStack: View {
#State private var toggles: [Bool]
private let count: Int
init(count: Int) {
self.count = count
toggles = Array(repeating: true, count: count)
}
var body: some View {
VStack {
ForEach(0 ..< count) { index in
Toggle(isOn: $toggles[index]) {
Text("Checkbox")
}
}
}
}
}
Result:

SwiftUI - How to pass data then initialise and edit data

I'm downloading data from Firebase and trying to edit it. It works, but with an issue. I am currently passing data to my EditViewModel with the .onAppear() method of my view. And reading the data from EditViewModel within my view.
class EditViewModel: ObservableObject {
#Published var title: String = ""
}
struct EditView: View {
#State var selected_item: ItemModel
#StateObject var editViewModel = EditViewModel()
var body: some View {
VStack {
TextField("Name of item", text: self.$editViewModel.title)
Divider()
}.onAppear {
DispatchQueue.main.async {
editViewModel.title = selected_item.title
}
}
}
}
I have given you the extremely short-hand version as it's much easier to follow.
However, I push to another view to select options from a list and pop back. As a result, everything is reset due to using the onAppear method. I have spent hours trying to use init() but I am struggling to get my application to even compile, getting errors in the process. I understand it's due to using the .onAppear method, but how can I use init() for this particular view/view-model?
I've search online but I've found the answers to not be useful, or different from what I wish to achieve.
Thank you.
You don't need to use State for input property - it is only for internal view usage. So as far as I understood your scenario, here is a possible solution:
struct EditView: View {
private var selected_item: ItemModel
#StateObject var editViewModel = EditViewModel()
init(selectedItem: ItemModel) {
selected_item = selectedItem
editViewModel.title = selectedItem.title
}
var body: some View {
VStack {
TextField("Name of item", text: self.$editViewModel.title)
Divider()
}.onAppear {
DispatchQueue.main.async {
editViewModel.title = selected_item.title
}
}
}
}

SwiftUI - Are views kept alive while navigating?

I have a bug when using NavigationLinks and an ObservableObject. I don't quite understand why because I don't understand what is happening to the views and data as I am navigating. This is some pseudo-code to illustrate the problem:
class Settings: ObservableObject {
#Published var data: [Int] = [0, 1, 2, 3, 4, 5]
}
struct ContentView: View {
#State var new_view: Bool = false
#ObservedObject var content_view_settings = Settings()
var body: some View {
NavigationView {
VStack {
Button(action: {
DeleteLastItem()
}) {
Text("Delete last item")
}
Button(action: {
self.new_view = true
}) {
Text("New View")
}
NavigationLink(destination: NewView(new_view_settings: content_view_settings), isActive: $new_view) {
EmptyView()
}
}
}
}
}
struct NewView: View {
#ObservedObject var new_view_settings: Settings
#State var index = -1
var body: some View {
VStack {
Button(action: {
self.index = self.new_view_settings.count - 1
}) {
Text("change index")
}
if self.index > -1 {
Text("\(self.new_view_settings.data[index])")
}
}
}
}
The description of the problem is this:
I have a view with an ObservedObject that I pass to a subsequent view upon navigating. This sub-view accesses the last element of the array, but it only does that once the index variable is validated through a button click. The text is then rendered only after the index is validated.
Now, suppose I validate the index so it would equal 5 in this example. Then I navigate back to the original view. If I delete the last element, the index 5 is no longer valid. As soon as I delete that last element I get an invalid index error and the simulator crashes.
But let's say I navigate backward and do not delete the last element. Then when I navigate forward, the index variable is reset.
Since I get the crash, this means the view is still alive and being updated or something but when I navigate to it once again the view is reloaded. Does this mean the view is alive until it gets initialized again? This is contrived code but it is essentially the issue I am having. I thought the original code would be a bit harder to understand.
Does this mean the view is alive until it gets initialized again?
Yes, the view may be alive even after you navigate back to the parent view.
To better understand what's happening run the same code on the iPad simulator (preferably in the horizontal mode). You'll notice that the NavigationView is split in two parts: master and detail - this way you can see both parent and child view at once.
Now, if you perform the same experiment from your question, you'll see the child view remains present even if you navigate back. The same happens on iOS.
One way to prevent this can be to check if indices are present in the array:
struct NewView: View {
#ObservedObject var new_view_settings: Settings
#State var index = -1
var body: some View {
VStack {
Button(action: {
//self.index = self.new_view_settings.count - 1
}) {
Text("change index")
}
// check if `index` is in array
if self.index > -1 && self.index < self.new_view_settings.data.count {
Text("\(self.new_view_settings.data[index])")
}
}
}
}
Note: in general I don't recommend dealing with indices in SwiftUI views - there usually is a better way to pass data. Dealing with indices is risky.