본문 바로가기
iOS_Swift 앱개발👍

[iOS_Swift] RxSwift를 이용한 로그인 화면 만들기 _ 28

by 개발하는윤기사 2022. 11. 6.
728x90
반응형

안녕하세요~ 개발하는 윤기사입니다!

 

이번 포스팅은 RxSwift를 이용한 실습을 해볼까 합니다! 

 

우리 이웃님들께 가장 쉽게 알려드릴 것이 뭘지 생각하다가 로그인 화면이 제일 괜찮지 않을까 해서 가져왔습니다!

 

 

RxSwift 간편하지만 적응하려면 쫌 걸릴듯..

 

 

🍎 RxSwift를 이용한 로그인 화면 만들기

 

저는 3가지의 코드를 다룰 겁니다.

 

1) RxSwift만을 이용한 Databinding

2) RxSwift + Input/Output을 이용한 Databinding

3) RxSwift + Input/Output + MVVM 패턴을 이용한 Databinding

 

이렇게 차근차근 올라가 보도록 하겠습니다! 가장 마지막에 전체 코드가 있으니 복사하셔서 실습하시면 좋을 것 같습니다.

 

바로 시작해보죠!

 

 

기본적인 UI 설정을 합니다. 2개의 텍스트 필드와, 2개의 Validation 표시해주는 View, 1개의 로그인 버튼으로 구성되어있습니다!

UI 설정하기!

 

이메일과 비밀번호의 Validation을 체크해주는 함수를 만들어줬습니다.

이메일과 비밀번호 체크

 

 

1) RxSwift만을 이용한 Databinding

 

1. 각각 텍스트 필드에 rx.text 해줍니다. 그러면 Optional String을 받게 되는데 String을 바로 받기 위해서 .orEmpty를 뒤에 붙여줍니다.

2. map을 써서 checkEmailValid func을 적용시켜 return 값인 Bool 타입을 받게 합니다.

3. subscribe(onNext: )를 이용해 구독해줍니다. 방법은 bind(to: ), bind(onNext: ), drive() 방식 등 여러 가지가 있지만 여기선 subscribe(onNext: )를 이용하겠습니다.

4. 구독을 통해 얻은 value 값은 map(checkEmailValid)를 통해 내려왔기 때문에 Bool 타입을 가지고 있을 겁니다. value 값을 이용해 Email의 Validation을 구별해주는 View를 바꿔주는 겁니다.

5. 마지막에 disposed(by: disposeBag)을 선언함으로써 dispose 해주면 됩니다.

 

CombineLatest

6. 저희는 Email과 Password 두 가지의 항목을 배출해야 하기 때문에 combineLatest를 씁니다. 안에 Source1과 Source2에 각각 텍스트 필드의 이메일 체크, 비밀번호 체크가 들어가 있는 Source를 넣어줍니다. 그리고 Source1과 Source2가 동시에 만족할 때 로그인 버튼을 활성화시켜줍니다.

 

🔥 CombineLatest : Observable 중 하나라도 항목을 배출할 경우에 마지막으로 배출된 항목들을 결합시켜 배출하는 법입니다.

 

 

2) RxSwift + Input/Output을 이용한 Databinding

 

1. Input과 Output을 나눕니다. 쉽게 말해서 Input은 사용자가 입력하거나 버튼을 누르는 행동을 뜻하고, Output은 그 행동으로 얻게 되는 결과라고 보시면 됩니다.

저희가 하고 있는 로그인 화면에서 Input과 Output은 각각 무엇일까요?

Input은 [이메일 입력하기], [비밀번호 입력하기], [이메일이 맞는지 확인해주기], [비밀번호가 맞는지 확인해주기]가 되겠고,

Output은 [빨간 View 조절하기], [로그인 버튼 활성화 하기] 정도가 될 겁니다.

한 가지 주의하실 건 TextField.rx.text은 ControlProperty<String> 타입입니다.

Button.rx.tap은 ControEvent<Void>랍니다.

1번에서 했던 RxSwift를 이용했던 예시와 다른 거라곤 Input과 Output을 나눠준 것뿐입니다.

 

3) RxSwift + Input/Output + MVVM 패턴을 이용한 Databinding

😎 대망의 MVVM, Rx와 Input/Output을 이용한 Databinding입니다.

ViewModel

1. ViewModel을 하나 만들어줍니다. 2번 예시해서 했던 Input과 Output을 구조체로 나눠줍니다. 2번에서 알려드렸다시피 TextField의 입력값은 ControlProperty <String> 타입, 버튼은 ControlEvent <Void>, Validation 체크해주는 것은 Observable <Bool> 타입입니다.

 

