SwiftUI: ObservableObject does not persist its State over being redrawn
Asked Answered
O

5

31

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.

Outspan answered 8/5, 2020 at 10:15 Comment(1)
Were you able to find a solution for this? I ended up creating an environmentObject so it only has one instance of the view model.Fanchet
O
22

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.

Outspan answered 22/6, 2020 at 22:30 Comment(5)
Hello! I was wondering if you found any solution for iOS 13? I really need to fix this issue and whatever I tried did not work sadly.Abdu
@AbdulelahHajjar, I think the only way would be to create the ViewModel way down in your View Hierarchy. It can be tricky at some points. Or update to 14+, this would be the most easiest solutionOutspan
Thanks @Outspan for you response, could you please elaborate as what it means to "create the ViewModel way down in the view hierarchy"? Thank you so much for your time.Abdu
@AbdulelahHajjar, already create the ViewModel in the Parent View (or the View that changes the State). This sometimes not workes when you have to pass data into that ViewModel. I would probably suggest updating your deployment Target then.Outspan
what if the StateObject takes parameters in its init, like: MyViewModel(status: 2), giving that we have to create this view model from outside the View, how can I make the view preserve its state in this case ? Apple discourages passing StateObject from outside the View in contrast to ObservableObjects but those don't preserve the state on View redrawing.Sanctimony
B
5

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.

Bagasse answered 25/5, 2020 at 17:44 Comment(2)
Hey, you are completely right. I think I mentioned this solution in my question as well. The problem is that the Parent View of MyView could be redrawn and we would end up with the same problem. But indeed I currently implement Solutions this way. But as a very general approach, there are problems too.Outspan
@KonDeichmann, I don't think that should be true. If a parent struct is modified, all child structs have to be copied/recreated, but any @State within those should be maintained in separate storage and restored after the child view struct init(). This "wrapper" pattern is the equivalent of writing @State @ObservedObject var viewModel : MyViewModel. It prevents your view model from being lost on redraw. The parent only references MyView, which is a wrapper that maintains state for MyViewImpl.Bagasse
C
3

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)

Caramelize answered 8/5, 2020 at 11:52 Comment(5)
sometimes you don't need to redraw (init) whole view every time something changes. In my case there was heavy shape calculations in init, which I didn't want to execute every time something changes. Especially when you have "state" singleton. You changes one property - all the views depending on state redrawn. Even if they depends on another property. I think topic starter dealing with similar problem.Polynices
Keeping the State outside of the View is a Solution indeed, but in some cases, it is not practical! Think about a complex Detail Page, where you make changes to the Object, that then updates the List of Items on the Parent View (State change). Also, in this case, the '@State' property keeps it State, the '@ObservedObject' does not.Outspan
@KonDeichmann, it does - used widely, and here on SO... moreover combined solution with both is also possible.Caramelize
But a combined Solution (a combination of @State, which persists, and @ObservedObject) could not keep subscriptions over the redraw. Sure I could "store" a certain State in the View (which is not a good practice, when the state should have been hidden in the ViewModel), but definitely not a Subscription.Outspan
Hello! I tried this solution with no luck, sadly. I was wondering if I did anything wrong on my part. I have the declaration inside the view, and I am passing the ViewModel in the View call from the parent view.Abdu
P
0

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?

Polynices answered 8/5, 2020 at 13:17 Comment(6)
In general, I think @State is for internal use in Views. @ObservedObject is for changing data between Views. Therefore ObservableObject is only for classes - they are links which can be used in other views. Its like you put them in init() as an input parameter. What do you need to do, what you don't know exactly, what's changed? redraw wholesaled the View.Polynices
I think we are not talking about the same thing. I know the purpose of @State and @ObservedObject. When I understand you correctly you just tried to rebuild the @State using ObservableObjects. The problem I see is keeping subscriptions and States over the Redraw of the Parent View in an ObservableObject! You might be able to see what I mean, if you build a very simple App with a "Parent View", that changes its State on the Press of a Button and a child View doing the same thing, keep both States in ObservableObjects (with @Published) and put prints in init and deinit.Outspan
Your problem is about using default values of @State variables. When you change something (whether it another property in @ObservedObject, or @EnvironmentObject or parameter passed through init()) outside this view, it is to be fully destroyed and created again. Therefore your @State variable sets to its default value. I show you a way how to avoid destroying the View by send changes with PassthroughSubject. –Polynices
please see this gist: gist.github.com/konDeichmann/9b4e2e7947068ffd906966edddf1d093 try just replace the ContentView of a newly created SwiftUI Project and play around with the 3 Buttons. As you can see the @State will remain its value, while the @ObservedObject will always reset on the ParentsView State change.Outspan
It looks like @State is able to restore its value after recreating View, and the @ObservedObject is not. If you put print() into CounterStateView init() you will see it redrawn too, but after init complete, it copes back is old value. Try create init of state View: init(){ self._counter = State(initialValue: 1) print("init state (with value of (counter))") }Polynices
found an article about that: medium.com/device-blogs/…Polynices
O
0

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).

Overact answered 24/3, 2022 at 3:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.