Changing #Published value triggers Init in View - swift

I am facing a problem initializing the view, it is reinitialized when the #Published property is set and I cannot figure out why.
The app structure is:
MyApp -> MainView -> fullscreenCover OrderView -> ListOfOrders -> fullscreenCover ProductView -> Bug Button. The view model is injected as #StateObject.
Here is a simplified version of the app I'm working on:
View model
class SystemService: ObservableObject {
#Published private(set) var testValue: Bool = false
#Published private(set) var products: [Product] = []
}
The App declaration
#main
struct MyApp: App {
#StateObject private var systemService = SystemService()
#ViewBuilder
var body: some Scene {
WindowGroup {
MainView(systemService: systemService)
}
}
}
The main view, which basically just shows a fullscreen modal view - OrderView
struct MainView: View {
#StateObject var systemService: SystemService
#State private var activeFullScreen: ActiveFullScreenEnum?
var body: some View {
VStack {
Button(action: {
activeFullScreen = .order
}, label: Text("Order Details"))
}
.fullScreenCover(item: $activeFullScreen, content: { item in
switch item {
case .order:
OrderView(systemService: systemService)
}
})
}
}
The Order View contains a list of products.
struct OrderView: View {
#StateObject var systemService: SystemService
#State private var activeFullScreen: OrderFullScreenEnum?
init(systemService: SystemService) {
_systemService = StateObject(wrappedValue: systemService)
print("OrderScreen Initialized")
}
var body: some View {
VStack {
ScrollView {
ForEach(systemService.products) { product in
Button(action: {
activeFullScreen = .product(product)
}, label: Text("Prodcut Details"))
}
}
}
.fullScreenCover(item: $activeFullScreen, content: { item in
switch item {
case .product(let product):
ProductView(systemService: systemService, product: product)
}
})
}
}
And finally the Product View where the bug is discovered.
The bug is - when pressing on the "Bug Button" the ProductView is dismisses, and OrderView's init calls and prints out "OrderScreen Initialized".
struct ProductView: View {
#StateObject var systemService: SystemService
var body: some View {
VStack {
Button(action: {
systemService.testValue.toggle()
}, label: Text("Bug Button"))
}
}
}
Probably the issue in my fundamental misunderstanding of how Combine works, I will be grateful if somebody could help.
***** Additional info *****
If I add .onAppear to the Order View
.onAppear {
print("Order View Did Appear")
}
The first call to systemService.testValue.toggle() from the Ordre View triggers .onAppear, but only once, only the first time. After that, the bug disappears and .fullScreenCover doesn't get dismissed anymore.

I think the main problem is that the application may be trying to operate with four different instances of SystemServices.
#StateObject - 'A property wrapper type that instantiates an observable object.'
#ObservedObject - 'A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.'
Instead of #StateObject, try giving #ObservedObject in the child views of App a go.
Good luck.

Related

SwiftUI: #StateObject init multiple times

