iOS 프로젝트/프로젝트 경진대회 (LSLP)

LSLP) RxSwift를 이용한 반응형 이메일 입력 화면 구현 (feat: MVVM & BehaviorRelay)

Dev.Andy 2023. 11. 15. 23:58

목차

    머리말

    구현 화면

    MVVM 개념 복습

    MVVM x Input-Output x RxSwift에 대한 블로그 링크

    RxSwift로 MVVM 패턴에서 반응형 이메일 입력 화면 구현하기

    0) 개요

    1. ViewController(이하 VC)에서 화면으로 받은 입력 값을, ViewModel(이하 VM) Input의 인스턴스인 "input"으로 받고 있다.
    2. 입력 값은 viewModel(VM의 인스턴스)의 transform 메서드를 거쳐 가공되어 출력 값(output; Output의 인스턴스)이 된다.
    3. 출력 값은 다시 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할 필요는 없었다.

    GitHub PR & comment 링크

    // 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)
        }
     }