여러개의 UITextField Validation 깔끔하게 관리하기 with Combine

peppermint100
PEPPERMINT100
Published in
9 min readAug 31, 2024

프로젝트를 진행하면서 프로필 편집 기능을 만드는데, 여러개의 텍스트 필드를 사용할 일이 생겼다.

각 텍스트 필드마다 입력되어야 하는 내용, 키보드의 타입 그리고 가장 중요한 Validation이 달랐다.

Validation은 텍스트필드 내에 입력된 텍스트가 유효한지 검증하는 내용을 뜻하는데, 예를 들어 닉네임의 경우 10자 제한, 핸드폰 번호의 경우 하이픈 제외, 이메일의 경우 이메일 형식을 따르는지를 뜻한다.

이러한 Validation을 Enum으로 구조화하고 Combine을 통해서 리액티브하면서도 UI 로직과 분리되도록 구현한 과정에 대해서 글을 적어보려고 한다.

Enum을 통해 Tag, Error 구조화

가장 먼저 Tag를 사용해야 한다. tag는 텍스트 필드를 구분해주는 identifier의 역할을 한다.

enum ProfileComposeTextFieldTag: Int {
case nickname = 1
case email = 2
case phoneNumber = 3
}

관리하는 텍스트 필드 개수만큼 지정해주고 TextField를 만들어준다.

이제 에러의 종류를 구성해준다.

enum ProfileComposeTextFieldError: String {
case none = ""
case nicknameTooLong = "닉네임은 최대 25자까지 설정할 수 있습니다."
case invalidPhoneNumber = "연락처에는 '-' 을 제외한 숫자만 입력 가능합니다."
case phoneNumberTooLong = "연락처에는 최대 10자까지 입력할 수 있습니다."
}

이렇게 에러의 종류와 각 에러마다 보여줄 메시지를 지정해준다. 만약 다국어 지원을 한다면 Localizable의 키를 RawValue에 넣고 텍스트를 보여주면 된다.

func setupTextFields() {
view.nicknameTextField.textField.delegate = self
view.phoneNumberTextField.textField.delegate = self
view.nicknameTextField.textField.tag = ProfileComposeTextFieldTag.nickname.rawValue
view.emailTextField.textField.tag = ProfileComposeTextFieldTag.email.rawValue
view.phoneNumberTextField.textField.tag = ProfileComposeTextFieldTag.phoneNumber.rawValue
}

이제 각 텍스트필드에 Tag를 지정해준다. 이렇게 Tag와 Error로 먼저 구조를 잡아준다.

Validation

이제 각 텍스트 필드의 들어가는 Text의 내용이 유효한지 판단하는 코드를 작성해준다. 코드는 TextFieldDelegate에서 사용할 예정이므로 이 텍스트를 적용할지에 대한 Bool 값을 리턴해준다.

그리고 만약 유효하지 않다면 ViewModel의 에러를 지정해준다.

private func validateNicknameTextField(_ text: String) -> Bool {
if text.count > ProfileComposeTextFieldHandler.nicknameMaxLength {
viewModel.updateNicknameError(.nicknameTooLong)
return false
}

viewModel.updateNicknameError(.none)
return true
}

private func validatePhoneNumber(_ text: String, replacementString string: String) -> Bool {
if text.count > ProfileComposeTextFieldHandler.phoneNumberMaxLength {
viewModel.updatePhoneNumberError(.phoneNumberTooLong)
return false
}

let allowedCharacters = CharacterSet.decimalDigits
let characterSet = CharacterSet(charactersIn: string)

if !allowedCharacters.isSuperset(of: characterSet) {
viewModel.updatePhoneNumberError(.invalidPhoneNumber)
return false
}

viewModel.updatePhoneNumberError(.none)
return true
}

코드가 굉장히 읽기 편하고 깔끔하다.

View, ViewModel

이제 위에서 돌려받은 Bool을 기준으로 Delegate의 shouldChangeCharactersIn을 작성해준다.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
guard let tag = ProfileComposeTextFieldTag(rawValue: textField.tag) else { return false }
let currentText = textField.text ?? ""
let newText = (currentText as NSString).replacingCharacters(in: range, with: string)

switch tag {
case .nickname:
if validateNicknameTextField(newText) {
viewModel.updateNicknameText(newText)
return true
} else {
return false
}
case .email:
return false
case .phoneNumber:
if validatePhoneNumber(newText, replacementString: string) {
viewModel.updatePhoneNumberText(newText)
return true
} else {
return false
}
}
}

text가 validate하다면 true로 TextField를 업데이트 하도록 하고, viewModel에서도 text를 관리할 수 있도록 해준다.

final class DefaultProfileComposeViewModel: ProfileComposeViewModel {
...
@Published var nicknameText: String = ""
var nicknameTextPublisher: Published<String>.Publisher { $nicknameText }
@Published var phoneNumberText: String = ""
var phoneNumberTextPublisher: Published<String>.Publisher { $phoneNumberText }

@Published var nicknameError: ProfileComposeTextFieldError = .none
var nicknameErrorPublisher: Published<ProfileComposeTextFieldError>.Publisher { $nicknameError }
@Published var phoneNumberError: ProfileComposeTextFieldError = .none
var phoneNumberErrorPublisher: Published<ProfileComposeTextFieldError>.Publisher { $phoneNumberError }
...
}

ViewModel에서는 이렇게 텍스트와 에러를 관리하게 된다. Publisher를 통해 ViewController에서 바인딩할 수 있도록 설정해준다.

viewModel.nicknameErrorPublisher.sink { [weak self] error in
switch error {
case .nicknameTooLong:
self?.profileComposeView.nicknameTextField.errorText = ProfileComposeTextFieldError.nicknameTooLong.rawValue
default:
self?.profileComposeView.nicknameTextField.errorText = ""

}
}
.store(in: &cancellables)

viewModel.phoneNumberErrorPublisher.sink { [weak self] error in
switch error {
case .invalidPhoneNumber:
self?.profileComposeView.nicknameTextField.errorText = ProfileComposeTextFieldError.invalidPhoneNumber.rawValue
case .phoneNumberTooLong:
self?.profileComposeView.phoneNumberTextField.errorText = ProfileComposeTextFieldError.phoneNumberTooLong.rawValue
default:
self?.profileComposeView.nicknameTextField.errorText = ""
}
}
.store(in: &cancellables)

이제 UI 관련된 코드는 ViewController에 작성해준다. 위와 같이 Publish된 에러들을 각 UI에 연결해주면 된다.

--

--

peppermint100
PEPPERMINT100

기억하기 위해 또는 잊어버리기 위해 작성하는 블로그입니다.