Preserve ViewModel values when #EnvironmentObject value changes - SwiftUI - swiftui

I am looking to preserve the viewModel.counter value even when valueComingFromParent value changes.
Right now, when Incrementer button is clicked it increments viewModel.counter value. But when Toast button is clicked, it changes value of a property in valueComingFromParent. and at the same time viewModel.counter value resets to 0
struct ChildView: View {
#ObservedObject var viewModel = ChildViewModel()
#EnvironmentObject var valueComingFromParent: SomeOtherViewModel
var body: some View {
VStack {
Button("Toast", action: {
valueComingFromParent.toast.toggle()
})
Button("Incrementer \(viewModel.counter)", action: {
viewModel.counter += 1
})
}
}
}
Can anybody tell me how can I preserve the value of viewModel.counter even if valueComingFromParent value changes.
Thanks

Related

SwiftUI state discarded when scrolling items in a list

I'm very new to Swift and SwiftUI so apologies for the very basic question. I must be misunderstanding something about the SwiftUI lifecycle and it's interaction with #State.
I've have a list, and when you click on the row, it increments a counter. If I click on some row items to increment the counter, scroll down, and scroll back up again - the state is reset to 0 again. Can anyone point out where I'm going wrong? Many thanks.
struct TestView : View {
#State private var listItems:[String] = (0..<50).map { String($0) }
var body: some View {
List(listItems, id: \.self) { listItem in
TestViewRow(item: listItem)
}
}
}
struct TestViewRow: View {
var item: String
#State private var count = 0
var body: some View {
HStack {
Button(item, action: {
self.count += 1
})
Text(String(self.count))
Spacer()
}
}
}
Items in a List are potentially lazily-loaded, depending on the os (macOS vs iOS), length of the list, number of items on the screen, etc.
If a list item is loaded and then its state is changed, that state is not reassigned to the item if that item has been since unloaded/reloaded into the List.
Instead of storing #State on each List row, you could move the state to the parent view, which wouldn't be unloaded:
struct ContentView : View {
#State private var listItems:[(item:String,count:Int)] = (0..<50).map { (item:String($0),count:0) }
var body: some View {
List(Array(listItems.enumerated()), id: \.0) { (index,item) in
TestViewRow(item: item.item, count: $listItems[index].count)
}
}
}
struct TestViewRow: View {
var item: String
#Binding var count : Int
var body: some View {
HStack {
Button(item, action: {
count += 1
})
Text(String(count))
Spacer()
}
}
}

SwiftUI: Switch .sheet on enum, does not work

I have two Modal/Popover .sheet's I would like to show based on which button is pressed by a user. I have setup an enum with the different choices and set a default choice.
Expected behaviour:
When the user selects any choice, the right sheet is displayed. When the user THEN selects the other choice, it also shows the correct sheet.
Observed behaviour:
In the example below, when the user first picks the second choice, the first sheet is shown and will continue to show until the user selects the first sheet, then it will start to switch.
Debug printing shows that the #State variable is changing, however, the sheet presentation does not observe this change and shows the sheets as described above. Any thoughts?
import SwiftUI
//MARK: main view:
struct ContentView: View {
//construct enum to decide which sheet to present:
enum ActiveSheet {
case sheetA, sheetB
}
//setup needed vars and set default sheet to show:
#State var activeSheet = ActiveSheet.sheetA //sets default sheet to Sheet A
#State var showSheet = false
var body: some View {
VStack{
Button(action: {
self.activeSheet = .sheetA //set choice to Sheet A on button press
print(self.activeSheet) //debug print current activeSheet value
self.showSheet.toggle() //trigger sheet
}) {
Text("Show Sheet A")
}
Button(action: {
self.activeSheet = .sheetB //set choice to Sheet B on button press
print(self.activeSheet) //debug print current activeSheet value
self.showSheet.toggle() //trigger sheet
}) {
Text("Show Sheet B")
}
}
//sheet choosing view to display based on selected enum value:
.sheet(isPresented: $showSheet) {
switch self.activeSheet {
case .sheetA:
SheetA() //present sheet A
case .sheetB:
SheetB() //present sheet B
}
}
}
}
//MARK: ancillary sheets:
struct SheetA: View {
var body: some View {
Text("I am sheet A")
.padding()
}
}
struct SheetB: View {
var body: some View {
Text("I am sheet B")
.padding()
}
}
With some very small alterations to your code, you can use sheet(item:) for this, which prevents this problem:
//MARK: main view:
struct ContentView: View {
//construct enum to decide which sheet to present:
enum ActiveSheet : String, Identifiable { // <--- note that it's now Identifiable
case sheetA, sheetB
var id: String {
return self.rawValue
}
}
#State var activeSheet : ActiveSheet? = nil // <--- now an optional property
var body: some View {
VStack{
Button(action: {
self.activeSheet = .sheetA
}) {
Text("Show Sheet A")
}
Button(action: {
self.activeSheet = .sheetB
}) {
Text("Show Sheet B")
}
}
//sheet choosing view to display based on selected enum value:
.sheet(item: $activeSheet) { sheet in // <--- sheet is of type ActiveSheet and lets you present the appropriate sheet based on which is active
switch sheet {
case .sheetA:
SheetA()
case .sheetB:
SheetB()
}
}
}
}
The problem is that without using item:, current versions of SwiftUI render the initial sheet with the first state value (ie sheet A in this case) and don't update properly on the first presentation. Using this item: approach solves the issue.

How to initialize a binding Bool value in a SwiftUI view

