3

I know there are a couple questions with similar structure but I am really struggling on how to make my code which calls the method that retrieves the Firestore data, wait until the asynchronous function is complete before returning.

At the moment I have the parent function which calls getPledgesInProgress, the method which retrieves the data from firebase. This method is called when the parent is initialised.

    @State var pledgesInProgress = [Pledge]()
    var pledgePicked: Pledge?

    var body: some View {
        VStack{
       
           //LOTS OF CODE HERE WHICH ISN'T RELEVANT
    }
    
    func initVars(){
        pledgesInProgress = getPledgesInProgress(pledgePicked: pledgePicked ?? emptyPledge)
    }
}

The issue is that the pledgesInProgress variable gets initialised with an empty array, because the parent doesn't wait until the called function is finished getting the Firestore documents before continuing.

func getPledgesInProgress(pledgePicked: Pledge)-> [Pledge]{
 
    let db = Firestore.firestore()
    var pledgesToReturn = [Pledge]() //INITIALISED AS EMPTY

  
   db.collection("Pledges")
      .getDocuments { (snapshot, error) in
         guard let snapshot = snapshot, error == nil else {
          //handle error
          return
        }
  
        snapshot.documents.forEach({ (documentSnapshot) in

          let documentData = documentSnapshot.data()
                pledgesToReturn.append(findPledgeWithThisID(ID: documentData["ID"] as! Int))
        })
      
      }

//PROBLEM!!!! returned before getDocuments() completed
return pledgedToReturn 
}

The issue is the the array pledgesToReturn is returned before the getDocuments() method is finished, and so it is returned as an empty array every time. Please can someone help me understand how to get the method to wait until this call has completed? Thanks

N.B Pledge is a custom data type but it doesn't matter, it's about understanding how to wait until the asynchronous function is completed. You can replace the Pledge data type with anything you like and it will still be the same principle

AAH
  • 99
  • 1
  • 10
  • you don't want to "wait" unless you are using iOS 15's new `async await`. you should put `pledgesToReturn` outside in an `@Published` so it gets updated and refreshes the `View` when it is done – lorem ipsum Jun 19 '21 at 13:16
  • use completion handler https://stackoverflow.com/a/67456200/14733292 – Raja Kishan Jun 19 '21 at 13:17
  • @loremipsum would it be the `pledgesInProgress` array I would want to be published? This is the one I am assigning the returned values to in the calling function, and will be using further on in the code? – AAH Jun 19 '21 at 13:19
  • no that would go away because you would reference the variable that is being filled directly. You haven't shown all your code but I assume your method is in a ViewModel just reference the variable in the ViewModel – lorem ipsum Jun 19 '21 at 13:25
  • You can call it whatever you want the name doesn't matter the point is that your method should update a SwiftUI wrapper variable so the View can get the updates – lorem ipsum Jun 19 '21 at 13:27

1 Answers1

11

Firebaser here. First, please know most (!) Firebase APIs are asynchronous, and require you to use completion handlers to receive the results. This will become easier with the general availability of async/await, which will enable you to write straight-line code. I recorded a video about this a while ago - check it out to get an idea of how you will be able to use Firebase with Swift 5.5.

There are two ways to solve this: using completion handlers or using async/await. I will describe both below, but please note async/await is only available in Swift 5.5 and requires iOS 15, so you might want to opt for completion handlers if you're working on an app that you want to ship to users that use iOS 14.x and below.

Using completion handlers

This is the current way of handling results from Firebase APIs.

Here is an updated version of your code, making use of completion handlers:

func getPledgesInProgress(pledgePicked: Pledge, completionHandler: ([Pledges]) -> Void) {
 
  let db = Firestore.firestore()
  var pledgesToReturn = [Pledge]() //INITIALISED AS EMPTY

  
  db.collection("Pledges")
    .getDocuments { (snapshot, error) in
    guard let snapshot = snapshot, error == nil else {
      //handle error
      return
    }
  
    snapshot.documents.forEach({ (documentSnapshot) in
      let documentData = documentSnapshot.data()
      pledgesToReturn.append(findPledgeWithThisID(ID: documentData["ID"] as! Int))
    })

      // call the completion handler and pass the result array
      completionHandler(pledgesToReturn]
  }
}

And here is the view:


struct PledgesInProgress: View {
  @State var pledgesInProgress = [Pledge]()
  var pledgePicked: Pledge?

  var body: some View {
    VStack {       
      // LOTS OF CODE HERE WHICH ISN'T RELEVANT
    }
    .onAppear {
      getPledgesInProgress(pledgePicked: pledgePicked) { pledges in
        self.pledgesPicked = pledges
      }
    }
  }    
}

Using async/await (Swift 5.5)

Your original code it pretty close to the code for an async/await implementation. The main things to keep in mind are:

  1. mark all asynchronous functions as async
  2. call all asynchronous functions using await
  3. (if you're using view models) mark your view model as @MainActor
  4. to call async code from within SwiftUI, you can either use the task view modifier to execute your code when the view appears. Or, if your want to call from a button handler or another synchronous context, wrap the call inside async { await callYourAsyncFunction() }.

To learn more about this, check out my article Getting Started with async/await in SwiftUI (video coming soon). I've got an article about async/await and Firestore in the pipeline - once it goes live, I will update this answer.

func getPledgesInProgress(pledgePicked: Pledge) async -> [Pledges] {
 
  let db = Firestore.firestore()
  var pledgesToReturn = [Pledge]() //INITIALISED AS EMPTY

  let snapshot = try await db.collection("Pledges").getDocuments()
  snapshot.documents.forEach { documentSnapshot in
    let documentData = documentSnapshot.data()
    pledgesToReturn.append(findPledgeWithThisID(ID: documentData["ID"] as! Int))
  }

  // async/await allows you to return the result just like in a normal function:
  return pledgesToReturn
  }
}

And here is the view:


struct PledgesInProgress: View {
  @State var pledgesInProgress = [Pledge]()
  var pledgePicked: Pledge?

  var body: some View {
    VStack {       
      // LOTS OF CODE HERE WHICH ISN'T RELEVANT
    }
    .task {
      self.pledgesPicked = await getPledgesInProgress(pledgePicked: pledgePicked)
    }
  }    
}

A few general remarks

There are a couple of things to make your code more resilient:

  1. Consider using Codable to map your documents. See Mapping Firestore Data in Swift - The Comprehensive Guide, in which I explain how to map the most common data structures between Swift and Firestore.
  2. Please don't use the force unwrap operator - your app will crash if the unwrapped object is nil. In this instance, using Codable will help you to avoid using this operator, but in other cases you should consider using if let, guard let, optional unwrapping with and without default values. Check out Optionals In Swift: The Ultimate Guide – LearnAppMaking for more details.
  3. I would strongly recommend using view models to encapsulate your data access logic, especially when accessing Firestore or any other remote backend. Check out this code to see how this can be done.
Peter Friese
  • 6,709
  • 31
  • 43
  • Hi @peter-friese - is there an async/await version for `addSnapshotListener`? Can't seem to find it in the documentation. – Marcio Cabral Apr 03 '23 at 21:55
  • 1
    There is not, as the closure of the the snapshot listener will be called whenever there is an update to the query it is installed on for the entire lifetime of the listener. This is different from a normal async/await call that will return exactly _once_. – Peter Friese Apr 04 '23 at 13:14