iOS/MVVM

MVVM 패턴 적용

HJ39 2023. 1. 18. 00:33

아이디, 비밀번호를 입력했는지 감지하는 기능

사용한 라이브러리: UIKit, SnapKit, RxSwift, RxCocoa (storyboard 사용 X)

설명: 아이디 비밀번호를 입력한 경우 버튼 문구가 '로그인'으로 변경된다. 둘 중 하나라도 입력하지 않은 경우 '정보 부족'을 출력한다.

 

 

 

 

 

 

 

 

 

 

 

 

 


 

□ View

import UIKit
import SnapKit
import RxSwift

class ViewController: UIViewController {

    /*
     UI 생성 코드
     */
    
    //ID Label
    lazy var IdLabel: UILabel = {
        let l1 = UILabel()
        l1.font = .systemFont(ofSize: 20)
        l1.text = "아이디"
        return l1
    }()
    
    // PW Label
    lazy var pwLabel: UILabel = {
        let l1 = UILabel()
        l1.font = .systemFont(ofSize: 20)
        l1.text = "비밀번호"
        return l1
    }()
    
    //ID TextField
    lazy var idInput:  UITextField = {
        let idInput = UITextField()
        idInput.placeholder = "id를 입력해주세요"
        idInput.backgroundColor = .white
        idInput.layer.cornerRadius = 3
        idInput.font = .systemFont(ofSize: 20)
        return idInput
    }()
    
    //PW TextField
    lazy var pwInput: UITextField = {
        let pwInput = UITextField()
        pwInput.placeholder = "password를 입력해주세요"
        pwInput.backgroundColor = .white
        pwInput.font = .systemFont(ofSize: 20)
        pwInput.layer.cornerRadius = 3
        pwInput.isSecureTextEntry = true    //비밀번호 숨기기
        return pwInput
    }()
    
