-
Notifications
You must be signed in to change notification settings - Fork 992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UIViewRepresentable for embedded #4520
Conversation
} | ||
} | ||
|
||
extension UIViewController { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested this VC stuff out in a very barebones SwiftUI app and confirmed it works. https://github.com/porter-stripe/swift-ui-embedded/tree/main
@@ -74,7 +74,7 @@ class EmbeddedPaymentMethodsViewSnapshotTests: STPSnapshotTestCase { | |||
appearance.embeddedPaymentElement.row.additionalInsets = 20 | |||
|
|||
let embeddedView = EmbeddedPaymentMethodsView(initialSelection: nil, | |||
paymentMethodTypes: [.stripe(.card), .stripe(.cashApp), .stripe(.klarna)], | |||
paymentMethodTypes: [.stripe(.card), .stripe(.cashApp), .stripe(.afterpayClearpay)], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Context on this change: https://stripe.slack.com/archives/C02HQBW38JX/p1738343322384659
private func calculateAndPublishHeight() { | ||
guard let embeddedPaymentElement else { return } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: take an embeddedPaymentElement
and we can remove the need to think (eg wonder how we end up in that situation, if we should assert, etc) about it being nil.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, nice!
} | ||
|
||
extension UIViewController { | ||
var topMostViewController: UIViewController { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have a couple methods already that do this (search "topmost" in the repo), should we use one of them?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I tried the one in StripeUICore, but it actually doesn't work, it can't find the topmost VC and hits an assert that we do not have a presenting VC. We have a few others in FinancialConnections and one in the PaymentSheet+SwiftUI. All three of these use pretty much the same logic as the StripeUICore one. They don't seem to account for NavigationControllers or TabBarControllers, which may be the issue.
I think it'd be best to consolidate all our logic for this kind of work, but I'm a little worried about changing it for the others in this PR and verifying we didn't break anything because I'm not sure of the context they're being used in. Apparently, they work for their use cases, but makes me question if they actually work given they don't work in our example app. Maybe a run ticket to investigate the others and see if they can be updated safely? WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, but can you own following up? I want to try and leave less tech debt behind in projects and I'm not sure there's a reason to punt on this one. Do we not know why this method works but the StripeUICore one doesn't?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on quick debugging, it appears that failing to handle the NavigationController case is causing the failure.
if let nav = self as? UINavigationController {
// Use visibleViewController for navigation stacks
return nav.visibleViewController?.topMostViewController ?? nav
I updated the SwiftUICore implementation to handle this case now, I did a quick test of everywhere that calls this function and things appear fine.
|
||
public var body: some View { | ||
EmbeddedViewRepresentable(viewModel: viewModel) | ||
.frame(height: viewModel.height) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious to understand this better (is this the standard way to deal with UIViews that can change size? what happens if you don't do this?), maybe we can chat live.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you remove this line SwiftUI thinks our view has a height of zero, so the layout of things is totally busted! SwiftUI doesn't seem to play very nice when it comes to view sizing and AutoLayout. It looks at the intrinsicContentSize of the view to determine the height (if you don't set it explicitly like we're doing). The intrinsicContentSize of our embedded view always has a height of zero, I believe this is b/c we use AutoLayout to handle the height. The width is computed correctly on the intrinsicContentSize but height is always zero.
An alternative approach would be to define the intrinsicContentSize on the container view and give it a non-zero height, we could compute the height the same way we currently do. This works and the view sizes itself correctly, however the tradeoff becomes that there doesn't seem to be a way to animate these height changes smoothly. The view jumps between heights.
Those are the two standard ways I've found, here's someone proposing what I am for a similar question. What I have in the PR seems to be the most reliable from what I have found, providing smooth animations.
There is one another option sizeThatFits(_:uiView:context:). But requires iOS 16+, and I haven't found it to be super easy to use, mainly due to the "proposed size" being somewhat confusing/not explained well in the docs. We can chat more about it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's what sizeThatFits(_:uiView:context:)
look like, then we remove the .height
you've flagged. The implementation is a more complex IMO, but maybe there's a better way to do it. This gives us a nice animation too.
@available(iOS 16.0, *)
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? {
guard let embeddedView = uiView.subviews.first else {
return nil
}
// Create a target size from the proposal.
// If no width or height is provided, fall back to UIView's compressed size.
let targetSize = CGSize(
width: proposal.width ?? UIView.layoutFittingCompressedSize.width,
height: UIView.layoutFittingCompressedSize.height
)
// Determine fitting priorities:
// If a dimension isn’t specified, use a lower priority to allow flexibility.
let horizontalPriority: UILayoutPriority = proposal.width == nil ? .defaultLow : .required
let verticalPriority: UILayoutPriority = proposal.height == nil ? .defaultLow : .required
// Use the embedded view's Auto Layout to determine its optimal size.
return embeddedView.systemLayoutSizeFitting(
targetSize,
withHorizontalFittingPriority: horizontalPriority,
verticalFittingPriority: verticalPriority
)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the details! Our view should have a valid intrinsicContentSize
but interestingly it sounds like that makes SwiftUI automatically lay out the superview without animation. Is that right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, intrinsicContentSize seems not viable due to animation limitations. Given that, I think it's between what is proposed in the PR or the iOS 16 approach commented above.
With intrinsicContentSize our view does resize properly, but I can't find any way to make it animate smoothly. It's abrupt height changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'm leaning towards the iOS 14 compatible approach, the implementation is easier to understand IMO. Lmk what you think, we can also discuss during co-working hours too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep agreed. It's a shame that correctly defining intrinsicContentSize on our embedded UIKit view causes SwiftUI to automatically lay out when it changes (good) but not animate with no way to make it animated.
We should override intrinsicContentSize
in our embedded view, make it return super.intrinsicContentSize, and add a comment explaining why we can't actually implement it correctly. O/w some enterprising future Stripe might implement it and not realize.
cc @davidme-stripe fyi
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, I added the comment in both the EmbeddedPaymentMethodsView and EmbeddedPaymentElementContainerView.
I also confirmed our snapshot tests added in this PR would catch this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh woah how does the snapshot test catch it? I'd expect the final snapshot to be the same, regardless of whether it was animated or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh you're probably right, I hardcoded in a bad size of 200x200 to test it, but if it had the actual "correct" size it probably wouldn't catch it!
|
||
init(viewModel: EmbeddedPaymentElementViewModel) { | ||
self.viewModel = viewModel | ||
STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: EmbeddedSwiftUIProduct.self) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why add it here vs the EmbeddedPaymentElementViewModel
init?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I very slightly prefer to put this here, since it's an internal class, and keep the public EmbeddedPaymentElementViewModel init empty. In an effort to keep our public classes a bit cleaner in case anyone is looking through them, kinda like EmbeddedPaymentElement+Internal motivation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. I haven't really thought about that before. Like, a developer is looking at our source code but doesn't want to see our implementation?
I think conceptually it makes more sense in the EmbeddedPaymentElementViewModel
init, since that is the class that best maps to the product. It's possible for a checkout to use Embedded without ever initializing this view if they have a FlowController-style UX and the customer never changes their payment method, in which case we'd currently fail to report product usage.
Putting it in EmbeddedPaymentElementViewModel
also lets us get rid of the EmbeddedSwiftUIProduct
workaround class, too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I see, I'll move it.
containerView.backgroundColor = .clear | ||
containerView.layoutMargins = .zero | ||
|
||
guard let embeddedPaymentElement = viewModel.embeddedPaymentElement else { return containerView } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's a good thought. I think it should! This case should only happen if you show the EmbeddedPaymentElementView in your SwiftUI view before isLoaded is true on the view model. According to the docs makeUIView is only ever called once, so this looks safe and appears so from testing in an example integration.
// MARK: UIWindow and UIViewController helpers | ||
|
||
extension UIWindow { | ||
static var current: UIWindow? { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one is also similar to stp_hackilyFumbleAroundUntilYouFindAKeyWindow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah didn't see that, I tested this out in our example app and in the barebones SwiftUI app and it works!
@@ -50,7 +51,9 @@ import Combine | |||
// MARK: - Public APIs | |||
|
|||
/// Creates an empty view model. Call `load` to initialize the `EmbeddedPaymentElementViewModel` | |||
public init() {} | |||
public init() { | |||
STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: SwiftUIProduct.self) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's pass self and get rid of SwiftUIProduct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 👍
...et/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementContainerView.swift
Outdated
Show resolved
Hide resolved
...aymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentMethodsView.swift
Outdated
Show resolved
Hide resolved
…dded/EmbeddedPaymentElementContainerView.swift Co-authored-by: Yuki <[email protected]>
…dded/EmbeddedPaymentMethodsView.swift Co-authored-by: Yuki <[email protected]>
Summary
Motivation
Testing
Changelog
N/A