Earlier this week, I showed you how to create a custom control using a bitmask. In this episode, we take it a step further by making the custom control we built reactive using RxSwift.
Adding Dependencies Using the Swift Package Manager
Fire up Xcode and open the starter project of this episode if you want to follow along with me. You can add RxSwift one of several ways. I prefer the Swift Package Manager. Select the project in the Project Navigator. Select the project in the Project section and open the Package Dependencies tab at the top.
Click the + button below the table view that lists the project's dependencies. Enter the URL of the RxSwift repository on GitHub. Set Dependency Rule to Up to Next Major Version with the lower bound set to 6.0.0. Click the Add Package button to continue.
Check the checkboxes of RxCocoa, RxRelay, and RxSwift. Click Add Package to add the package products to the project.
You should see RxSwift listed in the Package Dependencies section in the Project Navigator on the left.
Drivers and Behavior Relays
The technique I outline in this episode is a technique I picked up from Krunoslav Zaher, the creator of RxSwift. Open SchedulePicker.swift and add import statements for the RxSwift and RxCocoa libraries.
import UIKit
import RxSwift
import RxCocoa
import Foundation
struct Schedule: OptionSet {
...
}
Locate the schedule
property we defined in the previous episode.
final class SchedulePicker: UIControl {
// MARK: - Properties
var schedule: Schedule = [] {
didSet { updateView() }
}
...
}
We replace the schedule
property with a Driver
, making the SchedulePicker
class reactive. Subscribers can subscribe to the Driver
and be notified when a change occurs.
But we don't simply use a Driver
. The approach I have come to appreciate involves a private BehaviorRelay
and a Driver
the SchedulePicker
class exposes. The BehaviorRelay
is only used by the SchedulePicker
class.
This is what that looks like. The scheduleRelay
property is of type BehaviorRelay<Schedule>
and it is initialized with the same value the schedule
property was initialized with. The computed scheduleDriver
property is of type Driver<Schedule>
and returns the BehaviorRelay
as a driver.
final class SchedulePicker: UIControl {
// MARK: - Properties
private let scheduleRelay = BehaviorRelay<Schedule>(value: [])
var scheduleDriver: Driver<Schedule> {
scheduleRelay.asDriver()
}
...
}
Under the hood, a BehaviorRelay
instance manages a BehaviorSubject
instance. The asDriver()
method provides read access to the BehaviorSubject
. This is what the implementation of the asDriver()
method looks like in the RxCocoa library.
import RxSwift
import RxRelay
extension BehaviorRelay {
/// Converts `BehaviorRelay` to `Driver`.
///
/// - returns: Observable sequence.
public func asDriver() -> Driver<Element> {
let source = self.asObservable()
.observe(on:DriverSharingStrategy.scheduler)
return SharedSequence(source)
}
}
Refactoring the SchedulePicker Class
The compiler has generated a few errors we need to take care of. We need to refactor the SchedulePicker
class to make it work with the scheduleRelay
property. The changes are minor and easy to understand.
In updateButtons()
, we ask scheduleRelay
for its value and use the Schedule
instance to update the buttons of the SchedulePicker
instance.
private func updateButtons() {
let schedule = scheduleRelay.value
buttons[0].isSelected = schedule.contains(.monday)
buttons[1].isSelected = schedule.contains(.tuesday)
buttons[2].isSelected = schedule.contains(.wednesday)
buttons[3].isSelected = schedule.contains(.thursday)
buttons[4].isSelected = schedule.contains(.friday)
buttons[5].isSelected = schedule.contains(.saturday)
buttons[6].isSelected = schedule.contains(.sunday)
}
In toggleSchedule(_:)
, we make a similar change. We ask scheduleRelay
for its value and update the Schedule
instance. Instead of invoking sendActions(for:)
, we update the value of scheduleRelay
by passing the Schedule
object to its accept(_:)
method.
@IBAction func toggleSchedule(_ sender: UIButton) {
guard let index = buttons.firstIndex(of: sender) else {
return
}
let element: Schedule.Element
var schedule = scheduleRelay.value
switch index {
case 0: element = .monday
case 1: element = .tuesday
case 2: element = .wednesday
case 3: element = .thursday
case 4: element = .friday
case 5: element = .saturday
default: element = .sunday
}
if schedule.contains(element) {
schedule.remove(element)
} else {
schedule.insert(element)
}
updateButtons()
scheduleRelay.accept(schedule)
}
Under the hood, a next event is emitted by the BehaviorSubject
instance of the BehaviorRelay
instance. This is what the implementation of the accept(_:)
method looks like in the RxRelay library.
import RxSwift
/// BehaviorRelay is a wrapper for `BehaviorSubject`.
///
/// Unlike `BehaviorSubject` it can't terminate with error or completed.
public final class BehaviorRelay<Element>: ObservableType {
private let subject: BehaviorSubject<Element>
/// Accepts `event` and emits it to subscribers
public func accept(_ event: Element) {
self.subject.onNext(event)
}
...
}
We also need to refactor the setupButtons()
method. After setting up the user interface, the SchedulePicker
instance subscribes to the scheduleRelay
property. Every time the BehaviorRelay
instance emits a value, updateButtons()
is invoked.
private func setupButtons() {
...
scheduleDriver.drive(
onNext: { [weak self] schedule in
self?.updateButtons()
}
).disposed(by: disposeBag)
}
To keep the compiler happy, we declare a private, constant property named disposeBag
and use it to store a reference to a DisposeBag
instance.
// MARK: -
private let disposeBag = DisposeBag()
This also means that we can remove updateView()
. We no longer need it.
Refactoring the ViewController Class
We need to make a few changes to the ViewController
class. But before we do, we need to implement a setter method to set the schedule of the SchedulePicker
instance. We can no longer set the value of the schedule
property.
Add the following method to the SchedulePicker
class. There are other solutions, but this works fine. I don't recommend exposing the scheduleRelay
property. This solution allows us to keep the scheduleRelay
property private.
// MARK: - Public API
func setSchedule(_ newSchedule: Schedule) {
scheduleRelay.accept(newSchedule)
}
In the setupSchedulePicker()
method of the ViewController
class, we set the schedule using the setSchedule(_:)
method we added to the SchedulePicker
class.
private func setupSchedulePicker() {
let scheduleRawValue = UserDefaults.standard.integer(
forKey: UserDefaults.Keys.schedule
)
schedulePicker.setSchedule(.init(rawValue: scheduleRawValue))
}
Add an import statement for the RxSwift library and define a constant property named disposeBag
. We use it to store a reference to a DisposeBag
instance.
// MARK: -
private let disposeBag = DisposeBag()
Revisit the setupSchedulePicker()
method and subscribe to the scheduleDriver
property of the SchedulePicker
instance. Every time the schedule is modified, we update the value stored in the user's defaults database.
private func setupSchedulePicker() {
let scheduleRawValue = UserDefaults.standard.integer(
forKey: UserDefaults.Keys.schedule
)
schedulePicker.setSchedule(.init(rawValue: scheduleRawValue))
schedulePicker.scheduleDriver.drive(
onNext: { schedule in
UserDefaults.standard.set(
schedule.rawValue,
forKey: UserDefaults.Keys.schedule
)
}
).disposed(by: disposeBag)
}
We can now remove the scheduleDidChange(_:)
action and unwire the action in Main.storyboard.
What's Next?
Build and run the application to give it a try. You shouldn't see any warnings or errors. It is important to note that RxCocoa uses a different approach to make UIKit components reactive. The advantage of this technique is that it is lightweight and requires very little setup.