SwiftUI: Fetching Data from Firestore in Real Time

SwiftUI: Fetching Data from Firestore in Real Time

SwiftUI is an exciting new way to build UIs for iOS and Apple's other platforms. By using a declarative syntax, it allows developers to separate concerns and focus on what's relevant.

As with everything new, it sometimes takes a while to get used to how things are done in the new world. I often see people try to cram too many things into their SwiftUI views, resulting in less readable code that doesn't work well.

It's time to shake off the old habits of building MVCs (massive view controllers), folks!

In this blog post I'm going to show you a clean way to connect your SwiftUI app to Firebase and fetch data from Cloud Firestore.

The sample app

To get us started, consider the following app which displays a list of books:

struct Book: Identifiable {
  var id: String = UUID().uuidString
  var title: String
  var author: String
  var numberOfPages: Int
}


let testData = [
  Book(title: "The Ultimate Hitchhiker's Guide to the Galaxy: Five Novels in One Outrageous Volume", author: "Douglas Adams", numberOfPages: 815),
  Book(title: "Changer", author: "Matt Gemmell", numberOfPages: 474),
  Book(title: "Toll", author: "Matt Gemmell", numberOfPages: 474)
]

struct BooksListView: View {
  var books = testData

  var body: some View {
    NavigationView {
      List(books) { book in
        VStack(alignment: .leading) {
          Text(book.title)
            .font(.headline)
          Text(book.author)
            .font(.subheadline)
          Text("\(book.numberOfPages) pages")
            .font(.subheadline)
        }
      }
      .navigationBarTitle("Books")
    }
  }
}

Hard to believe, but in less than 40 lines of code, we're able to not only define a simple data model for books, as well as some sample data, but also a screen that displays the books in a list - that's the power of SwiftUI!

Adding Firebase

To fetch data from Firestore, you'll first have to connect your app to Firebase. I'm not going to go into great detail about this here - if you're new to Firebase (or your background is in Android or web development), check out this video which will walk you through the process (don't worry, it's not very complicated).

How to store our books in Firestore

Data in Firestore is stored in documents that contain a number of attributes. Documents are stored in collections. You cannot nest collections, but a document can contain collections, making it possible to create hierarchical data structures.

For our application, we will just use a simple collection named books, which contains a number of documents that will represent our books. Each document will be made up of the following attributes:

  • title: string
  • author: string
  • pages: number

Fetching data and subscribing to updates

Firestore does support one-time fetch queries, but it really shines when you use its realtime synchronisation to update data on any connected device. No longer will you have to implement pull-to-refresh - all data is kept in sync on all devices all of the time! Even better: the Firestore SDKs provide offline support out of the box, so all changes that the user made while their device was offline will automatically be synced back to Cloud Firestore once the device comes back online again.

Implementing all of this isn't even particularly complicated - quite the opposite, as you will see. Offline support is enabled by default, and implementing real-time sync is a matter of registering a snapshot listener to a Firestore collection you're interested in.

The boilerplate for registering a snapshot listener for the books collection in our sample app looks like this:

  Firestore.firestore().collection("books").addSnapshotListener { (querySnapshot, error) in
    guard let documents = querySnapshot?.documents else {
      print("No documents")
      return
    }

    documents.map { queryDocumentSnapshot -> Book in
      // map document to Book instance here
    }
  }

But where's a good place to put the snapshot listener? After everything I told you about massive view controllers, you're probably guessing it won't be in the view itself - and you're absolutely right!

Implementing the view model

A good way to keep our views clean and lean is to use an MVVM (Model, View, View Model) architecture. We've already got the view (BooksListView) and model (Book), so all that's missing is the view model.

The primary responsibility of the view model is to provide access to the data we want to display in our UI. This is typically done in a view-specific way: we might want to display the list of books in two different ways: a list view and a cover flow. The list might display more details about each book (such as number of pages as well as the author's name), whereas the cover flow would display gorgeous book covers, with just the book title. Both views need different attributes from the model, so the view models would expose different attributes (and the view model for the cover flow might also include code to fetch large book cover art).

A secondary responsibility of the view model is the provisioning of the data. In our simple application, we will handle data access directly in the view model, but in a more complex application I'd definitely recommend extracting this into a store or repository to make data access reusable across multiple view models.

To be able to use the view model in a SwiftUI view and subscribe to the data it manages, it needs to implement the ObservableObject protocol, and all properties that we want our UI to be able to listen to need to be flagged using the @Published property wrapper.

Here is the boilerplate for our view model:

import Foundation
import FirebaseFirestore

class BooksViewModel: ObservableObject {
  @Published var books = [Book]()

  // code to fetch data  
}

With this in place, we can now add the code for registering the snapshot listener, and map the documents we receive into Book instances:

import Foundation
import FirebaseFirestore

class BooksViewModel: ObservableObject {
  @Published var books = [Book]()

  private var db = Firestore.firestore()

  func fetchData() {
    db.collection("books").addSnapshotListener { (querySnapshot, error) in
      guard let documents = querySnapshot?.documents else {
        print("No documents")
        return
      }

      self.books = documents.map { queryDocumentSnapshot -> Book in
        let data = queryDocumentSnapshot.data()
        let title = data["title"] as? String ?? ""
        let author = data["author"] as? String ?? ""
        let numberOfPages = data["pages"] as? Int ?? 0

        return Book(id: .init(), title: title, author: author, numberOfPages: numberOfPages)
      }
    }
  }
}

Using the ViewModel in a SwiftUI view

Back in the BooksListView, we need to make two changes to use BooksViewModel instead of the static list of books:

struct BooksListView: View {
  @ObservedObject var viewModel = BooksViewModel() // (/1)

  var body: some View {
    NavigationView {
      List(viewModel.books) { book in // (2)
        VStack(alignment: .leading) {
          Text(book.title)
            .font(.headline)
          Text(book.author)
            .font(.subheadline)
          Text("\(book.numberOfPages) pages")
            .font(.subheadline)
        }
      }
      .navigationBarTitle("Books")
      .onAppear() { // (3)
        self.viewModel.fetchData()
      }
    }
  }
}

By using the @ObservedObject property wrapper (1), we tell SwiftUI to subscribe to the view model and invalidate (and re-render) the view whenever the observed object changes. And finally, we can connect the List view to the books property on the view model (2), and get rid of the local book array. Once the view appears, we can tell the view model to subscribe to the collection. Any changes that the user (and anyone else) makes to the books collection in Firestore will now be reflected in the app's UI in realtime.

Conclusion

With just a few lines of code, we've managed to connect an existing app to Firebase, subscribe to a collection of documents in Firestore and display any updates in real time.

The key for a clean architecture is to extract the data access logic into a dedicated view model, and harness SwiftUI's and Combine's power to drive UI updates effortlessly.

Where to go from here

If you'd like to learn more about Firestore, check out the Get to know Cloud Firestore series by @ToddKerpelman.

Interested in seeing how to build a real world application using Firestore and SwiftUI? Check out my multi-part blog post series in which I rebuild the iOS Reminders app using SwiftUI and Firebase:

Next week, we're going to look into how to make mapping DocumentSnapshots easier and more type-safe using the Codable protocol - stay tuned!