이번 포스트에서는 Combine framework를 이용해 처음에는 버튼을 비활성화하고 아이디, 비밀번호를 입력하는 텍스트 필드의 입력을 감지하여 두 텍스트 필드가 모두 입력 되었을때, 버튼을 활성화 하도록 구현해보려고 한다.
storyboard의 구성은 위와 같이 아이디와 비밀번호를 입력할 텍스트 필드 두개와 로그인 버튼 하나로 구성하였다.
//LoginViewModel.swift
import Combine
class LoginViewModel {
@Published var usrIDInput: String = ""
@Published var usrPasswordInput: String = ""
}
사용자의 아이디 값을 저장할 변수 usrIDInput
과 사용자의 비밀번호를 저장할 변수 usrPasswordInput
을 프로퍼티로 가지는 LoginViewModel
클래스를 생성하였다.
이때 두 프로퍼티는 값의 변화를 감지하고 이벤트를 발산하기 위해 @Published
property wrapper 속성으로 정의한다.
// 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
로부터 NotificationCenter
에 textDidChangeNotification
이 전달 되었을때 publisher로서 값을 전달한다. (따라서 publisher 메서드의 for 매개변수로 UITextField.textDidChangeNotification
을 object에는 UITextField 자체인 self
를 전달)
또한 publisher에 compactMap과 map 등 여러 operator
를 거치면서 반환 타입이 복잡해지는데 이것들을 다시 AnyPublisher
타입으로 반환하기 위해 eraseToAnyPublisher()
메서드를 사용한다.
먼저 텍스트 필드에 텍스트를 가져와 저장할 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.publisher
와 usrIDTextField.publisher
로 publisher 프로퍼티(아까 위에서 extension을 통해 정의)에 접근하고 assign
메서드로 해당 publisher를 subscribe하여 전달된 값으로 viewModel의 usrIDInput
, usrPasswordInput
프로퍼티의 값을 각각 변경하도록 한다.
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
Scheduler의 경우 RunLoop.Mode
가 존재하여 사용자 이벤트를 입력 받고 있는 중에는 tracking
으로 사용자 이벤트를 입력 받지 않을 때는 default
상태가 된다.
RunLoop.main
Scheduler는 RunLoop.Mode
가 default
일때 publisher가 수행할 작업을 수행할 수 있다. 즉, 사용자 이벤트가 입력으로 들어오고 있는 상황에서는 publisher가 수행할 작업을 수행할 수 없다.
반면, DispatchQueue.main
Scheduler의 경우 GCD 형태로 작업을 처리하기 때문에 사용자 이벤트가 들어오는 동시에 publisher가 수행할 작업을 수행할 수 있다.
정리하자면 사용자 이벤트가 입력으로 들어올때 UI가 변경되지 않아야 한다면 Runloop.min
을 사용자 이벤트가 들어올때 동시에 UI를 변경해야 한다면 DispatchQueue.main
을 사용하면 된다.
다시 위의 예제로 돌어와서 위의 예제의 경우 두 Scheduler 중 아무거나 사용하여도 큰 문제가 되진 않지만 사용자가 텍스트 필드 입력 후 입력 받는 내용을 publisher로 방출하기 위해 RunLoop.main
Scheduler를 사용해 보려고 한다.
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
는 두 publisher를 전달인자로 받아 새로운 publisher를 생성한다.
전달인자로 들어온 publisher 중 한 publisher라도 값을 방출하면 Publishers.CombineLatest
는 전달인자로 들어온 두 publisher가 마지막으로 방출한 값을 tuple
형태로 방출한다.
다시 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는 UIButton
의 isEnalbed
프로퍼티이다.
isEnabled 프로퍼티가 true
이면 버튼이 활성화 되고, false
이면 버튼이 비활성화 된다.
따라서 현재 프로젝트에서는 두 텍스트 필드 중 하나라도 비어 있으면 isEnabled
프로퍼티에 false
가 전달되어 버튼이 비활성화 되고, 두 텍스트 필드가 모두 입력되었을때, isEnabled
프로퍼티에 true
가 전달되어 버튼이 활성화 된다.