Imperative shell, functional core

Imperative shell, functional core is one of the concepts that influenced my programming career the most. It perfectly formulates how to write clean, easily testable code and always keeps you wondering whether you did enough to extract the functional pieces.

During my preparations for the Mobile Warsaw #62 talk I was looking for a really concise and powerful example of applying the technique to refactoring an existing code and simplifying the tests. After long hours of wandering I finally came across a great example. And oh, boys! How ironic that I found the example in the code of mine.

Disclaimer: the article implies that you are familiar with imperative shell, functional core concept. If not, please do not hesitate and watch this great Gary Bernhardt's talk named Boundaries. Although the strategy is applied in Ruby, it should be accessible enough for an iOS developer. In case it is not, there are also some other resources listed in the end of the article.

The problem

The code in question is a simple UIScrollView delegate that snaps user scroll to pages:

snapper

The pages are indicated with distinct background colors. When the view is no longer dragged, it stops scrolling at the nearest page. You can tell that the scroll view never stops in between the pages.

In order to do so you need to implement the following method of UIScrollViewDelegate:

func scrollViewWillEndDragging(
    _ scrollView: UIScrollView,
    withVelocity velocity: CGPoint,
    targetContentOffset: UnsafeMutablePointer<CGPoint>) {
  targetContentOffset.pointee.x = 100.0  
}

The idea is that a value under the pointer passed as an argument indicates the scrolling stoppage point. You can alter the stoppage point by mutating the pointee's value. This means the code above will make the scrolling stop at 100pts no matter what.

Original implementation

My original implementation was divided into two parts. First, the delegate object:

class SnappingCollectionViewDelegate: 
    NSObject, UICollectionViewDelegate {

  let position: SnapPosition
  var totalPages: Int = 0

  init(snapTo position: SnapPosition, totalPages: Int) {
    self.position = position
    self.totalPages = totalPages
    super.init()
  }

  // MARK: - UIScrollViewDelegate

  func scrollViewWillEndDragging(
      _ scrollView: UIScrollView,
      withVelocity velocity: CGPoint,
      targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    targetContentOffset.pointee = scrollView.offsetSnappedToPage(
      offset: targetContentOffset.pointee,
      totalPages: totalPages,
      at: position
    )
  }

}

The argument of the ScrollPosition type indicates which page (relative to the UIScrollView viewport) we should scroll to. It can be located either on the left, on the right or in the middle of the scroll view.

enum SnapPosition {
  case left
  case center
  case right
}

The second piece is an extension on the UIScrollView class that does all the necessary maths:

extension UIScrollView {

  func offsetSnappedToPage(
      offset: CGPoint,
      totalPages: Int,
      at position: SnapPosition = .center) -> CGPoint {
    guard offset.x + frame.size.width < contentSize.width else {
      return offset
    }

    let closest = pageIndex(
      forOffset: offset, 
      totalPages: totalPages, 
      at: position
    )

    return pageOffset(atIndex: closest, totalPages: totalPages)
  }

  func pageWidth(totalPages: Int) -> CGFloat {
    guard totalPages > 0 else {
      return 0.0
    }

    return contentSize.width / CGFloat(totalPages)
  }

  func pageOffset(atIndex index: Int, 
                  totalPages: Int) -> CGPoint {
    let width = pageWidth(totalPages: totalPages)
    let offset = width * CGFloat(index)
    let maxOffset = abs(contentSize.width - frame.size.width)

    return CGPoint(x: min(pageOffset, maxContentOffset), y: 0)
  }

  func pageIndex(forOffset offset: CGPoint, 
                 totalPages: Int, 
                 at position: SnapPosition = .center) -> Int {
    let pageWidth = self.pageWidth(totalPages: totalPages)
    let frameOffset = frame.size.width * position.frameOffsetMultiplier
    let pageOffset = offset.x + frameOffset

    return pageWidth > 0.0 ? Int(pageOffset / pageWidth) : 0
  }

}

extension SnapPosition {

  var frameOffsetMultiplier: CGFloat {
    switch self {
    case .left:
      return 0.0
    case .center:
      return 0.5
    case .right:
      return 1.0
    }
  }
}

