Skip to content

Instantly share code, notes, and snippets.

@peteranny
Last active March 13, 2024 03:43
Show Gist options
  • Save peteranny/a3598ebaee10d375346d7f2ac8fb7fa5 to your computer and use it in GitHub Desktop.
Save peteranny/a3598ebaee10d375346d7f2ac8fb7fa5 to your computer and use it in GitHub Desktop.
Combine-version twin of the MVVM data binding pattern in RxSwift. Referenced by: https://medium.com/@tingyishih/mvvm-data-binding-with-rxswift-single-interface-practice-1a7f5f1a655d
import Combine
import CombineCocoa // To allow publisher extensions such as button.tapPublisher
import UIKit
class SimpleViewController: UIViewController {
private let viewModel = SimpleViewModel()
private var cancellables: [AnyCancellable] = []
private let button = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
// Install the button
view.addSubview(button)
button.frame = UIScreen.main.bounds
// Bind the input to the view model
let input = SimpleViewModel.Input(
buttonTap: button.tapPublisher
)
let output = viewModel.bind(input)
// Bind the output from the view model
output.backgroundColor
.map { $0.uiColor }
.subscribe(on: DispatchQueue.main)
.sink(receiveValue: { [view] in view?.backgroundColor = $0 })
.store(in: &cancellables)
output.buttonTitle
.subscribe(on: DispatchQueue.main)
.sink(receiveValue: { [button] in button.setTitle($0, for: .normal) })
.store(in: &cancellables)
output.cancellable
.store(in: &cancellables)
}
}
extension SimpleViewModel.Color {
var uiColor: UIColor {
switch self {
case .red: return .red
case .blue: return .blue
case .purple: return .purple
}
}
}
import Combine
import CombineExt // To enable the Combine extension Publishers.withLatestFrom. Ref: https://github.com/CombineCommunity/CombineExt
protocol ViewModelBinding {
associatedtype Inputs
associatedtype Outputs
func bind(_ inputs: Inputs) -> Outputs
}
class SimpleViewModel: ViewModelBinding {
enum Color: CaseIterable {
case red, blue, purple
}
struct Input {
let buttonTap: AnyPublisher<Void, Never>
}
struct Output {
let backgroundColor: AnyPublisher<Color, Never>
let buttonTitle: AnyPublisher<String, Never>
let cancellable: AnyCancellable
}
func bind(_ input: Input) -> Output {
let colors = Just(Color.allCases)
let currentColorIndexSubject = CurrentValueSubject<Int, Never>(0)
let currentColor = Publishers
.CombineLatest(colors, currentColorIndexSubject)
.map { colors, currentColorIndex in colors[currentColorIndex] }
let nextColorIndex = Publishers
.CombineLatest(colors, currentColorIndexSubject)
.map { colors, currentColorIndex in (currentColorIndex + 1) % colors.count }
let nextColor = Publishers
.CombineLatest(colors, nextColorIndex)
.map { colors, nextColorIndex in colors[nextColorIndex] }
// Form the output event streams for the binder to subscribe
return Output(
backgroundColor: currentColor.eraseToAnyPublisher(),
buttonTitle: nextColor.map { "Change to \($0)" }.eraseToAnyPublisher(),
cancellable: input.buttonTap.withLatestFrom(nextColorIndex).sink(receiveValue: { currentColorIndexSubject.send($0) })
)
}
}
@testable import SimpleProject
import Combine
import Entwine
import EntwineTest
import XCTest
class SimpleViewModelTests: XCTestCase {
func test_binding() {
let scheduler = TestScheduler(initialClock: .zero)
// Create input
let buttonTap = scheduler.createAbsoluteTestablePublisher(TestSequence<Void, Never>([
(1, .input(())), (2, .input(())), (3, .input(())),
])).eraseToAnyPublisher()
// Create output observers
let backgroundColorSubscriber = scheduler.createTestableSubscriber(SimpleViewModel.Color.self, Never.self)
let buttonTitleSubscriber = scheduler.createTestableSubscriber(String.self, Never.self)
// Bind the input to the view model
let viewModel = SimpleViewModel()
let input = SimpleViewModel.Input(
buttonTap: buttonTap
)
let output = viewModel.bind(input)
// Bind the output from the view model
var cancellables: [AnyCancellable] = []
output.backgroundColor.receive(subscriber: backgroundColorSubscriber)
output.buttonTitle.receive(subscriber: buttonTitleSubscriber)
output.cancellable.store(in: &cancellables)
// Start testing
scheduler.resume()
XCTAssertEqual(backgroundColorSubscriber.recordedOutput, [
(0, .subscription),
(0, .input(.red)),
(1, .input(.blue)),
(2, .input(.purple)),
(3, .input(.red)),
])
XCTAssertEqual(buttonTitleSubscriber.recordedOutput, [
(0, .subscription),
(0, .input("Change to blue")),
(1, .input("Change to purple")),
(2, .input("Change to red")),
(3, .input("Change to blue")),
])
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment