목차
머리말
구현 화면
MVVM 개념 복습
MVVM x Input-Output x RxSwift에 대한 블로그 링크
RxSwift로 MVVM 패턴에서 반응형 이메일 입력 화면 구현하기
0) 개요
- ViewController(이하 VC)에서 화면으로 받은 입력 값을, ViewModel(이하 VM) Input의 인스턴스인 "input"으로 받고 있다.
- 입력 값은 viewModel(VM의 인스턴스)의 transform 메서드를 거쳐 가공되어 출력 값(output; Output의 인스턴스)이 된다.
- 출력 값은 다시 VC의 화면에 보여진다.
1) ViewController x input
화면의 입력 값을 뷰모델의 로직으로 전달하기
VM의 Input 타입에 대한 인스턴스를 input(텍스트 필드와 버튼 클릭)으로 받고 있다.
// ViewController
final class UserSigninViewController: BaseViewController {
private lazy var textField = SigninTextField()
private lazy var nextButton = SigninButton()
private let viewModel = UserSigninViewModel()
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
private func bind() {
let input = UserSigninViewModel.Input(text: textField.rx.text.orEmpty, nextButtonClicked: nextButton.rx.tap)
let output = viewModel.transform(input: input)
}
}
2) ViewModel x Input x transform x Output
a. 뷰모델에서 입력 값을 받아 이를 가공하여 출력 값으로 반환하기
1번의 input을 viewModel의 transform에서 가공하는 로직은 아래와 같다.
// ViewModel
final class UserSigninViewModel: ViewModelType {
struct Input {
let text: ControlProperty<String>
let nextButtonClicked: ControlEvent<Void>
}
struct Output {
let isTextValid: Observable<Bool>
}
func transform(input: Input) -> Output {
let isValid = input
.text
.throttle(.seconds(1), scheduler: MainScheduler.instance)
.map { $0.contains("@") && $0.contains(".") && $0.count >= 6 && $0.count < 50 }
return Output(isTextValid: isValid)
}
}
완벽한 로직은 아니지만, 과호출을 방지해 throttle을 사용하였고, map을 이용해 1차적인 유효성 검사를 진행했다.
(2023-11-16 추가)
(+) 단순히 contains나 count가 아닌 정규표현식(RegEx)를 사용하면 더 좋을 것 같다.
b. 초깃값을 가진 `BehaviorRelay`
처음에는 초깃값(false)을 가져야 하는 것에 초점을 맞췄다. 따라서 BehaviorRelay를 생각하여 이를 다시 bind하는 걸 생각했다.
RxSwift에서 `map<Result>` 메서드의 반환값은 `Observable<Result>`
하지만 이미 Input으로 받은 text를 map 메서드를 사용할 경우 반환 타입은 Observable이다. 따라서 단순한 텍스트 입력의 유효성 검사에서 굳이 BehaviorRelay를 이용해 한번 더 bind할 필요는 없었다.
// RxSwift에서 map 메서드
public func map<Result>(_ transform: @escaping (Element) throws -> Result)
-> Observable<Result> { // 반환 타입
Map(source: self.asObservable(), transform: transform)
}
UI에 특화되어 accept만 방출하는 Relay
(2023-11-16 추가)
하지만 error, complete 발생 시 시퀀스가 종료되어 실행할 수 없는 Observable과는 다르게 UI에 특화되어 error, complete에도 종료되지 않고 accept만 방출하는 Relay도 다시 생각해보는 게 좋을 것 같다.
리팩토링 전후 비교
리팩토링 이전 (BehaviorRelay)
final class UserSigninViewModel: ViewModelType {
struct Input {
let text: ControlProperty<String>
let nextButtonClicked: ControlEvent<Void>
}
struct Output {
let isTextValid: BehaviorRelay<Bool>
}
private let disposeBag = DisposeBag()
func transform(input: Input) -> Output {
// BehaviorRelay 사용
let isValid = BehaviorRelay(value: false)
input
.text
.throttle(.seconds(1), scheduler: MainScheduler.instance)
.map { $0.contains("@") && $0.contains(".") && $0.count >= 6 && $0.count < 50 }
.bind(to: isValid) // 입력 값을 BehaviorRelay에 다시 bind
.disposed(by: disposeBag)
return Output(isTextValid: isValid)
}
}
리팩토링 이후 (Observable)
final class UserSigninViewModel: ViewModelType {
struct Input {
let text: ControlProperty<String>
let nextButtonClicked: ControlEvent<Void>
}
struct Output {
let isTextValid: Observable<Bool> // 타입 변경
}
func transform(input: Input) -> Output {
// map의 반환 값을 이용해 Observable를 곧바로 출력 값으로 반환
let isValid = input
.text
.throttle(.seconds(1), scheduler: MainScheduler.instance)
.map { $0.contains("@") && $0.contains(".") && $0.count >= 6 && $0.count < 50 }
return Output(isTextValid: isValid)
}
}
3) ViewController x output
뷰모델에서 가공한 출력 값을 다시 화면으로 업데이트
이제 VC의 output을 다시 VC의 원하는 뷰에 보여주면 된다 :)
final class UserSigninViewController: BaseViewController {
private lazy var textField = SigninTextField()
private lazy var nextButton = SigninButton()
private let viewModel = UserSigninViewModel()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
private func bind() {
let input = UserSigninViewModel.Input(text: textField.rx.text.orEmpty, nextButtonClicked: nextButton.rx.tap)
let output = viewModel.transform(input: input)
output
.isTextValid
.bind(with: self, onNext: { owner, value in
let color = value ? Constant.Color.Button.valid : Constant.Color.Button.notValid
owner.nextButton.backgroundColor = color
owner.nextButton.isEnabled = value
})
.disposed(by: disposeBag)
}
}
댓글