Getting Started with Cloud Firestore and SwiftUI

In this tutorial, you’ll learn how to use Firebase Cloud Firestore to add persistence to a SwiftUI iOS application with Swift. By Libranner Santos.

4.8 (11) · 2 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Adding New Cards

Start by creating the Repository to access the data.

In the Project navigator, right-click Repositories and click New file…. Create a new Swift file called CardRepository.swift and add the following code to it:

// 1
import FirebaseFirestore
import FirebaseFirestoreSwift
import Combine 

// 2
class CardRepository: ObservableObject {
  // 3
  private let path: String = "cards"
  // 4
  private let store = Firestore.firestore()

  // 5
  func add(_ card: Card) {
    do {
      // 6
      _ = try store.collection(path).addDocument(from: card)
    } catch {
      fatalError("Unable to add card: \(error.localizedDescription).")
    }
  }
}

Here you:

FirebaseFirestoreSwift adds some cool functionalities to help you integrate Firestore with your models. It lets you convert Cards into documents and documents into Cards.

  1. Import FirebaseFirestore, FirebaseFirestoreSwift and Combine. FirebaseFirestore gives you access to the Firestore API and Combine provides a set of declarative APIs for Swift.

    FirebaseFirestoreSwift adds some cool functionalities to help you integrate Firestore with your models. It lets you convert Cards into documents and documents into Cards.

  2. Define CardRepository and make it conform to ObservableObject. ObservableObject helps this class emit changes, using a publisher, so other objects can listen to it and react accordingly.
  3. Then, declare path and assigned the value cards. This is the collection name in Firestore.
  4. Declare store and assign a reference to the Firestore instance.
  5. Next, you define add(_:) and use a do-catch block to capture any errors thrown by the code. If something goes wrong while updating a document, you terminate the app’s execution with a fatal error.
  6. Create a reference to the cards collection using path, and then pass card to addDocument(from:encoder:completion:). This adds a new card to the collection.

With the code above, the compiler will complain that addDocument(from:encoder:completion:) requires that Card conforms to Encodable. To fix this, open Card.swift and change the class definition to this:

struct Card: Identifiable, Codable {

By adding Codable, Swift can seamlessly serialize and deserialize Cards. This includes Encodable, which caused the error, and Decodable, which you’ll use when converting documents from Firestore documents to Swift objects.

You need a view model to connect your model with your views. In the Project navigator, under the ViewModels group, create a new Swift file called CardListViewModel.swift.

Add this to the new file:

// 1
import Combine

// 2
class CardListViewModel: ObservableObject {
  // 3
  @Published var cardRepository = CardRepository()

  // 4
  func add(_ card: Card) {
    cardRepository.add(card)
  }
}

Here’s a breakdown:

  1. Combine gives you the APIs to handle asynchronous code.
  2. You declare CardListViewModel and make it conform to ObservableObject. This lets you listen to changes emitted by objects of this type.
  3. @Published creates a publisher for this property so you can subscribe to it.
  4. You pass card to the repository so you can add it to the collection.

Open NewCardForm.swift and add a property for the view model you created, right after the other properties in NewCardForm:

@ObservedObject var cardListViewModel: CardListViewModel

The previous changes will make the Xcode Preview stop working, because now NewCardForm expects a CardListViewModel. To fix this, update NewCardForm_Previews:

static var previews: some View {
  NewCardForm(cardListViewModel: CardListViewModel())
}

Add the following addCard() method at the bottom of NewCardForm:

private func addCard() {
  // 1
  let card = Card(question: question, answer: answer)
  // 2  
  cardListViewModel.add(card)
  // 3
  presentationMode.wrappedValue.dismiss()
}

This code:

