@ObservedObject and Friends in SwiftUI
In this tutorial, we’re going to discuss an approach for 2-way data flow between a web-based JSON resource and changing that data in your app. Getting data, displaying it, and sending changes back. The centerpiece is the @ObservedObject
property wrapper, and we’ll also discuss how to use ObservableObject
, @Published
, @Binding
, Combine, MVVM, and much more.
Here’s what we’ll get into:
- How to set up 2-way data flow in a real-world Books app
- Working with
@ObservedObject
,@Binding
and@Published
- Getting JSON data from a URL with Combine and URLSession
- Mastering projected values, like bindings and publishers
- Working with Combine’s publishers and subscribers
- Making sure the single source of truth stays single forever
- Discuss how MVVM and SwiftUI are a near-perfect match already
- Tying it all together with data coming in, and going out
Ready? Let’s go.
- A Pragmatic Approach for 2-Way Data Flow
- Getting Started
- Building SwiftUI Views
- Combine, @Published, @ObservedObject and MVVM
- Building The View Model
- Getting Data with Combine and URLSession
- The @ObservedObject Property Wrapper
- Editing Data with TextField and @Binding
- How To Grab The Binding?
- Your Turn: Next Steps
- Further Reading
A Pragmatic Approach for 2-Way Data Flow
In essence, the goal of this tutorial is to walk you through working with the @ObservedObject
property wrapper.
But, that property wrapper is never used solely on its own – it always belongs to a greater whole of interconnected components. The real goal of this tutorial is to walk you through a common use case where you’d use @ObservedObject
, and friends, to manage two-way data flow in your app.
You can use a dozen approaches to work with @ObservedObject
. The idea behind this tutorial is not to give you the one approach that always works, but to get you 80% of the way there. You can use the concepts, tools and principles you pick up here to learn more, find alternatives, and improve your skills.
We’ll discuss the following components and approaches:
- Working with the
@ObservedObject
property wrapper andObservableObject
protocol - Working with the
@Published
property wrapper (and Combine publishers) - Using the MVVM (Model-View-ViewModel) software architectural pattern to manage data in this app project
- Using
Codable
, Combine andURLSession
to consume and publish a web-based JSON resource - Responding to user interaction with
@Binding
andTextField
In this tutorial, we’ll build a simple Books app. It consists of a list of books and a view to edit individual books. You could say that it’s an archetypal app project, because it consists of common actions like viewing and editing data (CRUD).
More importantly, it’s an essential project because we’re setting up a two-way flow between the (external) source of data and the view that you use to edit it, and back. How does all of it tie together? That’s what we’re here to find out!
Getting Started
Let’s get a move on! Feel free to set up a SwiftUI App project in Xcode, or apply the code in this tutorial to your own project. We’re going to start with the Book
struct.
struct Book: Identifiable, Codable {
var id: Int
var title: String
var author: String
}
The above Book
struct has 3 properties: an integer id
, and strings for title
and author
.
In the struct’s definition, you see that it conforms to the Identifiable
protocol. This means Book
must have a property id
that contains a unique value. That way we can uniquely identify a Book
object, and tell it apart from other books.
The Book
struct also conforms to Codable, which is the protocol that you use to encode from and decode to JSON. As you’ll soon see, we’re going to fetch a web-based JSON file that contains an array of books. The objects in the JSON data correspond to the structure of Book
.
This tutorial contains fewer prompts than usual to complete an action in Xcode. Most steps are self-explanatory, so you can follow along on the page or with your own project in Xcode.
Building SwiftUI Views
Building a project like this, an important question you can ask yourself is: Now that we’ve structured the data, are you going to build the views or the view models first? In other words, now you know what data to structure your app around, will you first get that data or put it to use in a view?
The approach we’ll pick is to build the views first. It’s easy to mock up data, and if you’re a visual thinker, apps will “click” for you when you’re looking at the User Interface. It’s harder to mock up the data first.
Add the following BookList
view to your app project:
struct BookList: View
{
var body: some View {
NavigationView {
List(books) { book in
BookRow(book: book)
}
.navigationBarTitle("Books")
}
}
}
What’s going on there? This BookList
view consists of a List view that iterates over a books
array. For each Book
object in the array, it’ll put a BookRow
view on screen.
Feel free to add some fake book data to the app, like this:
let books = [
Book(id: 1, title: "1984", author: "George Orwell"),
Book(id: 2, title: "Animal Farm", author: "George Orwell"),
Book(id: 3, title: "Brave New World", author: "Aldous Huxley")
]
And of course, also add the BookRow
view to your app project:
struct BookRow: View
{
var book:Book
var body: some View {
VStack(alignment: .leading, spacing: 5.0) {
Text(book.title)
.font(.headline)
.padding([.leading, .trailing], 5)
Text(book.author)
.font(.subheadline)
.padding([.leading, .trailing], 5)
}
.padding([.top, .bottom], 5)
}
}
The BookRow
view consists of a VStack
with 2 Text
views. It’ll show the title
and author
properties of a Book
object in a vertical stack, with some padding and spacing in between.
If you’ve started an empty SwiftUI project in Xcode, make sure to add BookList
to the App
struct. Like this:
@main
struct BooksApp: App {
var body: some Scene {
WindowGroup {
BookList()
}
}
}
When you run the app project in Xcode, the fake book list should show right up. Awesome!
Combine, @Published, @ObservedObject and MVVM
The next step in this Books app is to fetch the Book
objects from an external web-based data source.
It’s really just a JSON file, although it’s a stand-in for an essential component: Core Data, Realm, a database, a web-based API – anything external that provides data to your app. What we’re going to figure out, is a workflow to get data from outside your app into that books
array (with minimal headache).
The approach we’ll take is loosely based on Model-View-ViewModel, an architectural design pattern. SwiftUI and MVVM play well together, most importantly because SwiftUI is organized around views, models and bindings.
- A model is responsible for the data in your app
- A view is responsible for showing a UI, and handling interaction
- A binding is a (2-way) connection between a piece of data and some UI
The “VM” in MVVM stands for view model (or “ViewModel”), which is what we’re going to build next. The view model has taken the place of the Controller, from Model-View-Controller, and it’s the layer that goes between the view and the model.
The idea behind the ViewModel is that a view and a model don’t directly communicate with each other. Instead, they communicate through the ViewModel. The model, view and view model are loosely coupled and separate their concerns, which is important to keep your code from resembling spaghetti.
In addition to the view model, you’ve got bindings that connect some (or all) data from the model with the view, and vice versa.
In modern app development, you’ll find that the ViewModel is often responsible for fetching data from a data source, and providing an API for views to interact with, to get that data.
With SwiftUI, you’re already 90% towards working with MVVM without explicitly working with MVVM. You’ve got the @Published
and @Binding
property wrappers, and Core Data’s @FetchRequest
, for example, which is essentially state, a ViewModel and binding baked in one.
Let’s take a birds-eye view of what we’re going to build:
- A
BooksViewModel
is going to expose abooks
property using the@Published
property, that theBookList
can directly subscribe to. - We’ll use Combine and URLSession to assign a data task publisher directly to
books
, which will get the JSON and transform it into[Book]
data. - Wrap the view model with
@ObservedObject
, whose projected value we can bind to inBookEdit
, with aTextField
for example.
What’s perhaps baffling, is that this is going to take (about) the same amount of lines of code as you have toes and fingers! Let’s get to it.
SwiftUI’s greatest strength, and greatest weakness, is that it’s hard to see where SwiftUI ends and Combine begins. In fact, it’s hard to comprehend where anything ends up. You’re incredibly productive when “things just work”, but as you’re figuring out how, SwiftUI is like a magician that won’t give up its tricks. Think about it – that’s exactly the point!
Building The View Model
The next component we’re going to build is the BooksViewModel
. It’s a go-between that separates Book
models, and any view that relies on it, such as the BookList
view.
The purpose of the BooksViewModel
is to expose an API (i.e., functions) that allows us to subscribe to Book
objects. We also want the BooksViewModel
to fetch that data from the web-based JSON resource, so we can keep that concern away from the views.
Add the following class to your code:
class BooksViewModel: ObservableObject {
let url:URL! = URL(string: "···")
@Published var books = [Book]()
}
Make sure to replace the URL, between the quotes, in the above code with this URL to books.json (Right-click, Copy Link).
What’s going on in the code? You see 3 points of interest:
- The
BooksViewModel
class conforms to theObservableObject
protocol - It has one property
url
of typeURL
, which is implicitly unwrapped - The
books
property, of type[Book]
, is annotated with@Published
First off, ObservableObject
. When you adopt that protocol in your own class, you’re essentially turning the class instances into publishers that can emit values to anyone who subscribes to them. This is a core principle of the Combine framework.
The ObservableObject
protocol has a default implementation for its objectWillChange
publisher, which emits the changed value for any of the class’ @Published
properties. We’re using that property wrapper for the books
property.
Think about it like this: You can now observe a BooksViewModel
object for changes in the books
array. Whenever the data in that array changes, you get a ping. A ping where? Well, any subscribers will get a ping. Guess who’s going to subscribe? The BookList
view, of course!
Next, let’s talk about @Published
. We already know that ObservableObject
uses properties that have the @Published
property wrapper, but there’s more. The @Published
property wrapper essentially turns the property into a publisher. That means you can subscribe to the property and respond to changes the publisher emits. This is what we’ll do with @ObservedObject
, later on.
You can only use ObservableObject
with a class. Also, keep in mind that @Published
will emit to subscribers before the value changes. This is only relevant if you’re subscribing manually, but it’s good to know!
Getting Data with Combine and URLSession
The next step we’ll take, is fetching book data in the BooksViewModel
class.
As we’ve discussed, the view model is (loosely) responsible for getting data from an (external) data source. This isn’t the purpose of the view model in the strictest sense. Because we don’t have a separate database controller or persistence layer, it’s good enough for now.
Add the following function to the BooksViewModel
class:
func fetchBooks()
{
URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: [Book].self, decoder: JSONDecoder())
.replaceError(with: [])
.eraseToAnyPublisher()
.receive(on: DispatchQueue.main)
.assign(to: &$books)
}
Whoah, what’s going on here? From a birds-eye view, this is code that uses the Combine framework to get a web-based JSON file, transform it to an array of Book
objects, and assigns that to the books
property of BooksViewModel
. It’s a “chain” of Combine operators, that uses the builder pattern you already know from SwiftUI. Let’s take a closer look!
The URLSession.shared.dataTaskPublisher(for: url)
code creates a publisher that’ll emit a value when its data task succeeds (or fails). You may recognize it from working with URLSession. Instead of a completion handler for the response, this code creates a publisher that you can subscribe to.
The .map { $0.data }
transforms the data from the publisher, in a similar way as the higher-order function map(_:)
. We’re taking the output of the publisher (a tuple) and pick off the data
value.
With .decode(type: [Book].self, decoder: JSONDecoder())
we’re decoding the data
value, with the JSONDecoder
decoder, and create an array of Book
objects from it. It’s hard to believe that this one line of code is the equivalent of 10+ lines of code when working with JSON and Codable manually!
We’ll need to deal with errors that occur upstream, and that’s what the replaceError(with: [])
code is for. It’ll essentially replace any errors with an empty array; the best-case approach. That’s OK for now, but in your own apps you will want to respond to errors in another way. For example, you can choose to reject HTTP status codes other than 200.
The .eraseToAnyPublisher()
code will erase the type of the upstream publisher. Type erasure is a complex topic that warrants its own tutorial, but for now you can assume that eraseToAnyPublisher
will change the type of the publisher from something complex to the simpler AnyPublisher<[Book], Never>
.
With .receive(on: DispatchQueue.main)
, we’re telling the publisher we want to receive its updates on the main thread. Because we’re subscribing to the publisher from a SwiftUI view (later on), and view updates need to happen on the main thread.
Finally, with .assign(to: &$books)
we are telling the publisher – you know, the code above it – to republish values it emits, and assign them to the books
property. In fact, we’re republishing via $books
, which is the projected value of @Published
, which itself is a publisher. Think of it as a monkey that repeats everything another monkey says.
What happens when you call the fetchBooks()
function? In short, we’ve made a publisher and subscribed to it with assign(to:)
. That means that the data task kicks off, which will make the request, await the response, and transforms it to an array of Book
objects. That data is assigned to the books
array, which itself is a publisher that we can subscribe to later on.
The &
in .assign(to: &$books)
is needed because the parameter for to:
is marked with inout
. It passes $books
by reference, as opposed to by value. Instead of copying the $books
publisher, it’ll pass a reference to it into the assign(to:)
function.
The @ObservedObject Property Wrapper
Now that we’ve finished coding the view model BooksViewModel
, let’s put it to use. This is where @ObservedObject
comes in, finally!
Add the following property to the BookList
view:
@ObservedObject var viewModel: BooksViewModel
There we go! This adds a property viewModel
of type BooksViewModel
to the BookList
struct, marked with the @ObservedObject
property wrapper. The property does not have a default value, so we can use the memberwise initializer on BookList
to inject a value. Let’s do that now!
Add the following property to the App
struct, i.e. in BooksApp.swift
:
let booksViewModel = BooksViewModel()
Then, provide that property to BookList
. Like this:
BookList(viewModel: booksViewModel)
If you’re working with SwiftUI Previews, feel free to insert a BooksViewModel()
in there too.
Why are we creating the BooksViewModel
instance outside of BookList
? It doesn’t have any consequences in this app, but it’s likely that you’ll want to reuse that ViewModel elsewhere in your app. If you’re keeping an ObservableObject
contained within a view, you can use @StateObject
instead of @ObservedObject
.
What’s going on with @ObservedObject
, really?
Firstly, it’s important to understand that, by marking the viewModel
property with @ObservedObject
, we’re telling the BookList
view that it depends on the state of viewModel
. The @ObservedObject
property wrapper is similar to @State, in that regard.
Whenever the state of viewModel
changes, the BookList
view must update too. In SwiftUI, the UI is driven by state. You can tell the view its dependant on some state with property wrappers like @State
, @StateObject
, @ObservedObject
and @EnvironmentObject
.
How does the view know that the data in viewModel
has changed? That’s because the BooksViewModel
adopts the ObservableObject
protocol, and has a property books
marked with @Published
. And guess who’s changing the data in books
? Exactly, the (re)publisher in the fetchBooks()
function!
Author’s Note: It always helps me to lean a little into the names given to concepts like @ObservedObject
and ObservableObject
. An object that conforms to ObservableObject
is “an object you can observe”. A property marked with @ObservedObject
is “an object that the view is observing for changes”. A property marked with @Published
is “published”, it’s turned into a publisher that emits data. You can subscribe to data that’s published; that’s what it’s for.
Now that we’ve added the ViewModel to the view, how can we put it to use? Let’s make that happen!
Adjust the List
view in BookList
to the following:
List(viewModel.books) { book in
BookRow(book: book)
}
Feel free to remove any fake data you had for books
, of course.
The above change will get the Book
objects from the books
array on viewModel
, which is the array we’re filling with the publisher in fetchBooks()
. What’s interesting is that this code isn’t special in any way, it just assigns the books
array as the data source for the List
view. No wrappers, no projected values, no $
.
Next, add the following modifier inside the NavigationView
, right below the navigationBarTitle(···)
modifier:
.onAppear {
viewModel.fetchBooks()
}
This will call the fetchBooks()
function on the BooksViewModel
object right when the BookList
view appears on screen. This kicks off the fetching of books from the web-based JSON file, which will ultimately end up in viewModel.books
and prompt an update of the BookList
view.
When you run your app at this point, you’ll see that the book data shows up nicely in the list. Awesome!
Later on, you may want to kick off fetchBooks()
in a different place. If you keep it in onAppear
, you’ll see that the book data is fetched every time the BookList
view appears. You can also invoke fetchBooks()
in the initializer of BooksViewModel
, for example, or based on user action.
Editing Data with TextField and @Binding
So far we’ve completed the fetching part of the Books app. Data is requested from an online JSON file, and ends up on-screen. But that’s only one aspect of the archetypal CRUD app – what about changing data?
We’re going to create a BookEdit
view, and set up bindings with the data in the ViewModel. Let’s dive in!
Add the following code to a new SwiftUI View called BookEdit
:
struct BookEdit: View
{
@Binding var book: Book
var body: some View {
Form {
Section(header: Text("About This Book")) {
TextField("Title", text: $book.title)
TextField("Author", text: $book.author)
}
}
}
}
Working with SwiftUI Previews? You can provide a fake Book
binding using .constant()
, like this: BookEdit(book: .constant(Book(id: 1, title: "1984", author: "George Orwell")))
.
What’s going on in the BookEdit
view? A few components stand out:
- The
BookEdit
struct has a propertybook
of typeBook
, which is annotated with @Binding. This is the binding we discussed, in the context of MVVM. - The view’s body consists of a
Form
, with oneSection
, and 2TextField
views. Both textfields have a label, and some code that looks like$book.···
. This is the actual binding of some data with some UI component.
Let’s discuss what @Binding
is for. In short, a property marked with the @Binding
property wrapper exposes a value of type Binding
. You access that binding, called a projected value, by prepending the property’s name with $
. This binding can be passed to a view like TextField
. When you do so, you’re creating a 2-way connection between the data and the TextField
.
Whenever the value in the textfield changes, so does the value it’s bound to. So when the user types something in the TextField
for book.title
, the string value for book.title
changes with it. The opposite is true too: when the data changes, so does the text inside the textfield.
The property wrapper @State exposes a binding too, through its projected value. You access that the same way, with $
. Why are we using @Binding
here, and not @State
? That’s because @State
will keep and manage its own state (i.e., data), whereas @Binding
is merely a pass-through for data stored elsewhere.
That ties back into the oh-so-important principle of single source of truth. In essense, that means we’re only storing data in one place and do not make copies. If a SwiftUI view wants to know what data changes – so it can update the UI – it must unequivocally know the one place that data comes from.
For our Books app, that single source of truth is the books
array on the BooksViewModel
object in BookList
. Make any copies, with @State
or otherwise, and you mess up the order of things in SwiftUI’s little universe.
Must we get the bindings for TextField
via $viewModel.books
? No. You can also add local properties for author
and title
, wrap them @State
, and write from the local properties to book
from a Save action, once you’re done editing. Give it a try! What other alternative approaches can you discover and put to use?
How To Grab The Binding?
Back to the code! Now that we’ve made that BookEdit
view, let’s put it to use. We’re going to use NavigationLink
for that.
Locate the List
view in the BookList
view, and replace it with this:
List(viewModel.books) { book in
let index = viewModel.books.firstIndex(where: { $0.id == book.id })!
NavigationLink(destination: BookEdit(book: $viewModel.books[index])) {
BookRow(book: book)
}
}
What’s going on here?
It looks complicated, but we’ve only replaced the BookRow
view with a NavigationLink
view. The destination of this NavigationLink
– the defacto way to do navigation with SwiftUI – is the BookEdit
view. The NavigationLink’s content view is the same BookRow
as before.
What’s the other code? We’ve got 2 bits of it: the let index = ···
, and the $viewModel.books[index]
code. The former is the index of book
in viewModel.books
, and the latter a binding to that Book
object.
First off, it’s important to understand that we need to pass a Binding
for Book
into BookEdit
. The TextField
views can use that binding to change their wrapped values. In other words, we need to bind the book
property in BookEdit
to a Book
object from elsewhere. Where could we get that binding?
Fortunately, the viewModel
property exposes a binding because it’s wrapped by @ObservedObject
. We can bind to it because the viewModel
property is an observed object. Just as with @State
, you can get access to that binding by prefixing the property’s name with $
. This is how we get to $viewModel.books···
.
Pop Quiz: Why is it $viewModel.books
and not viewModel.$books
? (Because $books
would refer to Publisher
value for @Published
, whereas $viewModel
refers to Binding
for @ObservedObject
. Working with $viewModel.$books
would probably implode the universe.)
Now that we’ve got a way to get a binding, there’s a problem. Inside the content closure for List
, we’ve only got access to the current Book
value. In other words, we can bind to $viewModel
and we’ve got a Book
object, but how are we going to get a binding to an object from the books
array?
Take a look at the code once more:
List(viewModel.books) { book in
let index = viewModel.books.firstIndex(where: { $0.id == book.id })!
NavigationLink(destination: BookEdit(book: $viewModel.books[index])) {
BookRow(book: book)
}
}
In the content closure for List
, we’ve got a value book
. This is the current Book
object that we’re iterating and building a list of. Inside BookRow(book:)
, we’re providing that book
value. So far so good – this is how the BookRow
can display data like book.author
.
This won’t work for BookEdit
, because we need to pass a Binding
to its book
parameter. Something like BookEdit(book: book)
doesn’t work here. What we can do, however, is get a reference to the current Book
object directly from the viewModel.books
array. Because viewModel
is wrapped with @ObservedObject
, we can get a binding to it. That’s what we need!
The problem is: How do we find the current Book
object in the viewModel.books
array? With this code:
let index = viewModel.books.firstIndex(where: { $0.id == book.id })!
The firstIndex(where:) function returns the (first) index of the array item for which the given predicate is true. That predicate is the closure $0.id == book.id
. In other words, find the index in the array for which the array’s item’s id
property is equal to book.id
, which is the current item in the List
.
Note: If you search online, you’ll find a few alternatives to solve this problem. Most notably, using Array(books.enumerated())
and ForEach
, like in this tutorial, to get the index of the current book in the array. I’ve chosen to work with firstIndex(where:)
to get 1-on-1 parity between the book.id
and books
, but it has the disadvantage of O(n) time complexity inside the List
. If you end up using the array’s indices, keep in mind that the external data source may not have a fixed sort order. The ideal scenario would be a ViewModel with a custom collection type that provides Identifiable
and random access out of the box.
At this point, if you run the app, you should be able to edit the data in viewModel.books
. Tap on any of the rows in the list, edit the book’s author and title, and see your changes reflected back in the BookList
view. Neat!
As we’ve discussed, you may want to remove this code:
.onAppear {
viewModel.fetchBooks()
}
And add it somewhere else, like in the initializer of BooksViewModel
:
class BooksViewModel: ObservableObject
{
init() {
fetchBooks()
}
···
}
The above change ensures that fetchBooks()
is only called once, and not every time the BookList
view (re)appears.
Your Turn: Next Steps
We set out to look at an app project where SwiftUI, MVVM, @ObservedObject
and Combine come together to get some data and display it in a view. The goal was to leave the source of the data a bit open-ended, so you can replace that with whatever API or datastore you’re using.
Which begs the question: “What’s next?”
You’ve got 2 potential avenues to discover further:
- If the data for
books
inBooksViewModel
doesn’t come from a web-based JSON resource, where does it come from? You can use just about any publisher there! Core Data, Realm, a custom API, a JSON file on disk – anything! - Once you’ve edited the data in
BookEdit
, where does it go? Provided you’re working with an online API, you could send the changed data back there for persistence.
As for that last idea, here’s something to get you started:
var cancellables = Set<AnyCancellable>()
// Note: import Combine, too!
func updateBooks()
{
$books.sink { books in
for book in books {
print("Sending book '\(book.title)' to custom web API...")
}
}.store(in: &cancellables)
}
One of the cool things about Combine is that once you’ve got a publisher, you can do anything you want with it. Remember that we talked about how @Published
exposes a Publisher
? That’s what $books
is. With sink()
, you can subscribe to values that are emitted by the $books
publisher. That happens whenever the data in books
changes, including when you edit their properties in BookEdit
.
We’ve come full circle. Good luck!
Note: Give the code in updateBooks()
a try. You’ll only need it once, because cancellables
will keep the subscriber around. Keep in mind that the subscriber will respond to anything that happens to the books
array, including writing data into it, and for every keystroke in TextField
. It’s a start – I’m sure you can put it to good use!
Further Reading
Want to learn more? Check out these resources:
- Get Started with SwiftUI for iOS
- Create UIs with Views and Modifiers in SwiftUI
- The @State Property Wrapper in SwiftUI Explained
- Working with @Binding in SwiftUI
- Working with Stacks in SwiftUI
- Working with List in SwiftUI
- How To: Pass Data Between Views with SwiftUI
- MVVM and SwiftUI
- Combining Network Requests with Combine and Swift