diff --git a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj index f19d1cea323..b8a5518df5e 100644 --- a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj +++ b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj @@ -225,6 +225,8 @@ B63B2CF32BFBEE7B003810F3 /* VerticalPaymentMethodListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B2CF22BFBEE7B003810F3 /* VerticalPaymentMethodListViewController.swift */; }; B63B2CF52BFBEEAD003810F3 /* PaymentMethodFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B2CF42BFBEEAD003810F3 /* PaymentMethodFormViewController.swift */; }; B63B2CF72BFC116A003810F3 /* PaymentSheetVerticalViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63B2CF62BFC116A003810F3 /* PaymentSheetVerticalViewControllerSnapshotTest.swift */; }; + B63DC6782CC054410011C27E /* EmbeddedPaymentElementContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63DC6772CC0543D0011C27E /* EmbeddedPaymentElementContainerView.swift */; }; + B63DC67A2CC06AD10011C27E /* EmbeddedPaymentElementSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63DC6792CC06AC80011C27E /* EmbeddedPaymentElementSnapshotTests.swift */; }; B64FEF122C0FAC1E00F7CA26 /* PaymentSheetVerticalViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64FEF112C0FAC1E00F7CA26 /* PaymentSheetVerticalViewControllerTest.swift */; }; B65B42972C013DED00EC565D /* PaymentMethodFormViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B42962C013DED00EC565D /* PaymentMethodFormViewControllerTest.swift */; }; B65FE7092BED33EA009A73FC /* VerticalPaymentMethodListViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65FE7082BED33EA009A73FC /* VerticalPaymentMethodListViewControllerSnapshotTest.swift */; }; @@ -599,6 +601,8 @@ B63B2CF22BFBEE7B003810F3 /* VerticalPaymentMethodListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalPaymentMethodListViewController.swift; sourceTree = ""; }; B63B2CF42BFBEEAD003810F3 /* PaymentMethodFormViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentMethodFormViewController.swift; sourceTree = ""; }; B63B2CF62BFC116A003810F3 /* PaymentSheetVerticalViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentSheetVerticalViewControllerSnapshotTest.swift; sourceTree = ""; }; + B63DC6772CC0543D0011C27E /* EmbeddedPaymentElementContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPaymentElementContainerView.swift; sourceTree = ""; }; + B63DC6792CC06AC80011C27E /* EmbeddedPaymentElementSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPaymentElementSnapshotTests.swift; sourceTree = ""; }; B64FEF112C0FAC1E00F7CA26 /* PaymentSheetVerticalViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentSheetVerticalViewControllerTest.swift; sourceTree = ""; }; B65B42962C013DED00EC565D /* PaymentMethodFormViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodFormViewControllerTest.swift; sourceTree = ""; }; B65FE7082BED33EA009A73FC /* VerticalPaymentMethodListViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalPaymentMethodListViewControllerSnapshotTest.swift; sourceTree = ""; }; @@ -930,15 +934,16 @@ 6180A5C52C82434A009D1536 /* Embedded */ = { isa = PBXGroup; children = ( + 6117D70F2CB0654B005C4EC1 /* EmbeddedMandateContainerView.swift */, B615E8702CA4CBEE007D684C /* EmbeddedPaymentElement.swift */, B6CACC9D2CB8B8E800682ECE /* EmbeddedPaymentElement+Internal.swift */, B615E8722CA4CC04007D684C /* EmbeddedPaymentElementConfiguration.swift */, + B63DC6772CC0543D0011C27E /* EmbeddedPaymentElementContainerView.swift */, B615E8742CA4CC38007D684C /* EmbeddedPaymentElementDelegate.swift */, 6180A5C02C8222A9009D1536 /* EmbeddedPaymentMethodsView.swift */, + 6117D7112CB065E7005C4EC1 /* MandateTextProvider.swift */, 6180A5C62C824377009D1536 /* RadioButton.swift */, 6180A5CA2C8249D2009D1536 /* UIStackView+Separator.swift */, - 6117D7112CB065E7005C4EC1 /* MandateTextProvider.swift */, - 6117D70F2CB0654B005C4EC1 /* EmbeddedMandateContainerView.swift */, ); path = Embedded; sourceTree = ""; @@ -1430,6 +1435,7 @@ 31699A822BE183D40048677F /* DownloadManagerTest.swift */, 73FB30705EC36BD0868904A2 /* Elements+TestHelpers.swift */, B6CACC9F2CBD9A3300682ECE /* EmbeddedPaymentElementTest.swift */, + B63DC6792CC06AC80011C27E /* EmbeddedPaymentElementSnapshotTests.swift */, 61FB6BCC2C8901B200F8E074 /* EmbeddedPaymentMethodsViewSnapshotTests.swift */, 614068E12CB0BF10003D2F12 /* EmbeddedPaymentMethodsViewTests.swift */, 64C8F350CDB5A29F62E86592 /* FlowControllerStateTests.swift */, @@ -1732,6 +1738,7 @@ 10A336F0F2331F22F1A0AC1B /* LinkStubs.swift in Sources */, 61C87E1E2CB81FAD001B7DA9 /* CardBrandFilterTests.swift in Sources */, 316B33122B5F171C0008D2E5 /* UserDefaults+StripePaymentSheetTest.swift in Sources */, + B63DC67A2CC06AD10011C27E /* EmbeddedPaymentElementSnapshotTests.swift in Sources */, D77514C28C9A031908E99CA1 /* PaymentMethodMessagingViewFunctionalTest.swift in Sources */, D14478CFCABDF7455DA7472A /* PaymentMethodMessagingViewSnapshotTests.swift in Sources */, 3D3607748436E625FF6CF921 /* PaymentSheet+APITest.swift in Sources */, @@ -1900,6 +1907,7 @@ 88BA38BE8949815F4DB79509 /* PayWithLinkController.swift in Sources */, 6B28A6B62BE9494500B47DBF /* CustomerSheetDataSource.swift in Sources */, 8C91277A8FEFD0B914CC6564 /* PaymentSheet-LinkConfirmOption.swift in Sources */, + B63DC6782CC054410011C27E /* EmbeddedPaymentElementContainerView.swift in Sources */, 573E3DB554058AC1E34E34B6 /* AddPaymentMethodViewController.swift in Sources */, 8D4951AE0D793D01528F352D /* PaymentMethodTypeCollectionView.swift in Sources */, 2CE83364A23B4E3BAFD447CA /* WalletHeaderView.swift in Sources */, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift index 22636934077..5ad95f34e8e 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift @@ -17,8 +17,7 @@ public final class EmbeddedPaymentElement { /// A view that displays payment methods. It can present a sheet to collect more details or display saved payment methods. public var view: UIView { - // TODO: Make this a _container view_ so that we can swap out the inner `embeddedPaymentMethodsView` when `update` is called. - return embeddedPaymentMethodsView + return containerView } /// A view controller to present on. @@ -145,6 +144,7 @@ public final class EmbeddedPaymentElement { // At this point, we're the latest update - update self properties and inform our delegate. self.loadResult = loadResult self.embeddedPaymentMethodsView = embeddedPaymentMethodsView + self.containerView.updateEmbeddedPaymentMethodsView(embeddedPaymentMethodsView) if paymentOption != embeddedPaymentMethodsView.displayData { self.delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: self) } @@ -164,6 +164,7 @@ public final class EmbeddedPaymentElement { // MARK: - Internal + internal private(set) var containerView: EmbeddedPaymentElementContainerView internal private(set) var embeddedPaymentMethodsView: EmbeddedPaymentMethodsView internal private(set) var loadResult: PaymentSheetLoader.LoadResult internal private(set) var currentUpdateTask: Task? @@ -178,6 +179,14 @@ public final class EmbeddedPaymentElement { configuration: configuration, loadResult: loadResult ) + self.containerView = EmbeddedPaymentElementContainerView( + embeddedPaymentMethodsView: embeddedPaymentMethodsView + ) + + self.containerView.updateSuperviewHeight = { [weak self] in + guard let self else { return } + self.delegate?.embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: self) + } self.embeddedPaymentMethodsView.delegate = self } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementContainerView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementContainerView.swift new file mode 100644 index 00000000000..1e147da327f --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementContainerView.swift @@ -0,0 +1,78 @@ +// +// EmbeddedPaymentElementContainerView.swift +// StripePaymentSheet +// +// Created by Yuki Tokuhiro on 10/16/24. +// +import UIKit + +/// The view that's vended to the merchant, containing the embedded view. We use this to be able to swap out the embedded view with an animation when `update` is called. +class EmbeddedPaymentElementContainerView: UIView { + var updateSuperviewHeight: () -> Void = {} + private var view: EmbeddedPaymentMethodsView + private var bottomAnchorConstraint: NSLayoutConstraint! + + init(embeddedPaymentMethodsView: EmbeddedPaymentMethodsView) { + self.view = embeddedPaymentMethodsView + super.init(frame: .zero) + addInitialView(view) + } + + required init?(coder: NSCoder) { + fatalError() + } + + private func addInitialView(_ view: UIView) { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + bottomAnchorConstraint = view.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + view.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + view.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + bottomAnchorConstraint, + ]) + } + + func updateEmbeddedPaymentMethodsView(_ embeddedPaymentMethodsView: EmbeddedPaymentMethodsView) { + guard frame.size != .zero else { + // A zero frame means we haven't been laid out yet. Simply replace the old view to avoid laying out before the view is ready and breaking constraints. + self.view.removeFromSuperview() + self.view = embeddedPaymentMethodsView + addInitialView(embeddedPaymentMethodsView) + return + } + let oldView = view + let oldViewHeight = frame.height + // Add the new view w/ 0 alpha + embeddedPaymentMethodsView.translatesAutoresizingMaskIntoConstraints = false + addSubview(embeddedPaymentMethodsView) + view = embeddedPaymentMethodsView + embeddedPaymentMethodsView.alpha = 0 + NSLayoutConstraint.activate([ + embeddedPaymentMethodsView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + embeddedPaymentMethodsView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + embeddedPaymentMethodsView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + // Omit the bottom anchor so that the height is still fixed to the old view height + ]) + // important that the view is already laid out before the animation block so that it doesn't animate from zero size. + layoutIfNeeded() + + UIView.animate(withDuration: 0.2) { + // Re-pin bottom anchor to the new view, thus updating our height + self.bottomAnchorConstraint.isActive = false + self.bottomAnchorConstraint = embeddedPaymentMethodsView.bottomAnchor.constraint(equalTo: self.layoutMarginsGuide.bottomAnchor) + self.bottomAnchorConstraint.isActive = true + self.layoutIfNeeded() + // Fade old view out and new view in + oldView.alpha = 0 + embeddedPaymentMethodsView.alpha = 1 + if oldViewHeight != self.systemLayoutSizeFitting(.zero).height { + // Invoke EmbeddedPaymentElement delegate method so that height does not jump + self.updateSuperviewHeight() + } + } completion: { _ in + oldView.removeFromSuperview() + } + } +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementSnapshotTests.swift new file mode 100644 index 00000000000..803d5ddd2c5 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementSnapshotTests.swift @@ -0,0 +1,52 @@ +// +// EmbeddedPaymentElementSnapshotTests.swift +// StripePaymentSheet +// +// Created by Yuki Tokuhiro on 10/16/24. +// + +import StripeCoreTestUtils +@_spi(STP) @testable import StripePayments +@_spi(EmbeddedPaymentElementPrivateBeta) @testable import StripePaymentSheet +@testable import StripePaymentsTestUtils +@_spi(STP) @testable import StripeUICore +import XCTest + +class EmbeddedPaymentElementSnapshotTests: STPSnapshotTestCase, EmbeddedPaymentElementDelegate { + var delegateDidUpdateHeightCalled: Bool = false + var delegateDidUpdatePaymentOptionCalled: Bool = false + func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { + self.delegateDidUpdateHeightCalled = true + } + + func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { + self.delegateDidUpdatePaymentOptionCalled = true + } + + lazy var configuration: EmbeddedPaymentElement.Configuration = { + var config = EmbeddedPaymentElement.Configuration._testValue_MostPermissive(isApplePayEnabled: false) + config.apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + return config + }() + let paymentIntentConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .payment(amount: 1000, currency: "USD"), paymentMethodTypes: ["card"]) { _, _, _ in + // These tests don't confirm, so this is unused + } + let setupIntentConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .setup(setupFutureUsage: .offSession), paymentMethodTypes: ["card", "us_bank_account"]) { _, _, _ in + // These tests don't confirm, so this is unused + } + + func testUpdateFromCardToCardAndUSBankAccount() async throws { + // Given a EmbeddedPaymentElement instance... + let sut = try await EmbeddedPaymentElement.create(intentConfiguration: paymentIntentConfig, configuration: configuration) + sut.delegate = self + sut.view.autosizeHeight(width: 300) + + let loadResult = await sut.update(intentConfiguration: setupIntentConfig) + XCTAssertEqual(loadResult, .succeeded) + sut.view.autosizeHeight(width: 300) + + STPSnapshotVerifyView(sut.view) // Should show US Bank and card + XCTAssertTrue(delegateDidUpdateHeightCalled) + XCTAssertFalse(delegateDidUpdatePaymentOptionCalled) + } +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift index 0703a2593a6..2c268b479c0 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift @@ -112,7 +112,6 @@ class EmbeddedPaymentElementTest: STPNetworkStubbingTestCase { XCTAssertEqual(updateResult2, .succeeded) XCTAssertTrue(sut.loadResult.intent.isSettingUp) } - } extension EmbeddedPaymentElementTest: EmbeddedPaymentElementDelegate { diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.EmbeddedPaymentElementSnapshotTests/testUpdateFromCardToCardAndUSBankAccountWithCompletionHandler_@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.EmbeddedPaymentElementSnapshotTests/testUpdateFromCardToCardAndUSBankAccountWithCompletionHandler_@3x.png new file mode 100644 index 00000000000..cf39470bb41 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.EmbeddedPaymentElementSnapshotTests/testUpdateFromCardToCardAndUSBankAccountWithCompletionHandler_@3x.png differ