감자주먹밥

MVVM - UIKit + Combine 본문

Design Pattern

MVVM - UIKit + Combine

JustHm 2023. 4. 19. 21:42
728x90
참고한 유튜브 자료

MVVM 아키텍처를 처음 공부했을 땐 RxSwift로 시작을 했었는데, Combine을 알고 나서 UIKit에서 Combine으로 MVVM을 할 수 있지 않을까 라는 생각에 찾아보고 시도해봤다.

유튜브 예제는 통신 예제에 버튼을 누르면 랜덤한 글이 나오게 하는 것이고, 내가 한 건 로그인 화면을 구현한 것이다.


프로젝트 세팅은 Storyboard로 진행했고 Combine을 제외한 라이브러리는 사용하지 않았다.

간단한 로그인 회원가입 화면을 만들고 MVVM을 적용해봤다.

final class SignInViewController: UIViewController, UITextFieldDelegate {

    @IBOutlet weak var signInButton: UIButton!
    
    private let vm = SignViewModel()
    private let input: PassthroughSubject<SignViewModel.Input, Never> = .init()
    private var bag = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    }

    @IBAction func passwordFieldChanged(_ sender: UITextField) {}
    @IBAction func idTextFieldChanged(_ sender: UITextField) {}
    @IBAction func signInButtonTapped(_ sender: UIButton) {}
}

먼저 회원가입, 로그인 뷰는 다음과 같이 만들었다. 로그인 화면에 SignUp 버튼은 SignUp 화면으로 넘어가게 해 놨다.

연결한 ViewModel과 연결한 후 이벤트를 보낼 PassthroughSubject를 미리 정의해 놓는다. combine을 사용하면서 Cancellable을 관리하기 위해 Set<AnyCancellable> 타입의 변수를 생성한다. 참고로 AnyCancellable로 wrap된 Cancellable은 사용이 종료되면 자동으로 cancel된다.

ViewModel에는 사용자의 입력이 들어왔을 때 이벤트와, 데이터를 처리해서 화면에 보여줄 때의 이벤트를 정의해야한다.

final class SignViewModel {
    enum Input {
        case viewApear
        case idTextChanged(id: String)
        case passwordTextChanged(password: String)
        case againTextChanged(again: String)
        case signInButtonTapped
        case signUpButtonTapped
    }
    enum Output {
        case toggleButton(isEnable: Bool)
        case signUpButtonTapped
        case signInButtonTapped
    }
}

하나의 ViewModel에 회원가입, 로그인 이벤트를 모두 넣었다. 

화면이 보여졌을 때 이벤트 부터 텍스트필드 값이 변경될 때, 버튼이 눌렸을 때 이벤트를 Input으로 정의하고

Output으로는 로직을 처리한 후 ViewController에 보낼 이벤트를 정의했다.

Input, Output은 바인딩을 통해 ViewController와 ViewModel을 연결해 줄 것이다.

private var bag = Set<AnyCancellable>()
private let output: PassthroughSubject<Output, Never> = .init()

private var userID: PassthroughSubject<String, Never> = .init()
private var userPassword: PassthroughSubject<String, Never> = .init()
private var againPassword: PassthroughSubject<String, Never> = .init()

func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
    input.receive(on: DispatchQueue.main)
        .sink { [weak self] event in
            switch event {
            case .viewApear(let type):
                if type == .signIn {
                    self?.signInCheck()
                } else {
                    self?.signUpCheck()
                }
            case .idTextChanged(let id):
                self?.userID.send(id)
            case .passwordTextChanged(let password):
                self?.userPassword.send(password)
            case .againTextChanged(let again):
                self?.againPassword.send(again)
            case .signInButtonTapped:
                self?.signInButtonTapped()
            case .signUpButtonTapped:
                self?.signUpButtonTapped()
            }
        }
        .store(in: &bag)
    return output.eraseToAnyPublisher()
}

먼저 transform 함수를 정의한다. (viewApear 이벤트에서 

이 함수는 ViewController에서 Input 이벤트를 받았을 때의 동작을 구독하고, ViewModel에서 받을 Output을 Publisher 타입으로 전달한다.

텍스트필드의 값이 변경되면 input으로 Value와 함께 이벤트를 받고 받은 id, password 들을 publisher에 전달 해 놓는다.

변경시 마다 버튼을 활성화 할지 결정하는 코드는 viewApear 이벤트에 signInCheck 메서드에 정의했다.

private func signInCheck() {
    output.send(.toggleButton(isEnable: false))
    userID.combineLatest(userPassword) { id, password in
        if id.count >= 5 && password.count >= 5 {
            return true
        }
        return false
    }
    .sink { [weak self] isEnable in
        self?.output.send(.toggleButton(isEnable: isEnable))
    }
    .store(in: &bag)
}​

viewapear 이벤트를 받으면 먼저 로그인 버튼을 비활성화 한다. id, password가 정상적으로 입력됐을 때만 활성화 할 것이다.

TextChanged 이벤트가 Input으로 들어왔을 때 사용한 Publisher를 구독한다.

combineLatest를 사용해 id와 password가 둘이 같이 값이 들어왔을 때와 최소길이를 만족하는지를 확인해 Bool 타입을 넘긴다.

구독에서는 ViewController가 받을 output이벤트를 날려주면 정상적으로 동작을 한다.

private func bind() {
    let output = vm.transform(input: input.eraseToAnyPublisher())
    output
        .receive(on: DispatchQueue.main)
        .sink { [weak self] event in
            switch event {
            case .toggleButton(let isEnable):
                self?.signInButton.isEnabled = isEnable
            case .signInButtonTapped:
                self?.showAlert()
            default: break
            }
        }
        .store(in: &bag)
}

ViewController에 바인딩을 위한 함수를 작성한다. ViewModel에 만든 transform 함수를 사용해 Publisher를 보내고 output을 받는다.

반환받은 output Publisher를 구독하고 들어올 이벤트에 대한 동작을 정의한다. 이렇게 하면 VM <-> VC 양방향 통신을 정의한 것이다.

이렇게 연결된 후 ViewController에서는 input 프로퍼티를 사용해 이벤트가 발생했음을 알려줄 수 있다.

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    input.send(.viewApear)
}

물론 bind 함수가 실행 된 후 동작한다.

ViewModel에서는 transform에서 정의 한 것 처럼 input에 이벤트가 send되면 구독한 부분에서 이벤트를 잡아 동작을 하게 된다.

ViewModel에서 output으로 이벤트를 보내는 방법은 ViewController에서 보내는 방법과 똑같다.

private func signInButtonTapped() {
    //유저 정보 확인
    output.send(.signInButtonTapped)
}

이렇게 이벤트를 보내면 VC에서는 구독해놨던 이벤트를 잡아 Alert를 띄우게 된다.


https://github.com/JustHm/LoginExample-Combine-MVVM

설명이 좀 난리난거 같아 전체 코드도 같이 첨부했다.

 

GitHub - JustHm/LoginExample-Combine-MVVM: MVVM + Combine을 사용한 로그인 화면 예제

MVVM + Combine을 사용한 로그인 화면 예제. Contribute to JustHm/LoginExample-Combine-MVVM development by creating an account on GitHub.

github.com

 

728x90

'Design Pattern' 카테고리의 다른 글

MVP 알아보기 (간단한 테스트 작성)  (0) 2023.01.27
MVVM 개념과 MVVM + Rxswift  (0) 2023.01.11
Comments