Reactive programming was quite popular among iOS/OSX developers in recent years, although there was no "official" API. Community created wonderful RxSwit project, being one of most popular 3rd party tools used in Apple ecosystem. Things has changed little bit once Apple released their own Combine framework which introduced similar API and Rx-ish thinking.
Today, we are going to try it out via practical example of shopping cart functionality. We will be discussing how to model internal and external events happening in our buying process and thinking about posssible future extensions. Article assumes you have basic knowledge about swift and RxSwift or Combine.
Let's imagine hypotethical UI for our online shop and think about required functionality:

As we can see we provide easy API for:
- Getting list of current orders
- Adding new products to a cart
- Incrementing or decrementing number of ordered products
- Providing total price of a cart and for each order
- Getting total number of products
That brings two basic entities in our project, Product and ProductOrder:
struct Product {
var id: Int
var name: String
var price: Double
}
struct ProductOrder {
var product: Product
var quantity: UInt
}Now, let's define possible events happening on our UI:
- Adding new product to an existing cart
- Incrementing or decrementing order quantity
- Clearing content of a cart (e.g. after succesful payment process)
We can cover that by implementing CartAction enum:
enum CartAction {
case add(product: Product)
case incrementOrder(withProductId: Int)
case decrementOrder(withProductId: Int)
case clear
}Our ShoppingCart will take CartAction events as an input and publish array of ProductOrder as an output.
For simplicity, we will skip error cases like incrementing order which was not present in a cart.
class ShoppingCart {
let orders: AnyPublisher<[ProductOrder], Never>
// UI events might be mapped to instances to CartAction and streamed here
let input: AnySubscriber<CartAction, Never>
init() {
let actionInput = PassthroughSubject<CartAction, Never>()
// it does not do much right now
self.orders = Just([]).eraseToAnyPublisher()
self.input = AnySubscriber(actionInput)
}
}We have basic interface with our input and output declared. We also declared our subject which will be used for listening for CartAction events and transforming them into current orders.
Let's move to...
Although clients of our API will use orders as an array (which is simplest solution for UITableView or UICollectionView) we need to find products by theirs IDs as quickly as possible.
That indicates using Dictionary which will fetch items by ID faster than regular array. Nevertheless, we will expose it as an array to clients using Dictionary.values property.
In addition, we will implement our shopping experience in TDD spirit - by writing failing test cases:
func testAddingProduct() {
let expectation = XCTestExpectation(description: "Adding new product")
let cancellable = cart.orders
.sink(receiveValue: { (orders) in
// after calling add we are expecting cart to publish new array of orders with one element
XCTAssertEqual([ProductOrder(product: .apple, quantity: 1)], orders)
expectation.fulfill()
})
triggerCartActions(.add(product: .apple))
wait(for: [expectation], timeout: 5.0)
}Content of a cart is a function of a CartAction and previous state of a cart. As an example if user clicks on a increment Apple order cart becomes [otherOrders, appleOrder + 1].
In order to achieve this notion of previous state we will reach for scan operator:
// 1
self.orders = actionInput.scan([Int:ProductOrder]()) { (currentOrders, action) -> [Int:ProductOrder] in
// 2
var newOrders = currentOrders
switch (action) {
case .add(let product):
// 3
newOrders.updateValue(ProductOrder(product: product), forKey: product.id)
}
return newOrders
}Explanations for each lines in snippet above:
- We are passing default value of empty
Dictionaryintoscanoperator (user does not have any items by default) - We are creating mutable variable in order to modify our accumulator
- We are inserting new
ProductOrderwith default value of quantity1into accumulator
We are almost there, although we need to do final thing - map result of a scan operator ([Int:ProductOrder]) to Front-End-friendly structure of an [ProductOrder]:
...
.map(\.values)
.map(Array.init)We can implement incrementing, decrementing orders and clearing cart in similar manner:
case .incrementProduct(withId: let productId):
if let order = newOrders[productId] {
newOrders.updateValue(order.incremented, forKey: productId)
}
case .decrementProduct(withId: let productId):
if let order = newOrders[productId] {
let decrementedOrder = order.decremented
if (decrementedOrder.quantity == 0) {
// Let's remove order if quantity reaches 0
newOrders.removeValue(forKey: productId)
} else {
newOrders.updateValue(decrementedOrder, forKey: productId)
}
}
case .clear:
return [:]If user decrements order with only one instance of a product we remove it from cart completely. We are going to also add incremented and decremented helpers for our ProductOrder:
var incremented: ProductOrder {
return ProductOrder(product: product, quantity: quantity + 1)
}
var decremented: ProductOrder {
return ProductOrder(product: product, quantity: quantity - 1)
}We also should add a new test case where we add two products and after than increment one of them:
let expectation = XCTestExpectation(description: "Incrementing existing product")
let cancellable = cart
.orders
.sink { (orders) in
if orders.contains(ProductOrder(product: .beer, quantity: 2)) {
expectation.fulfill()
}
}
triggerCartActions(
.add(product: .apple),
.add(product: .beer),
.incrementProduct(withId: Product.beer.id)
wait(for: [expectation], timeout: 5.0)Things seem to work!
After achieving basic funcionality of modifying cart content we can easily add things like publishing number of products or total price:
extension ProductOrder {
var price: Double {
return product.price * Double(quantity)
}
}
extension ShoppingCart {
var numberOfProducts: AnyPublisher<Int, Never> {
return orders.map(\.count).eraseToAnyPublisher()
}
var totalPrice: AnyPublisher<Double, Never> {
return orders.map { $0.reduce(0) { (acc, order) -> Double in
acc + order.price
}
}.eraseToAnyPublisher()
}
}In addition we can track events and send them to our Analytics platform by subscribing to cart's input:
let subscriber = PassthroughSubject<CartAction, Never>()
subscriber.receive(subscriber: cart.input)
subscriber.sink { (action) in
// send it to Google Analytics!
Anaylitcs.track(action)
}Lastly, we can decorate current behavior with things like discounts:
extension ShoppingCart {
func discountedTotalPrice(discountRate: Double = 0.1) -> AnyPublisher<Double, Never> {
return totalPrice.map { $0 * (1.0 - discountRate) }.eraseToAnyPublisher()
}
}We used Combine API in order to implement simple shopping cart with scan and map operators. This implementation might be expanded to various new functionality without touching original codebase (O in SOLID) and easily covered with test cases as it is not coupled with any sort of UI.
Full example is available here, together with test cases.