stripe-ios icon indicating copy to clipboard operation
stripe-ios copied to clipboard

[BUG] PaymentSheet not presenting in SwiftUI

Open DevonAllary opened this issue 2 years ago • 15 comments

Summary

I have a list view in SwiftUI with many line items and I want to show a "Pay" button for each row that, when clicked, would generate and present a PaymentSheet bottom view. However, when I create the payment sheet and set it to isPresented, the modal doesn't appear.

I also don't want to generate a paymentIntent when the view loads because I don't know which item the client is purchasing. All the examples I've found through Stripe have the paymentIntent being generated when the view loads.

I've created an example with the basic functionality I'm looking for - the use case in my actual app is slightly different. I have a button that generates a paymentIntent from my server. However, the PaymentSheet isn't being presented.

Code to reproduce

import SwiftUI
import Stripe
import FirebaseFunctions

class ItemCheckoutViewModel: ObservableObject {
    @Published var paymentSheet: PaymentSheet?
    @Published var paymentResult: PaymentSheetResult?
    @Published var showPaymentSheet = false
    var stripePublishableKey = "[Redacted]"
    lazy var functions = Functions.functions()
    
    @MainActor
    func preparePaymentSheet(for item: ProductItem) async {
        do {
            let result = try await functions.httpsCallable("getStripeIntent").call([
                "itemId": item.id
            ])
            if let data = result.data as? [String: Any] {
                guard
                    let customerId = data["customer"] as? String,
                    let customerEphemeralKeySecret = data["ephemeralKey"] as? String,
                    let paymentIntentClientSecret = data["paymentIntent"] as? String
                else {
                    return
                }
                STPAPIClient.shared.publishableKey = stripePublishableKey
                var configuration = PaymentSheet.Configuration()
                configuration.merchantDisplayName = "Test"
                configuration.customer = .init(id: customerId, ephemeralKeySecret: customerEphemeralKeySecret)
                configuration.allowsDelayedPaymentMethods = true
                self.paymentSheet = PaymentSheet(paymentIntentClientSecret: paymentIntentClientSecret, configuration: configuration)
                self.showPaymentSheet = true
            }
            
        } catch {
            print(error)
        }
    }
    func handlePaymentCompletion(result: PaymentSheetResult) {
        //
    }
}

struct ProductItem: Identifiable, Codable {
    var id: String
    var title: String
    var amount: Int
}

struct PaymentSheetView: View {
    @StateObject var viewModel = ItemCheckoutViewModel()
    var items: [ProductItem]
    
    @ViewBuilder func itemRowView(item: ProductItem) -> some View {
        HStack {
            Text(item.title)
            Spacer()
            Button {
                Task {
                    await viewModel.preparePaymentSheet(for: item)
                }
            } label: {
                Text("Pay \(String(format: "$%.2f", item.amount/100))")
            }
        }
    }
    var body: some View {
        List(items) { item in
            itemRowView(item: item)
        }
        if let paymentSheet = viewModel.paymentSheet {
            EmptyView()
                .paymentSheet(isPresented: $viewModel.showPaymentSheet,
                              paymentSheet: paymentSheet,
                              onCompletion: viewModel.handlePaymentCompletion)
        }
    }
}

iOS version

16.1

Installation method

SPM

SDK version

22.8.4

DevonAllary avatar Jan 24 '23 20:01 DevonAllary

Hello @DevonAllary,

Thanks for writing in. I'm not yet sure how to fix the issue you've raised, but in the mean time -

I also don't want to generate a paymentIntent when the view loads because I don't know which item the client is purchasing. All the examples I've found through Stripe have the paymentIntent being generated when the view loads.

You can defer the creation of the PaymentIntent and PaymentSheet until the time the client taps your "Pay" button, as I think you're doing in the example code.

yuki-stripe avatar Feb 01 '23 20:02 yuki-stripe

