iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💾

SwiftUI + Firestore: Triggering Navigation and Data Transfer on Document Add/Update

に公開

What I want to achieve

  • Keep a standby screen displayed during normal times, and transition to a new screen if a Firestore document is added or updated "while waiting."
  • Display the contents of the added/updated document on the destination screen.
  • Change a field value when the "Confirm" button is pressed on the destination screen.

This might be useful for creating UIs triggered by real-time state changes, such as calling ticket numbers, real-time order processing, or call buttons.

Tools

Using addSnapshotListener allows for continuous asynchronous monitoring of Firestore.
Inside the function triggered by update detection, we will perform data formatting and assignment, and then use .navigationDestination to transition screens by observing variable changes.
When updating the document after the transition, we pass the document ID in advance and update it using .updateData.

Implementation

First, we will build only the part that detects updates, and then we will create the process to pass the updated data to the destination screen.
Once the operation is confirmed, we will add the process to pass the document contents to the destination screen.

Implementing only update detection and screen transition for now

Don't forget to add FirebaseCore and FirebaseFirestore to your dependencies.
Diagram of adding dependencies

Write the code while referring to the Firebase documentation.
https://firebase.google.com/docs/firestore/query-data/get-data?hl=en
https://firebase.google.com/docs/firestore/query-data/listen?hl=en

First, let's simply create a feature that automatically transitions the screen when an update is detected.

FirestoreListener.swift
import Combine
import FirebaseCore
import FirebaseFirestore

class FirestoreListener: ObservableObject{
    // Screen transition flag
    // We want this change to trigger a screen transition, so use @Published
    @Published var flagTransition: Bool = false
    // Initialization
    private var db = Firestore.firestore()
    private var listener: ListenerRegistration?
    
    // Flag to prevent the initial screen transition
    private var isFirstFetch: Bool = true
    
    // Start listening
    func startListening(){
        listener = db.collection("collection1")    // Specify the collection to monitor
            .whereField("status",isEqualTo: "hoge")  // Monitor only those where status is hoge
            .addSnapshotListener {querySnapshot, error in
                
                // Stop processing if an error occurs (if error is not nil)
                if let error = error {
                    print("An error occurred: \(error.localizedDescription)")
                    return
                }
                // querySnapshot can be nil due to permission errors or communication errors
                // It is not nil when there are 0 results from whereField
                guard let snapshot = querySnapshot else{
                    print("snapshot is empty")
                    return
                }
                // addSnapshotListener fetches current data once initially and fires, so end without transition on the first load
                if self.isFirstFetch {
                    self.isFirstFetch = false
                    return
                }
                
                // Check each changed document one by one
                for change in snapshot.documentChanges {
                    // If a document was added or modified
                    if change.type == .added || change.type == .modified {
                        print("Data with status == hoge was added or changed")
                        // Changes to transition flags must be done on the main thread (Swift convention)
                        DispatchQueue.main.async {
                            self.flagTransition = true
                        }
                    }
                }
            }
    }
    
    // Stop listening and return to initial state
    func stopListening(){
        listener?.remove()
        listener = nil
        isFirstFetch = true
    }
}
ContentView.swift
import SwiftUI

struct ContentView: View {
    // The class we just created to hold the Firestore monitoring state
    // Using @StateObject so it isn't re-initialized on every redraw
    @StateObject private var firestoreListener = FirestoreListener()
    
    var body: some View {
        NavigationStack{
            VStack(spacing: 20){
                ProgressView()
                    .controlSize(.large)
                Text("Monitoring Firestore...")
                    .font(.headline)
            }
            .navigationTitle("Home")
            // Transition to SuccessView when flagTransition becomes true
            .navigationDestination(isPresented: $firestoreListener.flagTransition){
                SuccessView()
            }
            // Start listening as soon as the screen appears
            .onAppear{
                firestoreListener.startListening()
            }
            // Stop listening when switching to another screen
            .onDisappear{
                firestoreListener.stopListening()
            }
        }
    }
}


// Destination screen (can be in a separate file)
struct SuccessView: View {
    var body: some View {
        VStack{
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 60))
                .symbolEffect(.breathe)
                .foregroundStyle(.green)
            Text("Update Detected")
                .font(.title)
                .padding()
        }
        .navigationTitle("Success")
    }
}

// For preview
#Preview {
    ContentView()
}

Quick check of operations

  1. Display the preview on Xcode (or run it in a simulator) and keep the "Monitoring Firestore..." screen visible.
  2. In the Firebase console in your browser, add a new document to collection1 and set the field status : "hoge" and save it.
  3. If the screen changes to "Update Detected", it's successful.

