stripe / stripe-ios

Stripe iOS SDK
https://stripe.com
MIT License
2.09k stars 976 forks source link

[BUG] PaymentSheet not presenting in SwiftUI #2240

Open DevonAllary opened 1 year ago

DevonAllary commented 1 year ago

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

yuki-stripe commented 1 year ago

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.

DevonAllary commented 1 year ago

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.

kibettheophilus commented 1 year ago

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

JUSTINMKAUFMAN commented 1 year ago

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.

kibettheophilus commented 1 year ago

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

varghesevisal commented 1 year ago

Any updates on this?

dneykov commented 1 year ago

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?

ChristoferAlexander commented 11 months ago

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

yuki-stripe commented 10 months ago

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
    }
}
unitedapps20 commented 10 months ago

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
}

}

YasirAmeen commented 5 months ago

Any update on this?

Michael-Espineli commented 5 months ago

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.

YanaSychevska commented 4 months ago

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)
}

}`

MarsYoung commented 3 weeks ago

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