[iOS] TVING(티빙) 로그인 화면 클론 코딩 UIKit 2편 - TextField
안녕하세요 띵지니어 😼 입니다.
오늘은 TVING앱의 로그인 화면에서 더 나아가, TextField에서 문자를 받고
로그인하기 버튼을 누르면 다음 뷰로, TextField에서 받은 문자를 넘겨주는 작업을 진행해 볼게요
위 글에서 이어서 진행합니다.
UITextField 를 중점으로 코드를 구현해 볼게요
먼저 TextField에 대한 이벤트를 받으려면 현재 LoginViewController에 UITextFieldDelegate 프로토콜을 채택받아야 합니다.
유효성 검사나, 관리할 수 있는 메서드를 사용하기 위해서는 꼭 해야 하는 작업이에요
먼저 LoginViewController.swift 파일에 아래와 같이 위임자 설정과 프로토콜 채택 작업을 진행해 줍니다.
이제 저희는 TextField에서 UITextFieldDelegate 프로토콜에서
TextField에 사용할 수 있는 메서드를 사용할 수 있습니다.
저번에는 UI를 구현하였으니 이번엔 구현에 초점을 두겠습니다.
기능 요구 사항
1. 아이디, 비밀번호 텍스트 필드 누르면 테두리 표시
2. 아이디, 비밀번호 정규표현식 도입하여 둘 다 일치해야 로그인을 누를 수 있음 (isEnable)
3. 비밀번호 쪽에 X 버튼과, Security 해제 버튼 구현
4. 로그인하면 아이디에 적힌 텍스트 welcomeViewController.swift로 옮기기
구현
저는 View와 ViewController를 분리하였습니다.
UI가 궁금하신 분은 LoginView만 보셔도 되고,
구현 부분이 궁금하신 분은 LoginViewController를 보시면 됩니다.
LoginView.swift
//
// LoginView.swift
// tvingProject
//
// Created by thingineeer on 4/16/24.
//
import UIKit
import SnapKit
import Then
final class LoginView: UIView {
// MARK: - Property
private let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
private let passwordRegex = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,}$"
var userNickName: String = ""
// MARK: - UIComponents
private lazy var loginTitle = UILabel().then {
$0.text = "TVING ID 로그인"
$0.font = UIFont.pretendardFont(weight: 500, size: 23)
$0.textColor = .gray1
}
lazy var idTextField = UITextField().then {
$0.attributedPlaceholder = NSAttributedString(
string: "아이디",
attributes: [
.font: UIFont.pretendardFont(weight: 600, size: 15),
.foregroundColor: UIColor.gray1
]
)
$0.addLeftPadding(width: 22)
$0.layer.cornerRadius = 3
$0.backgroundColor = .gray4
$0.textColor = .white
}
lazy var passwordTextField = UITextField().then {
$0.attributedPlaceholder = NSAttributedString(
string: "비밀번호",
attributes: [
.font: UIFont.pretendardFont(weight: 600, size: 15),
.foregroundColor: UIColor.gray1
]
)
$0.addLeftPadding(width: 22)
$0.isSecureTextEntry = true
$0.layer.cornerRadius = 3
$0.backgroundColor = .gray4
$0.textColor = .white
}
private lazy var vStackViewLogin = UIStackView(
arrangedSubviews: [
idTextField,
passwordTextField
]
).then {
$0.axis = .vertical
$0.spacing = 7
$0.distribution = .fillEqually
}
lazy var loginButton = UIButton().then {
$0.setTitle("로그인 하기", for: .normal)
$0.titleLabel?.font = .pretendardFont(weight: 600, size: 14)
$0.backgroundColor = .clear
$0.layer.borderWidth = 1
$0.layer.borderColor = UIColor.gray4.cgColor
$0.layer.cornerRadius = 3
$0.isEnabled = false
}
private let idSearch = UILabel().then {
$0.text = "아이디 찾기"
$0.font = .pretendardFont(weight: 600, size: 14)
$0.textColor = .gray2
}
private let dividerLabel = UILabel().then {
$0.text = "|"
$0.textColor = .gray4
}
private let passwordSearch = UILabel().then {
$0.text = "비밀번호 찾기"
$0.font = .pretendardFont(weight: 600, size: 14)
$0.textColor = .gray2
}
lazy var allDeleteButton = UIButton().then {
$0.setImage(UIImage(resource: .icCancel), for: .normal)
$0.tintColor = .white
$0.isHidden = true
}
lazy var togglePasswordButton = UIButton().then {
$0.setImage(UIImage(resource: .icEyeSlash), for: .normal)
$0.isHidden = true
}
private lazy var hStackViewInfoFirst = UIStackView(
arrangedSubviews: [
idSearch,
dividerLabel,
passwordSearch
]
).then {
$0.spacing = 36
$0.axis = .horizontal
$0.distribution = .equalSpacing
}
private let notAccountLabel = UILabel().then {
$0.text = "아직 계정이 없으신가요?"
$0.font = .pretendardFont(weight: 600, size: 14)
$0.textColor = .gray3
}
let makeNickNameLabel = UILabel().then {
let attributes: [NSAttributedString.Key: Any] = [
.underlineStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: UIColor.gray2,
.font: UIFont.pretendardFont(weight: 400, size: 14)
]
$0.attributedText = NSAttributedString(
string: "TVING ID 회원가입하기",
attributes: attributes
)
$0.isUserInteractionEnabled = true
}
private lazy var hStackViewInfoSecond = UIStackView(
arrangedSubviews: [
notAccountLabel,
makeNickNameLabel
]
).then {
$0.spacing = 8
$0.axis = .horizontal
$0.distribution = .equalSpacing
}
// MARK: - init
override init(frame: CGRect = .zero) {
super.init(frame: frame)
setUI()
setHierarchy()
setLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - UI & Layout
private func setUI() {
backgroundColor = .black
}
private func setHierarchy() {
addSubviews(
loginTitle,
vStackViewLogin,
allDeleteButton,
togglePasswordButton,
loginButton,
hStackViewInfoFirst,
hStackViewInfoSecond
)
}
private func setLayout() {
loginTitle.snp.makeConstraints {
$0.centerX.equalToSuperview()
$0.top.equalTo(safeAreaLayoutGuide).offset(4)
}
vStackViewLogin.snp.makeConstraints {
$0.top.equalTo(self.loginTitle.snp.bottom).offset(31)
$0.leading.trailing.equalToSuperview().inset(20)
$0.height.equalTo(52 + 52 + 7)
}
allDeleteButton.snp.makeConstraints {
$0.top.equalTo(passwordTextField.snp.top).offset(16)
$0.trailing.equalTo(passwordTextField.snp.trailing).inset(56)
}
togglePasswordButton.snp.makeConstraints {
$0.top.equalTo(passwordTextField.snp.top).offset(16)
$0.leading.equalTo(allDeleteButton.snp.trailing).offset(16)
}
loginButton.snp.makeConstraints {
$0.top.equalTo(vStackViewLogin.snp.bottom).offset(21)
$0.height.equalTo(52)
$0.leading.trailing.equalToSuperview().inset(20)
}
hStackViewInfoFirst.snp.makeConstraints {
$0.top.equalTo(loginButton.snp.bottom).offset(31)
$0.height.equalTo(22)
$0.leading.trailing.equalToSuperview().inset(85)
}
hStackViewInfoSecond.snp.makeConstraints {
$0.top.equalTo(hStackViewInfoFirst.snp.bottom).offset(31)
$0.height.equalTo(22)
$0.leading.trailing.equalToSuperview().inset(51)
}
}
}
LoginViewController.swift
//
// LoginViewController.swift
// tvingProject
//
// Created by thingineeer on 4/16/24.
//
import UIKit
final class LoginViewController: UIViewController {
// MARK: - Property
private let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
private let passwordRegex = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,}$"
// MARK: - UIComponents
private let loginView = LoginView()
// MARK: - Life Cycles
override func loadView() {
self.view = loginView
}
override func viewDidLoad() {
super.viewDidLoad()
setDelegate()
setAddTarget()
}
// MARK: - @objc Function
@objc
private func pushToLoginSuccess() {
let welcomeViewController = WelcomeViewController()
welcomeViewController.setWelcomeLabel(welcomeText: loginView.idTextField.text ?? "")
navigationController?.pushViewController(welcomeViewController, animated: true)
}
@objc
private func togglePasswordTapped() {
loginView.passwordTextField.isSecureTextEntry.toggle()
let isSecure = loginView.passwordTextField.isSecureTextEntry
if isSecure {
loginView.togglePasswordButton.setImage(UIImage(resource: .icEyeSlash), for: .normal)
} else {
loginView.togglePasswordButton.setImage(UIImage(resource: .icEye), for: .normal)
}
}
@objc
private func deletePasswordTapped() {
loginView.passwordTextField.text = ""
updateButtonEnable()
}
// MARK: - Methods
private func updateButtonEnable() {
let isPasswordFieldEmpty = loginView.passwordTextField.text?.isEmpty ?? true
loginView.allDeleteButton.isHidden = isPasswordFieldEmpty
loginView.togglePasswordButton.isHidden = isPasswordFieldEmpty
updateButtonStyle(button: loginView.loginButton, enabled: !isPasswordFieldEmpty)
}
private func validateAndToggleLoginButton() {
let isEmailValid = isValidEmail(loginView.idTextField.text)
let isPasswordValid = isValidPassword(loginView.passwordTextField.text)
let isFormValid = isEmailValid && isPasswordValid
updateButtonStyle(button: loginView.loginButton, enabled: isFormValid)
}
private func isValidEmail(_ string: String?) -> Bool {
guard let string = string else { return false }
return string.range(of: self.emailRegex, options: .regularExpression) != nil
}
private func isValidPassword(_ string: String?) -> Bool {
guard let string = string else { return false }
return string.range(of: self.passwordRegex, options: .regularExpression) != nil
}
private func updateButtonStyle(button: UIButton, enabled: Bool) {
loginView.loginButton.isEnabled = enabled
if enabled {
button.backgroundColor = .tvingRed
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = .clear
button.setTitleColor(.gray2, for: .normal)
}
}
private func setAddTarget() {
loginView.loginButton.addTarget(self, action: #selector(pushToLoginSuccess), for: .touchUpInside)
loginView.allDeleteButton.addTarget(self, action: #selector(deletePasswordTapped), for: .touchUpInside)
loginView.togglePasswordButton.addTarget(self, action: #selector(togglePasswordTapped), for: .touchUpInside)
}
private func setDelegate() {
loginView.idTextField.delegate = self
loginView.passwordTextField.delegate = self
}
}
// MARK: - UITextFieldDelegate
extension LoginViewController: UITextFieldDelegate {
func textFieldDidChangeSelection(_ textField: UITextField) {
validateAndToggleLoginButton()
if textField == loginView.passwordTextField {
if let text = textField.text, text.isEmpty {
loginView.allDeleteButton.isHidden = true
loginView.togglePasswordButton.isHidden = true
} else {
loginView.allDeleteButton.isHidden = false
loginView.togglePasswordButton.isHidden = false
}
}
}
// 해당 텍스트 필드 강조 코드
func textFieldDidBeginEditing(_ textField: UITextField) {
if textField == loginView.idTextField {
loginView.idTextField.layer.borderColor = UIColor.white.cgColor
loginView.idTextField.layer.borderWidth = 1
} else if textField == loginView.passwordTextField {
loginView.passwordTextField.layer.borderColor = UIColor.white.cgColor
loginView.passwordTextField.layer.borderWidth = 1
}
}
// 텍스트 필드 사용 끝나면
func textFieldDidEndEditing(_ textField: UITextField) {
textField.layer.borderWidth = 0
textField.layer.borderColor = .none
}
}
코드 설명
private let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
private let passwordRegex = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,}$"
이 부분은 정규표현식을 저장한 상수 부분입니다.
이메일은 형식을 맞춰야 하고, 비밀번호는 영문자, 숫자, 특수문자를 포함하여 8글자여야 하는 가정을 두고
정규표현식을 작성하였습니다.
// MARK: - UIComponents
private let loginView = LoginView()
// MARK: - Life Cycles
override func loadView() {
self.view = loginView
}
이 부분은 LoginView의 레이아웃을 LoginViewController에 넣어 준 부분입니다.
보통 loadView() 함수에 저렇게 넣어 주는 작업을 합니다.
override func viewDidLoad() {
super.viewDidLoad()
setDelegate()
setAddTarget()
}
private func setDelegate() {
loginView.idTextField.delegate = self
loginView.passwordTextField.delegate = self
}
private func setAddTarget() {
loginView.loginButton.addTarget(self, action: #selector(pushToLoginSuccess), for: .touchUpInside)
loginView.allDeleteButton.addTarget(self, action: #selector(deletePasswordTapped), for: .touchUpInside)
loginView.togglePasswordButton.addTarget(self, action: #selector(togglePasswordTapped), for: .touchUpInside)
}
앱의 생명주기에서 LoadView 다음으로 호출되는 viewDidLoad()에서 위임자 설정과 AddTarget을 설정해 주었습니다.
setDelegate()에서 위임자 설정을 해주고 있고,
setAddTarget() 함수를 통해 원하는 버튼을 누르면 어떠한 이벤트가 동작되게 할 수 있습니다.
여기서는 총 3개의 이벤트가 있네요
loginButton -> pushTologinSuccess 뷰이동
allDeleteButton -> deletePasswordTapped 패스워드 삭제
togglePasswordButton -> togglePasswordTapped 패드워드 숨김 / 해제
@objc
private func pushToLoginSuccess() {
let welcomeViewController = WelcomeViewController()
welcomeViewController.setWelcomeLabel(welcomeText: loginView.idTextField.text ?? "")
navigationController?.pushViewController(welcomeViewController, animated: true)
}
@objc
private func togglePasswordTapped() {
loginView.passwordTextField.isSecureTextEntry.toggle()
let isSecure = loginView.passwordTextField.isSecureTextEntry
if isSecure {
loginView.togglePasswordButton.setImage(UIImage(resource: .icEyeSlash), for: .normal)
} else {
loginView.togglePasswordButton.setImage(UIImage(resource: .icEye), for: .normal)
}
}
@objc
private func deletePasswordTapped() {
loginView.passwordTextField.text = ""
updateButtonEnable()
}
- pushToLoginSuccess
1. let welcomeViewController = WelcomeViewController() WelcomeViewController의 객체를 생성합니다.
2. welcomeViewController.setWelcomeLabel(welcomeText: loginView.idTextField.text ?? "") 를 통해 데이터 전달을 해줍니다.
3. navigationController?.pushViewController(welcomeViewController, animated: true) 내비게이션 컨트롤러에 push를 하여 뷰 이동을 해줍니다.
- togglePasswordTapped
1. passwordTextField.isSecureTextEntry 를 토글 해줍니다.
참고로 isSecureTextEntry = true 이면 비밀번호가 가려집니다.
toggle() 은 isSecureTextEntry = true 이면 false로 바꿔주고 isSecureTextEntry = false 이면 true로 바꿔주는 역할을 합니다.
2. isSecure 는 isSecureTextEntry 값을 관찰하여 상수로 담고
true와 false를 다른 이미지로 바꿔주는 작업을 합니다.
isSecure가 true이면 setImage(UIImage(resource: .icEyeSlash)
isSecure가 false이면 setImage(UIImage(resource: .icEye)
- deletePasswordTapped
passwordTextField 에 있는 문자를 지워줍니다. -> loginView.passwordTextField.text = ""
updateButtonEnable() 함수는 뒤에 설명
// MARK: - Methods
private func updateButtonEnable() {
let isPasswordFieldEmpty = loginView.passwordTextField.text?.isEmpty ?? true
loginView.allDeleteButton.isHidden = isPasswordFieldEmpty
loginView.togglePasswordButton.isHidden = isPasswordFieldEmpty
updateButtonStyle(button: loginView.loginButton, enabled: !isPasswordFieldEmpty)
}
private func validateAndToggleLoginButton() {
let isEmailValid = isValidEmail(loginView.idTextField.text)
let isPasswordValid = isValidPassword(loginView.passwordTextField.text)
let isFormValid = isEmailValid && isPasswordValid
updateButtonStyle(button: loginView.loginButton, enabled: isFormValid)
}
private func isValidEmail(_ string: String?) -> Bool {
guard let string = string else { return false }
return string.range(of: self.emailRegex, options: .regularExpression) != nil
}
private func isValidPassword(_ string: String?) -> Bool {
guard let string = string else { return false }
return string.range(of: self.passwordRegex, options: .regularExpression) != nil
}
private func updateButtonStyle(button: UIButton, enabled: Bool) {
loginView.loginButton.isEnabled = enabled
if enabled {
button.backgroundColor = .tvingRed
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = .clear
button.setTitleColor(.gray2, for: .normal)
}
}
- updateButtonEnable()
isPasswordFieldEmpty 는
passwordTextField가 비어있으면 true가 되어서
password에 있는 두 개의 버튼을 숨겨주는 이벤트입니다.
loginView.allDeleteButton.isHidden = isPasswordFieldEmpty
loginView.togglePasswordButton.isHidden = isPasswordFieldEmpty
이후 updateButtonStyle() 함수를 작동시켜 로그인 버튼을 비활성화해주는 코드입니다.
- validateAndToggleLoginButton
이메일 형식이 맞는지 확인하는 isEmailValid 와 비밀번호 형식이 맞는지 확인하는 isPasswordValid 두 Bool 값을 비교하여
이후 updateButtonStyle() 함수를 작동시켜 로그인 버튼을 활성화해 주는 코드입니다.
- isValidEmail(_ string: String?) & isValidPassword(_ string: String?)
인자로 들어오는 String 값을 정규식에 포함되는지 안되는지 Bool값으로 리턴해 줍니다.
- updateButtonStyle(button: UIButton, enabled: Bool)
버튼, enable 이라는 파라미터를 받아서 버튼의 상태를 업데이트해 주는 함수입니다.
updateButtonEnable() 에서 사용한 부분은 enabled=false 인 경우 여서 버튼이 꺼지는 이벤트입니다.
validateAndToggleLoginButton() 에서 사용한 부분은 enabled=true 가 되기 때문에 로그인하기 버튼을 빨간색으로 켜주는 작업입니다.
// MARK: - UITextFieldDelegate
extension LoginViewController: UITextFieldDelegate {
func textFieldDidChangeSelection(_ textField: UITextField) {
validateAndToggleLoginButton()
if textField == loginView.passwordTextField {
if let text = textField.text, text.isEmpty {
loginView.allDeleteButton.isHidden = true
loginView.togglePasswordButton.isHidden = true
} else {
loginView.allDeleteButton.isHidden = false
loginView.togglePasswordButton.isHidden = false
}
}
}
// 해당 텍스트 필드 강조 코드
func textFieldDidBeginEditing(_ textField: UITextField) {
textField.layer.borderColor = UIColor.white.cgColor
textField.layer.borderWidth = 1
}
// 텍스트 필드 사용 끝나면
func textFieldDidEndEditing(_ textField: UITextField) {
textField.layer.borderWidth = 0
textField.layer.borderColor = nil
}
}
- textFieldDidChangeSelection
textFieldDidChangeSelection 은 : TextField에 변경사항이 생기면, TextField에 실제로 작성된 후에 호출되는 함수입니다.
따라서 여기서 validateAndToggleLoginButton 함수를 호출하여 아이디 비밀번호가 들어올 시 유효한지 판별하는 함수를 작동시켰습니다.
validateAndToggleLoginButton() 함수는 위 설명 참고 (command + F로 검색하시면 됩니다.)
if textField == loginView.passwordTextField {
이 부분은 비밀번호가 지워졌을 때 hidden 처리를 해주거나, 그게 아닌 경우 다시 표시하는 역할을 합니다.
- textFieldDidBeginEditing
이 부분은 텍스트 필드에 입력을 하기 시작할 때 작동되는 함수입니다.
즉 여기서는 해당하는 텍스트 필드를 누르면 아래와 같이 테두리 강조되는 역할을 합니다.
- textFieldDidEndEditing
이 부분은 텍스트 필드에 입력이 끝날 때 작동되는 함수입니다.
해당 구현된 함수 역할은 다른 곳을 입력하면 현재 입력을 끝내는 부분의 테두리가 사라집니다.
정리
처음 써보는 것들이 좀 있었습니다.
로그인 같은 경우는 보통 소셜로그인으로 구현을 해봤기 때문에
비밀번호에 isSecureTextEntry 속성을 건드려서 비밀번호를 숨겨주는 속성을 처음 만져 봤습니다..ㅎ
- textField
textFieldDidChangeSelection 함수를 이번에 처음 사용을 해봤습니다.
과거 코드를 살펴보니 textFieldShouldReturn을 많이 사용해 봤네요!
텍스트 필드뿐만 아니라 정규식을 사용하여 유효성 검사까지 진행을 하였는데, 그 결과 텍스트 필드의 텍스트가 어떻게 작동을 하고 어떻게 응용을 할 수 있는지 배우는 시간이 되었습니다.
updateButtonStyle 함수를 재사용해서 로그인 버튼의 활성여부를 체크하였습니다.
아쉬웠던 점은..
남이 보면 조금 복잡할 수 있겠다는 생각을 하였습니다.
최대한 잘게 잘게 자른 것이 가독성을 약화시킨다고 느꼈으며 로직을 구현할 때 좀 더 간단하게 하는 방법은 없을지 고민을 해봐야겠습니다.
WelcomeViewController.swift 등 추가적인 파일은 아래 GitHub 링크에 정리해 두었습니다.
Xcode 15.1
iOS 17.4.1
MacOS Sonoma 14.2.1
환경에서 작성 한 글입니다.