DEV Community

Juan Wajnerman
Juan Wajnerman

Posted on

StateObject alternative for iOS 13

The addition of StateObject in iOS 14 is wonderful, because it enables to easily connect a view-model (or controller or whatever name you want to give to that stateful piece of code that manages the view) without having to use any global object.

Before it, and even nowadays, I see many examples where an ObservableObject is created this way:

// Don't do this!!
@ObservedObject var foo = Foo()

The issue there is that a new instance of Foo is created every time the containing view is created. And I'm not talking about the appearance of that view in the screen. SwiftUI instantiates the view's struct every time it needs to re-render. The whole process is really fast because views are structs, so no heap allocations is involved. Still the variable declarations are invoked each time, so Foo() is called every time as well.

In iOS 14 the solution is to use @StateObject. The initializer value is captured with an @autoclosure so it doesn't get invoked every time. And the SwiftUI runtime will make sure that the value created the first time is available on every render of the view.

Now, here's the issue. Even though iOS 14 adoption is growing really fast, it's very unlikely that anyone wants to publish an app without iOS 13 compatibility yet.

So, here's an idea that I came up to workaround this problem. Since in SwiftUI 1.0 the only tool we have to conserve a value between renders is a @State, we must store the object within a variable declared with that attribute. But @State is only meant for simple types and thus it doesn't care much about observing changes in an ObservableObject. That means we need to replicate the value in another variable that is actually observed by the framework. Fortunately there is another attribute we could use: @EnvironmentObject. This attribute expects an instance of an ObservableObject. Just what we need!

The solution then keeps the value within the state of a "wrapper" view, and injects the object to be available to the wrapped view. Instead of writing this pattern over and over, I propose this reusable view:

struct Observer<Obs, Content>: View where Obs: ObservableObject, Content: View {
    @State private var obs: Obs?
    private var content: Content
    private var initializer: () -> Obs

    init(_ initializer: @autoclosure @escaping () -> Obs, @ViewBuilder content: () -> Content) {
        self.content = content()
        self.initializer = initializer
    }

    var body: some View {
        if let obs = obs {
            content.environmentObject(obs)
        } else {
            Color.clear.onAppear(perform: initialize)
        }
    }

    private func initialize() {
        obs = initializer()
    }
}

The whole purpose of Color.clear here is to make the first appearance to trigger the initialization of the ObservableObject, but once it's created the content is rendered with the object injected.

This can be easily used within other views like this:

Observer(MyModel()) { MyView() }

Although I would recommend to hide the details and declare a static value instead:

// Just use `MyView` within other views
let MyView = Observer(MyModel()) { MyViewContent() }

The view must be declared like this:

struct MyViewContent: View {
  @EnvironmentObject var model: MyModel
}

In case you need to pass extra arguments, it can be declared as a closure instead:

// Use as `MyView(arg1, arg2)` this time
let MyView = { arg1, arg2 in Observer(MyModel(arg)) { MyViewContent(arg: arg2) }

I hope you find this useful and helps calm the anxiety until iOS 13 is at thing of the past and we can use StateObject on every project.

Top comments (2)

Collapse
 
angelolloqui profile image
Angel G. Olloqui

How is this working regarding memory management? I do not see when you release your model, it seems to me it is going to be kept forever

Collapse
 
its_junaid715 profile image
Junaid Khan

hey I understood your solution but they way you used , I didn't get it
Can you please help me to how use this ?