I'm trying to optimize my SwiftUI app. I have a strange behavior with a ViewModel stored as a #StateObject in its View. To understand the issue, I made a small project that reproduces it.
ContentView contains a button to open ChildView in a sheet. ChildView is stored as property as I don't want to recreate it every time the sheet is open by user (this works):
struct ContentView: View {
#State private var displayingChildView = false
private let childView = ChildView()
var body: some View {
Button(action: {
displayingChildView.toggle()
}, label: {
Text("Display child view")
})
.sheet(isPresented: $displayingChildView, content: {
childView // instead of: ChildView()
})
}
}
ChildView code:
struct ChildView: View {
#StateObject private var viewModel = ViewModel()
init() {
print("init() of ChildView")
}
var body: some View {
VStack {
Button(action: {
viewModel.add()
}, label: {
Text("Add 1 to count")
})
Text("Count: \(viewModel.count)")
}
}
}
And its ViewModel:
class ViewModel: ObservableObject {
#Published private(set) var count = 0
init() {
print("init() of ViewModel")
}
func add() {
count += 1
}
}
Here is the issue:
The ViewModel's init is called every time user opens the sheet. Why?
As ViewModel is a #StateObject in ChildView and ChildView is only init once, I am expecting that ViewModel is also only init once.
I have read this article that says that :
Observed objects marked with the #StateObject property wrapper don’t get destroyed and re-instantiated at times their containing view struct redraws.
Or here:
Use #StateObject once for each observable object you use, in whichever part of your code is responsible for creating it.
So I understand that ViewModel should stay alive, especially as ChildView is not destroyed.
And what confuses me the most is that if I replace #StateObject with #ObservedObject it works as expected. But it is not recommended to store an #ObservedObject inside a View.
Can anyone explain why this behavior and how to fix it as expected (ViewModel init should be called once) ?
A possible solution:
I've found a possible solution to fix this behavior:
a. Move the declaration of ViewModel into ContentView:
#StateObject private var viewModel = ViewModel()
b. Change the declaration of ViewModel in ChildView to be an EnvironmentObject:
#EnvironmentObject private var viewModel: ViewModel
c. And inject it in childView:
childView
.environmentObject(viewModel)
That means it's ContentView that is responsible to keep the ChildView's ViewModel alive. It works, but I find this solution quite ugly:
All future child Views of ChildView could get access to ViewModel through environment objects. But it's no sense as it should be only useful for its View.
I would prefer declare a ViewModel inside its View instead of inside its parent View.
And this solution still doesn't explain above questions about #StateObject that should stay alive...
SwiftUI initializes the #State variables when a view is inserted into the view hierarchy. This is why your attempt to keep the state of the child view alive by assigning it to a var fails. Every time your sheet is presented, the child view is added to the view hierarchy and its state variables are initialized.
The correct way to do this is to pass the viewModel to the child view.
struct ContentView: View {
#StateObject private var viewModel = ViewModel()
#State private var displayingChildView = false
var body: some View {
Button(action: {
displayingChildView.toggle()
}, label: {
Text("Display child view")
})
.sheet(isPresented: $displayingChildView, content: {
ChildView(viewModel: viewModel)
})
}
}
struct ChildView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Button(action: {
viewModel.add()
}, label: {
Text("Add 1 to count")
})
Text("Count: \(viewModel.count)")
}
}
}
Objects slow down SwiftUI, to use it effectively we need to forget about view model objects and learn value types, structs, mutating func, closure captures, etc. Here is how it should be done:
struct Counter {
private(set) var count = 0
init() {
print("init() of Counter")
}
mutating func add() {
count += 1
}
}
struct ChildView: View {
#State private var counter = Counter()
init() {
print("init() of ChildView")
}
var body: some View {
VStack {
Button(action: {
counter.add()
}, label: {
Text("Add 1 to count")
})
Text("Count: \(counter.count)")
}
}
}

Share binding between two ViewModels in Swift

