Chapters

Hide chapters

Design Patterns by Tutorials

Third Edition · iOS 13 · Swift 5 · Xcode 11

15. State Pattern
Written by Joshua Greene

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

The state pattern is a behavioral pattern that allows an object to change its behavior at runtime. It does so by changing its current state. Here, “state” means the set of data that describes how a given object should behave at a given time.

This pattern involves three types:

  1. The context is the object that has a current state and whose behavior changes.

  2. The state protocol defines required methods and properties. Developers commonly substitute a base state class in place of a protocol. By doing so, they can define stored properties in the base, which isn’t possible using a protocol.

    Even if a base class is used, it’s not intended to be instantiated directly. Rather, it’s defined for the sole purpose of being subclassed. In other languages, this would be an abstract class. Swift currently doesn’t have abstract classes, however, so this class isn’t instantiated by convention only.

  3. Concrete states conform to the state protocol, or if a base class is used instead, they subclass the base. The context holds onto its current state, but it doesn’t know its concrete state type. Instead, the context changes behavior using polymorphism: concrete states define how the context should act. If you ever need a new behavior, you define a new concrete state.

An important question remains, however: where do you actually put the code to change the context’s current state? Within the context itself, the concrete states, or somewhere else?

You may be surprised to find out that the state pattern doesn’t tell you where to put state change logic! Instead, you’re responsible for deciding this. This is both a strength and weakness of this pattern: It permits designs to be flexible, but at the same time, it doesn’t provide complete guidance on how to implement this pattern.

You’ll learn two ways to implement state changes in this chapter. In the playground example, you’ll put change logic within the context, and in the tutorial project, you’ll let the concrete states themselves handle changes.

When should you use it?

Use the state pattern to create a system that has two or more states that it changes between during its lifetime. The states may be either limited in number (a “closed“ set) or unlimited (an “open” set). For example, a traffic light can be defined using a closed set of “traffic light states.” In the simplest case, it progresses from green to yellow to red to green again.

An animation engine can be defined as an open set of “animation states.” It has unlimited different rotations, translations and other animations that it may progress through during its lifetime.

Both open- and closed-set implementations of the state pattern use polymorphism to change behavior. As a result, you can often eliminate switch and if-else statements using this pattern.

Instead of keeping track of complex conditions within the context, you pass through calls to the current state; you’ll see how this works in both the playground example and tutorial project. If you have a class with several switch or if-else statements, try to define it using the state pattern instead. You’ll likely create a more flexible and easier maintain system as a result.

Playground example

Open IntermediateDesignPatterns.xcworkspace in the Starter directory, and then open the State page.

import UIKit
import PlaygroundSupport

// MARK: - Context
public class TrafficLight: UIView {

  // MARK: - Instance Properties
  // 1
  public private(set) var canisterLayers: [CAShapeLayer] = []

  // MARK: - Object Lifecycle
  // 2
  @available(*, unavailable,
    message: "Use init(canisterCount: frame:) instead")
  public required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) is not supported")
  }

  // 3
  public init(canisterCount: Int = 3,
              frame: CGRect =
                CGRect(x: 0, y: 0, width: 160, height: 420)) {
    super.init(frame: frame)
    backgroundColor =
      UIColor(red: 0.86, green: 0.64, blue: 0.25, alpha: 1)
    createCanisterLayers(count: canisterCount)
  }

  // 4
  private func createCanisterLayers(count: Int) {

  }
}
// 1
let paddingPercentage: CGFloat = 0.2
let yTotalPadding = paddingPercentage * bounds.height
let yPadding = yTotalPadding / CGFloat(count + 1)

// 2
let canisterHeight = (bounds.height - yTotalPadding) / CGFloat(count)
let xPadding = (bounds.width - canisterHeight) / 2.0
var canisterFrame = CGRect(x: xPadding,
                           y: yPadding,
                           width: canisterHeight,
                           height: canisterHeight)

// 3
for _ in 0 ..< count {
  let canisterShape = CAShapeLayer()
  canisterShape.path = UIBezierPath(
    ovalIn: canisterFrame).cgPath
  canisterShape.fillColor = UIColor.black.cgColor

  layer.addSublayer(canisterShape)
  canisterLayers.append(canisterShape)

  canisterFrame.origin.y += (canisterFrame.height + yPadding)
}
let trafficLight = TrafficLight()
PlaygroundPage.current.liveView = trafficLight

// MARK: - State Protocol
public protocol TrafficLightState: class {

  // MARK: - Properties
  // 1
  var delay: TimeInterval { get }