Revised Example
Based on the thoughtful response from #pawello2222 I received in the comments below I have revised an example to demonstrate the issue I am wrestling with.
In order to demonstrate the issue I am having I am using 2 views a parent and a child. In my code code the parent view executes multiple steps but sometimes the animation subview is not visible in the first step. However when it does become visible the animation has already taken on the appearance of the end state. You can see this behavior in the following example.
Parent View
struct ContentView: View {
#State var firstTime = true
#State var breath = false
var body: some View {
VStack {
// This subview is not displayed until after the first time
if !firstTime {
SecondView(breath: $breath)
}
Spacer()
// A button click simulates the steps in my App by toggling the #Binding var
Button("Breath") {
withAnimation {
self.breath.toggle()
self.firstTime = false
}
}
// This vies shows what happens when the subview is being displayed with an intial state of false for the #Binding var
Spacer()
SecondView(breath: $breath)
}
}
}
Here is the subview containing the animation and using a #Binding var to control the animation appearance.
struct SecondView: View {
#Binding var breath: Bool
var body: some View {
Image(systemName: "flame")
.resizable()
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center)
.scaleEffect(breath ? 1 : 0.2)
.opacity(breath ? 1 : 0.75)
.animation(.easeInOut(duration: 2))
.foregroundColor(Color.red)
}
}
When you execute this the first time thru the top subview is not being displayed and when you click the button the lower subview executes the expected animation and then toggles the firstTime var so that the top subview becomes visible. Notice that the animation is fully expanded and if I was to then have another step (click) with the same value of true for the #Binding property the view would not change at all. This is the issue I am wrestling with. I would like to keep the subview from being at the end state if the first step is one that has toggled the Bool value even if the subview was not being displayed. In other words I would like to only initialize the subview when it is actually being displayed with a value of true so that the animation will always start out small.
This is why I was hoping to have the subview initialized the Binding var to false until it actually gets invoked for the first time (or to reset its state to the shrunk version of the animation) whichever is more feasible.
It looks like you may want to initialise _breath with the provided parameter:
struct ContentView: View {
#Binding var breath: Bool
init(breath: Binding<Bool>) {
_breath = breath
}
}
However, if you want to use a constant value (in your example false) you can do:
struct ContentView: View {
#Binding var breath: Bool
init(breath: Binding<Bool>) {
_breath = .constant(false)
}
}
But then, why do you need the breath: Binding<Bool> parameter?
EDIT
Here is an example how to control an animation of a child view using a #Binding variable:
struct ContentView: View {
#State var breath = false
var body: some View {
VStack {
Button("Breath") {
withAnimation {
self.breath.toggle()
}
}
SecondView(breath: $breath)
}
}
}
struct SecondView: View {
#Binding var breath: Bool
var body: some View {
Image(systemName: "flame")
.imageScale(.large)
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center)
.scaleEffect(breath ? 1 : 0.2)
.opacity(breath ? 1 : 0.75)
.animation(.easeInOut(duration: 2))
}
}

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.

What is the difference between #State and #ObservedObject, can they both be used to persist state?

When I Googled "State vs ObservedObject" the first result was from Hacking with Swift and it said about #ObservedObject:
This is very similar to #State except now we’re using an external reference type rather than a simple local property like a string or an integer.
Can I use #ObservedObject to create persisted state? Is it as simple as #State is for simple properties and #ObservedObject is for complex objects, or is there more nuance to it?
#ObservedObject does not persist state
Can I use #ObservedObject to create persisted state?
On its own, you cannot. The Apple documentation has this to say about #State:
A persistent value of a given type, through which a view reads and monitors the value.
But I found no mention of persistence with #ObservedObject so I constructed this little demo which confirms that #ObservedObject does not persist state:
class Bar: ObservableObject {
#Published var value: Int
init(bar: Int) {
self.value = bar
}
}
struct ChildView: View {
let value: Int
#ObservedObject var bar: Bar = Bar(bar: 0)
var body: some View {
VStack(alignment: .trailing) {
Text("param value: \(value)")
Text("#ObservedObject bar: \(bar.value)")
Button("(child) bar.value++") {
self.bar.value += 1
}
}
}
}
struct ContentView: View {
#State var value = 0
var body: some View {
VStack {
Spacer()
Button("(parent) value++") {
self.value += 1
}
ChildView(value: value)
Spacer()
}
}
}
Whenever you click on the value++ button, it results in a re-render of ChildView because the value property changed. When a view is re-rendered as a result of a property change, it's #ObservedObjects are reset
In contrast, if you add a #State variable to the ChildView you'll notice that it's value is not reset when the #ObservedObject is reset.
Using persisted state with #ObservedObject
To persist state with #ObservedObject, instantiate the concrete ObservableObject with #State in the parent view. So to fix the previous example, would go like this:
struct ChildView: View {
let value: Int
#ObservedObject var bar: Bar // <-- passed in by parent view
var body: some View {
VStack(alignment: .trailing) {
Text("param value: \(value)")
Text("#ObservedObject bar: \(bar.value)")
Button("(child) bar.value++") {
self.bar.value += 1
}
}
}
}
struct ContentView: View {
#State var value = 0
#State var bar = Bar(bar: 0) // <-- The ObservableObject
var body: some View {
VStack {
Spacer()
Button("(parent) value++") {
self.value += 1
}
ChildView(value: value, bar: bar).id(1)
Spacer()
}
}
}
The definition of the class Bar is unchanged from the first code example. And now we see that the value is not reset even when the value property changes: