Last active
August 12, 2022 17:32
-
-
Save andreashanft/339fdb95c5778048541ec9e89eb6bcd9 to your computer and use it in GitHub Desktop.
Combine MVVM Demo Playground
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//: 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