안녕하세요 띵지니어 😼 입니다.
오늘은 TVING앱의 로그인 화면에서 더 나아가, TextField에서 문자를 받고
로그인하기 버튼을 누르면 다음 뷰로, TextField에서 받은 문자를 넘겨주는 작업을 진행해 볼게요
[iOS] TVING(티빙) 로그인 화면 클론 코딩 UIKit 1편 - View 작업
안녕하세요 띵지니어 😼 입니다. 오늘은 TVING 앱의 로그인 화면(View)만 똑같이 구현을 해보려고 합니다. 전체 코드가 궁금하신 분은 맨 아래 참고해 주세요! 다음은 우리가 구현해야 할 View입니
thingjin.tistory.com
위 글에서 이어서 진행합니다.
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 링크에 정리해 두었습니다.
LeeMyeongJin-assignment/tvingProject/tvingProject/Presentation/Login/LoginViewController.swift at main · NOW-SOPT-iOS-Part/LeeM
Contribute to NOW-SOPT-iOS-Part/LeeMyeongJin-assignment development by creating an account on GitHub.
github.com

Xcode 15.1
iOS 17.4.1
MacOS Sonoma 14.2.1
환경에서 작성 한 글입니다.
'iOS' 카테고리의 다른 글
[iOS] Compositional Layout 으로 복잡한 CollectionView 구현 - TVING 메인 뷰 (0) | 2024.05.06 |
---|---|
[iOS] UIPickerView 커스텀 구현 - UIKit (1) | 2024.05.02 |
[iOS] TVING(티빙) 로그인 화면 클론 코딩 UIKit 1편 - View 작업 (0) | 2024.04.08 |
[iOS] SPM(Swift Package Manager) 설치 방법 (2) | 2024.03.10 |
[iOS] AutoLayout 충돌 해결하는 방법 (feat: SnapKit) (1) | 2024.03.05 |