The code is neither really good or very bad. It is separated into a bunch of short methods with descriptive names, so it was okay enough to pass the review. However, it violates imperative shell, functional core design. To prove that, let's throw a bunch of unit tests for the delegate:

import XCTest

class SnappingDelegateTests: XCTestCase {

  var scrollView: UIScrollView!
  var targetOffset: CGPoint = .zero
  var sut: SnappingDelegate!

  override func setUp() {
    super.setUp()

    let frame = CGRect(x: 0, y: 0, width: 300, height: 50)
    scrollView = UIScrollView(frame: frame)
    scrollView.contentSize = CGSize(width: 8 * 100, height: 50)

    targetOffset = .zero
  }

  override func tearDown() {
    super.tearDown()

    sut = nil
    scrollView = nil
  }

  func testSnappingToLeft() {
    sut = SnappingDelegate(snapTo: .left, totalPages: 8)

    targetOffset.x = 99.0
    let p = withUnsafeMutablePointer(to: &targetOffset) { $0 }
    sut.scrollViewWillEndDragging(
      scrollView, 
      withVelocity: .zero, 
      targetContentOffset: p
    )

    XCTAssertEqual(targetOffset.x, 0)
  }

  func testSnappingToCenter() {
    sut = SnappingDelegate(snapTo: .center, totalPages: 8)

    targetOffset.x = 99.0
    let p = withUnsafeMutablePointer(to: &targetOffset) { $0 }
    sut.scrollViewWillEndDragging(
      scrollView,
      withVelocity: .zero, 
      targetOffset: p
    )

    XCTAssertEqual(targetOffset.x, 200)
  }

  func testSnappingToRight() {
    sut = SnappingDelegate(snapTo: .right, totalPages: 8)

    targetOffset.x = 99.0
    let p = withUnsafeMutablePointer(to: &targetOffset) { $0 }
    sut.scrollViewWillEndDragging(
      scrollView, 
      withVelocity: .zero, 
      targetContentOffset: p
    )

    XCTAssertEqual(targetOffset.x, 300)
  }

  func testSnappingAllowsLast() {
    sut = SnappingDelegate(snapTo: .left, totalPages: 8)

    targetContentOffset.x = 700.0
    let p = withUnsafeMutablePointer(to: &targetOffset) { $0 }
    sut.scrollViewWillEndDragging(
      scrollView, 
      withVelocity: .zero,
      targetContentOffset: p
    )

    XCTAssertEqual(targetOffset.x, 700.0)
  }

  func testSnappingToBeginningWhenNoPages() {
    sut = SnappingDelegate(snapTo: .right, totalPages: 0)

    targetOffset.x = 199
    let p = withUnsafeMutablePointer(to: &targetOffset) { $0 }
    sut.scrollViewWillEndDragging(
      scrollView, 
      withVelocity: .zero, 
      targetContentOffset: p
    )

    XCTAssertEqual(targetOffset.x, 0)
  }
    
}

The tests are ridiculously hard to set up. What happened? The code does not adhere to imperative shell, functional core approach:

  • The test is driven by managed state instead of simple values:
    • contentSize and frame of the scroll view,
    • targetOffset value.
  • The decisions (guard and if statements) rely on the state and are mixed with performing side effects (setting the pointer value).

Extracting functional core

Let's try the test first approach to sketch out the functional core:

import XCTest

class SnapCalculatorTests: XCTestCase {

  var sut: SnapCalculator!

  override func setUp() {
    super.setUp()
    sut = SnapCalculator()
  }

  override func tearDown() {
    super.tearDown()
    sut = nil
  }

  func testSnappingToLeft() {
    let result = sut.offset(
      99.0,
      snappedTo: .left,
      pages: 8,
      contentWidth: 800,
      scrollWidth: 300
    )

    XCTAssertEqual(result, 0.0)
  }
  
  func testSnappingToCenter() {
    let result = sut.offset(
      99.0,
      snappedTo: .center,
      pages: 8,
      contentWidth: 800,
      scrollWidth: 300
    )

    XCTAssertEqual(result, 0.0)
  }

}

