DRIVE
Functional Reactive Form Validation in iOS with RxSwift
![Ryan Gosling with a Dispose Bag](https://raw.githubusercontent.com/scottorly/drive/main/src/drive.webp)
WHY DRIVE?
SwiftUI has been getting all the love since it was announced but I want to take some time to write about the productivity benefits of functional reactive programming using RxSwift when combined with the stability of UIKit.
Observables are an excellent data-binding mechanism when escaping target-action, delegate based MVC patterns but even after the steep learning curve remembering tedious boilerplate and dodging footguns can be time consuming and error prone. Furthermore type inference across API boundaries can result in frustrating fights with the Swift compiler.
An example from the RxSwift documentation is an effective demonstration of the implementation complexity faced when using Rx with UIKit properly.
let results = query.rx.text
.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
.flatMapLatest { query in
fetchAutoCompleteItems(query)
.observeOn(MainScheduler.instance) // results are returned on MainScheduler
.catchErrorJustReturn([]) // in the worst case, errors are handled
}
.share(replay: 1) // HTTP requests are shared and results replayed
// to all UI elements
results
.map { "\($0.count)" }
.bind(to: resultCount.rx.text)
.disposed(by: disposeBag)
Thankfully RxSwift provides us with some wrappers around common UI patterns that can help simplify implementations. RxSwift calls these wrappers traits and today we are going to focus on the Driver trait.
RxSwift Traits are simple structs that implement the builder pattern to return an observable sequence guaranteed to have certain properties. The Driver trait guarantees three properties that happen to be integral to correct UI implementations: events are observed on the main thread, the observable sequence can't error out, and side effects are shared so that each subscription will share the same computational resources.
let safeSequence = xs
.observeOn(MainScheduler.instance) // observe events on main scheduler
.catchErrorJustReturn(onErrorJustReturn) // can't error out
.share(replay: 1, scope: .whileConnected) // side effects sharing
return Driver(raw: safeSequence) // wrap it up
let results = query.rx.text.asDriver() // This converts a normal sequence into a 'Driver' sequence.
.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
.flatMapLatest { query in
fetchAutoCompleteItems(query)
.asDriver(onErrorJustReturn: []) // Builder just needs info about what to return in case of error.
}
results
.map { "($0.count)" }
.drive(resultCount.rx.text) // If there is a 'drive' method available instead of 'bind(to:)',
.disposed(by: disposeBag) // that means that the compiler has proven that all properties
// are satisfied.
UITEXTFIELD VALIDATION
Form validation that provides the user with immediate feedback if a text field meets predefined requirements is a common UX pattern and this example will show you how you can use the Driver trait for effective results with a very small amount of clean, testable code.
BRAKING ZONE
For the purpose of narrowing the focus of this article I won't be going over project setup, how to connect outlets in storyboards, the basics of RxSwift/RxCocoa etc. I will also be handwaving the networking, validation and network activity tracking utilities as those implementation details are outside the scope of this article. The working project source code is available here if you would like to take a closer look or test a working example on a simulator or device.
This project will use MVVM but there are a few ground rules to help enforce seperation of concerns:
- The ViewModel can never import UIKit or reference the ViewController.
- The ViewModel will never subscribe to an observable.
- The ViewController will never directly call any methods defined on the ViewModel.
First we define inputs to the ViewModel. The initializer for our new ViewModel class below takes a pair of Driver<String>
for the email and password and a Driver<Void>
for the sign in button tap. We are not going to explicitly store references to these sequences.
import Foundation
import RxSwift
import RxCocoa
class ViewModel {
//MARK: - Inputs
init(
email: Driver<String>,
password: Driver<String>,
signIn: Driver<Void>
) {
}
}
In the ViewController we initialize the ViewModel passing in the Observables from the IBOutlets. Note the call to .asDriver()
to build the Drivers from the ControlEvent observables.
import UIKit
import RxSwift
import RxCocoa
class ViewController: UIViewController {
// MARK: - IBOutlets
@IBOutlet weak var email: UITextField!
@IBOutlet weak var password: UITextField!
@IBOutlet weak var signIn: UIButton!
lazy var viewModel: ViewModel = {
ViewModel(email: email.rx.text.orEmpty.asDriver(),
password: password.rx.text.orEmpty.asDriver(),
signIn: signIn.rx.tap.asDriver())
}()
}
Next we define output properties to store the reference to the result of the operator transformations we are going to perform on the input observables.
class ViewModel {
//MARK: - Outputs
let validatedEmail: Driver<Validation>
let validatedPassword: Driver<Validation>
//MARK: - Inputs
init(
email: Driver<String>,
password: Driver<String>,
signIn: Driver<Void>
) {
let validation = ValidationService.shared
validatedEmail = Driver.combineLatest(email, signIn).flatMapLatest {
validation.validate(email: $0.0)
.asDriver(onErrorJustReturn: .failed("Email required."))
}
validatedPassword = Driver.combineLatest(password, signIn).flatMapLatest {
validation.validate(password: $0.0)
.asDriver(onErrorJustReturn: .failed("Password required."))
}
}
}
Above we are using Driver.combineLatest
to combine events from the UITextField Drivers and the sign in button tap. The purpose of this is to exploit a behavior of combine latest that the result observable will not emit an event until both source observables have at least one in order to prevent displaying validation errors before user interaction. Then we .flatMapLatest
the combined text and tap events passing the string into our validation service's appropriate validation method and return a Driver<Validation>
that is stored in the output properties defined above.
Close the loop by calling drive(onNext:)
on the output observables in the ViewController. Don't forget to put the results of the drive(onNext:)
calls in the bag
or to call bind()
in viewDidLoad()
.
class ViewController: UIViewController { ...
let bag = DisposeBag()
func bind() {
viewModel.validatedEmail.drive(onNext: { [weak self] result in
if case .failed(let message) = result {
self?.emailError.text = message
self?.emailError.isHidden = false
} else {
self?.emailError.isHidden = true
}
UIView.animate(withDuration: 0.2) {
self?.view.layoutIfNeeded()
}
}).disposed(by: bag)
viewModel.validatedPassword.drive(onNext: { [weak self] result in
if case .failed(let message) = result {
self?.emailError.text = message
self?.emailError.isHidden = false
} else {
self?.emailError.isHidden = true
}
UIView.animate(withDuration: 0.2) {
self?.view.layoutIfNeeded()
}
}).disposed(by: bag)
}
}
THE GETAWAY
Add two more output properties to the ViewModel. The first is signingIn: Driver<Bool>
to manage the state of the current request so we can disable the sign in button when a request is in-flight. The second is the output Driver for the response from the network request to sign in.
let signingIn: Driver<Bool>
let signedIn: Driver<LoginResponse>
The ViewModel snippet below is preparation to handle the sign in implementation. Compose the email and password text drivers (the inputs) with the validation result drivers (the outputs) in addition we create another Driver wrapped observable from a utility class borrowed from RxExample that provides the ability to track the activity of an observable so we can prevent the user from creating a duplicate request if one is already in-flight and the user mashes the sign in button.
let emailPassword = Driver.combineLatest(email, password)
let activity = ActivityIndicator()
signingIn = activity.asDriver()
let validated = Driver.combineLatest(
emailPassword,
validatedEmail,
validatedPassword,
signingIn
)
Finally we hook everything together, there is a lot going on here so let's break it down line by line. First call withLatestFrom()
on the signIn: Driver<Void>
passing in the combined inputs and outputs composed above (validated
). Then flatMapLatest
the sign in button tap event with the observables composition, guard for successful validation and no requests in flight, then flatMap
the observable from the network sign in request, check the response status (the example code is oversimplified but in a real world application here is where you determine error messsages to display if for instance the response was 401 unauthorized) and return an Observable<LoginResponse>
, add the activity tracking and finally build the signedIn: Driver<LoginResponse>
output Driver.
signedIn = signIn.withLatestFrom(validated).flatMapLatest { combined in
let (emailPassword, validatedUsername, validatedPassword, signingIn) = combined
guard case (.success, .success, false) = (validatedUsername, validatedPassword, signingIn) else {
return .just(.validating)
}
return Network.shared.login(email: emailPassword.0,
password: emailPassword.1)
.flatMap { response -> Observable<LoginResponse> in
if case .success = response {
return .just(.success)
}
return .just(response)
}
.trackActivity(activity)
.asDriver(onErrorJustReturn: .failure)
}
Now in the ViewController we use drive(onNext:)
to drive the state of the isEnabled
property of the sign in button and drive signedIn
to handle the result of the sign in network call. Again taking caution to use [weak self]
and don't forget to dispose of the result of drive
in the bag.
viewModel.signingIn.drive(onNext: { [weak self] signingIn in
self?.signIn.isEnabled = !signingIn
}).disposed(by: bag)
viewModel.signedIn.drive(onNext: { [weak self] signedIn in
if case .success = signedIn {
self?.emailError.isHidden = true
self?.passwordError.isHidden = true
self?.view.endEditing(true)
}
if case .failure = signedIn {
let message = "Failed, please try again."
self?.passwordError.text = message
self?.passwordError.isHidden = false
self?.emailError.isHidden = true
}
UIView.animate(withDuration: 0.2) {
self?.view.layoutIfNeeded()
}
}).disposed(by: bag)
The success state is where the application would presumably handle navigating elsewhere or dismissing the sign in screen if presented modally. In a real world application the response should wrap a more informative error message that can then be displayed to the user.
TEST DRIVE
What about unit testing the ViewModel you ask? Simple, since all the ViewModel knows is that it needs 3 Drivers we can provide those easily.
import XCTest
import RxSwift
import RxCocoa
@testable import Drive
class TestDrive: XCTestCase {
var subject: ViewModel?
let bag = DisposeBag()
override func setUpWithError() throws {
let email: Driver<String> = Observable<String>.just("email@email").asDriver(onErrorJustReturn: "")
let password: Observable<String> = .just("email@email")
let signIn: Observable<Void> = .just(())
subject = ViewModel(
email: email.asDriver(onErrorJustReturn: ""),
password: password.asDriver(onErrorJustReturn: ""),
signIn: signIn.asDriver(onErrorJustReturn:())
)
}
func testDrive() throws {
XCTAssertNotNil(subject)
subject?.validatedEmail.drive(onNext: { result in
XCTAssertEqual(result, .failed("Please enter a valid email address."))
}).disposed(by: bag)
}
}
IN THE BAG
That's the post, you can find the completed working project here. Feel free to drop some feedback or questions on Twitter or you can go to the source of this blog post itself and create an issue or pull-request. Until next time.