[UIKit] Combine: 2-Way Bindings

Junyoung Park·2022년 10월 3일
0

UIKit

목록 보기
50/142
post-thumbnail

The missing piece when you want to use Combine with UIKit - Create 2-way bindings from UI elements

Combine: 2-Way Bindings

Communiation Patterns

  • SwiftUI의 @State: Combine 프레임워크의 데이터 스트림 사용
  • UIKit: 델리게이트 패턴, 타겟-액션 패턴 등 사용
  • UIKit에서의 Combine 사용: 2-way 바인딩 필요

구현 목표

구현 태스크

  1. UITextField의 텍스트 및 UI 라벨 연동
  2. UISlider의 값과 UI 라벨 연동
  3. 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)
    }
}
  • 위의 커스텀 퍼블리셔를 사용, 각 UI 컴포넌트마다 별도의 퍼블리셔 구현 가능
  • 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라는 서드파티 라이브러리가 나온 까닭이다.

profile
JUST DO IT

0개의 댓글