Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 677
KonDeichmann Avatar asked May 08 '20 10:05

KonDeichmann


2 Answers

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.

like image 120
KonDeichmann Avatar answered Sep 19 '22 22:09

KonDeichmann


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.

like image 42
Timothy Avatar answered Sep 19 '22 22:09

Timothy