iOS

[iOS] TVING(티빙) 로그인 화면 클론 코딩 UIKit 2편 - TextField

띵지니어 2024. 4. 20. 00:16

안녕하세요 띵지니어 😼 입니다.

오늘은 TVING앱의 로그인 화면에서 더 나아가, TextField에서 문자를 받고
로그인하기 버튼을 누르면 다음 뷰로, TextField에서 받은 문자를 넘겨주는 작업을 진행해 볼게요

https://thingjin.tistory.com/entry/iOS-TVING%ED%8B%B0%EB%B9%99-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%ED%99%94%EB%A9%B4-%ED%81%B4%EB%A1%A0-%EC%BD%94%EB%94%A9-UIKit-1%ED%8E%B8-View-%EC%9E%91%EC%97%85

 

[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
환경에서 작성 한 글입니다.