[Swift/Combine] text field reactive하게 구현하기

이정훈·2023년 2월 21일
1

Combine Framework

목록 보기
3/4
post-thumbnail

이번 포스트에서는 Combine framework를 이용해 처음에는 버튼을 비활성화하고 아이디, 비밀번호를 입력하는 텍스트 필드의 입력을 감지하여 두 텍스트 필드가 모두 입력 되었을때, 버튼을 활성화 하도록 구현해보려고 한다.


storyboard 구성

storyboard의 구성은 위와 같이 아이디와 비밀번호를 입력할 텍스트 필드 두개와 로그인 버튼 하나로 구성하였다.


ViewModel 구성

//LoginViewModel.swift
import Combine

class LoginViewModel {
    @Published var usrIDInput: String = ""
    @Published var usrPasswordInput: String = ""
}

사용자의 아이디 값을 저장할 변수 usrIDInput과 사용자의 비밀번호를 저장할 변수 usrPasswordInput을 프로퍼티로 가지는 LoginViewModel 클래스를 생성하였다.

이때 두 프로퍼티는 값의 변화를 감지하고 이벤트를 발산하기 위해 @Published property wrapper 속성으로 정의한다.


ViewController 구성

//  ViewController.swift
import UIKit

class ViewController: UIViewController {
    @IBOutlet var usrIDTextField: UITextField!
    @IBOutlet var usrPasswordTextField: UITextField!
    @IBOutlet var loginBtn: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
     
        ...
    }

}

두 텍스트 필드와 버튼을 연결할 @IBOutlet 속성의 변수를 선언하고 storyboard의 각 객체와 연결한다.


텍스트 필드 변화 감지

//  ViewController.swift
import UIKit
import Combine

...

extension UITextField {
    var publisher: AnyPublisher<String, Never> {
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: self)
            //NotificationCenter로 들어온 notification의 optional 타입 object 프로퍼티를 UITextField로 타입 캐스팅
            .compactMap{ $0.object as? UITextField}
            //text 프로퍼티만 가져오기
            .map{ $0.text ?? "" }    //값이 없는 경우 빈 문자열 반환
            .print()
            .eraseToAnyPublisher()
    }
}

텍스트 필드의 변화를 감지하고 observer에게 값을 전달하기 위해 UITextField extension으로 publisher를 생성한다.

publisher는 AnyPublisher<String, Never> 타입으로 텍스트 필드에 입력된 String을 observer에게 전달하도록 한다.

해당 publisher는 UITextField로부터 NotificationCentertextDidChangeNotification이 전달 되었을때 publisher로서 값을 전달한다. (따라서 publisher 메서드의 for 매개변수로 UITextField.textDidChangeNotification을 object에는 UITextField 자체인 self를 전달)

eraseToAnyPublisher()

또한 publisher에 compactMap과 map 등 여러 operator를 거치면서 반환 타입이 복잡해지는데 이것들을 다시 AnyPublisher 타입으로 반환하기 위해 eraseToAnyPublisher() 메서드를 사용한다.

publisher subscribe


먼저 텍스트 필드에 텍스트를 가져와 저장할 viewModel을 선언하고 view가 로드 되었을때 인스턴스를 생성하기 위해 viewDidLoad() 메서드 내부에 인스턴스를 생성 해준다.

//  ViewController.swift
import UIKit
import Combine

class ViewController: UIViewController {
	...
    
    var viewModel: LoginViewModel!    //viewModel
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
     
        ...
        
        viewModel = LoginViewModel()    //인스턴스 생성
        
        //MARK: usrIDTextField subsribe
        usrIDTextField.publisher
            .receive(on: RunLoop.main)
            .assign(to: \.usrIDInput, on: viewModel)
            .store(in: &subscribtion)
        
        //MARK: usrPasswordTextField subscribe
        usrPasswordTextField.publisher
            .receive(on: RunLoop.main)
            .assign(to: \.usrPasswordInput, on: viewModel)
            .store(in: &subscribtion)
    }
}

view가 로드 되면 publisher를 생성하고 subscribe 할 수 있도록 viewDidLoad() 메서드 내부에 usrIDTextField.publisherusrIDTextField.publisher로 publisher 프로퍼티(아까 위에서 extension을 통해 정의)에 접근하고 assign 메서드로 해당 publisher를 subscribe하여 전달된 값으로 viewModel의 usrIDInput, usrPasswordInput 프로퍼티의 값을 각각 변경하도록 한다.