    // 확인 버튼
    lazy var checkBtn1: UIButton = {
        let checkBtn1 = UIButton()
        checkBtn1.setTitle("정보 부족", for: .normal)
        checkBtn1.backgroundColor = .green
        checkBtn1.titleLabel?.font = .systemFont(ofSize: 20)
        checkBtn1.addTarget(self, action: #selector(checkBtnAction), for: .touchUpInside)
        checkBtn1.isEnabled = false
        return checkBtn1
    }()
    
    // 버튼 눌렀을 때 실행되는 함수
    @objc func checkBtnAction(_ sender: Any){
        print("clicked button")
    }
    
    /*
     UI 적용 코드
     */
    private func addUI(){
        self.view.addSubview(IdLabel)
        self.view.addSubview(idInput)
        self.view.addSubview(pwLabel)
        self.view.addSubview(pwInput)
        self.view.addSubview(checkBtn1)
    }
    
    /*
     AutoLayout 코드
     */
    private func addAutoLayout(){
        IdLabel.snp.makeConstraints({ make in
            make.top.equalTo(100)
            make.leading.equalTo(30)
            
        })
        pwLabel.snp.makeConstraints({ make in
            make.top.equalTo(IdLabel.snp.bottom).offset(10)
            make.leading.equalTo(30)
            
        })
        
        idInput.snp.makeConstraints({ make in
            make.top.equalTo(100)
            make.leading.equalTo(IdLabel).offset(80)
            make.trailing.equalTo(-30)
        })
        
        pwInput.snp.makeConstraints({ make in
            make.top.equalTo(idInput.snp.bottom).offset(10)
            make.leading.equalTo(pwLabel).offset(80)
            make.trailing.equalTo(-30)
        })
        
        checkBtn1.snp.makeConstraints({ make in
            make.top.equalTo(pwInput.snp.bottom).offset(50)
            make.centerX.equalToSuperview()
        })
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .orange
        
        addUI() //UI 추가
        addAutoLayout() // AutoLayout 추가
        bindInput()    // ViewModel 데이터 바인딩하는 함수
        bindOutput()    // View to ViewModel
    }
    
    let disposeBag = DisposeBag()
    private let viewModel = ViewModelController()
    
    // 아이디, 비밀번호 textField의 입력을 감지하는 함수
    private func bindOutput(){  // View to ViewModel
        idInput.rx.text.map({$0 ?? ""}).bind(to: viewModel.id)
            .disposed(by: disposeBag)
        pwInput.rx.text.map({$0 ?? ""}).bind(to: viewModel.pw)
            .disposed(by: disposeBag)
    }
    
    // ViewModel에서 가져온 데이터를 UI에 적용
    private func bindInput(){    // ViewModel to View
        viewModel.checkValidInput.bind(to: checkBtn1.rx.isEnabled).disposed(by: disposeBag)
        viewModel.checkValidInput.subscribe(
            onNext: { [weak self] isValid in
                let text = isValid ? "로그인" : "정보 부족"
                self?.checkBtn1.setTitle(text, for: .normal)
            })
        .disposed(by: disposeBag)
    }
    
    
}

UI를 코드로 작성하여 구현하니까 iOS 앱 제작하는데 조금 더 깊게 알게 된 기분이다. 

AutoLayout을 조금 더 쉽게 적용하기 위해 SnapKit을 사용하였다. 

 

아무튼

이번 핵심인 MVVM패턴을 적용하기 위해서 많이 사용하는 RxSwift를 사용한다( 바인딩 기능을 쉽게 사용할 수 있기 때문! )

bindInput(), bindOutput() 함수 두 개가 MVVM을 구성하는데 전부이다 (허무..)

 

ViewModelController 클래스 내부에 있는 Subject에 데이터를 넣어 주고 Observable을 이용하여 데이터를 가져와 UI에 적용시키면 된다. ( 간단한 코드 설명은 한 줄이 끝!!)

 

MVVM 함수가 궁금하면 아래 함수를 누르세요!

더보기
 // 아이디, 비밀번호 textField의 입력을 감지하는 함수
private func bindOutput(){  // View to ViewModel
    idInput.rx.text.map({$0 ?? ""}).bind(to: viewModel.id)
        .disposed(by: disposeBag)
    pwInput.rx.text.map({$0 ?? ""}).bind(to: viewModel.pw)
        .disposed(by: disposeBag)
}

bindOutput이 하는 기능은 주석에 있듯이 textField에 입력을 감지하여 viewModel의 id와 pw라는 subject에게 알려주는 역할이다.

idInput.rx.text.map({$0 ?? ""})  : textField에서 입력을 감지하여 가져온 데이터

. bind(to: viewModel.id) : . 이전에 데이터를 viewModel.id에 전달

 

간단하죠?

 

더보기
// ViewModel에서 가져온 데이터를 UI에 적용
private func bindInput(){    // ViewModel to View
    viewModel.checkValidInput.bind(to: checkBtn1.rx.isEnabled).disposed(by: disposeBag)
    viewModel.checkValidInput.subscribe(
        onNext: { [weak self] isValid in
            let text = isValid ? "로그인" : "정보 부족"
            self?.checkBtn1.setTitle(text, for: .normal)
        })
    .disposed(by: disposeBag)
}

bindOutput() 함수에서 bind관련된 설명 참고하세요!

ViewModel에 있는 Observable에서 데이터를 가져오는 방법으로 subscribe가 있다.

subscribe를 사용하여 데이터를 가져온 뒤 stream 방식으로 데이터를 처리하면 된다.

 

※ weak self를 사용하는 이유

순환 참조를 방지하여 메모리 누수를 막기 위해 사용한다.

(이 부분이 이해가 안 된다면 ARC 메모리 관리 방법을 공부하세요!)

 

 

□ViewModel

import Foundation
import RxSwift
import RxCocoa

class ViewModelController {
    
    var id: BehaviorSubject<String> = BehaviorSubject(value: "")
    var pw: BehaviorSubject<String> = BehaviorSubject(value: "")
    
    var checkValidId: Observable<Bool>{ id.map({ print("\($0)"); return $0.isEmpty ? false : true }) }
    var checkValidpw: Observable<Bool>{ pw.map({ print("\($0)"); return $0.isEmpty ? false : true }) }
    
    var checkValidInput: Observable<Bool> {
        return Observable.combineLatest(checkValidId,checkValidpw).map({$0 && $1})
    }
    
}

View에 비해 코드가 약간 초라.. 하네...?

BehaviorSubject(최근 데이터를 알려주는 subject)를 이용하여 View에서 데이터를 가져온다.

데이터를 가공한 후 checkValidInput이라는 Observable을 생성하여 Observable 타입의 두 개의 데이터를 비교하여 Observable에 저장한다.

이후 View에서 해당 데이터를 바인딩한다.

 

 

 

# 참고한 사이트

  1. https://www.youtube.com/watch?v=IbRM3t5QUxk