Thanks for getting back to me, @yuki-stripe. The code is generating the PaymentIntent and creating the PaymentSheet when the "Buy" button is pressed (I've confirmed this through the debugger and logs). The only issue is actually updating the view. For some reason, the payment sheet isn't rendering to the bottom sheet even though the isPresented binding is set to true and the payment sheet is non-nil. The viewModel.paymentSheet nil check isn't the problem because I can confirm that clause is being rendered after I construct the paymentSheet.

I inspected the PaymentSheet.PaymentButton and it looks like it's just a wrapper around the PaymentSheet that presents when the the button is tapped and the isPresented flag is set to true so I expected my implement to work similarly.

DevonAllary avatar Feb 02 '23 00:02 DevonAllary

@DevonAllary were you able to resolve this while maintaining the approach ?

kibettheophilus avatar Jun 07 '23 13:06 kibettheophilus

This issue persists for me on the latest versions of the Stripe SDK (23.9.0). The hack I'm using is to delay toggling the PaymentSheet's isPresented binding by a fraction of a second as seen in this screenshot: Screenshot 2023-06-09 at 1 14 52 PM

If I remove the delay, the payment sheet never appears and Xcode says: PaymentSheet+SwiftUI.swift:242 Modifying state during view update, this will cause undefined behavior. Screenshot: Screenshot 2023-06-09 at 1 16 11 PM

It'd be great to get this sorted out since I worry that my hack may stop working for reasons I don't understand. Happy to provide any additional information that might help.

JUSTINMKAUFMAN avatar Jun 09 '23 20:06 JUSTINMKAUFMAN

@JUSTINMKAUFMAN Thanks for this, I will give a try.

kibettheophilus avatar Jun 09 '23 20:06 kibettheophilus

Any updates on this?

varghesevisal avatar Jul 20 '23 06:07 varghesevisal

When I'm using EmptyView() the sheet is not working. Looks like EmptyView() does not support sheet modifier or so. Does not look like Stripe SDK bug. This worked for me without any delays

if let paymentSheet = shoppingCart.paymentSheet {
    Spacer()
        .frame(height: 0)
        .paymentSheet(
            isPresented: $shoppingCart.isCheckoutReady,
            paymentSheet: paymentSheet,
            onCompletion: shoppingCart.onCompletion(result:)
        )
}

@yuki-stripe would be very handy if the sheet accept optional PaymentSheet?

dneykov avatar Sep 12 '23 07:09 dneykov

Noticed same issue. Spacer() or other views did not work, delay did. I also wanted to get rid of the PaymentSheet.PaymentButton for the same reasons as the author (have a button that makes the post call via a ViewModel and returns back the result as an observable and I have this logic common as it is a KMM project). The way Android works is a different in the sense that you do not need to play with a @Published var showPaymentSheet = false and you can just call

paymentSheet.presentWithPaymentIntent(
        paymentIntentClientSecret =  ...,
        configuration = PaymentSheet.Configuration()
        )
    )

and from my understanding it will start a new activity. Running on 23.16.0

ChristoferAlexander avatar Sep 28 '23 15:09 ChristoferAlexander

I finally had some time to look into this more - very sorry for the delay here. I see .paymentSheet(..) has some issues when it's first initialized with isPresented = true and when it's called on an EmptyView().

