How to make credit card input UI in Swift

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

a1 a2

BackCard.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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

1
2
3
4
5
6
7
8
9
10
11
12
13
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

Comments