  // MARK: - Instance Methods
  // 2
  func apply(to context: TrafficLight)
}
public private(set) var currentState: TrafficLightState
public private(set) var states: [TrafficLightState]
public init(canisterCount: Int = 3,
            frame: CGRect =
              CGRect(x: 0, y: 0, width: 160, height: 420),
            states: [TrafficLightState]) {

  // 1
  guard !states.isEmpty else {
    fatalError("states should not be empty")
  }
  self.currentState = states.first!
  self.states = states

  // 2
  super.init(frame: frame)
  backgroundColor =
    UIColor(red: 0.86, green: 0.64, blue: 0.25, alpha: 1)
  createCanisterLayers(count: canisterCount)
}
public func transition(to state: TrafficLightState) {
  removeCanisterSublayers()
  currentState = state
  currentState.apply(to: self)
}

private func removeCanisterSublayers() {
  canisterLayers.forEach {
    $0.sublayers?.forEach {
      $0.removeFromSuperlayer()
    }
  }
}
transition(to: currentState)
// MARK: - Concrete States
public class SolidTrafficLightState {

  // MARK: - Properties
  public let canisterIndex: Int
  public let color: UIColor
  public let delay: TimeInterval

  // MARK: - Object Lifecycle
  public init(canisterIndex: Int,
              color: UIColor,
              delay: TimeInterval) {
    self.canisterIndex = canisterIndex
    self.color = color
    self.delay = delay
  }
}
extension SolidTrafficLightState: TrafficLightState {

  public func apply(to context: TrafficLight) {
    let canisterLayer = context.canisterLayers[canisterIndex]
    let circleShape = CAShapeLayer()
    circleShape.path = canisterLayer.path!
    circleShape.fillColor = color.cgColor
    circleShape.strokeColor = color.cgColor
    canisterLayer.addSublayer(circleShape)
  }
}
// MARK: - Convenience Constructors
extension SolidTrafficLightState {
  public class func greenLight(
    color: UIColor =
      UIColor(red: 0.21, green: 0.78, blue: 0.35, alpha: 1),
    canisterIndex: Int = 2,
    delay: TimeInterval = 1.0) -> SolidTrafficLightState {
    return SolidTrafficLightState(canisterIndex: canisterIndex,
                                  color: color,
                                  delay: delay)
  }

  public class func yellowLight(
    color: UIColor =
      UIColor(red: 0.98, green: 0.91, blue: 0.07, alpha: 1),
    canisterIndex: Int = 1,
    delay: TimeInterval = 0.5) -> SolidTrafficLightState {
    return SolidTrafficLightState(canisterIndex: canisterIndex,
                                  color: color,
                                  delay: delay)
  }

  public class func redLight(
    color: UIColor =
      UIColor(red: 0.88, green: 0, blue: 0.04, alpha: 1),
    canisterIndex: Int = 0,
    delay: TimeInterval = 2.0) -> SolidTrafficLightState {
    return SolidTrafficLightState(canisterIndex: canisterIndex,
                                  color: color,
                                  delay: delay)
  }
}
let greenYellowRed: [SolidTrafficLightState] =
  [.greenLight(), .yellowLight(), .redLight()]
let trafficLight = TrafficLight(states: greenYellowRed)
PlaygroundPage.current.liveView = trafficLight

// MARK: - Transitioning
extension TrafficLightState {
  public func apply(to context: TrafficLight, 
                    after delay: TimeInterval) {
    let queue = DispatchQueue.main
    let dispatchTime = DispatchTime.now() + delay
    queue.asyncAfter(deadline: dispatchTime) { 
      [weak self, weak context] in
      guard let self = self, let context = context else { 
        return 
      }
      context.transition(to: self)
    }
  }
}
public var nextState: TrafficLightState {
  guard let index = states.firstIndex(where: { 
      $0 === currentState 
    }), 
    index + 1 < states.count else {
      return states.first!
  }
  return states[index + 1]
}
nextState.apply(to: self, after: currentState.delay)

What should you be careful about?

Be careful about creating tight coupling between the context and concrete states. Will you ever want to reuse the states in a different context? If so, consider putting a protocol between the concrete states and context, instead of having concrete states call methods on a specific context.

Tutorial project

You’ll continue the Mirror Pad app from the previous chapter.

import UIKit

public class DrawViewState {

  // MARK: - Class Properties
  // 1
  public class var identifier: AnyHashable {
    return ObjectIdentifier(self)
  }

  // MARK: - Instance Properties
  // 2
  public unowned let drawView: DrawView

  // MARK: - Object Lifecycle
  public init(drawView: DrawView) {
    self.drawView = drawView
  }

  // MARK: - Actions
  // 3
  public func animate() { }
  public func copyLines(from source: DrawView) { }
  public func clear() { }
  public func touchesBegan(_ touches: Set<UITouch>, 
                           with event: UIEvent?) { }
  public func touchesMoved(_ touches: Set<UITouch>, 
                           with event: UIEvent?) { }

