DEV Community

Cover image for SwiftUI Architecture: Observable Objects, the Environment and Combine
marinbenc🐧 for CometChat

Posted on • Edited on • Originally published at cometchat.com

SwiftUI Architecture: Observable Objects, the Environment and Combine

By now you already have a good understanding of how SwiftUI views work. You create a state variable and a body computed variable that spits out a view based on that state. Whenever the state changes, SwiftUI calls body and updates what's on the screen.

This paradigm works incredibly well, but there are some special cases in this paradigm that need extra care. One of those cases is when you want to share a piece of state between two views.

There are a couple of ways to share state in SwiftUI:

  1. Pass the current value of the state in the initializer.
  2. Use a binding.
  3. Use observable environment objects.

In this part of the SwiftUI course, you'll take a look at these ways to share data. You'll get an understanding of when and how to share data, as well as the advantages and disadvantages of these three approaches.

As always, you can find a link to the finished project code on GitHub.

Sharing data with View children and stateless Views

First, let's take a look at the simplest possible way to share data: Passing it in the initializer of a view. You already used the pattern when you created the AvatarView:

let isOnline: Bool

init(url: URL?, isOnline: Bool) {
  ...
  self.isOnline = isOnline
  ...
}

var body: some View {
  ...
  Circle()
    .foregroundColor(isOnline ? .green : .gray)
  ...
}
Enter fullscreen mode Exit fullscreen mode

You gave AvatarView a property called isOnline that you pass in its initializer. You made the online indicator green or gray based on this property.

You later called this initializer from ContactRow with a value of whether the user is online or not:

AvatarView(url: nil, isOnline: item.contact.isOnline)
Enter fullscreen mode Exit fullscreen mode

Because SwiftUI is reactive, whenever the contact's status changes to offline, SwiftUI will know to rebuild the avatar view with the new value.

This isn't anything fancy, it's just plain old Swift initializers. That's the biggest advantage of this approach: Simplicity. Here, AvatarView has no state, all properties are marked with let, which means it's completely immutable. Instead of responding to state changes, it will be rebuilt when the state changes. This type of immutable view has different names in different reactive frameworks: functional, stateless... dumb. (Yes, dumb.)

Using stateless views has a lot of advantages. For starters, they're simple. You don't have to track or debug state changes. You can easily preview and test the view without having to modify its state. Stateless views are decoupled from your frameworks and libraries, whether it's networking with Alamofire or a database framework like Core Data. They're easy to reuse and even copy and paste to different parts of the app or entirely different projects.

As you can see, statelessness is a good thing. You can't always use this pattern, though. To pass state into the initializer, you need to be the view creating the stateless view. If you think of the view hierarchy as a tree, passing state in the initializer is only possible to the view's direct children.

Passing data through the initializer in SwiftUI

Think hard about whether a view needs to track its internal state variables or if you can get away with passing the state in the initializer. Try to use stateless views as much as you can.

I am creating this course in collaboration with CometChat, a modern chat platform to help you add chat to you Swift app. During the next few weeks, we’ll be releasing installments of our free SwiftUI course here on dev.to! In the course, you’ll dive deep into SwiftUI by building a real-world production-scale chat app, learning SwiftUI in a practical way, on a scale larger than a simple example app. Follow me to get notified of future parts of this course! You can also follow @CometChat on Twitter see the course of CometChat’s blog.

Using Binding for two-way data sharing between a view and its children

In families, just like in SwiftUI, parents are usually the ones telling kids the state of the world. Sometimes, however, your dad might not know how to install Viber to call your uncle. In that case, you're the one passing information to your parents.

This happens in SwiftUI too. Passing data in the initializer is great if you want to pass data to the view's children. If you want the reverse, however, you need a binding.

Bindings enable two-way communication between your views. They're usually used with interactive views like text fields, toggles, checkboxes and similar. They allow two views to share a single piece of state.

For instance, when you created ErrorTextField, you used a binding to a String instead of a state variable.

struct ErrorTextField: View {
  ...
  let text: Binding<String>
  ...
}
Enter fullscreen mode Exit fullscreen mode

Later, you initialized this view in the login screen by passing it the binding:

struct LoginView: View {

  @State private var email = ""

