본문 바로가기
iOS/RxSwift

RxSwift + MVVM(Input / Output)패턴 회원가입 화면 적용

by iOS rOar 2023. 2. 22.

만들어 볼 회원가입 화면


UI까지 똑같이 만드시려면 참고하시면 되는 CustomView코드

0000


Input/Output은 왜 필요할까? 생각해보면 ViewController와 ViewModel간의 흐름이

굉장히 직관적이게 된다.

MVVM패턴을 적용시키는데에 있어서 강제적으로 좋은 구조를 적용시키게끔? 해주는 것 같다.

여러가지 방법이 있겠지만 내 주관적인 방법대로 흐름을 정리해보고자 한다.

(전체 코드는 아래에...)

1. Input은 사용자가 UI에 입력하는 이벤트를 받아오는 역할.
2. Output은 Input으로 들어온 사용자 이벤트를 어떠한 가공을 통해 방출하는 역할.

emailTextField의 유효성 검증을 해서 아래 Label에 반응형으로 표현한다고 가정해보자.

1. 가장 먼저 TextField에서 사용자가 발생시키는 어떤 이벤트를 가지고 유효성 검증을 하는가?
-> String값을 가지고 유효성 검증을 하게 될 것이다.
TextField는 초기값이 있는가?? -> 없다.
그렇기에 Signal<String>과 emit을 써주려고 한다.


final class SignUpViewModel: ViewModelType {
	
    struct Input {
    	let emailTextField: Signal<String>
    }
	
    struct Output {
    	
    }
    var disposeBag = DisposeBag()
	
    func transform(input: Input) -> Output {
    	
        return Output()
    }
}


2. 유효성 검증을 하고 얻고자 하는 값이 무엇인가?에 대한 생각을 해보자.
유효성 통과 or 실패 Bool값을 얻어야 할 것이다.
이 Bool값을 가지고 UI에 변화를 주어야 하니까 Relay를 사용하는게 좋을것이고,
내 코드 상으로는 초기값이 있어야 처음 UI가 바인딩 되는 시점에 제대로 나타난다.
그렇기에 BehaviorRelay<Bool>()을 생성해 줄 것이다.

final class SignUpViewModel: ViewModelType {
	
    private let emailValid = BehaviorRelay<Bool>(value: false)
	
    struct Input {
    	let emailTextField: Signal<String>
    }
	
    struct Output {
    	
    }
    var disposeBag = DisposeBag()
	
    func transform(input: Input) -> Output {
    	
        return Output()
    }
}


3. 이제 만들어 준 emailValid 프로퍼티에 유효성 검증 성공, 실패에 대한 Bool값을 accept시켜주면 된다.

final class SignUpViewModel: ViewModelType {
	
    private let emailValid = BehaviorRelay<Bool>(value: false)
    
    struct Input {
    	let emailTextField: Signal<String>
    }
	
    struct Output {
    	
    }
    var disposeBag = DisposeBag()
	
    func transform(input: Input) -> Output {
    	input.emailTextField
        	.map { $0.isValidEmail() }
            .withUnretained(self)
            .emit { vc, value in
            	vc.emailValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        
        return Output()
    }
}


4. emailValid에 저장된 값을 Output으로 내보내서 ViewController UI에 적용시켜주면 된다.
그럼 결과적으로 가공된 값의 타입은 BehaiorRelay<Bool>에 저장되어 있는것인데,
초기값이 있고, UI에 적용시키니까 Driver와 Drive를 쓰면 좋을 것 같다.
그럼 Driver<Bool> 타입으로 내보내면 될 것이다.

final class SignUpViewModel: ViewModelType {
	
    private let emailValid = BehaviorRelay<Bool>(value: false)
    
    struct Input {
    	let emailTextField: Signal<String>
    }
	
    struct Output {
    	let emailValid: Driver<Bool>
    }
    var disposeBag = DisposeBag()
	
    func transform(input: Input) -> Output {
    	input.emailTextField
        	.map { $0.isValidEmail() }
            .withUnretained(self)
            .emit { vc, value in
            	vc.emailValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        
        return Output()
    }
}


5. transform 함수에서 return Output을 해야하는데, BehaviorRelay에 저장된 값을 Driver 타입으로 내보내야 하는것이다.

final class SignUpViewModel: ViewModelType {
	
    private let emailValid = BehaviorRelay<Bool>(value: false)
    
