DEV Community

Cover image for 3 ways to share state in SwiftUI that you NEED to know πŸš€πŸ’―
Alex M.
Alex M.

Posted on • Originally published at amodrono.eu

3 ways to share state in SwiftUI that you NEED to know πŸš€πŸ’―

It is common in many SwiftUI architecture patterns to separate your logic from your UI into small ObservableObjects as follows:

import Foundation
import SwiftUI

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.text)
    }
}

extension ContentView {
    class ViewModel: ObservableObject {
        @Published var text: String
    } 
}
Enter fullscreen mode Exit fullscreen mode

And this method is excellent since it enables you to split this code into various files, preventing the need for large files with a huge amount of code.

However, there are numerous occasions where you want to access the properties of other ViewModels that are external to your view, like in the example that follows:

extension PreferencesView {
    class ViewModel: ObservableObject {
        [...]
        @AppStorage("showFollowersInProfileView")
        var showFollowersInProfileView: Bool = true
    }
}
Enter fullscreen mode Exit fullscreen mode
extension ProfileView {
    class ViewModel: ObservableObject {
        [...]
        @Published var followers: Int
    }                  
}
Enter fullscreen mode Exit fullscreen mode
struct ProfileView: View {
    @ObservedObject var viewModel = ViewModel()
    var body: some View {
        [...]
        Text("\(viewModel.followers)")
    }
}
Enter fullscreen mode Exit fullscreen mode

How would you know if the setting for showing followers was enabled?

Solution #1: Singletons

A very simple solution for this would be to add a singleton to PreferencesView.viewModel so that every view is able to access its shared state:

extension PreferencesView {
    class ViewModel: ObservableObject {
        [...]

        /// A singleton everybody can access to.
        static let shared = ViewModel()

        @AppStorage("showFollowersInProfileView")
        var showFollowersInProfileView: Bool = true
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we would be able to access the preferences in our ProfileView.swift as follows:

struct ProfileView: View {
    @ObservedObject var viewModel = ViewModel()

    @ObservedObject
    var preferences = PreferencesView.ViewModel.shared

