Last active
March 13, 2024 03:43
-
-
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
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
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 | |
} | |
} | |
} |
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
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) }) | |
) | |
} | |
} |
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
@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