In this section, we enhance our understanding of RxSwift by implementing the Model-View-ViewModel (MVVM) pattern in a simple form app. The SimpleFormMVVMViewController
demonstrates how to bind UI elements to a ViewModel
, making it reactive and decoupled.
import UIKit
import RxSwift
import RxCocoa
class SimpleFormMVVMViewController: UIViewController {
let disposeBag = DisposeBag()
let viewModel = SimpleFormViewModel()
let nameTextField: UITextField = {
let textField = UITextField()
textField.translatesAutoresizingMaskIntoConstraints = false
textField.borderStyle = .roundedRect
textField.placeholder = "Enter your name"
return textField
}()
let lastNameTextField: UITextField = {
let textField = UITextField()
textField.translatesAutoresizingMaskIntoConstraints = false
textField.borderStyle = .roundedRect
textField.placeholder = "Enter your last name"
return textField
}()
let submitButton: UIButton = {
let button = UIButton(type: .roundedRect)
button.setTitle("Submit", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = .systemBlue
button.tintColor = .white
button.layer.cornerRadius = 8
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupBindings()
}
private func setupUI() {
view.addSubview(nameTextField)
view.addSubview(lastNameTextField)
view.addSubview(submitButton)
NSLayoutConstraint.activate([
nameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
nameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
nameTextField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
lastNameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
lastNameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
lastNameTextField.topAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 16),
submitButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
submitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
submitButton.topAnchor.constraint(equalTo: lastNameTextField.bottomAnchor, constant: 16),
])
}
private func setupBindings() {
nameTextField.rx.text.orEmpty
.bind(to: viewModel.name)
.disposed(by: disposeBag)
lastNameTextField.rx.text.orEmpty
.bind(to: viewModel.lastName)
.disposed(by: disposeBag)
viewModel.isSubmitButtonEnable
.observe(on: MainScheduler.instance)
.bind(to: submitButton.rx.isEnabled)
.disposed(by: disposeBag)
viewModel.isSubmitButtonEnable
.observe(on: MainScheduler.instance)
.subscribe { [weak self] isEnabled in
self?.submitButton.backgroundColor = isEnabled ? .systemBlue : .systemGray
}
.disposed(by: disposeBag)
submitButton.rx.tap
.bind(to: viewModel.submitAction)
.disposed(by: disposeBag)
}
}
Understanding the SimpleFormMVVMViewController
This view controller utilizes the MVVM architecture:
- View (ViewController):
- Handles the UI and binds it to the
ViewModel
.
- Handles the UI and binds it to the
- ViewModel:
- Contains the reactive logic and state management.
- Model:
- Represents the underlying data structure (not explicitly included in this example).
This structure ensures clear separation of concerns, making the code modular, testable, and scalable.
Code Walkthrough
1. UI Setup
The setupUI
function creates and arranges three UI elements:
nameTextField
for the first name.lastNameTextField
for the last name.submitButton
to trigger the form submission.
The constraints align these elements vertically with consistent spacing:
private func setupUI() {
view.addSubview(nameTextField)
view.addSubview(lastNameTextField)
view.addSubview(submitButton)
NSLayoutConstraint.activate([
nameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
nameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
nameTextField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
lastNameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
lastNameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
lastNameTextField.topAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 16),
submitButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
submitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
submitButton.topAnchor.constraint(equalTo: lastNameTextField.bottomAnchor, constant: 16),
])
}
This straightforward layout centers the form on the screen, adhering to best practices for responsive design.
2. Reactive Bindings
The setupBindings
function is where the magic of reactive MVVM happens. Each UI component is bound to a corresponding property or action in the ViewModel
.
private func setupBindings() {
nameTextField.rx.text.orEmpty
.bind(to: viewModel.name)
.disposed(by: disposeBag)
lastNameTextField.rx.text.orEmpty
.bind(to: viewModel.lastName)
.disposed(by: disposeBag)
}
- TextField Binding:
nameTextField
andlastNameTextField
are bound toviewModel.name
andviewModel.lastName
.- As the user types, the text is updated in the
ViewModel
.
3. Button State Management
The submitButton
is enabled or disabled based on the ViewModel
‘s logic, using the isSubmitButtonEnable
observable:
viewModel.isSubmitButtonEnable
.observe(on: MainScheduler.instance)
.bind(to: submitButton.rx.isEnabled)
.disposed(by: disposeBag)
viewModel.isSubmitButtonEnable
.observe(on: MainScheduler.instance)
.subscribe { [weak self] isEnabled in
self?.submitButton.backgroundColor = isEnabled ? .systemBlue : .systemGray
}
.disposed(by: disposeBag)
- Reactive Enabling:
isSubmitButtonEnable
determines if the button should be enabled based on the input validation logic in theViewModel
.
- Dynamic Styling:
- Subscribing to
isSubmitButtonEnable
allows us to update the button’s background color dynamically for better UX.
- Subscribing to
4. Handling Button Taps
submitButton.rx.tap
.bind(to: viewModel.submitAction)
.disposed(by: disposeBag)
- Button Tap Binding:
- The
submitButton
‘s tap event is bound toviewModel.submitAction
, triggering the form submission logic.
- The
Understanding the SimpleFormViewModel
The SimpleFormViewModel
is a key component in the MVVM (Model-View-ViewModel) architecture, designed to handle the logic and state management of a simple form. It leverages RxSwift to provide reactive streams for managing user input and actions.
import UIKit
import RxSwift
import RxCocoa
class SimpleFormViewModel {
let name = BehaviorRelay<String>(value: "")
let lastName = BehaviorRelay<String>(value: "")
let submitAction = PublishSubject<Void>()
private let disposeBag = DisposeBag()
var isSubmitButtonEnable: Observable<Bool> {
return Observable
.combineLatest(name, lastName) { name, lastName in
return !name.isEmpty && !lastName.isEmpty
}
}
init() {
setupBindings()
}
private func setupBindings() {
submitAction
.withLatestFrom(Observable.combineLatest(name, lastName))
.subscribe(onNext: { name, lastName in
print(name)
print(lastName)
})
.disposed(by: disposeBag)
}
}
Code Breakdown
1. Properties
let name = BehaviorRelay<String>(value: "")
let lastName = BehaviorRelay<String>(value: "")
let submitAction = PublishSubject<Void>()
private let disposeBag = DisposeBag()
name
andlastName
:- These are
BehaviorRelay
s, which hold the latest value of the user’s input for the name and last name fields. They are used as observable sources that can emit updates whenever their values change.
- These are
submitAction
:- A
PublishSubject
that emits an event when the submit button is tapped. This acts as a trigger for handling form submission.
- A
disposeBag
:- Manages the lifecycle of subscriptions to avoid memory leaks. When the
ViewModel
is deallocated, all active subscriptions in thedisposeBag
are disposed of automatically.
- Manages the lifecycle of subscriptions to avoid memory leaks. When the
2. Reactive Output: isSubmitButtonEnable
var isSubmitButtonEnable: Observable<Bool> {
return Observable
.combineLatest(name, lastName) { name, lastName in
return !name.isEmpty && !lastName.isEmpty
}
}
- What it does:
- Combines the latest values of
name
andlastName
. - Emits
true
if both fields are non-empty; otherwise, emitsfalse
.
- Combines the latest values of
- Why it matters:
- This is used to enable or disable the submit button dynamically based on the form’s validity. For example:
- If the user types “John” in the
name
field and leaveslastName
empty, the button remains disabled. - Once both fields have values, the button becomes enabled.
- If the user types “John” in the
- This is used to enable or disable the submit button dynamically based on the form’s validity. For example:
- Key RxSwift Operator:
combineLatest
:- Combines the latest emissions from multiple observables and applies a transformation (closure) to produce a single observable output.
3. Initializer: init()
init() {
setupBindings()
}
The initializer calls setupBindings
to configure the reactive logic for handling the form submission action.
4. Private Method: setupBindings()
private func setupBindings() {
submitAction
.withLatestFrom(Observable.combineLatest(name, lastName))
.subscribe(onNext: { name, lastName in
print(name)
print(lastName)
})
.disposed(by: disposeBag)
}
- What it does:
- Listens for taps on the submit button (via
submitAction
). - When the submit button is tapped, retrieves the latest values of
name
andlastName
usingwithLatestFrom
. - Prints the values of
name
andlastName
to the console.
- Listens for taps on the submit button (via
- Key RxSwift Operators:
withLatestFrom
:- Retrieves the latest values from the specified observable (in this case, the combined
name
andlastName
) when thesubmitAction
emits an event. - This ensures the form submission always uses the most recent user input.
- Retrieves the latest values from the specified observable (in this case, the combined
subscribe
:- Observes and handles the emitted event. Here, it prints the combined
name
andlastName
values to the console.
- Observes and handles the emitted event. Here, it prints the combined
- Why it matters:
- This method encapsulates the form submission logic. The
ViewModel
is responsible for processing the user’s input, ensuring that theViewController
stays focused on managing the UI.
- This method encapsulates the form submission logic. The
How It Works Together
The SimpleFormViewModel
integrates the user’s inputs and actions as follows:
- Dynamic Input Handling:
- The
name
andlastName
properties are continuously updated as the user types. - The
isSubmitButtonEnable
observable dynamically reflects the form’s validity.
- The
- Form Submission:
- When the submit button is tapped,
submitAction
emits an event. - The latest values of
name
andlastName
are retrieved and processed (in this case, printed to the console).
- When the submit button is tapped,
- Decoupling Logic:
- All business logic (e.g., checking if fields are empty or handling form submissions) resides in the
ViewModel
, ensuring a clear separation of concerns.
- All business logic (e.g., checking if fields are empty or handling form submissions) resides in the
Extending the SimpleFormViewModel
The SimpleFormViewModel
can be extended to include more complex logic, such as:
- Error Handling: Validate the input fields and emit error messages to be displayed in the
ViewController
.
var nameErrorMessage: Observable<String?> {
return name.map { $0.isEmpty ? "Name is required" : nil }
}
- Network Requests: Use
submitAction
to trigger an API call or perform data processing asynchronously.
private func setupBindings() {
submitAction
.withLatestFrom(Observable.combineLatest(name, lastName))
.flatMapLatest { name, lastName in
return self.submitForm(name: name, lastName: lastName)
.catchAndReturn("Submission failed")
}
.subscribe(onNext: { response in
print(response)
})
.disposed(by: disposeBag)
}
private func submitForm(name: String, lastName: String) -> Observable<String> {
// Simulate a network call
return Observable.just("Form submitted successfully")
.delay(.seconds(1), scheduler: MainScheduler.instance)
}
Unit Tests for SimpleFormMVVMViewController
The provided test class validates the behavior of the SimpleFormMVVMViewController
‘s submitButton
, ensuring it dynamically responds to user input in the text fields. These tests verify the interaction between the UI and the SimpleFormViewModel
.
import XCTest
import RxSwift
import RxCocoa
import RxTest
import RxBlocking
@testable import RXSwift_Exercise
class SimpleFormMVVMViewControllerTests: XCTestCase {
var viewController: SimpleFormMVVMViewController!
override func setUp() {
super.setUp()
viewController = SimpleFormMVVMViewController()
_ = viewController.view
}
override func tearDown() {
viewController = nil
super.tearDown()
}
func testSubmitButtonIsDisableWhenFormEmpty() {
viewController.nameTextField.text = ""
viewController.nameTextField.sendActions(for: .editingChanged)
viewController.lastNameTextField.text = ""
viewController.lastNameTextField.sendActions(for: .editingChanged)
XCTAssertFalse(viewController.submitButton.isEnabled)
}
func testSubmitButtonIsEnabledWhenFormNotEmpty() {
viewController.nameTextField.text = "Batman"
viewController.nameTextField.sendActions(for: .editingChanged)
viewController.lastNameTextField.text = "Returns"
viewController.lastNameTextField.sendActions(for: .editingChanged)
XCTAssertTrue(viewController.submitButton.isEnabled)
}
}
Test Class Overview
- Properties:
viewController
: An instance ofSimpleFormMVVMViewController
, which is the subject under test.
- Lifecycle Methods:
setUp
: Initializes theviewController
before each test and ensures its view is loaded.tearDown
: Cleans up theviewController
after each test to avoid shared state or memory leaks.
1. Test: testSubmitButtonIsDisableWhenFormEmpty
func testSubmitButtonIsDisableWhenFormEmpty() {
viewController.nameTextField.text = ""
viewController.nameTextField.sendActions(for: .editingChanged)
viewController.lastNameTextField.text = ""
viewController.lastNameTextField.sendActions(for: .editingChanged)
XCTAssertFalse(viewController.submitButton.isEnabled)
}
Purpose:
This test ensures the submitButton
is disabled when both nameTextField
and lastNameTextField
are empty.
How It Works:
- Simulate User Input:
- Sets the text of
nameTextField
andlastNameTextField
to empty strings (""
). - Triggers
.editingChanged
actions usingsendActions(for:)
to simulate the user clearing the text fields.
- Sets the text of
- Assert Button State:
- Asserts that
submitButton.isEnabled
isfalse
.
- Asserts that
Expected Behavior:
When the form is empty, the submitButton
should remain disabled.
2. Test: testSubmitButtonIsEnabledWhenFormNotEmpty
func testSubmitButtonIsEnabledWhenFormNotEmpty() {
viewController.nameTextField.text = "Batman"
viewController.nameTextField.sendActions(for: .editingChanged)
viewController.lastNameTextField.text = "Returns"
viewController.lastNameTextField.sendActions(for: .editingChanged)
XCTAssertTrue(viewController.submitButton.isEnabled)
}
Purpose:
This test ensures the submitButton
is enabled when both nameTextField
and lastNameTextField
have valid input.
How It Works:
- Simulate User Input:
- Sets the text of
nameTextField
to"Batman"
andlastNameTextField
to"Returns"
. - Triggers
.editingChanged
actions to simulate the user typing in both fields.
- Sets the text of
- Assert Button State:
- Asserts that
submitButton.isEnabled
istrue
.
- Asserts that
Expected Behavior:
When both fields have valid input, the submitButton
should be enabled.
Key Concepts in These Tests
1. UI and Reactive Binding Validation
These tests indirectly validate the reactive binding between:
- The
nameTextField
andlastNameTextField
inputs. - The
submitButton
‘sisEnabled
state, which is controlled by theisSubmitButtonEnable
observable in theSimpleFormViewModel
.
By simulating user interaction and asserting the button’s state, these tests confirm that the bindings work correctly.
2. Simulating User Interaction
The use of sendActions(for:)
is critical to simulating user input. It triggers the .editingChanged
event programmatically, mimicking the behavior of a real user typing in the text fields.
3. State Assertions
The tests rely on the XCTAssertTrue
and XCTAssertFalse
assertions to check the state of the submitButton
. These assertions ensure the button’s state accurately reflects the form’s validity.
Why These Tests Are Important
- Form Validation:
- They confirm the form validation logic works as expected by enabling or disabling the submit button based on input.
- MVVM Integration:
- These tests indirectly validate the interaction between the
SimpleFormMVVMViewController
(View) and theSimpleFormViewModel
.
- These tests indirectly validate the interaction between the
- User Experience Assurance:
- Ensures a seamless user experience where the button state dynamically reflects the validity of the form inputs.
Next Steps
These tests cover basic functionality. To expand coverage, consider:
- Validating additional scenarios, such as partial input (e.g., only one text field is filled).
- Testing the behavior when the
submitButton
is tapped (e.g., triggering thesubmitAction
and observing the output). - Verifying that the
submitButton
’s appearance changes dynamically (e.g., background color for disabled/enabled states).
These additional tests would further ensure the correctness and reliability of the app’s reactive behavior.
Unit Tests for SimpleFormViewModel
Unit testing ensures the SimpleFormViewModel
behaves as expected, especially its reactive properties and actions. The provided test cases validate key aspects, such as form validation and submit action logic, using tools like RxBlocking
and RxTest
.
import XCTest
import RxSwift
import RxCocoa
import RxTest
import RxBlocking
@testable import RXSwift_Exercise
class SimpleFormViewModelTests: XCTestCase {
var viewModel: SimpleFormViewModel!
var scheduler: TestScheduler!
var disposeBag: DisposeBag!
override func setUp() {
super.setUp()
viewModel = SimpleFormViewModel()
scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()
}
override func tearDown() {
viewModel = nil
scheduler = nil
disposeBag = nil
super.tearDown()
}
func testIsSubmitButtonEnable_WhenFieldsAreEmpty_ShouldBeDisabled() {
let isEnabled = try! viewModel.isSubmitButtonEnable
.toBlocking(timeout: 1.0)
.first()
XCTAssertFalse(isEnabled ?? true)
}
func testIsSubmitButtonEnable_WhenFieldsAreFilled_ShouldBeEnabled() {
viewModel.name.accept("John")
viewModel.lastName.accept("Doe")
let isEnabled = try! viewModel.isSubmitButtonEnable
.toBlocking(timeout: 1.0)
.first()
XCTAssertTrue(isEnabled ?? false)
}
func testSubmitAction_WhenButtonTapped_ShouldPrintNameAndLastName() {
viewModel.name.accept("Jane")
viewModel.lastName.accept("Smith")
let observer = scheduler.createObserver((String, String).self)
viewModel.submitAction
.withLatestFrom(Observable.combineLatest(viewModel.name, viewModel.lastName))
.subscribe(observer)
.disposed(by: disposeBag)
viewModel.submitAction.onNext(())
scheduler.start()
let expectedEvents = [
Recorded.next(0, ("Jane", "Smith"))
]
XCTAssertEqual(observer.events.count, expectedEvents.count, "Wrong Events number")
for (index, event) in observer.events.enumerated() {
XCTAssertEqual(event.time, expectedEvents[index].time, "Wrong index: \(index).")
XCTAssertEqual(event.value.element?.0, expectedEvents[index].value.element?.0, "Wrong first value \(index).")
XCTAssertEqual(event.value.element?.1, expectedEvents[index].value.element?.1, "Wrong seccond value \(index).")
}
}
}
Test Class Overview
- Properties:
viewModel
: TheSimpleFormViewModel
instance being tested.scheduler
: ATestScheduler
to simulate and control the timing of events.disposeBag
: A container for disposing subscriptions after each test.
- Lifecycle Methods:
setUp
: Initializes the test environment before each test.tearDown
: Cleans up resources after each test.
1. Test: testIsSubmitButtonEnable_WhenFieldsAreEmpty_ShouldBeDisabled
func testIsSubmitButtonEnable_WhenFieldsAreEmpty_ShouldBeDisabled() {
let isEnabled = try! viewModel.isSubmitButtonEnable
.toBlocking(timeout: 1.0)
.first()
XCTAssertFalse(isEnabled ?? true)
}
Purpose:
Verifies that the isSubmitButtonEnable
observable emits false
when both fields (name
and lastName
) are empty.
How It Works:
- Access the Observable:
isSubmitButtonEnable
is converted to a blocking observable usingtoBlocking()
to capture the value synchronously.
- Assert Output:
- Checks if the first emitted value is
false
.
- Checks if the first emitted value is
Expected Behavior:
The button should be disabled (isEnabled == false
) because the fields are empty.
2. Test: testIsSubmitButtonEnable_WhenFieldsAreFilled_ShouldBeEnabled
func testIsSubmitButtonEnable_WhenFieldsAreFilled_ShouldBeEnabled() {
viewModel.name.accept("John")
viewModel.lastName.accept("Doe")
let isEnabled = try! viewModel.isSubmitButtonEnable
.toBlocking(timeout: 1.0)
.first()
XCTAssertTrue(isEnabled ?? false)
}
Purpose:
Ensures the isSubmitButtonEnable
observable emits true
when both fields are filled.
How It Works:
- Simulate User Input:
- The
name
andlastName
properties are updated using.accept()
, simulating a user entering text into the fields.
- The
- Assert Output:
- Captures the first emitted value from
isSubmitButtonEnable
and asserts that it istrue
.
- Captures the first emitted value from
Expected Behavior:
The button should be enabled (isEnabled == true
) because both fields have valid input.
3. Test: testSubmitAction_WhenButtonTapped_ShouldPrintNameAndLastName
func testSubmitAction_WhenButtonTapped_ShouldPrintNameAndLastName() {
viewModel.name.accept("Jane")
viewModel.lastName.accept("Smith")
let observer = scheduler.createObserver((String, String).self)
viewModel.submitAction
.withLatestFrom(Observable.combineLatest(viewModel.name, viewModel.lastName))
.subscribe(observer)
.disposed(by: disposeBag)
viewModel.submitAction.onNext(())
scheduler.start()
let expectedEvents = [
Recorded.next(0, ("Jane", "Smith"))
]
XCTAssertEqual(observer.events.count, expectedEvents.count, "Wrong Events number")
for (index, event) in observer.events.enumerated() {
XCTAssertEqual(event.time, expectedEvents[index].time, "Wrong index: \(index).")
XCTAssertEqual(event.value.element?.0, expectedEvents[index].value.element?.0, "Wrong first value \(index).")
XCTAssertEqual(event.value.element?.1, expectedEvents[index].value.element?.1, "Wrong seccond value \(index).")
}
}
Purpose:
Validates that the submitAction
emits the correct name
and lastName
values when triggered.
How It Works:
- Set Up Input:
- Simulates user input by setting
name
to"Jane"
andlastName
to"Smith"
.
- Simulates user input by setting
- Create an Observer:
- Uses
TestScheduler
to create an observer for monitoring the combinedname
andlastName
emitted bysubmitAction
.
- Uses
- Trigger the Action:
- Simulates a button tap by calling
submitAction.onNext(())
.
- Simulates a button tap by calling
- Verify Emitted Events:
- Compares the emitted events from the observer to the expected events:
Recorded.next(0, ("Jane", "Smith"))
: At time0
, the values"Jane"
and"Smith"
are emitted.
- Compares the emitted events from the observer to the expected events:
Expected Behavior:
The combined name
and lastName
should be emitted as ("Jane", "Smith")
when the submit button is tapped.
Key Testing Concepts
1. RxBlocking
for Synchronous Testing
RxBlocking
converts asynchronous observables into blocking calls, making it easier to test emitted values:
Usage:
try! viewModel.isSubmitButtonEnable.toBlocking(timeout: 1.0).first()
Benefit:
Captures emitted values synchronously for assertions.
2. RxTest
for Event Simulation
RxTest
provides utilities like TestScheduler
and TestableObserver
to simulate and observe events in a controlled environment:
TestScheduler
:- Simulates time for reactive streams, allowing precise control over when events are emitted.
createObserver
:- Captures events emitted by an observable for comparison against expected results.
- Event Format:
Recorded.next(time, value)
:- Represents an event emitted at a specific time.
Conclusion
These unit tests ensure the SimpleFormViewModel
:
- Dynamically enables or disables the submit button based on form input.
- Correctly emits the
name
andlastName
values when the submit button is tapped.
By leveraging RxBlocking
and RxTest
, the tests cover both real-time observable behavior and precise event simulation, providing confidence in the reactive logic’s correctness.