    struct Input {
    	let emailTextField: Signal<String>
    }
	
    struct Output {
    	let emailValid: Driver<Bool>
    }
    var disposeBag = DisposeBag()
	
    func transform(input: Input) -> Output {
    	input.emailTextField
        	.map { $0.isValidEmail() }
            .withUnretained(self)
            .emit { vc, value in
            	vc.emailValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        
        return Output(emailValid: emailValid.asDriver(onErrorJustReturn: false))
    }
}


6. 이제 ViewController에서 가공된 값을 가지고 UI에 적용시켜주면 되는것이다.!

final class SignUpViewController: UIViewController {
    
    //MARK: - UI
    
    private var emailTextField = DefaultTextField(placeHolder: "Email")
    private var emailValidLabel = DefaultLabel()
    
    //MARK: - Properties
    
    private lazy var input = SignUpViewModel.Input(
        emailTextField: emailTextField.rx.text.orEmpty.asSignal())
    private lazy var output = viewModel.transform(input: input)
    private let viewModel: SignUpViewModel
    private var disposeBag = DisposeBag()
    
    //MARK: - Init

    init(viewModel: SignUpViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    //MARK: - LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
        setConstraints()
        bind()
    }
    
    //MARK: - SetUI

    private func configureUI() {
    	view.addSubView(emailTextField)
        view.addSubView(emailValidLabel)
    }
    
    private func setConstraints() {
        // layout
    }
    
    //MARK: - Method
    
    private func bind() {
        output.emailValid
            .drive(emailValidLabel.rx.isValid)
            .disposed(by: disposeBag)
        
    }
}


이런 식으로 하나씩 적용하다 보면 마지막으로 적용시켜봐야 할 부분은
password와 passwordCheck
그리고 email과 password가 모두 유효한지에 따라 버튼 색 변경.

방법론은 동일하지만 combineLatest Operator를 사용해 보았다.

final class SignUpViewModel: ViewModelType {
    
    private weak var coordinator: LoginCoordinator?
    
    private let emailValid = BehaviorRelay<Bool>(value: false)
    private let passwordValid = BehaviorRelay<Bool>(value: false)
    private let passwordCheckValid = BehaviorRelay<Bool>(value: false)
    private let signUpValid = BehaviorRelay<Bool>(value: false)
    private let makeToast = PublishRelay<String>()
    
    struct Input {
        let nicknameTextField: Signal<String>
        let emailTextField: Signal<String>
        let passwordTextField: Signal<String>
        let passwordCheckTextField: Signal<String>
        let signUpButtonTapped: Signal<Void>
    }
    
    struct Output {
        let emailValid: Driver<Bool>
        let passwordCheckValid: Driver<Bool>
        let signUpValid: Driver<Bool>
        let makeToast: Signal<String>
    }
    var disposeBag = DisposeBag()
    
    init(coordinator: LoginCoordinator?) {
        self.coordinator = coordinator
    }
    