    var body: some View {
        [...]
        if preferences.showFollowersInProfileView {
            Text("\(viewModel.followers)")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This way, everytime the preferences view model changes, the changes will be reflected on other views.

NOTE: If you use this approach, in order for changes to be reflected in other views, you need to modify PreferencesView.ViewModel.shared, not PreferencesView.viewModel(). This also applies to PreferencesView.

However, this method should only be used if the ViewModel in question is going to be accessed by several views. If you only wish to access a specific property in a few views, you should try the next method.

Solution #2: Dependency Injection

From Wikipedia:

In software engineering, dependency injection isΒ a design pattern in which an object or function receives other objects or functions that it depends on. A form of inversion of control, dependency injection aims to separate the concerns of constructing objects and using them, leading to loosely coupled programs.

Simply put, dependency injection means that rather than creating things for itself, any given piece of code you write should be given everything it needs to function by a parent. For instance, all the components an object or method need can be sent as arguments.

Let's say you have a VStack with two views, and you want both views to have access to the same view model.

struct ContentView: View {

    var body: some View {
        TabView {
            FirstView()
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView()
                .tabItem {
                    Label(
                        "Second",
                        systemImage: "square.and.pencil"
                    )
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct FirstView: View {
    @StateObject var viewModel = ViewModel()
    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            Button("Increment", action: {
                viewModel.incrementCount()
            })
            Button("Decrement", action: {
                viewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct SecondView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

How would you access the count in SecondView? Pretty simple: you would need ContentView to instantiate the view model, and then pass it to the ancestors through the constructor.

This can be achieved as follows:

struct ContentView: View {

    // We initialize the view model in the parent view
    @StateObject var viewModel = FirstView.ViewModel()

    var body: some View {
        TabView {
            // And we pass it down the ancestors through the constructor
            FirstView(viewModel: viewModel)
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView(firstViewModel: viewModel)
                .tabItem {
                    Label("Second", systemImage: "square.and.pencil")
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct FirstView: View {
    @StateObject var viewModel: ViewModel

    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            Button("Increment", action: {
                viewModel.incrementCount()
            })
            Button("Decrement", action: {
                viewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct SecondView: View {
    @StateObject var firstViewModel: FirstView.ViewModel

    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how we first create the view model in the parent view, and then pass it down the ancestors through the constructors? This way, both FirstView and SecondView will share state.

Obviously, this example is overkill as we could have fixed this using simple @Binding and @State variables. However, when things get complex, we can use this approach.

Using environment objects

While the example shown previously works correctly, SwiftUI provides us with the @EnvironmentObject property wrapper for data that needs to be shared among numerous views in your project. This enables us to distribute model data wherever it is required and guarantees that our views are continuously updated as soon as that data changes.

Consider @EnvironmentObject as a better, more straightforward alternative to using @ObservedObject on several views. You can create some data in some view and place it in the environment so that the rest of the views automatically have access to it.

The example shown previously can be rewritten as follows:

struct ContentView: View {

    // We initialize the view model in the parent view
    @StateObject var viewModel = FirstView.ViewModel()

    var body: some View {
        TabView {
            // And we pass it down the ancestors through the constructor
            FirstView()
                .environmentObject(viewModel)
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView()
                .environmentObject(viewModel)
                .tabItem {
                    Label("Second", systemImage: "square.and.pencil")
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct FirstView: View {
    @EnvironmentObject var viewModel: ViewModel

    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            Button("Increment", action: {
                viewModel.incrementCount()
            })
            Button("Decrement", action: {
                viewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct SecondView: View {
    @EnvironmentObject var firstViewModel: FirstView.ViewModel

    var body: some View {
        Text("\(firstViewModel.count)")
            .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

If you run the app, you will notice that it behaves exactly the same as in the previous example.

Solution #3: Stores (Nested observable objects)

The final solution is to implement a Store into our app. Borrowed from Redux, a store is an immutable object tree that is in charge of maintaining an application's state.

While stores in Redux are a bit different, we can implement a similar behaviour in SwiftUI using nested observable objects.

We begin by creating our Store object as shown below, where we have FirstView's view model as a published property:

final class Store: ObservableObject {
    @Published var firstViewModel = FirstView.ViewModel()
}
Enter fullscreen mode Exit fullscreen mode

And we initialize it in our App and pass it down the ancestor views through .environmentObject:

import SwiftUI

@main
struct ExampleApp: App {
    @StateObject var store = Store()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can initialize it in our ContentView, though I prefer doing it in our app because that way we ensure that there is only one single Store.

Then, this should be our ContentView:

struct ContentView: View {

    var body: some View {
        TabView {
            FirstView()
                .tabItem {
                    Label("First", systemImage: "house")
                }

            SecondView()
                .tabItem {
                    Label(
                        "Second",
                        systemImage: "square.and.pencil"
                    )
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how we no longer need to add .environmentObject to each of the views? Since we are initializing the object in our main app, every single ancestor view will have access to it, which is one of the main reasons why I prefer environment objects over plain dependency injection.

struct FirstView: View {
    @EnvironmentObject var store: Store

    var body: some View {
        VStack {
            Text("\(store.firstViewModel.count)")
            Button("Increment", action: {
                store.firstViewModel.incrementCount()
            })
            Button("Decrement", action: {
                store.firstViewModel.decrementCount()
            })
        }
    }
}

extension FirstView {
    class ViewModel: ObservableObject {
        @Published var count: Int = 0

        func incrementCount() {
            count+=1
        }

        func decrementCount() {
            count-=1
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
struct SecondView: View {
    @EnvironmentObject var store: Store

    var body: some View {
        Text("\(store.firstViewModel.count)")
            .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we are able to access the data from other views. When you launch the app, though, you'll see that it behaves differently than you might have anticipated and that UI changes are not reflected.

The reason for this is that SwiftUI only redraws the user interface when it detects a change in one of its state variables and state/observed objects. How does SwiftUI detect when one of the @Published variables change inside an observable object? Pretty simple: thanks to objectWillChange, which is, according to Apple, a publisher that emits before the object has changed.

You probably already know what a Publisher is if you've ever used Apple's Combine framework. If not, a publisher is, to put it briefly, something that discloses values that are subject to change and on which a subscriber subscribes to receive all those updates.

This publisher tells SwiftUI when an observed/state object has changed, and, this way, it is able to know when to redraw the UI. When you change a @Published variable, it is calling this objectWillChange publisher under the hood.

However, when a observable object changes inside another observable object, the parent object's objectWillChange is not triggered, instead, it is only triggered in the child, and, as a result, SwiftUI does not know when to update our interface.

Fortunately, there is an easy fix for this: all we need to do is subscribe for this publisher inside our store, and manually trigger the store's objectWillChange publisher.

This can be achieved using Combine as follows:

import Combine

final class Store: ObservableObject {
    @Published var firstViewModel = FirstView.ViewModel()

    private var anyCancellable: AnyCancellable? = nil

    init() {
        anyCancellable = firstViewModel.objectWillChange.sink { [weak self] _ in
            self?.objectWillChange.send()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, if you run the app, you will see that it works properly again.

Conclusion

In this article you have seen several ways to share state between views in SwiftUI.

I hope these tricks help you have a cleaner and more organized codebase. Thanks for reading!

Top comments (0)