I just got started with SwiftUI and I would like to use ViewModels to encapsulate my logic, and separate it from my Views.
Now I just hit my first roadblock and I am not sure how to get passed this.
So my app so far is fairly simple. I have two Views, each with their own ViewModels: Parent and Child.
The Parent ViewModel holds a list of Items, which are fetched from a backend API. I want to pass this to Child and its ViewModel, since it is responsible for adding Items to the list.
Here's the simplified code for this:
struct ParentView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
ChildView()
Text("Items: \(viewModel.items.count)")
}
}
}
extension ParentView {
#MainActor class ViewModel: ObservableObject {
#Published var items: [Item] = []
}
}
struct ChildView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
List {
ForEach(viewModel.items) { item in
Text(item.name)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.AddItem()
} label: {
Label("Add item", systemImage: "plus")
}
}
}
}
}
extension ChildView {
#MainActor class ViewModel: ObservableObject {
#Published var items: [Item] = []
func AddItem() {
items.append(Item(name: "Test"))
}
}
}
How can I make it so that the list of items from the parent view model is passed down to the view model of the child, ensuring that there is only a single list, while also making sure that both views get refreshed when this list changes?
Thanks!
You must only have one source of truth, in your ParentView that you then pass to the child views. Currently you have multiple ViewModel that have no relations to each other.
In ChildView replace #StateObject private var viewModel = ViewModel() with
#ObservedObject var viewModel: ViewModel, and in ParentView,
use ChildView(viewModel: viewModel) to pass the ViewModel to it.
Remove also extension ChildView ... and take #MainActor class ViewModel: ObservableObject out of the extension ParentView.
Have a look at this link, it gives you some good examples of how to use ObservableObject and manage data in your app https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
EDIT-1:
Here is my full test example code to show how the parent view model
is passed down to the child view ...ensuring that there is only a single list, while also making sure that both views get refreshed when this list changes
struct ContentView: View {
var body: some View {
NavigationStack { // <-- here
ParentView()
}
}
}
struct Item: Identifiable {
let id = UUID()
var name:String
}
struct ParentView: View {
#StateObject var viewModel = ViewModel() // <-- here
var body: some View {
VStack {
ChildView(viewModel: viewModel) // <-- here
Text("Items: \(viewModel.items.count)")
}
}
}
// -- here
#MainActor class ViewModel: ObservableObject {
#Published var items: [Item] = [Item(name: "item-1"), Item(name: "item-2")]
}
struct ChildView: View {
#ObservedObject var viewModel: ViewModel // <-- here
var body: some View {
List {
ForEach(viewModel.items) { item in
Text(item.name)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.items.append(Item(name: "Test")) // <-- here
} label: {
Label("Add item", systemImage: "plus")
}
}
}
}
}

SwiftUI macOS NavigationView - onChange(of: Bool) action tried to update multiple times per frame

I'm seeing onChange(of: Bool) action tried to update multiple times per frame warnings when clicking on NavigationLinks in the sidebar for a SwiftUI macOS App.
Here's what I currently have:
import SwiftUI
#main
struct BazbarApp: App {
#StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
class ModelData: ObservableObject {
#Published var myLinks = [URL(string: "https://google.com")!, URL(string: "https://apple.com")!, URL(string: "https://amazon.com")!]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
NavigationLink(destination: DetailView(selected: $selected) ) {
Text(url.absoluteString)
}
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
Text("Choose a link")
}
}
}
struct DetailView: View {
#Binding var selected: URL?
var body: some View {
if let selected = selected {
Text("Currently selected: \(selected)")
}
else {
Text("Choose a link")
}
}
}
When I alternate clicking on the second and third links in the sidebar, I eventually start seeing the aforementioned warnings in my console.
Here's a gif of what I'm referring to:
Interestingly, the warning does not appear when alternating clicks between the first and second link.
Does anyone know how to fix this?
I'm using macOS 12.2.1 & Xcode 13.2.1.
Thanks in advance
I think the issue is that both the List(selection:) and the NavigationLink are trying to update the state variable selected at once. A List(selection:) and a NavigationLink can both handle the task of navigation. The solution is to abandon one of them. You can use either to handle navigation.
Since List look good, I suggest sticking with that. The NavigationLink can then be removed. The second view under NavigationView is displayed on the right, so why not use DetailView(selected:) there. You already made the selected parameter a binding variable, so the view will update if that var changes.
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
Text(url.absoluteString)
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
DetailView(selected: $selected)
}
}
}
I can recreate this problem with the simplest example I can think of so my guess is it's an internal bug in NavigationView.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("A", destination: Text("A"))
NavigationLink("B", destination: Text("B"))
NavigationLink("C", destination: Text("C"))
}
}
}
}

Reload aync call on view state update

