Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -478,4 +478,17 @@ class PaymentSheetVerticalUITests: PaymentSheetUITestCase {
// ...should cause Alipay to no longer be the selected payment method, since it is not valid for setup.
XCTAssertEqual(app.buttons["Payment method"].label, "None")
}

func testLongList_floatingButton() {
var settings = PaymentSheetTestPlaygroundSettings.defaultValues()
settings.customerMode = .returning
settings.layout = .vertical
loadPlayground(app, settings)
app.buttons["Present PaymentSheet"].waitForExistenceAndTap()
let primaryButton = app.buttons["Pay $50.99"]
// Wait for animation to complete before checking isHittable
let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: primaryButton)
XCTAssertEqual(XCTWaiter().wait(for: [expectation], timeout: 5), .completed)
XCTAssertTrue(primaryButton.isHittable)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
private lazy var errorLabel: UILabel = {
ElementsUI.makeErrorLabel(theme: configuration.appearance.asElementsTheme)
}()
private lazy var bottomSpacer: UIView = {
let spacer = UIView()
spacer.translatesAutoresizingMaskIntoConstraints = false
let buttonHeight = primaryButton.bounds.height
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This captures the height of the primary button when bottomSpacer is first accessed, if the primaryButton hasn't been laid out yet the height will be 0 and the spacer will have the wrong height. Can you confirm the bottomSpacer is first accessed after the primaryButton has been laid out? If you're not sure, maybe try recalculating this dynamically or using a constraint approach.

let totalBottomPadding = buttonHeight + buttonSpacing - 20 // account for stackView spacing
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this -20 come from? If it's the stack view spacing, let's reference that value directly instead of hardcoding.

spacer.heightAnchor.constraint(equalToConstant: totalBottomPadding).isActive = true
return spacer
}()
let stackView: UIStackView = UIStackView()

// MARK: - Initializers
Expand Down Expand Up @@ -275,6 +283,7 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
updatePrimaryButton()
updateMandate()
updateError()
updateFloatingButton()
}

func updatePrimaryButton() {
Expand Down Expand Up @@ -329,9 +338,7 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
self.mandateView.setHiddenIfNecessary(newMandateText == nil)
let hasLabelInStackView = newMandateText != nil || self.errorLabel.text != nil
if self.isViewLoaded, hadLabelInStackView != hasLabelInStackView {
self.primaryButtonTopAnchorConstraint.isActive = false
self.primaryButtonTopAnchorConstraint = self.stackView.bottomAnchor.constraint(equalTo: self.primaryButton.topAnchor, constant: hasLabelInStackView ? -20 : -32)
self.primaryButtonTopAnchorConstraint.isActive = true
self.primaryButtonTopAnchorConstraint.constant = -self.buttonSpacing
}
}
}
Expand All @@ -353,6 +360,80 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
})
}

func updateFloatingButton() {
guard isViewLoaded, view.window != nil else {
return
}
// Skip floating button updates when the keyboard is visible
guard !isKeyboardVisible else {
return
}

if shouldButtonFloat {
activateFloatingButton()
} else {
deactivateFloatingButton()
}
}

private func activateFloatingButton() {
guard !isButtonFloating, let bottomSheet = bottomSheetController else {
return
}

NSLayoutConstraint.deactivate([
primaryButtonTopAnchorConstraint,
primaryButtonBottomConstraint,
])

// Create floating constraint
primaryButtonFloatingBottomConstraint = primaryButton.bottomAnchor.constraint(equalTo: bottomSheet.view.safeAreaLayoutGuide.bottomAnchor)
stackViewBottomConstraint = stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
stackViewBottomConstraint.priority = .defaultLow
Comment on lines +391 to +392
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this constraint needed and why .defaultLow priority?


NSLayoutConstraint.activate([
primaryButtonFloatingBottomConstraint,
stackViewBottomConstraint,
])

// Add bottom spacer to allow scrolling past content
stackView.addArrangedSubview(bottomSpacer)

UIView.animate(withDuration: 0.3) {
bottomSheet.view.layoutIfNeeded()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why in activate and deactivate floating button are we animating different views?
activateFloatingButton() animates the bottomSheet but in deactivateFloatingButton we animate self.view.

}

isButtonFloating = true
}