🍎 꿀팁 : 프로토콜을 이용한 Input / Output 프로토콜을 만들어 줄 수 있습니다 ^^

 

2. transform 함수를 이용해서 Input 값을 받아서 Output 값으로 리턴해줍니다. 아까 얘기했듯이 Output은 [텍스트 필드 옆에 있는 2개의 View 조절]과 [로그인 버튼 활성화]입니다.

 

3. 그리곤 ViewController에서 input과 output을 ViewModel로부터 받아와서 사용하면 됩니다.

4. 이번 3번째 예시에서는 Validation 체크해주는 View를 숨기는 게 아닌 색상을 바꿔줘 봤습니다. 

5. 로그인 버튼 활성화해주는 것은 drive를 사용해서 처리했습니다. 

완성한 결과!!

 

전체 코드 (ViewModel / ViewController)

//
//  LoginViewModel.swift
//  RxSwift_Study
//
//  Created by 윤여진 on 2022/11/06.
//

import Foundation

import RxCocoa
import RxSwift

protocol CommonViewModel {
    
    associatedtype Input
    associatedtype Output
    
    func transform(input: Input) -> Output
}

final class LoginViewModel: CommonViewModel {
  
    struct Input {
        let idText: ControlProperty<String>
        let pwText: ControlProperty<String>
        let loginTap: ControlEvent<Void>
    }
    
    struct Output {
        let idValid: Observable<Bool>
        let pwValid: Observable<Bool>
        let isValid: Observable<Bool>
        let loginTap: ControlEvent<Void>
    }
    
    func transform(input: Input) -> Output {
        
        let idValid = input.idText
            .map { $0.contains("@") && $0.contains(".") }
            .share()
        
        let pwValid = input.pwText
            .map { $0.count > 5 }
            .share()
        
        var isValid: Observable<Bool> {
            return Observable
                .combineLatest(input.idText, input.pwText)
                .map { email, password in
                    return email.contains("@") && email.contains(".") && password.count > 5
                }
        }
        
        return Output(idValid: idValid, pwValid: pwValid, isValid: isValid, loginTap: input.loginTap)
    }
    
}
//
//  LoginViewController.swift
//  RxSwift_Study
//
//  Created by 윤여진 on 2022/11/06.
//

import UIKit

import RxCocoa
import RxSwift
import SnapKit
import Then

final class LoginViewController: UIViewController {
    
    //MARK: UI Setting
    
    let idTextField = UITextField().then {
        $0.placeholder = "E-mail"
        $0.backgroundColor = .systemBackground
    }
    let idValidView = UIView().then {
        $0.backgroundColor = .systemRed
    }
    let pwTextField = UITextField().then {
        $0.placeholder = "PassWord"
        $0.backgroundColor = .systemBackground
    }
    let pwValidView = UIView().then {
        $0.backgroundColor = .systemRed
    }
    let loginButton = UIButton().then {
        $0.setTitle("로그인하기", for: .normal)
        $0.backgroundColor = .systemMint
    }
    
    let viewModel = LoginViewModel()
    
    var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureUI()
        setConstraints()
        
        viewModelInputOutputBind()
        
        loginButton.addTarget(self, action: #selector(loginButtonClicked), for: .touchUpInside)
    }
    
    //MARK: 로그인 버튼 클릭 시 Alert 띄우기
    
    @objc func loginButtonClicked() {
        
        let alert = UIAlertController(title: "RxSwift 재밌어요!", message: "어렵기는 하지만..", preferredStyle: .alert)
        let cancel = UIAlertAction(title: "취소", style: .cancel)
        let ok = UIAlertAction(title: "확인", style: .default)
        alert.addAction(cancel)
        alert.addAction(ok)
        self.present(alert, animated: true)
        
    }
    
    //MARK: ConfigureUI
    
    private func configureUI() {
        
        [idTextField, idValidView, pwTextField, pwValidView, loginButton].forEach {
            self.view.addSubview($0)
        }
        
    }
    
    //MARK: SetConstraints
    
