diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/UIImageView+Extensions.swift b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/UIImageView+Extensions.swift index 0ca62fc20b49..a482063a07a1 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/UIImageView+Extensions.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/UIImageView+Extensions.swift @@ -3,21 +3,29 @@ // StripePaymentSheet // +@_spi(STP) import StripePaymentsUI import UIKit extension UIImageView { // Helper extension for downloading and setting image. Optionally process it before setting. // On failure or no URL, set fallback image. + // - shimmeringImage: If provided, displayed with a shimmer animation overlay while the download is in progress. func setImage( with url: URL?, processOnDownloadedImage: ((UIImage) -> UIImage)? = nil, - fallbackImage: UIImage + fallbackImage: UIImage, + shimmeringImage: UIImage? ) { guard let url else { self.image = fallbackImage return } + if let shimmeringImage { + self.image = shimmeringImage + addShimmer() + } + // We use `tag` to ensure that if we call `setImage(with:)` multiple times, // we ONLY set the image from the `urlString` for the last `urlString` passed. // @@ -30,15 +38,35 @@ extension UIImageView { await MainActor.run { if self?.tag == url.hashValue { self?.image = processedImage + self?.removeShimmer() } } } catch { await MainActor.run { if self?.tag == url.hashValue { self?.image = fallbackImage + self?.removeShimmer() } } } } } + + func addShimmer() { + removeShimmer() + let shimmer = ShimmerView() + shimmer.translatesAutoresizingMaskIntoConstraints = false + clipsToBounds = true + addSubview(shimmer) + NSLayoutConstraint.activate([ + shimmer.topAnchor.constraint(equalTo: topAnchor), + shimmer.bottomAnchor.constraint(equalTo: bottomAnchor), + shimmer.leadingAnchor.constraint(equalTo: leadingAnchor), + shimmer.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + func removeShimmer() { + subviews.compactMap { $0 as? ShimmerView }.forEach { $0.stopShimmering() } + } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentMethodCollectionView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentMethodCollectionView.swift index 1697a931969d..22128bbbf72e 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentMethodCollectionView.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentMethodCollectionView.swift @@ -361,11 +361,14 @@ extension SavedPaymentMethodCollectionView { if let cardArtURL = paymentMethod.cardArtCDNURL(cardArtEnabled: cardArtEnabled) { if paymentMethodLogo.tag != cardArtURL.hashValue { paymentMethodLogo.tag = cardArtURL.hashValue - paymentMethodLogo.image = nil + paymentMethodLogo.image = STPImageLibrary.cardBrandChoiceImage() + paymentMethodLogoHeightConstraint.constant = CGFloat(STPPaymentMethod.cardArtHeight) + paymentMethodLogo.addShimmer() } Task { let image = try? await DownloadManager.sharedManager.downloadImage(url: cardArtURL) guard paymentMethodLogo.tag == cardArtURL.hashValue else { return } + paymentMethodLogo.removeShimmer() if let image { paymentMethodLogo.image = image.roundedWithBorder(radius: 3) paymentMethodLogoHeightConstraint.constant = CGFloat(STPPaymentMethod.cardArtHeight) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift index fc0d83dc21ac..ffc08559b510 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift @@ -554,7 +554,8 @@ extension RowButton { if appearance.cardArtEnabled { imageView.setImage(with: paymentMethod.cardArtCDNURL(cardArtEnabled: appearance.cardArtEnabled), processOnDownloadedImage: { $0.roundedWithBorder(radius: 3) }, - fallbackImage: savedPaymentMethodRowImage) + fallbackImage: savedPaymentMethodRowImage, + shimmeringImage: STPImageLibrary.cardBrandChoiceImage()) } else { imageView.image = savedPaymentMethodRowImage } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ShimmerView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ShimmerView.swift new file mode 100644 index 000000000000..b7b65cb3a379 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/ShimmerView.swift @@ -0,0 +1,53 @@ +// +// ShimmerView.swift +// StripePaymentSheet +// + +import UIKit + +/// A view that displays a sliding shimmer animation overlay. +/// Used as a loading indicator while card art images are downloaded. +class ShimmerView: UIView { + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isAccessibilityElement = false + clipsToBounds = true + setupGradient() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + private func setupGradient() { + gradientLayer.colors = [ + UIColor.white.withAlphaComponent(0.0).cgColor, + UIColor.white.withAlphaComponent(0.4).cgColor, + UIColor.white.withAlphaComponent(0.0).cgColor, + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.locations = [-0.5, -0.25, 0.0] + layer.addSublayer(gradientLayer) + + let animation = CABasicAnimation(keyPath: "locations") + animation.fromValue = [-0.5, -0.25, 0.0] + animation.toValue = [1.0, 1.25, 1.5] + animation.duration = 1.2 + animation.repeatCount = .infinity + gradientLayer.add(animation, forKey: "shimmer") + } + + func stopShimmering() { + gradientLayer.removeAllAnimations() + removeFromSuperview() + } +}