  1. Creates a Card using the question and answer properties already declared at the top.
  2. Adds the new card using the view model.
  3. Dismisses the current view.

Then, call this new method as the action for Add New Card, by replacing Button(action: {}) { with:

Button(action: addCard) {

Finally, open CardListView.swift, find the .sheet modifier and fix the compiler error by passing a new view model instance for now. You’ll use a shared instance later.

.sheet(isPresented: $showForm) {
  NewCardForm(cardListViewModel: CardListViewModel())
}

Build and run.

Tap + on top right corner. Fill the question and answer fields, and tap Add New Card.

New Card Form Screen

Hmm, nothing happens. :( The cards don’t appear in the main screen:

No Cards Screen

Open the Firebase Console in your web browser and go to the Cloud Firestore section. Firestore created the cards collection automatically. Click the identifier to navigate into your new document:

Card Document in Cloud Firestore

Your data is stored in Firebase, but you still haven’t implemented the logic to retrieve and display the cards.

Retrieving and Displaying Cards

Now it’s time to show your cards! First, you’ll need to create a view model to represent a single Card.

In the Project navigator, under ViewModels, create a new Swift file called CardViewModel.swift.

Add this to the new file:

import Combine

// 1
class CardViewModel: ObservableObject, Identifiable {
  // 2
  private let cardRepository = CardRepository()
  @Published var card: Card
  // 3
  private var cancellables: Set<AnyCancellable> = []
  // 4
  var id = ""

  init(card: Card) {
    self.card = card
    // 5
    $card
      .compactMap { $0.id }
      .assign(to: \.id, on: self)
      .store(in: &cancellables)
  }
}

Here you:

  1. Declare CardViewModel and make it conform to ObservableObject, so it can emit changes, and Identifiable, which guarantees you can iterate over an array of CardViewModels.
  2. This holds a reference to the actual card model. @Published creates a publisher for this property so you can subscribe to it.
  3. cancellables is used to store your subscriptions so you can cancel them later.
  4. id is a property required to conform to Identifiable. It should be a unique identifier.
  5. Set up a binding for card between the card’s id and the view model’s id. Then store the object in cancellables so it can be canceled later on.

Your repository needs to handle the logic for getting the cards. Open CardRepository.swift and add the following code at the top, below the property definitions:

// 1
@Published var cards: [Card] = []

// 2
init() {
  get()
}

func get() {
  // 3
  store.collection(path)
    .addSnapshotListener { querySnapshot, error in
      // 4
      if let error = error {
        print("Error getting cards: \(error.localizedDescription)")
        return
      }

      // 5
      self.cards = querySnapshot?.documents.compactMap { document in
        // 6
        try? document.data(as: Card.self)
      } ?? []
    }
}

In the code above, you:

  1. Define cards. @Published creates a publisher for this property so you can subscribe to it. Every time this array is modified, all listeners will react accordingly.
  2. Create the initialization method and call get().
  3. Get a reference to the collection’s root using path and add a listener to receive changes in the collection.
  4. Checks if an error occurred, prints the error message and returns.
  5. Use compactMap(_:) on querySnapshot.documents to iterate over all the elements. If querySnapshot is nil, you’ll set an empty array instead.
  6. Map every document as a Card using data(as:decoder:). You can do this thanks to FirebaseFirestoreSwift, which you imported at the top, and because Card conforms to Codable.

Next, open CardListViewModel.swift and add these two properties to CardListViewModel:

// 1
@Published var cardViewModels: [CardViewModel] = []
// 2
private var cancellables: Set<AnyCancellable> = []

In this code, you:

  1. Define cardViewModels with the @Published property wrapper, so you can subscribe to it. It’ll contain the array of CardViewModels.
  2. Create a set of AnyCancellables. It’ll serve to store your subscriptions so you can cancel them later.

While still in the view model, add the following initializer:

init() {
  // 1
  cardRepository.$cards.map { cards in
    cards.map(CardViewModel.init)
  }
  // 2
  .assign(to: \.cardViewModels, on: self)
  // 3
  .store(in: &cancellables)
}

The code you added:

  1. Listens to cards and maps every Card element of the array into a CardViewModel. This will create an array of CardViewModels.
  2. Assigns the results of the previous map operation to cardViewModels.
  3. Stores the instance of this subscription in cancellables so it is automatically canceled when CardListViewModel is deinitialized.

Open CardView.swift and make the following changes.

Replace var card: Card with:

var cardViewModel: CardViewModel

This lets the view use a view model instead of a Card model directly.

Then, in frontView, replace card.question with:

cardViewModel.card.question

Next, in backView, replace card.answer with:

cardViewModel.card.answer

Finally, change CardView_Previews, to this:

struct CardView_Previews: PreviewProvider {
  static var previews: some View {
    let card = testData[0]
    return CardView(cardViewModel: CardViewModel(card: card))
  }
}

With these changes, you’re now passing the expected CardViewModel instead of the Card model directly. But, you need one more update before previews work again.

You’ll also need to change the wrapping list view so it works with the card view model.

Open CardListView.swift and replace the cards array property with:

@ObservedObject var cardListViewModel = CardListViewModel()

With this change, CardListView now expects a CardListViewModel instead of an array of Cards. @ObservedObject will subscribe to the property so it can listen to changes in the view model.

Look for a ForEach statement inside body, and change it to look like this:

ForEach(cardListViewModel.cardViewModels) { cardViewModel in
  CardView(cardViewModel: cardViewModel)
    .padding([.leading, .trailing])
}

You’ll now iterate over cardListViewModel‘s individual card view models and create a CardView for each of them.

Since CardListView now expects a CardListViewModel instead of an array of Cards, change CardListView_Previews to:

CardListView(cardListViewModel: CardListViewModel())

Build and run.

Displaying Cards

Add as many cards as you want and see how they immediately appear on the main screen.