I have the following view:
struct SpriteView: View {
#Binding var name: String
#State var sprite: Image = Image(systemName: "exclamationmark")
var body: some View {
VStack{
sprite
}
.onAppear(perform: loadSprite)
}
func loadSprite() {
// async function
getSpriteFromNetwork(self.name){ result in
switch result {
// async callback
case .success(newSprite):
self.sprite = newSprite
}
}
}
What I want to happen is pretty simple: a user modifies name in text field (from parent view), which reloads SpriteView with the new sprite. But the above view doesn't work since when the view is reloaded with the new name, loadSprite isn't called again (onAppear only fires when the view is first loaded). I also can't put loadSprite in the view itself (and have it return an image) since it'll lead to an infinite loop.
There is a beta function onChange that is exactly what I'm looking for, but it's only in the beta version of Xcode. Since Combine is all about async callbacks and SwiftUI and Combine are supposed to play well together, I thought this sort of behavior would be trivial to implement but I've been having a lot of trouble with it.
I don't particular like this solution since it requires creating a new ObservableObject but this how I ended up doing it:
class SpriteLoader: ObservableObject {
#Published var sprite: Image = Image(systemName: "exclamationmark")
func loadSprite(name: String) {
// async function
self.sprite = Image(systemName: "arrow.right")
}
}
struct ParentView: View {
#State var name: String
#State var spriteLoader = SpriteLoader()
var body: some View {
SpriteView(spriteLoader: spriteLoader)
TextField(name, text: $name, onCommit: {
spriteLoader.loadSprite(name: name)
})
}
}
struct SpriteView: View {
#ObservedObject var spriteLoader: SpriteLoader
var body: some View {
VStack{
spriteLoader.sprite
}
}
}
Old answer:
I think the best way to do this is as follows:
Parent view:
struct ParentView: View {
#State var name: String
#State spriteView = SpriteView()
var body: some View {
spriteView
TextField(value: $name, onCommit: {
spriteView.loadSprite(name)
})
}
And then the sprite view won't even need the #Binding name member.

SwiftUI: ObservableObject does not persist its State over being redrawn

Problem
In Order to achieve a clean look and feel of the App's code, I create ViewModels for every View that contains logic.
A normal ViewModel looks a bit like this:
class SomeViewModel: ObservableObject {
#Published var state = 1
// Logic and calls of Business Logic goes here
}
and is used like so:
struct SomeView: View {
#ObservedObject var viewModel = SomeViewModel()
var body: some View {
// Code to read and write the State goes here
}
}
This workes fine when the Views Parent is not being updated. If the parent's state changes, this View gets redrawn (pretty normal in a declarative Framework). But also the ViewModel gets recreated and does not hold the State afterward. This is unusual when you compare to other Frameworks (eg: Flutter).
In my opinion, the ViewModel should stay, or the State should persist.
If I replace the ViewModel with a #State Property and use the int (in this example) directly it stays persisted and does not get recreated:
struct SomeView: View {
#State var state = 1
var body: some View {
// Code to read and write the State goes here
}
}
This does obviously not work for more complex States. And if I set a class for #State (like the ViewModel) more and more Things are not working as expected.
Question
Is there a way of not recreating the ViewModel every time?
Is there a way of replicating the #State Propertywrapper for #ObservedObject?
Why is #State keeping the State over the redraw?
I know that usually, it is bad practice to create a ViewModel in an inner View but this behavior can be replicated by using a NavigationLink or Sheet.
Sometimes it is then just not useful to keep the State in the ParentsViewModel and work with bindings when you think of a very complex TableView, where the Cells themself contain a lot of logic.
There is always a workaround for individual cases, but I think it would be way easier if the ViewModel would not be recreated.
Duplicate Question
I know there are a lot of questions out there talking about this issue, all talking about very specific use-cases. Here I want to talk about the general problem, without going too deep into custom solutions.
Edit (adding more detailed Example)
When having a State-changing ParentView, like a list coming from a Database, API, or cache (think about something simple). Via a NavigationLink you might reach a Detail-Page where you can modify the Data. By changing the data the reactive/declarative Pattern would tell us to also update the ListView, which would then "redraw" the NavigationLink, which would then lead to a recreation of the ViewModel.
I know I could store the ViewModel in the ParentView / ParentView's ViewModel, but this is the wrong way of doing it IMO. And since subscriptions are destroyed and/or recreated - there might be some side effects.
Finally, there is a Solution provided by Apple: #StateObject.
By replacing #ObservedObject with #StateObject everything mentioned in my initial post is working.
Unfortunately, this is only available in ios 14+.
This is my Code from Xcode 12 Beta (Published June 23, 2020)
struct ContentView: View {
#State var title = 0
var body: some View {
NavigationView {
VStack {
Button("Test") {
self.title = Int.random(in: 0...1000)
}
TestView1()
TestView2()
}
.navigationTitle("\(self.title)")
}
}
}
struct TestView1: View {
#ObservedObject var model = ViewModel()
var body: some View {
VStack {
Button("Test1: \(self.model.title)") {
self.model.title += 1
}
}
}
}
class ViewModel: ObservableObject {
#Published var title = 0
}
struct TestView2: View {
#StateObject var model = ViewModel()
var body: some View {
VStack {
Button("StateObject: \(self.model.title)") {
self.model.title += 1
}
}
}
}
As you can see, the StateObject Keeps it value upon the redraw of the Parent View, while the ObservedObject is being reset.
I agree with you, I think this is one of many major problems with SwiftUI. Here's what I find myself doing, as gross as it is.
struct MyView: View {
#State var viewModel = MyViewModel()
var body : some View {
MyViewImpl(viewModel: viewModel)
}
}
fileprivate MyViewImpl : View {
#ObservedObject var viewModel : MyViewModel
var body : some View {
...
}
}
You can either construct the view model in place or pass it in, and it gets you a view that will maintain your ObservableObject across reconstruction.
Is there a way of not recreating the ViewModel every time?
Yes, keep ViewModel instance outside of SomeView and inject via constructor
struct SomeView: View {
#ObservedObject var viewModel: SomeViewModel // << only declaration
Is there a way of replicating the #State Propertywrapper for #ObservedObject?
No needs. #ObservedObject is-a already DynamicProperty similarly to #State
Why is #State keeping the State over the redraw?
Because it keeps its storage, ie. wrapped value, outside of view. (so, see first above again)
You need to provide custom PassThroughSubject in your ObservableObject class. Look at this code:
//
// Created by Франчук Андрей on 08.05.2020.
// Copyright © 2020 Франчук Андрей. All rights reserved.
//
import SwiftUI
import Combine
struct TextChanger{
var textChanged = PassthroughSubject<String,Never>()
public func changeText(newValue: String){
textChanged.send(newValue)
}
}
class ComplexState: ObservableObject{
var objectWillChange = ObservableObjectPublisher()
let textChangeListener = TextChanger()
var text: String = ""
{
willSet{
objectWillChange.send()
self.textChangeListener.changeText(newValue: newValue)
}
}
}
struct CustomState: View {
#State private var text: String = ""
let textChangeListener: TextChanger
init(textChangeListener: TextChanger){
self.textChangeListener = textChangeListener
print("did init")
}
var body: some View {
Text(text)
.onReceive(textChangeListener.textChanged){newValue in
self.text = newValue
}
}
}
struct CustomStateContainer: View {
//#ObservedObject var state = ComplexState()
var state = ComplexState()
var body: some View {
VStack{
HStack{
Text("custom state View: ")
CustomState(textChangeListener: state.textChangeListener)
}
HStack{
Text("ordinary Text View: ")
Text(state.text)
}
HStack{
Text("text input: ")
TextInput().environmentObject(state)
}
}
}
}
struct TextInput: View {
#EnvironmentObject var state: ComplexState
var body: some View {
TextField("input", text: $state.text)
}
}
struct CustomState_Previews: PreviewProvider {
static var previews: some View {
return CustomStateContainer()
}
}
First, I using TextChanger to pass new value of .text to .onReceive(...) in CustomState View. Note, that onReceive in this case gets PassthroughSubject, not the ObservableObjectPublisher. In last case you will have only Publisher.Output in perform: closure, not the NewValue. state.text in that case would have old value.
Second, look at the ComplexState class. I made an objectWillChange property to make text changes send notification to subscribers manually. Its almost the same like #Published wrapper do. But, when the text changing it will send both, and objectWillChange.send() and textChanged.send(newValue). This makes you be able to choose in exact View, how to react on state changing. If you want ordinary behavior, just put the state into #ObservedObject wrapper in CustomStateContainer View. Then, you will have all the views recreated and this section will get updated values too:
HStack{
Text("ordinary Text View: ")
Text(state.text)
}
If you don't want all of them to be recreated, just remove #ObservedObject. Ordinary text View will stop updating, but CustomState will. With no recreating.
update:
If you want more control, you can decide while changing the value, who do you want to inform about that change.
Check more complex code:
//
//
// Created by Франчук Андрей on 08.05.2020.
// Copyright © 2020 Франчук Андрей. All rights reserved.
//
import SwiftUI
import Combine
struct TextChanger{
// var objectWillChange: ObservableObjectPublisher
// #Published
var textChanged = PassthroughSubject<String,Never>()
public func changeText(newValue: String){
textChanged.send(newValue)
}
}
class ComplexState: ObservableObject{
var onlyPassthroughSend = false
var objectWillChange = ObservableObjectPublisher()
let textChangeListener = TextChanger()
var text: String = ""
{
willSet{
if !onlyPassthroughSend{
objectWillChange.send()
}
self.textChangeListener.changeText(newValue: newValue)
}
}
}
struct CustomState: View {
#State private var text: String = ""
let textChangeListener: TextChanger
init(textChangeListener: TextChanger){
self.textChangeListener = textChangeListener
print("did init")
}
var body: some View {
Text(text)
.onReceive(textChangeListener.textChanged){newValue in
self.text = newValue
}
}
}
struct CustomStateContainer: View {
//var state = ComplexState()
#ObservedObject var state = ComplexState()
var body: some View {
VStack{
HStack{
Text("custom state View: ")
CustomState(textChangeListener: state.textChangeListener)
}
HStack{
Text("ordinary Text View: ")
Text(state.text)
}
HStack{
Text("text input with full state update: ")
TextInput().environmentObject(state)
}
HStack{
Text("text input with no full state update: ")
TextInputNoUpdate().environmentObject(state)
}
}
}
}
struct TextInputNoUpdate: View {
#EnvironmentObject var state: ComplexState
var body: some View {
TextField("input", text: Binding( get: {self.state.text},
set: {newValue in
self.state.onlyPassthroughSend.toggle()
self.state.text = newValue
self.state.onlyPassthroughSend.toggle()
}
))
}
}
struct TextInput: View {
#State private var text: String = ""
#EnvironmentObject var state: ComplexState
var body: some View {
TextField("input", text: Binding(
get: {self.text},
set: {newValue in
self.state.text = newValue
// self.text = newValue
}
))
.onAppear(){
self.text = self.state.text
}.onReceive(state.textChangeListener.textChanged){newValue in
self.text = newValue
}
}
}
struct CustomState_Previews: PreviewProvider {
static var previews: some View {
return CustomStateContainer()
}
}
I made a manual Binding to stop broadcasting objectWillChange. But you still need to gets new value in all the places you changing this value to stay synchronized. Thats why I modified TextInput too.
Is that what you needed?
My solution is use EnvironmentObject and don't use ObservedObject at view it's viewModel will be reset, you pass through hierarchy by
.environmentObject(viewModel)
Just init viewModel somewhere it will not be reset(example root view).