If anyone's still having problems, could you try the below approach and report back? This lets you call PaymentSheet's present API directly with a view controller rather than using our .paymentSheet(...) view modifiers.

  1. Add this code somewhere in your project
 /// Adding this to a SwiftUI view causes its `viewController` to be added as a child view controller and able to present.
 struct ViewControllerProvider: UIViewControllerRepresentable {
    let viewController = UIViewController()
    
    func makeUIViewController(context: Context) -> some UIViewController {
        return viewController
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
    
    init() {}
}
  1. Add an instance of ViewControllerProvider somewhere in your SwiftUI checkout screen. All it does is get SwiftUI to add its viewController property as a child view controller, allowing you to present on top of it later.

  2. Present PaymentSheet directly using the ViewControllerProvider's viewController.

Example:

struct MyCheckoutView: View {
    let viewControllerProvider = ViewControllerProvider()

    var body: some View {
        Button(action: {
            // ⭐️ Present PaymentSheet directly
            paymentSheet.present(from: paymentSheetPresenter.viewController, completion: model.onCompletion)
        }) {
            Text("Pay")
        }
        // ⭐️ Include `viewControllerProvider` somewhere on your checkout screen.
        viewControllerProvider
    }
}

yuki-stripe avatar Nov 09 '23 22:11 yuki-stripe

I have the same issue in present the sheet but they did not present if @all any one have the answer please suggest me what I can do :-

import SwiftUI import Combine import StripePaymentSheet

struct ContentView: View { @ObservedObject var model = MyBackendModel() @State private var selectedAmount: Int? @State private var showAlert = false @State private var isSheetPresented = false @State private var sheet : PaymentSheet?

var body: some View {
    VStack {
        HStack {
            AmountSelectionBox(amount: 39, isSelected: selectedAmount == 39, onTap: { selectAmount(39) })
            AmountSelectionBox(amount: 49, isSelected: selectedAmount == 49, onTap: { selectAmount(49) })
            AmountSelectionBox(amount: 59, isSelected: selectedAmount == 59, onTap: { selectAmount(59) })
        }.padding(.bottom , 100)

        Button(action: {
            if let selectedAmount = selectedAmount {
                model.preparePaymentSheet(createuserdata: PaymentReq(currency: "USD", amount: "\(selectedAmount)00"))

            
                    isSheetPresented =  true
                
            } else {
                showAlert = true
            }
        }) {
            Text("Buy")
        }.alert(isPresented: $showAlert) {
            Alert(title: Text("Error"), message: Text("Please select the amount."), dismissButton: .default(Text("OK")))
        }
        if let paymentSheet = model.paymentSheet{
            Spacer()
                .frame(height: 0)
                .paymentSheet(isPresented: $isSheetPresented, paymentSheet: paymentSheet, onCompletion: model.onPaymentCompletion)
       
        }
    
 
      
        if let result = model.paymentResult {
            switch result {
            case .completed:
                Text("Payment complete")
            case .failed(let error):
                Text("Payment failed: \(error.localizedDescription)")
            case .canceled:
                Text("Payment canceled.")
            }
        }
    }.onAppear {
        model.preparePaymentSheet(createuserdata: PaymentReq(currency: "USD", amount: "5800"))
    }
}

private func selectAmount(_ amount: Int) {
    selectedAmount = amount
}

}

#Preview { ContentView() }

// // PaymentIntent.swift // iOSPaymentGatway // // Created by John on 15/11/23. //

import Foundation import SwiftUI import Combine import StripePaymentSheet

class MyBackendModel: ObservableObject {

@Published var paymentSheet :  PaymentSheet?

@Published var paymentResult: PaymentSheetResult?
@Published var paymentintent: [String: Any] = [:]
@Published var appResponse: AppResponse = AppResponse()
private var apiService = APIService()
@Published var isLoading = false
var cancellables = Set<AnyCancellable>()
@Published var error: Error?
@Published var client_Secret: String?
@Published var isSelected = false



func preparePaymentSheet(createuserdata: PaymentReq) {
  
       apiService.apiHandler(endpoint: "payment_intents", parameters: createuserdata, method: .post, objectType: createuser.self)
           .sink(receiveCompletion: { [weak self] completion in
               switch completion {
               case .finished:
                   break
               case .failure(let error):
                
                   self?.error = error
               }
           }, receiveValue: { [weak self] res in
           
               if let clientSecret = res.client_secret {
                   print("Client Secret:", clientSecret)
                   self?.client_Secret = clientSecret
                   self?.isSelected = true
                   self?.makePayment()
               } else {
                   // Handle the case where client_secret is not received
                   print("Client Secret not received.")
               }
           })
           .store(in: &cancellables)
    
   }

func makePayment() {
    
    STPAPIClient.shared.publishableKey = "pk_test_51OA8V1JYZCpvm4VGYouV8l4KiQFAs7s5*****************************"
    
    var configuration = PaymentSheet.Configuration()
    configuration.merchantDisplayName = "Example, Inc."
    configuration.allowsDelayedPaymentMethods = true
    configuration.applePay = .init(merchantId: "merchant.com.iOSPaymentGatway", merchantCountryCode: "US")
    
    DispatchQueue.main.async {
        if let clientttSecret = self.client_Secret {
            self.paymentSheet = PaymentSheet(paymentIntentClientSecret: clientttSecret, configuration: configuration)
          
        }
    }
    
}

func onPaymentCompletion(result: PaymentSheetResult) {
    self.paymentResult = result
}

}

unitedapps20 avatar Nov 15 '23 12:11 unitedapps20

Any update on this?

YasirAmeen avatar Apr 06 '24 07:04 YasirAmeen

Weirdly enough, I got it working when I don't call the payment sheet inside of a navigation stack. If I call it inside of my navigation stack, it freaks out.

Michael-Espineli avatar Apr 12 '24 16:04 Michael-Espineli

Stripe 23.27.2 Xcode 15.4 iOS 17.2

Works for me with code:

`import SwiftUI import StripePaymentSheet struct ContentView: View { @ObservedObject var model = StripeHandler()

@State private var enteredNumber = ""
var enteredNumberFormatted: Double {
    return (Double(enteredNumber) ?? 0) / 100
}

var body: some View {
    VStack {
        Text("Enter the amount")
        ZStack(alignment: .center) {
            Text("$\(enteredNumberFormatted, specifier: "%.2f")").font(Font.system(size: 30))
            TextField("", text: $enteredNumber)
                .keyboardType(.numberPad)
                .foregroundColor(.clear)
                .disableAutocorrection(true)
                .accentColor(.clear)
        }
        Spacer()
        if let paymentSheet = model.paymentSheet {
            PaymentSheet.PaymentButton(
                paymentSheet: paymentSheet,
                onCompletion: model.onPaymentCompletion
            ) {
                Text("Buy")
            }
        }
    }
    .onAppear {
        model.prepareWebhook()
        model.preparePaymentSheet()
    }
    .padding(.horizontal)
    .padding(.top, 50)
    .padding(.bottom)
}

}`

YanaSychevska avatar May 15 '24 16:05 YanaSychevska

the problem was gone when I updated to the lateset version 23.29.2

MarsYoung avatar Aug 24 '24 08:08 MarsYoung