Using document contents on the destination screen

Now for the main topic. As stated in the heading, we want to display the fields of the updated document on the destination screen.
For now, we will display the contents of the text field.

At this point, we are already capturing documents where the status field changed to hoge, so we just need to add the process to extract the document contents and put them into a variable.

FirestoreListener.swift
import Combine
import FirebaseCore
import FirebaseFirestore

+// Create a struct to bundle the data you want to pass to the destination
+// We'll use this as a transition trigger, so use Identifiable and Hashable
+struct TransitionData: Identifiable, Hashable{
+    let id = UUID()     // Randomly generated unique ID. Required for Identifiable
+    let text: String    // Variable to hold the value of the text field
+}

class FirestoreListener: ObservableObject{
+   // Trigger screen transition when this variable is no longer nil (data is added)
+   @Published var transitionData: TransitionData?
-   // Screen transition flag
-   // We want this change to trigger a screen transition, so use @Published
-   @Published var flagTransition: Bool = false
    // Initialization
    private var db = Firestore.firestore()
    
    private var listener: ListenerRegistration?
    
    // Flag to prevent the initial screen transition
    private var isFirstFetch: Bool = true
    
    // Start listening
    func startListening(){
        listener = db.collection("collection1")    // Specify the collection to monitor
            .whereField("status",isEqualTo: "hoge")  // Monitor only those where status is hoge
            .addSnapshotListener {querySnapshot, error in
                
                // Stop processing if an error occurs
                if let error = error {
                    print("An error occurred: \(error.localizedDescription)")
                    return
                }
                // querySnapshot can be nil due to permission errors or communication errors. It is not nil when there are 0 results from whereField
                guard let snapshot = querySnapshot else{
                    print("snapshot is empty")
                    return
                }
                // addSnapshotListener fetches current data once initially and fires, so end without transition on the first load
                if self.isFirstFetch {
                    self.isFirstFetch = false
                    return
                }
                
                // Check each changed document one by one
                for change in snapshot.documentChanges {
                    // If a document was added or modified
                    if change.type == .added || change.type == .modified {
+                       // Extract (all) the contents of the document
+                       let documentData = change.document.data()
+                       // Extract value for key "text" as String; otherwise default to "no text"
+                       let fetchedText = documentData["text"] as? String ?? "no text"
+                       print("Data added or changed. Fetched text: \(fetchedText)")
-                       print("Data with status == pending was added or changed")
+                       // Put the extracted text into the variable on the main thread
                        DispatchQueue.main.async {
+                           self.transitionData = TransitionData(text: fetchedText)
-                           self.flagTransition = true
                        }
                    }
                }
            }
    }
    // Stop listening and return to initial state
    func stopListening(){
        listener?.remove()
        listener = nil
        isFirstFetch = true
    }
}

Previously, we used a flag for screen transition, but this time we will rewrite it to trigger the transition when the variable is no longer nil.

ContentView.swift
import SwiftUI

struct ContentView: View {
    // The class we just created to hold the Firestore monitoring state
    @StateObject private var firestoreListener = FirestoreListener()
    
    var body: some View {
        NavigationStack{
            VStack(spacing: 20){
                ProgressView()
                    .controlSize(.large)
                Text("Monitoring Firestore...")
                    .font(.headline)
            }
            .navigationTitle("Home")
+           // Transition to SuccessView when data enters transitionData
+           .navigationDestination(item: $firestoreListener.transitionData){data in
+               SuccessView(receivedText: data.text)
+           }
-           // Transition to SuccessView when flagTransition becomes true
-           .navigationDestination(isPresented: $firestoreListener.flagTransition){
-               SuccessView()
-           }
            .onAppear{
                // Start listening as soon as the screen appears
                firestoreListener.startListening()
            }
            .onDisappear{
                // Stop listening when switching to another screen
                firestoreListener.stopListening()
            }
        }
    }
}


// Destination screen (can be in a separate file)
struct SuccessView: View {
+   // Variable to receive the text
+   let receivedText: String
    var body: some View {
        VStack{
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 60))
                .symbolEffect(.breathe)
                .foregroundStyle(.green)
            Text("Update Detected")
                .font(.title)
                .padding()
+           // Display the received text
+           Text(receivedText)
+               .font(.title)
+               .bold()
+               .padding()
+               .background(Color.gray.opacity(0.1))
+               .cornerRadius(10)
        }
        .navigationTitle("Success")
    }
}

// For preview
#Preview {
    ContentView()
}