  // MARK: - State Management
  // 4
  @discardableResult internal func transitionToState(
    matching identifier: AnyHashable) -> DrawViewState {
    // TODO: - Implement this
    return self
  }
}
import UIKit

public class AcceptInputState: DrawViewState {

}
import UIKit

public class AnimateState: DrawViewState {

}
import UIKit

public class ClearState: DrawViewState {

}
import UIKit

public class CopyState: DrawViewState {

}
public lazy var currentState = 
  states[AcceptInputState.identifier]!

public lazy var states = [
  AcceptInputState.identifier: AcceptInputState(drawView: self),
  AnimateState.identifier: AnimateState(drawView: self),
  ClearState.identifier: ClearState(drawView: self),
  CopyState.identifier: CopyState(drawView: self)
]
let state = drawView.states[identifier]!
drawView.currentState = state
return state
// 1
public override func animate() {
  let animateState = transitionToState(
    matching: AnimateState.identifier)
  animateState.animate()
}

public override func clear() {
  let clearState = transitionToState(
    matching: ClearState.identifier)
  clearState.clear()
}

public override func copyLines(from source: DrawView) {
  let copyState = transitionToState(
    matching: CopyState.identifier)
  copyState.copyLines(from: source)
}

// 2
public override func touchesBegan(_ touches: Set<UITouch>, 
                                  with event: UIEvent?) {
  guard let point = touches.first?.location(in: drawView) else {
    return
  }
  let line = LineShape(color: drawView.lineColor,
                       width: drawView.lineWidth,
                       startPoint: point)
  drawView.lines.append(line)
  drawView.layer.addSublayer(line)
}

public override func touchesMoved(_ touches: Set<UITouch>, 
                                  with event: UIEvent?) {
  guard let point = touches.first?.location(in: drawView),
    drawView.bounds.contains(point),  
    let currentLine = drawView.lines.last else { return }
  currentLine.addPoint(point)
}
public override func touchesBegan(_ touches: Set<UITouch>,
                                  with event: UIEvent?) {
  currentState.touchesBegan(touches, with: event)
}

public override func touchesMoved(_ touches: Set<UITouch>,
                                  with event: UIEvent?) {
  currentState.touchesMoved(touches, with: event)
}
public override func animate() {
  guard let sublayers = drawView.layer.sublayers, 
    sublayers.count > 0 else {
    // 1
    transitionToState(
      matching: AcceptInputState.identifier)
    return
  }
  sublayers.forEach { $0.removeAllAnimations() }
  UIView.animate(withDuration: 0.3) {
    CATransaction.begin()
    CATransaction.setCompletionBlock { [weak self] in
      // 2
      self?.transitionToState(
        matching: AcceptInputState.identifier)
    }
    self.setSublayersStrokeEnd(to: 0.0)
    self.animateStrokeEnds(of: sublayers, at: 0)
    CATransaction.commit()
  }
}

private func setSublayersStrokeEnd(to value: CGFloat) {
  drawView.layer.sublayers?.forEach {
    guard let shapeLayer = $0 as? CAShapeLayer else { return }
    shapeLayer.strokeEnd = 0.0
    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.fromValue = value
    animation.toValue = value
    animation.fillMode =  .forwards
    shapeLayer.add(animation, forKey: nil)
  }
}

private func animateStrokeEnds(of layers: [CALayer], at index: Int) {
  guard index < layers.count else { return }
  let currentLayer = layers[index]
  CATransaction.begin()
  CATransaction.setCompletionBlock { [weak self] in
    currentLayer.removeAllAnimations()
    self?.animateStrokeEnds(of: layers, at: index + 1)
  }
  if let shapeLayer = currentLayer as? CAShapeLayer {
    shapeLayer.strokeEnd = 1.0
    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.duration = 1.0
    animation.fillMode =  .forwards
    animation.fromValue = 0.0
    animation.toValue = 1.0
    shapeLayer.add(animation, forKey: nil)
  }
  CATransaction.commit()
}
public func animate() {
  currentState.animate()
}
public override func clear() {
  drawView.lines = []
  drawView.layer.sublayers?.removeAll()
  transitionToState(matching: AcceptInputState.identifier)
}
public func clear() {
  currentState.clear()
}
public override func copyLines(from source: DrawView) {
  drawView.layer.sublayers?.removeAll()
  drawView.lines = source.lines.deepCopy()
  drawView.lines.forEach { drawView.layer.addSublayer($0) }
  transitionToState(matching: AcceptInputState.identifier)
}
public func copyLines(from source: DrawView) {
  currentState.copyLines(from: source)
}

Key points

You learned about the state pattern in this chapter. Here are its key points:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now