    private func setConstraints() {
        
        idTextField.snp.makeConstraints { make in
            make.leading.top.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
            make.height.equalTo(50)
        }
        idValidView.snp.makeConstraints { make in
            make.trailing.equalTo(idTextField.snp.trailing).inset(8)
            make.top.equalTo(idTextField.snp.top).inset(8)
            make.width.height.equalTo(20)
        }
        pwTextField.snp.makeConstraints { make in
            make.top.equalTo(idTextField.snp.bottom).offset(16)
            make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
            make.height.equalTo(50)
        }
        pwValidView.snp.makeConstraints { make in
            make.trailing.equalTo(pwTextField.snp.trailing).inset(8)
            make.top.equalTo(pwTextField.snp.top).inset(8)
            make.width.height.equalTo(20)
        }
        loginButton.snp.makeConstraints { make in
            make.top.equalTo(pwTextField.snp.bottom).offset(16)
            make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
            make.height.equalTo(50)
        }
        
    }
    
    //MARK: MVVM & RxSwift & Input/Output
    
    private func viewModelInputOutputBind() {
        
        let input = LoginViewModel.Input(idText: idTextField.rx.text.orEmpty, pwText: pwTextField.rx.text.orEmpty, loginTap: loginButton.rx.tap)
        
        let output = viewModel.transform(input: input)

        
        output.idValid
            .map { $0 == true ? UIColor.systemMint : UIColor.systemRed}
            .bind(to: self.idValidView.rx.backgroundColor)
            .disposed(by: disposeBag)
        
        output.pwValid
            .map { $0 == true ? UIColor.systemMint : UIColor.systemRed}
            .bind(to: self.pwValidView.rx.backgroundColor)
            .disposed(by: disposeBag)
        
        output.isValid
            .asDriver(onErrorJustReturn: false)
            .drive(loginButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        output.isValid
            .map { $0 == true ? UIColor.systemMint : UIColor.opaqueSeparator }
            .asDriver(onErrorJustReturn: .opaqueSeparator)
            .drive(loginButton.rx.backgroundColor)
            .disposed(by: disposeBag)
        
    }
    
    
    //MARK: RxSwift & Input/Output

    private func inputOutputBind() {
        
        //MARK: Input, Output
        //Input: 아이디 입력, 비밀번호 입력
        let idInput: ControlProperty<String> = idTextField.rx.text.orEmpty
        let pwInput: ControlProperty<String> = pwTextField.rx.text.orEmpty
        
        let idValid = idInput.map(checkEmailValid) //Bool 타입
        let pwValid = pwInput.map(checkPasswordValid) //Bool 타입
        
        //Output: 빨간 View, 로그인 버튼
        idValid.subscribe { value in
            self.idValidView.isHidden = value
        } onError: { error in
            print(error)
        } .disposed(by: disposeBag)
        
        pwValid.subscribe { value in
            self.pwValidView.isHidden = value
        } onError: { error in
            print(error)
        } .disposed(by: disposeBag)
        
        Observable.combineLatest(idValid, pwValid, resultSelector: { $0 && $1 })
            .subscribe { value in
                self.loginButton.isEnabled = value
            } onError: { error in
                print(error)
            } .disposed(by: disposeBag)
        
    }
    
    //MARK: RxSwift
    
    private func bind() {
        
        idTextField.rx.text.orEmpty //text를 받는다. -> orEmpty : String이 바로 내려옴
            .map(checkEmailValid) // Bool 타입으로 온다.
            .subscribe(onNext: { value in
                self.idValidView.isHidden = value
            })
            .disposed(by: disposeBag)
        
        pwTextField.rx.text.orEmpty //text를 받는다. -> orEmpty : String이 바로 내려옴
            .map(checkPasswordValid) // Bool 타입으로 온다.
            .subscribe(onNext: { value in
                self.pwValidView.isHidden = value
            })
            .disposed(by: disposeBag)
        
        //CombineLatest : Observable 중 하나라도 항목을 배출할 경우에 마지막으로 배출된 항목들을 결합시켜 배출하는 법 (ID, Password)
        //Zip과 비교해보기
        Observable.combineLatest(
            idTextField.rx.text.orEmpty.map(checkEmailValid),
            pwTextField.rx.text.orEmpty.map(checkPasswordValid)) { s1, s2 in
                s1 && s2 //2개의 Stream을 받아서 내려간다.
            }.subscribe { value in
                self.loginButton.isEnabled = value
            }.disposed(by: disposeBag)
        
        
    }
    
    private func checkEmailValid(_ email: String) -> Bool {
        return email.contains("@") && email.contains(".")
    }
    
    private func checkPasswordValid(_ password: String) -> Bool {
        return password.count > 5
    }
    
}

 

이번 포스팅은 여기서 마치겠습니다~

 

Rxswift 관련된 내용은 앞으로도 포스팅 해드리겠습니다!

 

윤기사는 오늘도 빡코딩하겠습니다 ^^

728x90
반응형