    func transform(input: Input) -> Output {
        
        input.emailTextField
            .map { $0.isValidEmail() }
            .withUnretained(self)
            .emit { vc, value in
                vc.emailValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        input.passwordTextField
            .map { $0.count > 8 }
            .withUnretained(self)
            .emit { vc, value in
                vc.passwordValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        Observable.combineLatest(input.passwordTextField.asObservable(), input.passwordCheckTextField.asObservable())
            .map { $0 == $1 && !$0.isEmpty && !$1.isEmpty }
            .withUnretained(self)
            .bind { vc, value in
                vc.passwordCheckValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        Observable.combineLatest(emailValid, passwordCheckValid)
            .map { $0 && $1 }
            .withUnretained(self)
            .bind { vc, value in
                vc.signUpValid.accept(value)
            }
            .disposed(by: disposeBag)
        
        input.signUpButtonTapped
            .withLatestFrom(signUpValid.asSignal(onErrorJustReturn: false))
            .withUnretained(self)
            .emit { vc, value in
                if value == true {
                    vc.coordinator?.popToLoginViewController()
                } else {
                    vc.makeToast.accept("Please Check Information")
                }
            }
            .disposed(by: disposeBag)
        
        
        return Output(
            emailValid: emailValid.asDriver(onErrorJustReturn: false),
            passwordCheckValid: passwordCheckValid.asDriver(onErrorJustReturn: false),
            signUpValid: signUpValid.asDriver(onErrorJustReturn: false),
            makeToast: makeToast.asSignal(onErrorJustReturn: ""))
    }
}
final class SignUpViewController: UIViewController {
    
    //MARK: - UI
    
    private var nickNameTextField = DefaultTextField(placeHolder: "Username")
    private var emailTextField = DefaultTextField(placeHolder: "Email")
    private var passwordTextField = DefaultTextField(placeHolder: "Password")
    private var passwordCheckTextField = DefaultTextField(placeHolder: "Password")
    
    private var emailValidLabel = DefaultLabel()
    private var passwordValidLabel = DefaultLabel()
    
    private var signUpButton = DefaultButton(title: "Sign Up")
    
    //MARK: - Properties
    
    private lazy var input = SignUpViewModel.Input(
        nicknameTextField: nickNameTextField.rx.text.orEmpty.asSignal(onErrorJustReturn: ""),
        emailTextField: emailTextField.rx.text.orEmpty.asSignal(onErrorJustReturn: ""),
        passwordTextField: passwordTextField.rx.text.orEmpty.asSignal(onErrorJustReturn: ""),
        passwordCheckTextField: passwordCheckTextField.rx.text.orEmpty.asSignal(onErrorJustReturn: ""),
        signUpButtonTapped: signUpButton.rx.tap.asSignal())
    private lazy var output = viewModel.transform(input: input)
    private let viewModel: SignUpViewModel
    private var disposeBag = DisposeBag()
    
    //MARK: - Init

    init(viewModel: SignUpViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    //MARK: - LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
        setConstraints()
        bind()
    }
    
    //MARK: - SetUI

    private func configureUI() {
        view.backgroundColor = .black
        [nickNameTextField, emailTextField, passwordTextField,
         passwordCheckTextField, emailValidLabel, passwordValidLabel, signUpButton]
            .forEach { view.addSubview($0) }
    }
    
    private func setConstraints() {
        nickNameTextField.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide).offset(40)
            make.horizontalEdges.equalToSuperview().inset(12)
            make.height.equalTo(44)
        }
        emailTextField.snp.makeConstraints { make in
            make.top.equalTo(nickNameTextField.snp.bottom).offset(12)
            make.horizontalEdges.equalToSuperview().inset(12)
            make.height.equalTo(44)
        }
        emailValidLabel.snp.makeConstraints { make in
            make.top.equalTo(emailTextField.snp.bottom).offset(8)
            make.centerX.equalTo(emailTextField.snp.centerX)
        }
        passwordTextField.snp.makeConstraints { make in
            make.top.equalTo(emailValidLabel.snp.bottom).offset(12)
            make.horizontalEdges.equalToSuperview().inset(12)
            make.height.equalTo(44)
        }
        passwordCheckTextField.snp.makeConstraints { make in
            make.top.equalTo(passwordTextField.snp.bottom).offset(12)
            make.horizontalEdges.equalToSuperview().inset(12)
            make.height.equalTo(44)
        }
        passwordValidLabel.snp.makeConstraints { make in
            make.top.equalTo(passwordCheckTextField.snp.bottom).offset(8)
            make.centerX.equalTo(passwordCheckTextField.snp.centerX)
        }
        signUpButton.snp.makeConstraints { make in
            make.top.equalTo(passwordValidLabel.snp.bottom).offset(36)
            make.horizontalEdges.equalToSuperview().inset(12)
            make.height.equalTo(44)
        }
    }
    
    //MARK: - Method
    
    private func bind() {
        
        output.emailValid
            .drive(emailValidLabel.rx.isValid)
            .disposed(by: disposeBag)
        
        output.passwordCheckValid
            .drive(passwordValidLabel.rx.isValid)
            .disposed(by: disposeBag)
        
        output.signUpValid
            .drive(signUpButton.rx.isValid)
            .disposed(by: disposeBag)
        
        output.makeToast
            .withUnretained(self)
            .emit { vc, value in
                vc.view.makeToast(value, duration: 0.5, position: .top)
            }
            .disposed(by: disposeBag)
        
    }
    
}


Coordinator패턴도 적용시켜서 사이드 프로젝트를 하고있는데, 역시 디자인 패턴은 어렵다.
Coordinator패턴도 나만의 직관적인 이해 방식이 생긴다면.. 기록해야지.

-끝-





































'iOS > RxSwift' 카테고리의 다른 글

RxSwift 학습내용 정리  (0) 2023.02.14