private func deactivateFloatingButton() {
guard isButtonFloating else {
return
}

// Remove bottom spacer when not floating
stackView.removeArrangedSubview(bottomSpacer)

// Move button back to normal position
NSLayoutConstraint.deactivate([
primaryButtonFloatingBottomConstraint,
stackViewBottomConstraint,
])

primaryButtonTopAnchorConstraint.constant = -buttonSpacing

NSLayoutConstraint.activate([
primaryButtonTopAnchorConstraint,
primaryButtonBottomConstraint,
])

UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}

isButtonFloating = false
}

/// Returns the default selected row in the vertical list - the previous payment option, the last VC's selection, or the customer's default.
func calculateInitialSelection() -> RowButtonType? {
if let previousPaymentOption {
Expand Down Expand Up @@ -494,7 +575,33 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
fatalError("init(coder:) has not been implemented")
}

var primaryButtonTopAnchorConstraint: NSLayoutConstraint!
private var stackViewBottomConstraint: NSLayoutConstraint!
private var primaryButtonTopAnchorConstraint: NSLayoutConstraint!
private var primaryButtonBottomConstraint: NSLayoutConstraint!
private var primaryButtonFloatingBottomConstraint: NSLayoutConstraint!
private var isButtonFloating: Bool = false
private var isKeyboardVisible: Bool = false
private var shouldButtonFloat: Bool {
// Only float when on the payment method list screen
guard let scrollView = bottomSheetController?.scrollView, let paymentMethodListViewController, children.contains(paymentMethodListViewController) else {
return false
}

// Only float if the top of the button in its natural position would not be visible at all
let contentHeight = scrollView.contentSize.height
let scrollViewVisibleHeight = scrollView.bounds.height

// Calculate where the button's top would be in its natural (non-floating) position
let topOfButtonInNaturalPosition = isButtonFloating ?
contentHeight - bottomSpacer.bounds.height + buttonSpacing :
contentHeight - primaryButton.bounds.height - configuration.appearance.formInsets.bottom

return topOfButtonInNaturalPosition > scrollViewVisibleHeight
}
private var buttonSpacing: CGFloat {
mandateView.attributedText == nil && errorLabel.text == nil ? 32 : 20
}

// MARK: - UIViewController Methods
override func viewDidLoad() {
super.viewDidLoad()
Expand All @@ -517,7 +624,9 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
subview.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(subview)
}
primaryButtonTopAnchorConstraint = stackView.bottomAnchor.constraint(equalTo: primaryButton.topAnchor, constant: mandateView.attributedText == nil && errorLabel.text == nil ? -32 : -20)
primaryButtonTopAnchorConstraint = stackView.bottomAnchor.constraint(equalTo: primaryButton.topAnchor, constant: -buttonSpacing)
primaryButtonBottomConstraint = primaryButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -configuration.appearance.formInsets.bottom)

NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
Expand All @@ -526,8 +635,20 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo

stackView.topAnchor.constraint(equalTo: view.topAnchor),
primaryButtonTopAnchorConstraint,
primaryButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -configuration.appearance.formInsets.bottom),
primaryButtonBottomConstraint,
])

// Observe keyboard to prevent floating state changes when the keyboard is visible
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}

@objc private func keyboardWillShow() {
isKeyboardVisible = true
}

@objc private func keyboardWillHide() {
isKeyboardVisible = false
}

private var canPresentLinkOnPrimaryButton: Bool {
Expand Down Expand Up @@ -580,6 +701,12 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo
logInitialDisplayedPaymentMethods()
isLinkWalletButtonSelected = false
linkConfirmOption = nil
updateFloatingButton()
}

override func viewWillDisappear(_ animated: Bool) {
deactivateFloatingButton()
super.viewWillDisappear(animated)
Comment on lines +707 to +709
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? Also nit: but can we move the viewWillDisappear to the top-ish of the file if it's needed.

}

private func logInitialDisplayedPaymentMethods() {
Expand Down
Loading