The missing piece when you want to use Combine with UIKit - Create 2-way bindings from UI elements
@State
: Combine 프레임워크의 데이터 스트림 사용UITextField
의 텍스트 및 UI 라벨 연동UISlider
의 값과 UI 라벨 연동
private func addSubscriber() {
viewModel.textSubject
.sink { [weak self] text in
guard let self = self else { return }
DispatchQueue.main.async {
self.label.text = text
}
}
.store(in: &cancellables)
textField
.createBinding(with: viewModel.textSubject, storeIn: &cancellables)
clearButton
.tapPublisher
.sink { [weak self] _ in
guard let self = self else { return }
self.viewModel.textSubject.send("")
}
.store(in: &cancellables)
}
textSubject
의 값은 텍스트 필드와 바인딩extension UIControl {
func controlPublisher(for event: UIControl.Event) -> UIControl.EventPublisher {
return UIControl.EventPublisher(control: self, event: event)
}
// Publisher
struct EventPublisher: Publisher {
typealias Output = UIControl
typealias Failure = Never
let control: UIControl
let event: UIControl.Event
func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, UIControl == S.Input {
let subscription = EventSubscription(control: control, subscrier: subscriber, event: event)
subscriber.receive(subscription: subscription)
}
}
// Subscription
fileprivate class EventSubscription<EventSubscriber: Subscriber>: Subscription where EventSubscriber.Input == UIControl, EventSubscriber.Failure == Never {
let control: UIControl
let event: UIControl.Event
var subscriber: EventSubscriber?
init(control: UIControl, subscrier: EventSubscriber, event: UIControl.Event) {
self.control = control
self.subscriber = subscrier
self.event = event
control.addTarget(self, action: #selector(eventDidOccur), for: event)
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
subscriber = nil
control.removeTarget(self, action: #selector(eventDidOccur), for: event)
}
@objc func eventDidOccur() {
_ = subscriber?.receive(control)
}
}
}
extension UITextField {
var textPublisher: AnyPublisher<String, Never> {
controlPublisher(for: .editingChanged)
.map { $0 as! UITextField }
.map { $0.text! }
.eraseToAnyPublisher()
}
func createBinding(with subject: CurrentValueSubject<String, Never>, storeIn cancellables: inout Set<AnyCancellable>) {
subject
.sink { [weak self] value in
guard let self = self else { return }
if value != self.text {
self.text = value
}
}
.store(in: &cancellables)
textPublisher
.sink { value in
if value != subject.value {
subject.send(value)
}
}
.store(in: &cancellables)
}
}
textPublisher
는 편집 이벤트마다 데이터 스트림createBinding
은 해당 컴포넌트 및 데이터 퍼블리셔 간의 양방향 바인딩을 한 번에 하기 위한 함수import UIKit
import Combine
class TextViewController: UIViewController {
private let label: UILabel = {
let label = UILabel()
label.textColor = .black
label.font = .preferredFont(forTextStyle: .headline)
label.numberOfLines = 0
label.text = "Mock Data"
return label
}()
private let clearButton: UIButton = {
let button = UIButton()
var config = UIButton.Configuration.filled()
config.background.backgroundColor = .clear
button.configuration = config
button.setTitle("Clear", for: .normal)
button.setTitleColor(.systemBlue, for: .normal)
return button
}()
private let textField: UITextField = {
let textField = UITextField()
textField.borderStyle = .roundedRect
return textField
}()
private let viewModel = TextViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
setTextViewUI()
addSubscriber()
}
private func addSubscriber() {
viewModel.textSubject
.sink { [weak self] text in
guard let self = self else { return }
DispatchQueue.main.async {
self.label.text = text
}
}
.store(in: &cancellables)
// viewModel.textSubject
// .sink { [weak self] text in
// guard let self = self else { return }
// DispatchQueue.main.async {
// self.textField.text = text
// }
// }
// .store(in: &cancellables)
// NotificationCenter.default
// .publisher(for: UITextField.textDidChangeNotification, object: textField)
// .map {($0.object as? UITextField)?.text ?? ""}
// .eraseToAnyPublisher()
// .sink { [weak self] text in
// guard let self = self else { return }
// self.viewModel.textSubject.send(text)
// }
// .store(in: &cancellables)
// textField
// .textPublisher
// .sink { [weak self] text in
// guard let self = self else { return }
// self.viewModel.textSubject.send(text)
// }
// .store(in: &cancellables)
textField
.createBinding(with: viewModel.textSubject, storeIn: &cancellables)
clearButton
.tapPublisher
.sink { [weak self] _ in
guard let self = self else { return }
self.viewModel.textSubject.send("")
}
.store(in: &cancellables)
}
private func setTextViewUI() {
view.backgroundColor = .systemBackground
label.translatesAutoresizingMaskIntoConstraints = false
clearButton.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
view.addSubview(clearButton)
view.addSubview(textField)
textField.topAnchor.constraint(equalTo: view.topAnchor, constant: 200).isActive = true
textField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50).isActive = true
textField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -100).isActive = true
label.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 10).isActive = true
label.leadingAnchor.constraint(equalTo: textField.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: textField.trailingAnchor).isActive = true
clearButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 200).isActive = true
clearButton.leadingAnchor.constraint(equalTo: textField.trailingAnchor, constant: 20).isActive = true
}
}
NotificationCenter
를 통해서도 구현할 수 있지만, 품이 많이 든다.createBinding
은 양방향 바인딩을 익스텐션 하단에서 하고 있는 함수class TextViewModel {
let textSubject = CurrentValueSubject<String, Never>("Hello, World!")
}
class SliderViewController: UIViewController {
private let label: UILabel = {
let label = UILabel()
label.textColor = .black
label.font = .preferredFont(forTextStyle: .headline)
label.numberOfLines = 0
label.text = "Mock Data"
return label
}()
private let clearButton: UIButton = {
let button = UIButton()
var config = UIButton.Configuration.filled()
config.background.backgroundColor = .clear
button.configuration = config
button.setTitle("Clear", for: .normal)
button.setTitleColor(.systemBlue, for: .normal)
return button
}()
private let slider: UISlider = {
let slider = UISlider()
slider.value = 0.0
slider.maximumValue = 1.0
slider.minimumValue = 0.0
return slider
}()
private var cancellables = Set<AnyCancellable>()
private var viewModel = SliderViewModel()
override func viewDidLoad() {
super.viewDidLoad()
setSliderViewUI()
addSubscriber()
}
private func addSubscriber() {
slider
.createBinding(with: viewModel.numberSubject, storeIn: &cancellables)
viewModel.numberSubject
.throttle(for: .milliseconds(500), scheduler: RunLoop.main, latest: true)
.map { number in
return "Number: \(number)"
}
.sink { [weak self] text in
guard let self = self else { return }
self.label.text = text
}
.store(in: &cancellables)
clearButton
.tapPublisher
.sink { [weak self] _ in
guard let self = self else { return }
self.viewModel.numberSubject.send(0.0)
}
.store(in: &cancellables)
}
private func setSliderViewUI() {
view.backgroundColor = .systemBackground
label.translatesAutoresizingMaskIntoConstraints = false
clearButton.translatesAutoresizingMaskIntoConstraints = false
slider.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
view.addSubview(clearButton)
view.addSubview(slider)
slider.topAnchor.constraint(equalTo: view.topAnchor, constant: 200).isActive = true
slider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50).isActive = true
slider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -100).isActive = true
label.topAnchor.constraint(equalTo: slider.bottomAnchor, constant: 10).isActive = true
label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
clearButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 200).isActive = true
clearButton.leadingAnchor.constraint(equalTo: slider.trailingAnchor, constant: 20).isActive = true
}
}
class SliderViewModel {
let numberSubject = CurrentValueSubject<Double, Never>(0)
}
import Foundation
import UIKit
import Combine
extension UIControl {
func controlPublisher(for event: UIControl.Event) -> UIControl.EventPublisher {
return UIControl.EventPublisher(control: self, event: event)
}
// Publisher
struct EventPublisher: Publisher {
typealias Output = UIControl
typealias Failure = Never
let control: UIControl
let event: UIControl.Event
func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, UIControl == S.Input {
let subscription = EventSubscription(control: control, subscrier: subscriber, event: event)
subscriber.receive(subscription: subscription)
}
}
// Subscription
fileprivate class EventSubscription<EventSubscriber: Subscriber>: Subscription where EventSubscriber.Input == UIControl, EventSubscriber.Failure == Never {
let control: UIControl
let event: UIControl.Event
var subscriber: EventSubscriber?
init(control: UIControl, subscrier: EventSubscriber, event: UIControl.Event) {
self.control = control
self.subscriber = subscrier
self.event = event
control.addTarget(self, action: #selector(eventDidOccur), for: event)
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
subscriber = nil
control.removeTarget(self, action: #selector(eventDidOccur), for: event)
}
@objc func eventDidOccur() {
_ = subscriber?.receive(control)
}
}
}
extension UIButton {
var tapPublisher: AnyPublisher<Void, Never> {
controlPublisher(for: .touchUpInside)
.map { _ in }
.eraseToAnyPublisher()
}
}
extension UITextField {
var textPublisher: AnyPublisher<String, Never> {
controlPublisher(for: .editingChanged)
.map { $0 as! UITextField }
.map { $0.text! }
.eraseToAnyPublisher()
}
func createBinding(with subject: CurrentValueSubject<String, Never>, storeIn cancellables: inout Set<AnyCancellable>) {
subject
.sink { [weak self] value in
guard let self = self else { return }
if value != self.text {
self.text = value
}
}
.store(in: &cancellables)
textPublisher
.sink { value in
if value != subject.value {
subject.send(value)
}
}
.store(in: &cancellables)
}
}
extension UISlider {
var valuePublisher: AnyPublisher<Float, Never> {
controlPublisher(for: .valueChanged)
.map { $0 as! UISlider }
.map { $0.value }
.eraseToAnyPublisher()
}
func createBinding<T: BinaryFloatingPoint>(with subject: CurrentValueSubject<T, Never>, storeIn cancellables: inout Set<AnyCancellable>) {
subject
.map { point in
return Float(point)
}
.sink { [weak self] value in
guard let self = self else { return }
if self.value != value {
self.value = value
}
}
.store(in: &cancellables)
self.valuePublisher
.map { value in
return T(value)
}
.sink { [weak self] value in
guard let self = self else { return }
if value != subject.value {
subject.send(value)
}
}
.store(in: &cancellables)
}
}
RXSwift와 함께 RXCocoa를 함께 사용하는 이유를 절실히 느낄 수 있었다.
CombineCocoa
라는 서드파티 라이브러리가 나온 까닭이다.