Skip to content

Instantly share code, notes, and snippets.

@pzmudzinski
Last active March 27, 2020 10:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pzmudzinski/27ab0e8a939dfef1748568e2e4220975 to your computer and use it in GitHub Desktop.
Save pzmudzinski/27ab0e8a939dfef1748568e2e4220975 to your computer and use it in GitHub Desktop.

Reactive Shopping Cart with Swift and Combine

Overview

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.

Basics

Let's imagine hypotethical UI for our online shop and think about required functionality:
UI

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...

Implementation

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:

  1. We are passing default value of empty Dictionary into scan operator (user does not have any items by default)
  2. We are creating mutable variable in order to modify our accumulator
  3. We are inserting new ProductOrder with default value of quantity 1 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!

Extra functionality

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()
    }
}

Summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment