onmyway133 / blog

šŸ What you don't know is what you haven't learned
https://onmyway133.com/
MIT License
676 stars 33 forks source link

How to make credit card input UI in Swift #346

Open onmyway133 opened 5 years ago

onmyway133 commented 5 years ago

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

a1 a2

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])
        }
    }
}
asadqazi commented 4 years ago

Anyone has implemented this code? There are alot off issues in it.What about NSLayoutConstraint.on([