What happened?

  • The calculations were moved outside of an extension method.
  • SnapCalculator does not rely on an internal state of a scroll view anymore.
  • Total content width and scroll view's width are now passed to a method as input values.

It's way more testable than before, but the API can still be improved. Let's try to group the content description together:

import XCTest

class SnapCalculatorTests: XCTestCase {

  var sut: SnapCalculator!

  override func setUp() {
    super.setUp()
    sut = SnapCalculator()
  }

  override func tearDown() {
    super.tearDown()
    sut = nil
  }

  func testSnapping() {
    let info = ContentInfo(pages: 8, width: 800, window: 300)
    
    let left = sut.offset(99.0, snappedTo: .left, with: info)
    let center = sut.offset(99.0, snappedTo: .center, with: info)
    let right = sut.offset(99.0, snappedTo: .right, with: info)

    XCTAssertEqual(left, 0.0)
    XCTAssertEqual(center, 200.0)
    XCTAssertEqual(right, 300.0)
  }

}

Thanks to applying functional core principle:

  • Test case readability has improved a lot.
  • The tests are no longer a pain to set up. In fact, they have now become ridiculously easy to set up.
  • The boundaries between the two systems are sharper.
  • We can have a broader coverage for edge cases.

Since we already have the tests, we can proceed to implementing the calculator:

class SnapCalculator {

  func offset(
      _ offset: CGFloat, 
      snappedTo position: ScrollPosition, 
      with content: ContentInfo) -> CGFloat {
    guard offset + content.window < content.width else {
      return offset
    }

    let index = pageIndex(
      atOffset: offset, 
      snappedTo: position, 
      with: content
    )

    return pageOffset(index: index, content: content)
  }

  private func pageIndex(
      atOffset offset: CGFloat, 
      snappedTo position: ScrollPosition, 
      with content: ContentInfo) -> Int {
    guard content.pageWidth > 0 else {
      return 0
    }

    let pageStart = offset + content.window * multiplier(at: position)
    return Int(pageStart / content.pageWidth)
  }

  private func pageOffset(
      index: Int, 
      content: ContentInfo) -> CGFloat {
    return min(
      content.maxOffset, 
      content.pageWidth * CGFloat(index)
    )
  }

  private func multiplier(at position: ScrollPosition) -> CGFloat {
    switch position {
    case .left:
      return 0.0
    case .center:
      return 0.5
    case .right:
      return 1.0
    }
  }

}

extension ContentInfo {

  var pageWidth: CGFloat {
    return pages > 0 ? width / CGFloat(pages) : 0.0
  }

  var maxOffset: CGFloat {
    return abs(width - window)
  }

}

The final part is to attach the calculator to the delegate:

// MARK: - UIScrollViewDelegate
func scrollViewWillEndDragging(
    _ scrollView: UIScrollView,
    withVelocity velocity: CGPoint,
    targetContentOffset: UnsafeMutablePointer<CGPoint>) {
  let target = targetContentOffset.pointee.x
  let info = ContentInfo(
    pages: totalPages,
    width: scrollView.contentSize.width,
    widnow: scrollView.frame.width
  )

  targetContentOffset.pointee.x = calculator.offset(
    target,
    snappedTo: position,
    with: info
  )
}

private lazy var calculator: SnapCalculator = SnapCalculator()

Despite introducing a new dependency, we do not have to stub it at all. Since the calculator is purely functional, it also executes really fast and is 100% deterministic.

Summary

Imperative shell, functional core proved to be a really effective technique for structuring a short codebase behind a snapping scroll view. However, the benefits of using it are way superior when applied to a system-wide problems like routing or presenting modal controllers.

Imperative shell, functional core should be viewed as one of the most important patterns for writing clean, testable code, right next to the likes of SOLID and dependency injection.

Do not forget to isolate the business logic and never let it mix up with a stateful world! 🔥

Appendix: additional resources

Some excellent resources on imperative shell, functional core topic:

Show Comments