Skip to content

Instantly share code, notes, and snippets.

@andreashanft
Last active August 12, 2022 17:32
Show Gist options
  • Save andreashanft/339fdb95c5778048541ec9e89eb6bcd9 to your computer and use it in GitHub Desktop.
Save andreashanft/339fdb95c5778048541ec9e89eb6bcd9 to your computer and use it in GitHub Desktop.
Combine MVVM Demo Playground
//: A UIKit based Playground for presenting user interface
/// Add an event publisher to UIControls
/// From: https://forums.swift.org/t/a-uicontrol-event-publisher-example/26215/19
extension UIControl {
private class EventObserver {
let control: UIControl
let event: UIControl.Event
let subject: PassthroughSubject<UIControl, Never>
init(control: UIControl, event: UIControl.Event) {
self.control = control
self.event = event
self.subject = .init()
}
func start() {
control.addTarget(self, action: #selector(handleEvent(from:)), for: event)
}
func stop() {
control.removeTarget(self, action: nil, for: event)
}
@objc
func handleEvent(from sender: UIControl) {
subject.send(sender)
}
}
struct ControlEventPublisher: Publisher {
typealias Output = UIControl
typealias Failure = Never
let control: UIControl
let event: UIControl.Event
init(control: UIControl, event: UIControl.Event) {
self.control = control
self.event = event
}
func receive<S>(subscriber: S)
where S : Subscriber,
S.Failure == Failure,
S.Input == Output {
let observer = EventObserver(control: control, event: event)
observer
.subject
.handleEvents(
receiveSubscription: { _ in observer.start() },
receiveCancel: observer.stop
)
.receive(subscriber: subscriber)
}
}
func eventPublisher(for event: UIControl.Event) -> ControlEventPublisher {
return ControlEventPublisher(control: self, event: event)
}
}
// Must be a class otherwise @Published is not supported
final class ViewModel {
// Mark: Outputs
@Published private(set) var outHeadlineText: String? = "Hello there!"
@Published private(set) var outButtonText: String? = "Press Me!"
// Mark: Inputs
// Use a Void property as a small "trick" to be able to use 'assign' also for inputs
var inTestAction: Void = () {
didSet {
outHeadlineText = "Button pressed!"
outButtonText = "Press me again!"
}
}
}
// If you know RxSwift you know DisposeBag.
// This is to demonstrate that Combine uses the same concept.
typealias DisposeBag = Set<AnyCancellable>
final class ViewController: UIViewController {
let viewModel = ViewModel()
var label = UILabel()
var button = UIButton(type: .system)
var text: String = ""
private var subscriptions = DisposeBag()
override func loadView() {
let view = UIView()
view.backgroundColor = .white
label.text = "Hello World!"
label.textColor = .black
label.textAlignment = .center
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.widthAnchor.constraint(equalToConstant: 200),
label.heightAnchor.constraint(equalToConstant: 20),
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 200),
button.heightAnchor.constraint(equalToConstant: 44),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 32)
])
self.view = view
}
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
}
private func bindViewModel() {
// How to bind method 1)
// Syntax with sink is not that pretty...
viewModel.$outHeadlineText.sink { [weak self] text in
self?.text = text ?? ""
}.store(in: &subscriptions)
// How to bind method 2)
// Using assign and a keypath makes for a quite streamlined syntax.
// The published value and the assigned value have to match 100%
// You can not assign a non-optional type to a keypath that
// expects an optional type!
// See also https://heckj.github.io/swiftui-notes/#reference-assign
subscriptions.add([
// Outputs
viewModel.$outHeadlineText.assign(to: \.text, on: label),
viewModel.$outButtonText.assign(to: \.defaultTitle, on: button),
// Inputs
button.eventPublisher(for: .touchUpInside).map({_ in ()}).assign(to: \.viewModel.inTestAction, on: self)
])
}
}
// Some syntactic sugar to make adding multiple subscriptions less cumbersome
extension DisposeBag {
mutating func add(_ subscriptions: [AnyCancellable]) {
for item in subscriptions {
insert(item)
}
}
}
// It seems there is no default way to set the title using combine, add a custom
// computed property to be able to use 'assign'.
extension UIButton {
var defaultTitle: String? {
set {
setTitle(newValue, for: .normal)
}
get {
title(for: .normal)
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = ViewController()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment