SwiftUI - Are views kept alive while navigating? - swiftui

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.

Related

SwiftUI Navigation: why is Timer.publish() in View body breaking nav stack

SwiftUI n00b here. I'm trying some very simple navigation using NavigationView and NavigationLink. In the sample below, I've isolated to a 3 level nav. The 1st level is just a link to the 2nd, the 2nd to the 3rd, and the 3rd level is a text input box.
In the 2nd level view builder, I have a
private let timer = Timer.publish(every: 2, on: .main, in: .common)
and when I navigate to the 3rd level, as soon as I start typing into the text box, I get navigated back to the 2nd level.
Why?
A likely clue that I don't understand. The print(Self._printChanges()) in the 2nd level shows
NavLevel2: #self changed.
immediately when I start typing into the 3rd level text box.
When I remove this timer declaration, the problem goes away. Alternatively, when I modify the #EnvironmentObject I'm using in the 3rd level to just be #State, the problem goes away.
So trying to understand what's going on here, if this is a bug, and if it's not a bug, why does it behave this way.
Here's the full ContentView building code that repos this
import SwiftUI
class AuthDataModel: ObservableObject {
#Published var someValue: String = ""
}
struct NavLevel3: View {
#EnvironmentObject var model: AuthDataModel
var body: some View {
print(Self._printChanges())
return TextField("Level 3: Type Something", text: $model.someValue)
// Replacing above with this fixes everything, even when the
// below timer is still in place.
// (put this decl instead of #EnvironmentObject above
// #State var fff: String = ""
// )
// return TextField("Level 3: Type Something", text: $fff)
}
}
struct NavLevel2: View {
// LOOK HERE!!!! Removing this declaration fixes everything.
private let timer = Timer.publish(every: 2, on: .main, in: .common)
var body: some View {
print(Self._printChanges())
return NavigationLink(
destination: NavLevel3()
) { Text("Level 2") }
}
}
struct ContentView: View {
#StateObject private var model = AuthDataModel()
var body: some View {
print(Self._printChanges())
return NavigationView {
NavigationLink(destination: NavLevel2())
{
Text("Level 1")
}
}
.environmentObject(model)
}
}
First, if you remove #StateObject from model declaration in ContentView, it will work.
You should not set the whole model as a State for the root view.
If you do, on each change of any published property, your whole hierarchy will be reconstructed. You will agree that if you type changes in the text field, you don't want the complete UI to rebuild at each letter.
Now, about the behaviour you describe, that's weird.
Given what's said above, it looks like when you type, the whole view is reconstructed, as expected since your model is a #State object, but reconstruction is broken by this unmanaged timer.. I have no real clue to explain it, but I have a rule to avoid it ;)
Rule:
You should not make timers in view builders. Remember swiftUI views are builders and not 'views' as we used to represent before. The concrete view object is returned by the 'body' function.
If you put a break on timer creation, you will notice your timer is called as soon as the root view is displayed. ( from NavigationLink(destination: NavLevel2())
That's probably not what you expect.
If you move your timer creation in the body, it will work, because the timer is then created when the view is created.
var body: some View {
var timer = Timer.publish(every: 2, on: .main, in: .common)
print(Self._printChanges())
return NavigationLink(
destination: NavLevel3()
) { Text("Level 2") }
}
However, it is usually not the right way neither.
You should create the timer:
in the .appear handler, keep the reference,
and cancel the timer in .disappear handler.
in a .task handler that is reserved for asynchronous tasks.
I personally only declare wrapped values ( #State, #Binding, .. ) in view builders structs, or very simple primitives variables ( Bool, Int, .. ) that I use as conditions when building the view.
I keep all functional stuffs in the body or in handlers.
To stop going back to the previous view when you type in the TextField add .navigationViewStyle(.stack) to the NavigationView
in ContentView.
Here is the code I used to test my answer:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject var model = AuthDataModel()
var body: some View {
NavigationView {
NavigationLink(destination: NavLevel2()){
Text("Level 1")
}
}.navigationViewStyle(.stack) // <--- here the important bit
.environmentObject(model)
}
}
class AuthDataModel: ObservableObject {
#Published var someValue: String = ""
}
struct NavLevel3: View {
#EnvironmentObject var model: AuthDataModel
var body: some View {
TextField("Level 3: Type Something", text: $model.someValue)
}
}
struct NavLevel2: View {
#EnvironmentObject var model: AuthDataModel
#State var tickCount: Int = 0 // <-- for testing
private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
var body: some View {
NavigationLink(destination: NavLevel3()) {
Text("Level 2 tick: \(tickCount)")
}
.onReceive(timer) { val in // <-- for testing
tickCount += 1
}
}
}

SwiftUI Navigation popping back when modifying list binding property in a pushed view

When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.

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:

How can I prevent re-Initializing ForEach in 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)

How do I make a switch.toggle() take effect within a ForEach loop?

I am trying to alternate at the background color of a List/ForEach using a #State var and toggling it on each repetition. The result is alway that the last color behind the entire List. I have set a breakpoint a Text view inside the ForEach and executing it, I see a stop once per item in the input array, then a display on the screen as expected (i.e. every second row is red and the rest are blue). Then, for some reason, we iterate through the code again, one for each item and leave the loop with the background color of all rows being blue.
The code below is a simplified version of my original problem, which iterates over a Realm Results and is expected to handle a NavigationLink rather than the Text-view and handle deleting items as well.
struct ContentView: View {
let array = ["olle", "kalle", "ville", "valle", "viktor"]
#State var mySwitch = false
var body: some View {
List {
ForEach(array, id: \.self) { name in
Text(name)
.onAppear() {
mySwitch.toggle()
print("\(mySwitch)")
}
.listRowBackground(mySwitch ? Color.blue : Color.red)
}
}
}
}
It because of #State var mySwitch = false variable is attached with all row of ForEach so whenever your mySwitch var change it will affect your all row.
So if you want to make some alternative way, you can use the index of item and check whether your number is even or not and do your stuff according to them.
Demo:
struct ContentView: View {
let array = ["olle", "kalle", "ville", "valle", "viktor"]
#State var mySwitch = false
var body: some View {
List {
ForEach(array.indices, id: \.self) { index in
Text(array[index])
.onAppear() {
mySwitch.toggle()
print("\(mySwitch)")
}
.listRowBackground(index.isMultiple(of: 2) ? Color.blue : Color.red)
}
}
}
}