Quick check of operations

  1. Display the preview on Xcode (or run it in a simulator) and keep the "Monitoring Firestore..." screen visible.
  2. In the Firebase console in your browser, add a new document to collection1 and set the fields status : "hoge" and text : fuga, then save it.
  3. If the screen changes to "Update Detected" and fuga is displayed, it's successful.
    Appearance of the text field value being displayed on the destination screen

Updating Firestore data when a button is pressed on the destination screen

We will add a "Confirm" button to the destination screen, and when pressed, we will add a process to change the Firestore status field from hoge to completed.
To identify the document that needs to be updated at the destination, we will add a process to pass the document ID to the destination screen as well.
As a bonus, since document IDs are unique, we will use them instead of a UUID.

FirestoreListener.swift (Excerpt)
struct TransitionData: Identifiable, Hashable {
-   let id = UUID()
+   let id: String
    let text: String
}
FirestoreListener.swift (Excerpt)
for change in snapshot.documentChanges {
    if change.type == .added || change.type == .modified {
        
        let documentData = change.document.data()
        let fetchedText = documentData["text"] as? String ?? "no text"
        print("Data added or changed. Fetched text: \(fetchedText)")
+       // Get the document ID
+       let docId = change.document.documentID 
        
        DispatchQueue.main.async {
+           // Put the document ID along with the text into the variable
+           self.transitionData = TransitionData(id: docId, text: fetchedText)
-           self.transitionData = TransitionData(text: fetchedText)
        }
    }
}

Add the logic to the View to manipulate Firestore as well, changing the contents of the document specified by the document ID when the button is pressed.

ContentView.swift
import SwiftUI
+import FirebaseFirestore

struct ContentView: View {
    // The class we just created to hold the Firestore monitoring state
    @StateObject private var firestoreListener = FirestoreListener()
    
    var body: some View {
        NavigationStack{
            VStack(spacing: 20){
                ProgressView()
                    .controlSize(.large)
                Text("Monitoring Firestore...")
                    .font(.headline)
            }
            .navigationTitle("Home")
           // Transition to SuccessView when data enters transitionData
          .navigationDestination(item: $firestoreListener.transitionData){data in
+               // Pass the whole thing
+               SuccessView(receivedData: data)
-               SuccessView(receivedText: data.text)
            }
            .onAppear{
                // Start listening as soon as the screen appears
                firestoreListener.startListening()
            }
            .onDisappear{
                // Stop listening when switching to another screen
                firestoreListener.stopListening()
            }
        }
    }
}


// Destination screen (can be in a separate file)
struct SuccessView: View {
+   // Receive everything
+   let receivedData: TransitionData
-   let receivedText: String
    // Automatically return to the previous screen when the button is pressed
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack{
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 60))
                .symbolEffect(.breathe)
                .foregroundStyle(.green)
            Text("Update Detected")
                .font(.title)
                .padding()
           // Extract and display the text from the received data
+           Text(receivedData.text)
-           Text(receivedText)
               .font(.title)
               .bold()
               .padding()
               .background(Color.gray.opacity(0.1))
               .cornerRadius(10)
        }
        .navigationTitle("Success")
+       // Confirm button
+       Button(action: {
+           // Execute function when button is pressed
+           updateStatusToCompleted()
+       }){
+           Text("Confirm")
+               .font(.headline)
+               .foregroundStyle(.white)
+               .padding()
+               .frame(maxWidth: .infinity)
+               .background(Color.blue)
+               .cornerRadius(10)
+       }
    }
    
+   // Function to change the document status field to completed
+   private func updateStatusToCompleted(){
+       // Action when button is pressed
+       let db = Firestore.firestore()
+       db.collection("collection1")    // Specify the monitored collection
+           .document(receivedData.id)  // Specify the update target by document ID
+           .updateData([
+               "status": "completed"   // Update status
+           ]){error in
+               if let error = error{
+                   print("Update error: \(error.localizedDescription)")
+               } else {
+                   print("Status updated → completed")
+                   // Return to the previous screen after a successful update
+                   dismiss()
+               }
+           }
+   }
}

// For preview
#Preview {
    ContentView()
}

Quick check of operations

  1. Display the preview on Xcode (or run it in a simulator) and keep the "Monitoring Firestore..." screen visible.
  2. In the Firebase console in your browser, add a new document to collection1 and set the fields status : "hoge" and text : fuga, then save it.
  3. The screen changes to "Update Detected" and fuga is displayed.
  4. It is successful if you can confirm on the Firebase console that the field has been modified after pressing the "Confirm" button.
    Screen transition -> Confirm button press -> Resuming monitoring

Closing

There are many points to correct when using this in a real-world application, but I'm leaving it here as a note for now.

Discussion