Alex M.
Posted on August 29, 2022
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
}
}
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
}
}
extension ProfileView {
class ViewModel: ObservableObject {
[...]
@Published var followers: Int
}
}
struct ProfileView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
[...]
Text("\(viewModel.followers)")
}
}
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
}
}
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)")
}
}
}
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
, notPreferencesView.viewModel()
. This also applies toPreferencesView
.
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"
)
}
}
}
}
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
}
}
}
struct SecondView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
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")
}
}
}
}
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
}
}
}
struct SecondView: View {
@StateObject var firstViewModel: FirstView.ViewModel
var body: some View {
Text("Hello, world!")
.padding()
}
}
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")
}
}
}
}
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
}
}
}
struct SecondView: View {
@EnvironmentObject var firstViewModel: FirstView.ViewModel
var body: some View {
Text("\(firstViewModel.count)")
.padding()
}
}
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()
}
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)
}
}
}
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"
)
}
}
}
}
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
}
}
}
struct SecondView: View {
@EnvironmentObject var store: Store
var body: some View {
Text("\(store.firstViewModel.count)")
.padding()
}
}
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()
}
}
}
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!
Posted on August 29, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.