Search code examples
swiftswiftuiclosures

How to return a view asynchronously? What?


I have a button like this

Button(action: {
  // do something
}, label: {
  Text("The price is \(price)")
})

The first problem is that the label part of the button expects a View.

Also, the price is retrieved asynchronously from the app store using something like

func priceFor(_ productID: String,
                onConclusion:GetPriceHanler?) {
   
    inapp?.fetchProduct(productIdentifiers: [productID],
                       handler: { (result) in
      switch result {
      case .success(let products):
        let products = products
        
        guard let product = products.first
        else {
          onConclusion?("")
          return
        }
        
        onConclusion?("\(product.price)")
        
      case .failure(let error):
        print(error.localizedDescription)
        onConclusion?("")
      }
    })
  }

So, this function runs a closure when it is done.

I have thought about creating another function that returns a view to call this one... something like

@ViewBuilder
func priceViewFor(_ productID: String) -> some View { }

I am OK if the function returns the price or empty if invalid, but if I do that, I will end inside a closure and from there I cannot return a view.

Something like

@ViewBuilder
func priceViewFor(_ productID: String) -> some View {
  myAppStore.priceFor(productID) { price in
  return Text(price) ?????????????
}

How in the name of bits do I do that?


Solution

  • The basic idea is to have a @State that represents the price whether or not it has been fetched. Check this state in the label: view builder, and if it is not fetched, show something else.

    A simple design is to use an optional:

    @State var price: String?
    
    Button {
      // do something
    } label: {
        // check if the price has been fetched
        if let price {
            Text("The price is \(price)")
        } else {
            ProgressView("Loading Price...")
        }
    }
    .disabled(price == nil) // you might want to disable the button before the price has been fetched
    .onAppear {
        // set the state in the completion handler
        myAppStore.priceFor(productID) { price in
            self.price = price
        }
    }
    

    Seeing how the price-fetching could fail, I would suggest using a Result instead:

    @State var price: Result<String, Error>?
    
    switch price {
    case .none:
        ProgressView("Loading Price...")
    case .failure(let error):
        Label("Failed! (\(error))", systemImage: "xmark")
    case .success(let price):
        Text("The price is \(price)")
    }
    

    This would mean you would change priceFor so that it passes a Result<String, Error> to the onConclusion closure.

    Also, consider rewriting with the Swift Concurrency APIs. The usage would then look like:

    .task {
        do {
            price = .success(try await myAppStore.priceFor(productID))
        } catch {
            price = .failure(error)
        }
    }