receive(on:)

publisher를 생성하고 subscribe하는 과정에서 receive 메서드를 볼 수 있는데 publisher가 observer에게 값을 전달할때 Scheduler를 지정할 수 있는 메서드이다.

이때 사용되는 Scheduler는 주로 두가지로

  • RunLoop.main - 터치 이벤트, 스크롤 이벤트 등 사용자 입력을 처리할 수 있는 Scheduler

  • DispatchQueue.main - main thread와 연관된 dispatch queue Scheduler

그리고 두 Scheduler 모두 main thread에서 동작한다. 따라서 두 Scheduler 모두 publisher가 값을 전달할때 UI를 업데이트 할 수 있다.

RunLoop.main

RunLoop.main Scheduler의 경우 RunLoop.Mode가 존재하여 사용자 이벤트를 입력 받고 있는 중에는 tracking으로 사용자 이벤트를 입력 받지 않을 때는 default 상태가 된다.

RunLoop.main Scheduler는 RunLoop.Modedefault일때 publisher가 수행할 작업을 수행할 수 있다. 즉, 사용자 이벤트가 입력으로 들어오고 있는 상황에서는 publisher가 수행할 작업을 수행할 수 없다.

DispatchQueue.main

반면, DispatchQueue.main Scheduler의 경우 GCD 형태로 작업을 처리하기 때문에 사용자 이벤트가 들어오는 동시에 publisher가 수행할 작업을 수행할 수 있다.

정리하자면 사용자 이벤트가 입력으로 들어올때 UI가 변경되지 않아야 한다면 Runloop.min을 사용자 이벤트가 들어올때 동시에 UI를 변경해야 한다면 DispatchQueue.main을 사용하면 된다.

다시 위의 예제로 돌어와서 위의 예제의 경우 두 Scheduler 중 아무거나 사용하여도 큰 문제가 되진 않지만 사용자가 텍스트 필드 입력 후 입력 받는 내용을 publisher로 방출하기 위해 RunLoop.main Scheduler를 사용해 보려고 한다.


두 텍스트 필드가 모두 입력 되었을때 값을 전달하는 publisher

LoginViewModel 클래스가 있는 LoginViewModel.swift 파일로 돌아와서

이제 두개의 텍스트 필드가 모두 입력 되었을때 값을 전달하도록 하는 publisher를 생성한다.

//LoginViewModel.swift
import Combine

class LoginViewModel {
    ...
    
    lazy var isFilled: AnyPublisher<Bool, Never> = Publishers.CombineLatest($usrIDInput, $usrPasswordInput)
        .map{
            if $0 == "" || $1 == "" {
                return false
            } else {
                return true
            }
        }
        .eraseToAnyPublisher()
}

해당 publisher는 usrIDInput, usrPasswordInput가 모두 초기화된 이후에 사용할 수 있도록 lazy 키워드로 지연 연산한다.

Publishers.CombineLatest

Publishers.CombineLatest는 두 publisher를 전달인자로 받아 새로운 publisher를 생성한다.

전달인자로 들어온 publisher 중 한 publisher라도 값을 방출하면 Publishers.CombineLatest는 전달인자로 들어온 두 publisher가 마지막으로 방출한 값을 tuple 형태로 방출한다.


Publishers.CombineLatest subscribe

다시 ViewController.swift 파일로 돌아와 위에서 생성한 Publishers.CombineLatest를 subscribe한다.

//  ViewController.swift
import UIKit
import Combine

class ViewController: UIViewController {
    ...

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        ...
        
        //MARK: isFilled subscribe
        viewModel.isFilled
            .receive(on: RunLoop.main)
            .assign(to: \.isEnabled, on: loginBtn)
            .store(in: &subscribtion)
    }
}

이때 assign으로 값을 변경하는 path는 UIButtonisEnalbed 프로퍼티이다.

isEnabled 프로퍼티가 true이면 버튼이 활성화 되고, false이면 버튼이 비활성화 된다.

따라서 현재 프로젝트에서는 두 텍스트 필드 중 하나라도 비어 있으면 isEnabled 프로퍼티에 false가 전달되어 버튼이 비활성화 되고, 두 텍스트 필드가 모두 입력되었을때, isEnabled 프로퍼티에 true가 전달되어 버튼이 활성화 된다.


완성된 모습

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글