@ObservedObject and Friends in SwiftUI

Written by LearnAppMaking on February 11 2021 in App Development, iOS, SwiftUI

@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.

  1. A Pragmatic Approach for 2-Way Data Flow
  2. Getting Started
  3. Building SwiftUI Views
  4. Combine, @Published, @ObservedObject and MVVM
  5. Building The View Model
  6. Getting Data with Combine and URLSession
  7. The @ObservedObject Property Wrapper
  8. Editing Data with TextField and @Binding
  9. How To Grab The Binding?
  10. Your Turn: Next Steps
  11. 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 and ObservableObject 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 and URLSession to consume and publish a web-based JSON resource
  • Responding to user interaction with @Binding and TextField

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 a books property using the @Published property, that the BookList 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 in BookEdit, with a TextField 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:

  1. The BooksViewModel class conforms to the ObservableObject protocol
  2. It has one property url of type URL, which is implicitly unwrapped
  3. 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 property book of type Book, 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 one Section, and 2 TextField 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:

  1. If the data for books in BooksViewModel 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!
  2. 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:

LearnAppMaking

LearnAppMaking

At LearnAppMaking.com, app developers learn how to build and launch awesome iOS apps.