Issue #346

We have FrontCard that contains number and expiration date, BackCard that contains CVC. CardView is used to contain front and back sides for flipping transition.

We leverage STPPaymentCardTextField from Stripe for working input fields, then CardHandler is used to parse STPPaymentCardTextField content and update our UI.

For masked credit card numbers, we pad string to fit 16 characters with symbol, then chunk into 4 parts and zip with labels to update.

For flipping animation, we use UIView.transition with showHideTransitionViews

BackCard.swift

import UIKit

final class BackCard: UIView {
    lazy var rectangle: UIView = {
        let view = UIView()
        view.backgroundColor = R.color.darkText
        return view
    }()

    lazy var cvcLabel: UILabel = {
        let label = UILabel()
        label.font = R.customFont.medium(14)
        label.textColor = R.color.darkText
        label.textAlignment = .center
        return label
    }()

    lazy var cvcBox: UIView = {
        let view = UIView()
        view.backgroundColor = R.color.lightText
        return view
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }

    private func setup() {
        addSubviews([rectangle, cvcBox, cvcLabel])
        NSLayoutConstraint.on([
            rectangle.leftAnchor.constraint(equalTo: leftAnchor),
            rectangle.rightAnchor.constraint(equalTo: rightAnchor),
            rectangle.heightAnchor.constraint(equalToConstant: 52),
            rectangle.topAnchor.constraint(equalTo: topAnchor, constant: 30),

            cvcBox.rightAnchor.constraint(equalTo: rightAnchor, constant: -16),
            cvcBox.topAnchor.constraint(equalTo: rectangle.bottomAnchor, constant: 16),
            cvcBox.widthAnchor.constraint(equalToConstant: 66),
            cvcBox.heightAnchor.constraint(equalToConstant: 30),

            cvcLabel.centerXAnchor.constraint(equalTo: cvcBox.centerXAnchor),
            cvcLabel.centerYAnchor.constraint(equalTo: cvcBox.centerYAnchor)
        ])
    }
}

FrontCard.swift

import UIKit

final class FrontCard: UIView {
    lazy var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.distribution = .equalSpacing

        return stackView
    }()

    lazy var numberLabels: [UILabel] = Array(0..<4).map({ _ in return UILabel() })
    lazy var expirationStaticLabel: UILabel = {
        let label = UILabel()
        label.font = R.customFont.regular(10)
        label.textColor = R.color.darkText
        return label
    }()

    lazy var expirationLabel: UILabel = {
        let label = UILabel()
        label.font = R.customFont.medium(14)
        label.textColor = R.color.darkText
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }

    private func setup() {
        addSubview(stackView)
        numberLabels.forEach {
            stackView.addArrangedSubview($0)
        }

        addSubviews([expirationStaticLabel, expirationLabel])

        numberLabels.forEach {
            $0.font = R.customFont.medium(16)
            $0.textColor = R.color.darkText
            $0.textAlignment = .center
        }

        NSLayoutConstraint.on([
            stackView.heightAnchor.constraint(equalToConstant: 50),
            stackView.leftAnchor.constraint(equalTo: leftAnchor, constant: 24),
            stackView.rightAnchor.constraint(equalTo: rightAnchor, constant: -24),
            stackView.topAnchor.constraint(equalTo: centerYAnchor),

            expirationStaticLabel.topAnchor.constraint(equalTo: stackView.bottomAnchor),
            expirationStaticLabel.leftAnchor.constraint(equalTo: rightAnchor, constant: -70),

            expirationLabel.leftAnchor.constraint(equalTo: expirationStaticLabel.leftAnchor),
            expirationLabel.topAnchor.constraint(equalTo: expirationStaticLabel.bottomAnchor)
        ])
    }
}

CardView.swift

import UIKit

final class CardView: UIView {
    let backCard = BackCard()
    let frontCard = FrontCard()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }

    private func setup() {
        addSubview(backCard)
        addSubview(frontCard)

        [backCard, frontCard].forEach {
            NSLayoutConstraint.on([
                $0.pinEdges(view: self)
            ])

            $0.clipsToBounds = true
            $0.layer.cornerRadius = 10
            $0.backgroundColor = R.color.card.background
        }
    }
}

CardHandler.swift

import Foundation
import Stripe

final class CardHandler {
    let cardView: CardView

    init(cardView: CardView) {
        self.cardView = cardView
    }

    func reset() {
        cardView.frontCard.expirationStaticLabel.text = R.string.localizable.cardExpiration()
        cardView.frontCard.expirationLabel.text = R.string.localizable.cardExpirationPlaceholder()
        cardView.backCard.cvcLabel.text = R.string.localizable.cardCvcPlaceholder()
    }

    func showFront() {
        flip(
            from: cardView.backCard,
            to: cardView.frontCard,
            options: .transitionFlipFromLeft
        )
    }

    func showBack() {
        flip(
            from: cardView.frontCard,
            to: cardView.backCard,
            options: .transitionFlipFromRight
        )
    }

    func handle(_ textField: STPPaymentCardTextField) {
        handle(number: textField.cardNumber ?? "")
        handle(month: textField.formattedExpirationMonth, year: textField.formattedExpirationYear)
        handle(cvc: textField.cvc)
    }

    private func handle(number: String) {
        let paddedNumber = number.padding(
            toLength: 16,
            withPad: R.string.localizable.cardNumberPlaceholder(),
            startingAt: 0
        )

        let chunkedNumbers = paddedNumber.chunk(by: 4)
        zip(cardView.frontCard.numberLabels, chunkedNumbers).forEach { tuple in
            tuple.0.text = tuple.1
        }
    }

    private func handle(cvc: String?) {
        if let cvc = cvc, !cvc.isEmpty {
            cardView.backCard.cvcLabel.text = cvc
        } else {
            cardView.backCard.cvcLabel.text = R.string.localizable.cardCvcPlaceholder()
        }
    }

    private func handle(month: String?, year: String?) {
        guard
            let month = month, let year = year,
            !month.isEmpty
        else {
            cardView.frontCard.expirationLabel.text = R.string.localizable.cardExpirationPlaceholder()
            return
        }

        let formattedYear = year.ifEmpty(replaceWith: "00")
        cardView.frontCard.expirationLabel.text = "\(month)/\(formattedYear)"
    }

    private func flip(from: UIView, to: UIView, options: UIView.AnimationOptions) {
        UIView.transition(
            from: from,
            to: to,
            duration: 0.25,
            options: [options, .showHideTransitionViews],
            completion: nil
        )
    }
}

String+Extension.swift

extension String {
    func ifEmpty(replaceWith: String) -> String {
        return isEmpty ? replaceWith : self
    }

    func chunk(by length: Int) -> [String] {
        return stride(from: 0, to: count, by: length).map {
            let start = index(startIndex, offsetBy: $0)
            let end = index(start, offsetBy: length, limitedBy: endIndex) ?? endIndex
            return String(self[start..<end])
        }
    }
}

Updated at 2020-07-12 08:43:21