  var body: some View {
    ErrorTextField(
      ...
      text: $email,
      ...)
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above code, the $ operator converted a state to a binding. A binding is a kind of pointer to a piece of state. LoginView owns the state, but by giving ErrorTextField a binding to that state, it lets the text field change the value of the state.

This means that both LoginView and ErrorTextField can change the value of the email, and both will always update to match the latest value.

Using a Binding to share data between two SwiftUI views

Passing bindings, just like passing the state values directly, is used when you want to share state between a parent view and its direct children. As opposed to passing state values, though, a binding adds statefulness to the component, making it more complex to read, write, test and preview. Keep that in mind when using this approach.

Sharing data between unrelated views

The above two ways of sharing data have all been between two directly linked views. Sometimes, you want to share data between two unrelated screens. For instance, the currently logged in user is shared between the feed, the settings screen and the profile screen. Or, there might be a registration flow with multiple screens that stores the data of each screen at a shared location.

One way to do this is to use bindings or passing the user directly to child views. The disadvantage of that approach is that it's far too cumbersome. You'd have to pass the user to each child separately. For any change that one of the child views make, you'd have to roll back up to the main view to update the user, and then propagate back down the hierarchy so all views update.

Instead, it's much easier to have a single place where the user is stored. Whenever any view changes the user, all other views update instantly.

You can do this type of global state sharing using a combination of Combine and SwiftUI features: Observable objects and environment objects.

Creating Combine's Observable Objects in SwiftUI

Even though this is a SwiftUI course, we'll take a brief detour and talk a little about Combine. Combine is a Swift framework that enables you to track changes to some value over time. Instead of using a plain String, Combine gives you a Publisher that emits a series of strings over time. If you're familiar with RxSwift or ReactiveCocoa, Combine does the same thing, but it's built into Swift.

SwiftUI uses Combine to track changes to state variables. While you might see the email as a String, SwiftUI sees it as a series of different string values over time. Using Combine, it can listen for changes to the value and update the view accordingly.

You can do the same thing SwiftUI does by using Combine's ObservableObject to track changes to any value, whether it's in a View or an entirely different object. ObservableObject object tracks changes to its properties and publishes a Combine event whenever a change occurs.

Let's create a new observable object to track the currently logged in user. Create a new plain Swift file and name it AppStore.swift. No, you're not building the iOS App Store, instead, you're making a shared storage location for different views in your app.

Add the following class to the file:

import SwiftUI
import Combine

class AppStore: ObservableObject {

  struct AppState {
    var currentUser: Contact?
  }

  @Published private(set) var state = AppState(currentUser: nil)

  func setCurrentUser(_ user: Contact?) {
    state.currentUser = user
  }

}
Enter fullscreen mode Exit fullscreen mode

First, you import both Combine and SwiftUI. Then, you declare a class that conforms to the ObservableObject protocol. Inside the class, you write a nested struct that will hold the current user and create a property of that type.

The ObservableObject protocol has one requirement: objectWillChange. This is a Combine Publisher, an object that fires a series of events whenever the observed object changes. Thankfully, you don't have to implement this yourself.

By marking state with @Published, you tell Combine to automatically generate an implementation of objectWillChange that tracks changes to state and publishes an event whenever you change the state. Thanks, Combine!

You also mark the property with private(set). If you haven't seen this before, this is an access control modifier in Swift that makes the property private for modification, but public for access. This means that everyone can read state, but only AppStore can change the state. This is just an insurance policy: When dealing with shared state, it's always a good idea to limit how other objects can change that state.

Finally, you add a method that lets users of this class change the state by setting a new user. Whenever this method is called, currentUser is changed, and AppStore publishes an event telling everyone that's listening that the state has changed.

For instance, a profile screen could listen to these changes and update the user it's showing when a new one is set. Since you can have multiple listeners, different unrelated views can be updated all at once whenever AppState changes.

Adding Observable Objects to the Environment in SwiftUI

Writing the observable object is only one part of the equation, though. You still need a way to create the object and share it with different views in your app. You'll do this using SwiftUI's Environment.

Each view and all of its children exist in an environment. The environment is a shared pool of floating objects and values that the view or any of its children can grab and use at any time.

You'll add an instance of AppStore to the environment of your root view. This will enable any view in your app to grab the store and its data.

Remember, the SceneDelegate is where you create your root view, so open SceneDelegate.swift and add a new property:

let store = AppStore()
Enter fullscreen mode Exit fullscreen mode

This is the store you'll add to the environment. Since you need to add it to your root component, call the environmentObject method on the contentView:

let contentView: some View = NavigationView {
  WelcomeView()
}.environmentObject(store)
Enter fullscreen mode Exit fullscreen mode

This will add the store to the environment.

Now that the store is in there, it's time to fetch it from different views in your app. Start by opening LoginView.swift. Add the following property to the struct:

@EnvironmentObject private var store: AppStore
Enter fullscreen mode Exit fullscreen mode

By declaring the property an @EnvironmentObject, you tell SwiftUI to automatically look for an object of that type in the environment. No need to manually instantiate or look for it!

At the bottom of login, add a line to save a new user in the store:

store.setCurrentUser(Contact(name: "Me", avatar: nil, id: "me", isOnline: true))
Enter fullscreen mode Exit fullscreen mode

When a user logs in, they will get saved in the app store. This same app store is shared with other views, which will update automatically. One of those is ContactsView: Open ContactsView.swift.

Instead of hard-coding a user, you'll change the view to grab the user in the app store. First, fetch the store the same way you did for LoginView:

@EnvironmentObject private var store: AppStore
Enter fullscreen mode Exit fullscreen mode

Then, inside body, replace the ForEach block with a ZStack of the contact row and a navigation link to the chat screen:

List {
  ForEach(0..<items.count) { i in
    // New code from here
    ZStack {
      ContactRow(item: self.items[i])

      self.store.state.currentUser.map { currentUser in
        NavigationLink(destination: ChatView(
          currentUser: currentUser,
          receiver: self.items[i].contact
        )) {
          EmptyView()
        }
      }
    }
    .background(Color.white)
    .shadow(
       color: i == self.items.count - 1 ? Color.shadow : Color.clear,
       radius: 10, x: 0, y: 2)
    .listRowInsets(EdgeInsets())
    // ... to here
    ...
  }
}.navigationBarTitle("Contacts", displayMode: .inline)
Enter fullscreen mode Exit fullscreen mode

You might be confused by the use of map. store.state.currentUser is optional, so you need to unwrap it before you can use it. However, writing if let inside a body will result in a compiler error because Swift will get confused about what kind of view you're trying to make. To get around this, you can use map on the optional. It will work the same way as if let: The function passed to map will only get called if the optional is not nil. If the current user is nil, the function won't get called, and no navigation link will get created.

Build and run the project now.

Navigating to a detail View from a SwiftUI List

You now have the app store, an observable object, inside the environment. When you log in, the view will update the store in the environment with a new user. Since the store is an observable object, all other views will get notified of the change. This includes ContactView, which will use the currently logged in user to create a chat screen.

Sharing data like this is perfect for global state of your app, like the currently logged in user, whether or not the user is logged in, does the user have a premium or regular account and other app-level data.

When to use which approach

Stateless views, bindings, environment objects... All of these approaches have advantages and disadvantages and should be used at different times. To give you a better understanding of when to use which, I prepared a little cheat sheet. Don't worry, I won't take your exam away for using it!

SwiftUI Architecture Cheat Sheet: A diagram of different ways to pass data between SwiftUI views

Using Combine to track the keyboard height

Run your app and navigate to the chat screen. When you start typing your message, you'll see the keyboard appear. Only, it appears on top of the text field, so you can't even see what you're typing! That's no good. Let's dive further into Combine with another use case for observable objects: Observing the keyboard.

You'll fix this issue by creating an observable object that will listen to NotificationCenter notifications for changes to the keyboard's height. As the keyboard rises or falls, you'll publish events to everyone that's listening.

You'll listen to this event in the chat screen. When the keyboard pops up, you'll increase the padding of the text field so that it's always above the keyboard.

Create a new plain Swift file and call it KeyboardObserver.swift. Add a new ObservableObject class to the file:

import Combine
import SwiftUI

class KeyboardObserver: ObservableObject {
  @Published private(set) var keyboardHeight: CGFloat = 0
}
Enter fullscreen mode Exit fullscreen mode

Just like earlier, you use @Published to automatically publish events whenever that value changes.

You'll update the height based on two notifications: keyboardWillShowNotification and keyboardWillHideNotification. Notification Center has Combine extensions that use Publishers instead of manually subscribing to the notification. Add the following property to the class:

let keyboardWillShow = NotificationCenter.default
  .publisher(for: UIResponder.keyboardWillShowNotification)
  .compactMap { ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height }
Enter fullscreen mode Exit fullscreen mode

You call publisher(for:) on the default Notification Center to get a publisher that emits that notification's events. Remember, Combine lets you deal with streams of values over time. These streams, just like arrays, are collections. This means that all of the collection methods you're used to, like map, compactMap and others, are already there. In the above example, you use compactMap to get a value from the userInfo of the notification that corresponds to the keyboard's height. This makes keyboardWillShow a stream of CGFloat values over time.

Next, you'll add another property that tracks when the keyboard hides. Add it below the one you just created:

let keyboardWillHide = NotificationCenter.default
  .publisher(for: UIResponder.keyboardWillHideNotification)
  .map { _ -> CGFloat in 0 }
Enter fullscreen mode Exit fullscreen mode

Whenever you receive a notification that the keyboard has hidden, you'll convert that notification to a CGFloat of 0, since a hidden keyboard has no height. Essentially, keyboardWillHide is a stream of zeros.

Now that you are publishing events for those two notifications, it's time to merge them together to update the keyboard height property. Add the following initializer to the class:

init() {
  Publishers.Merge(keyboardWillShow, keyboardWillHide)
    .subscribe(on: RunLoop.main)
    .assign(to: \.keyboardHeight, on: self)
}
Enter fullscreen mode Exit fullscreen mode

keyboardWillShow and keyboardWillHide are both Publishers. Publishers emit values. To listen and respond to those values, you have to subscribe to the publishers. Subscribing allows you to call a function or closure (using the sink method), or update a value whenever a Publisher emits a new event (using assign(to:on:)).

In the above case, you first merge the two publishers into one. That way, you'll get the keyboard's height when it raises and a zero when it falls. You'll subscribe to the new publisher on the main thread because it's responsible for updating the UI. By calling assign, you tell Combine to set keyboardHeight whenever to whichever value the Publisher emits.

At this point, you'll get a warning that the result of assign is unused. This is because assign returns a cancellable that you're not using. A cancellable doesn't do much, it's more of a reference to the subscription. If you used Notification Center, you might remember that you need to eventually remove observers to avoid memory loops. The same is true for Combine: If you don't remove subscriptions from KeyboardObserver, it will never get deallocated.

If you store the cancellable in a property of the class, the cancellable will get deallocated when the class does. Upon deallocation, the cancellable automatically destroys the subscription, removing all reference cycles that were created and letting the object die peacefully, as opposed to going on forever as a zombie.

Add a new property to the class:

private var cancellables: Set<AnyCancellable> = []
Enter fullscreen mode Exit fullscreen mode

This is a Set which will house all of your cancellables for this class.

Next, add the following line to the bottom of init:

.store(in: &cancellable)
Enter fullscreen mode Exit fullscreen mode

This method will store the cancellable in the set. No more warnings, no more memory leaks!

Now that you have the keyboard observer, it's time to use it ChatView.swift. Open the file and add a new property to the struct:

@ObservedObject private var keyboardObserver = KeyboardObserver()
Enter fullscreen mode Exit fullscreen mode

By using @ObservedObject you tell SwiftUI to update the UI whenever the object changes. Next, use the observer to grab the keyboard's height in body:

ZStack {
  Color.background.edgesIgnoringSafeArea(.top)

  VStack {
    List {
      ...
    }

    ChatTextField(sendAction: onSendTapped)
      // These two lines are new:
      .padding(.bottom, keyboardObserver.keyboardHeight)
      .animation(.easeInOut(duration: 0.3))
  }
}.navigationBarTitle(Text(receiver.name), displayMode: .inline)
Enter fullscreen mode Exit fullscreen mode

When the keyboard pops up, you'll raise the text field by the height of the keyboard. Since everything is in a VStack, the List will get shorter automatically. You'll also animate this change by calling animation after padding. SwiftUI will calculate the to and from values to animate without you having to do anything. Pretty neat, right?

Handle keyboard rising in SwiftUI using Combine

Run the project and navigate to the chat screen. As you start typing, you'll see the keyboard pop up and the text field raises to match it. Now you can see what you're typing.

Conclusion

You are now almost done with your chat app! In this section of the SwiftUI course, you went one step further from static, hard-coded values. You used initializers, bindings and observable objects to pass data between your views and breathe a bit of life into your app.

You learned that passing data in the initializer is the simplest way to give a bit of state to a direct child of a view. You also learned that you can use bindings for two-way communication between a child and its parent. Using environment objects, you added global state to your app that any view can access. Finally, using Combine you converted a Notification Center notification into an observable object that updates the UI. All in a day's work, right?

Now that you have an understanding of how to deal with data, there's only one final step: Getting the data. The next section of the SwiftUI course deals with connecting everything up to CometChat, a chat SDK that will let you send and receive messages over the Internet. Keep reading to see your app come to life!

Top comments (0)