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
Dictionary
intoscan
operator (user does not have any items by default) - We are creating mutable variable in order to modify our accumulator
- We are inserting new
ProductOrder
with default value of quantity1
into 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.