I am trying to implement passcode screen, but I am having trouble with alignment, as you can see in this picture.
What I'm trying to do is, have three buttons in each row, so it actually looks like a "keypad". I am not quite sure how could I do this. I thought about making inside of first stack view which is vertical, four others horizontal stack views, but couldn't manage to do it. Any suggestion or help would be appreciated. Thanks :)
Code is below.
class ViewController: UIViewController {
var verticalStackView: UIStackView = {
var verticalStackView = UIStackView()
verticalStackView.translatesAutoresizingMaskIntoConstraints = false
verticalStackView.axis = .vertical
verticalStackView.distribution = .fillEqually
verticalStackView.spacing = 13
verticalStackView.alignment = .fill
verticalStackView.contentMode = .scaleToFill
verticalStackView.backgroundColor = .red
return verticalStackView
}()
var horizontalStackView: UIStackView = {
var buttons = [PasscodeButtons]()
var horizontalStackView = UIStackView(arrangedSubviews: buttons)
horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
horizontalStackView.axis = .horizontal
horizontalStackView.distribution = .fillEqually
horizontalStackView.alignment = .fill
horizontalStackView.spacing = 25
horizontalStackView.contentMode = .scaleToFill
horizontalStackView.backgroundColor = .green
return horizontalStackView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
configureStackView()
configureConstraints()
}
func configureStackView() {
view.addSubview(verticalStackView)
verticalStackView.addSubview(horizontalStackView)
addButtonsToStackView()
}
func addButtonsToStackView() {
let numberOfButtons = 9
for i in 0...numberOfButtons {
let button = PasscodeButtons()
button.setTitle("\(i)", for: .normal)
button.tag = i
horizontalStackView.addArrangedSubview(button)
}
}
func configureConstraints() {
verticalStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 200).isActive = true
verticalStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 50).isActive = true
verticalStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -50).isActive = true
verticalStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100).isActive = true
horizontalStackView.topAnchor.constraint(equalTo: verticalStackView.topAnchor, constant: 10).isActive = true
horizontalStackView.leadingAnchor.constraint(equalTo: verticalStackView.leadingAnchor, constant: 10).isActive = true
}
}
In case PasscodeButtons matters, here is code from there too.
class PasscodeButtons: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setupButton()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupButton()
}
override func awakeFromNib() {
super.awakeFromNib()
setupButton()
}
private func setupButton() {
setTitleColor(UIColor.black, for: .normal)
setTitleColor(UIColor.black, for: .highlighted)
}
private func updateView() {
layer.cornerRadius = frame.width / 2
layer.masksToBounds = true
layer.borderColor = UIColor(red: 0/255.0, green: 0/255.0, blue: 0, alpha:1).cgColor
layer.borderWidth = 2.0
}
override func layoutSubviews() {
super.layoutSubviews()
updateView()
backgroundColor = .cyan
}
}
The general idea is:
.fillEqually
Then, to generate everything, create an array of arrays of Ints for the key numbers, laid out like a keypad:
let keyNums: [[Int]] = [
[7, 8, 9],
[4, 5, 6],
[1, 2, 3],
[0],
]
Loop through, creating each row of buttons.
Here's a quick example (I modified your PasscodeButton class slightly):
class PasscodeButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setupButton()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupButton()
}
override func awakeFromNib() {
super.awakeFromNib()
setupButton()
}
private func setupButton() {
setTitleColor(UIColor.black, for: .normal)
setTitleColor(UIColor.lightGray, for: .highlighted)
layer.masksToBounds = true
layer.borderColor = UIColor(red: 0/255.0, green: 0/255.0, blue: 0, alpha:1).cgColor
layer.borderWidth = 2.0
backgroundColor = .cyan
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height * 0.5
}
}
class PassCodeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let outerStack = UIStackView()
outerStack.axis = .vertical
outerStack.distribution = .fillEqually
outerStack.spacing = 16
let keyNums: [[Int]] = [
[7, 8, 9],
[4, 5, 6],
[1, 2, 3],
[0],
]
keyNums.forEach { rowNums in
let hStack = UIStackView()
hStack.distribution = .fillEqually
hStack.spacing = outerStack.spacing
rowNums.forEach { n in
let btn = PasscodeButton()
btn.setTitle("\(n)", for: [])
// square / round (1:1 ratio) buttons
// for all buttons except the bottom "Zero" button
if rowNums.count != 1 {
btn.heightAnchor.constraint(equalTo: btn.widthAnchor).isActive = true
}
btn.addTarget(self, action: #selector(numberTapped(_:)), for: .touchUpInside)
hStack.addArrangedSubview(btn)
}
outerStack.addArrangedSubview(hStack)
}
outerStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(outerStack)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
outerStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
outerStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
outerStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// no bottom or height constraint
])
}
@objc func numberTapped(_ sender: UIButton) -> Void {
guard let n = sender.currentTitle else {
// button has no title?
return
}
print("Number \(n) was tapped!")
}
}
Output:
You'll likely want to play with the sizing, but that should get you on your way.
Edit - comment "I would like for 0 to stay in last row in the middle, and on the left side I would pop in touch id icon and on the right backspace button, how could I leave last row out of a shuffle?"
When you create your "grid" of buttons:
Change the keyNums
array to:
let keyOrder: [Int] = [
7, 8, 9,
4, 5, 6,
1, 2, 3,
]
// you may want to show the "standard order" first,
// so pass a Bool parameter
// shuffle the key order if specified
let keyNums = shouldShuffle
? keyOrder.shuffled()
: keyOrder
// loop through and update the button titles
// with the new order
Here's some updated code, using a "KeyPad" UIView
subclass:
enum PasscodeButtonType {
case NUMBER, TOUCH, BACKSPACE
}
class PasscodeButton: UIButton {
var pcButtonType: PasscodeButtonType = .NUMBER
override init(frame: CGRect) {
super.init(frame: frame)
setupButton()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupButton()
}
override func awakeFromNib() {
super.awakeFromNib()
setupButton()
}
private func setupButton() {
setTitleColor(UIColor.black, for: .normal)
setTitleColor(UIColor.lightGray, for: .highlighted)
layer.masksToBounds = true
layer.borderColor = UIColor(red: 0/255.0, green: 0/255.0, blue: 0, alpha:1).cgColor
layer.borderWidth = 2.0
backgroundColor = .cyan
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height * 0.5
// button font and image sizes... adjust as desired
let ptSize = bounds.height * 0.4
titleLabel?.font = .systemFont(ofSize: ptSize)
let config = UIImage.SymbolConfiguration(pointSize: ptSize)
setPreferredSymbolConfiguration(config, forImageIn: [])
}
}
class KeyPadView: UIView {
// closures so we can tell the controller something happened
var touchIDTapped: (()->())?
var backSpaceTapped: (()->())?
var numberTapped: ((String)->())?
var spacing: CGFloat = 16
private let outerStack = UIStackView()
init(spacing spc: CGFloat) {
self.spacing = spc
super.init(frame: .zero)
commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() -> Void {
// load your TouchID and Backspace button images
var touchImg: UIImage!
var backImg: UIImage!
if let img = UIImage(named: "myTouchImage") {
touchImg = img
} else {
if #available(iOS 14.0, *) {
touchImg = UIImage(systemName: "touchid")
} else if #available(iOS 13.0, *) {
touchImg = UIImage(systemName: "snow")
} else {
fatalError("No TouchID button image available!")
}
}
if let img = UIImage(named: "myBackImage") {
backImg = img
} else {
if #available(iOS 13.0, *) {
backImg = UIImage(systemName: "delete.left.fill")
} else {
fatalError("No BackSpace button image available!")
}
}
outerStack.axis = .vertical
outerStack.distribution = .fillEqually
outerStack.spacing = spacing
// add 3 "rows" of NUMBER buttons
for _ in 1...3 {
let hStack = UIStackView()
hStack.distribution = .fillEqually
hStack.spacing = outerStack.spacing
for _ in 1...3 {
let btn = PasscodeButton()
// these are NUMBER buttons
btn.pcButtonType = .NUMBER
// square / round (1:1 ratio) buttons
// for all buttons except the bottom "Zero" button
btn.heightAnchor.constraint(equalTo: btn.widthAnchor).isActive = true
btn.addTarget(self, action: #selector(keyButtonTapped(_:)), for: .touchUpInside)
hStack.addArrangedSubview(btn)
}
outerStack.addArrangedSubview(hStack)
}
// now add bottom row of TOUCH / 0 / BACKSPACE buttons
let hStack = UIStackView()
hStack.distribution = .fillEqually
hStack.spacing = outerStack.spacing
var btn: PasscodeButton!
btn = PasscodeButton()
btn.pcButtonType = .TOUCH
btn.setImage(touchImg, for: [])
btn.heightAnchor.constraint(equalTo: btn.widthAnchor).isActive = true
btn.addTarget(self, action: #selector(keyButtonTapped(_:)), for: .touchUpInside)
hStack.addArrangedSubview(btn)
btn = PasscodeButton()
btn.pcButtonType = .NUMBER
btn.setTitle("0", for: [])
btn.heightAnchor.constraint(equalTo: btn.widthAnchor).isActive = true
btn.addTarget(self, action: #selector(keyButtonTapped(_:)), for: .touchUpInside)
hStack.addArrangedSubview(btn)
btn = PasscodeButton()
btn.pcButtonType = .BACKSPACE
btn.setImage(backImg, for: [])
btn.heightAnchor.constraint(equalTo: btn.widthAnchor).isActive = true
btn.addTarget(self, action: #selector(keyButtonTapped(_:)), for: .touchUpInside)
hStack.addArrangedSubview(btn)
// add bottom buttons row
outerStack.addArrangedSubview(hStack)
outerStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(outerStack)
NSLayoutConstraint.activate([
outerStack.topAnchor.constraint(equalTo: topAnchor, constant: spacing),
outerStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: spacing),
outerStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -spacing),
outerStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -spacing),
])
// use "standard number pad order" for the first time
updateNumberKeys(shouldShuffle: false)
}
func updateNumberKeys(shouldShuffle b: Bool = true) -> Void {
let keyOrder: [Int] = [
7, 8, 9,
4, 5, 6,
1, 2, 3,
0,
]
// shuffle the key order if specified
let keyNumbers = b == true
? keyOrder.shuffled()
: keyOrder
// index to step through array
var numIDX: Int = 0
// get first 3 rows of buttons
let rows = outerStack.arrangedSubviews.prefix(3)
// loop through buttons, changing their titles
rows.forEach { v in
guard let hStack = v as? UIStackView else {
fatalError("Bad Setup!")
}
hStack.arrangedSubviews.forEach { b in
guard let btn = b as? PasscodeButton else {
fatalError("Bad Setup!")
}
btn.setTitle("\(keyNumbers[numIDX])", for: [])
numIDX += 1
}
}
// change title of center button on bottom row
guard let lastRowStack = outerStack.arrangedSubviews.last as? UIStackView,
lastRowStack.arrangedSubviews.count == 3,
let btn = lastRowStack.arrangedSubviews[1] as? PasscodeButton
else {
fatalError("Bad Setup!")
}
btn.setTitle("\(keyNumbers[numIDX])", for: [])
}
@objc func keyButtonTapped(_ sender: Any?) -> Void {
guard let btn = sender as? PasscodeButton else {
return
}
switch btn.pcButtonType {
case .TOUCH:
// tell the controller TouchID was tapped
touchIDTapped?()
case .BACKSPACE:
// tell the controller BackSpace was tapped
backSpaceTapped?()
default:
guard let n = btn.currentTitle else {
// button has no title?
return
}
// tell the controller a NUmber Key was tapped
numberTapped?(n)
}
// update the number keys, but shuffle them
updateNumberKeys()
}
}
class PassCodeViewController: UIViewController {
var keyPad: KeyPadView!
override func viewDidLoad() {
super.viewDidLoad()
// play with these to see how the button sizes / spacing looks
let keyPadSpacing: CGFloat = 12
let keyPadWidth: CGFloat = 240
// init with button spacing as desired
keyPad = KeyPadView(spacing: keyPadSpacing)
keyPad.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(keyPad)
let g = view.safeAreaLayoutGuide
// center keyPad view
// its height will be set by its layout
NSLayoutConstraint.activate([
keyPad.widthAnchor.constraint(equalToConstant: keyPadWidth),
keyPad.centerXAnchor.constraint(equalTo: g.centerXAnchor),
keyPad.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
// let's show the frame of the keyPad
keyPad.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
// set closures
keyPad.numberTapped = { [weak self] str in
guard let self = self else {
return
}
print("Number key tapped:", str)
// do something with the number string
}
keyPad.touchIDTapped = { [weak self] in
guard let self = self else {
return
}
print("TouchID was tapped!")
// do something because TouchID button was tapped
}
keyPad.backSpaceTapped = { [weak self] in
guard let self = self else {
return
}
print("BackSpace was tapped!")
// do something because BackSpace button was tapped
}
}
}
and here's how it looks, setting the keypad view width to 240
and the button spacing to 12
: