Combine Introduction

May 17, 2020

Joshua Schmidt

Build a Rock, Paper, Scissors Game With SwiftUI and Combine.

Reactive Programming

rock paper scissors image

Introduction

Combine is Apple’s version of functional reactive programming. According to Apple, it is a declarative Swift API for processing values over time. The functional reactive paradigm is a combination of functional programming with reactive programming.

In functional programming, everything is modeled as a function. When using the paradigm, the program avoids changing the state of the program or mutating data. This paradigm is combined with the reactive paradigm. Reactive programming enables the handling of asynchronous data streams and event streams. For this reason, it works really well when working with UI, such as keyboard inputs or button taps.

Combining the two creates a programming paradigm in which events or asynchronous calls are handled with functions. This is my first attempt to use Combine or any sort of functional reactive programming. I will make a basic Roshambo (Rock, Paper, Scissors) game.

Publisher

A publisher is the provider of data. In this mini-app, the first publisher is going to be the input of user selections. I am going to publish the events as they come in from the user. When the user opens the app, we will offer them three options: Water, Fire, and Grass. Fire beats grass. Grass beats Water. Water beats Fire.

Player One vs Opponent

All the code for the mini-app of the Roshambo can be found in my GitHub repo. The ContentView class has an EnvironmentObject called App that I’m using to manage the Combine logic. When I finish with this tutorial, the ContentView will be:

struct ContentView: View {
  @EnvironmentObject var app: App
  let playerOne = "Player One"
  let playerTwo = "Opponent"
  var body: some View {
    VStack {
      Spacer()
      Text(playerOne)
        .font(.title)
        .padding()
      ChooseElementView(selectedElement: $app.user.element)
        .disabled(app.winner != nil)
        .opacity(app.user.element != Element.none ? 0.5 : 1)
      Text(app.user.element == Element.none ? "" : "Selected \(app.user.element.title)")
        .padding()
      control
      Text(app.opponent.element == Element.none ? "" : "Selected \(app.opponent.element.title)")
        .padding()
      ChooseElementView(selectedElement: $app.opponent.element)
        .disabled(true)
        .opacity(app.opponent.element != Element.none ? 0.5 : 1)
      Text(playerTwo)
        .font(.title)
        .padding()
      Spacer()
  }
...
}

The App object is instantiated in the scene function within the SceneDelegate and set as the environment object. It could not be any easier to create a publisher when using the ObservableObject protocol. The only needed addition is a simple annotation, @Published. Here is the first publisher:

class App: ObservableObject {
  @Published var user: Player
}

Now, any time the Player object, user, changes it will provide the updated data. The ChooseElementView holds the buttons for the user to be able to select an option.

struct ChooseElementView: View {
  @Binding var selectedElement: Element
  var body: some View {
    VStack(spacing: 5) {
      ForEach(Element.allItems, id: \.self) { element in
        RoshamboButton(element: element) {
          self.selectedElement = element
        }
      }
    }
  }
}

When one of the Roshambo buttons is pressed, it will update the element in the Player object. This is because there is a binding between the selectedElement in ChooseElementView which is instantiated when creating the element view: ChooseElementView(selectedElement: $app.user.element)

The Player object is another simple ObservableObject which has a single published variable.

class Player: ObservableObject {
  @Published var element = Element.none
}

The ObservableObject protocol is a really powerful concept within SwiftUI. It makes creating a publisher easy. The @Published annotation is built into Combine. The publisher will trigger any time the property is changed. The output type of the publisher is inferred from the property’s type. In this case, the publisher type is <Element, Never>, which means the data type is Element and there is no failure.

Subscriber

There is not much value in a publisher without a subscriber. The subscriber requests and receives data from the publisher. Until a publisher has a subscription request, it will not send any data. In order for the subscriber to request data from a publisher, the input type of the subscriber must match the output type of the publisher.

In my Roshambo mini-app, the opponent, CopyCatPlayer, is really annoying to play against. She always waits to see what we do and makes the same choice! So the game result is always a tie.

This will be implemented by the CopyCatPlayer subscribing to the publisher. When the publisher sends a new value, the other player will listen, wait a short period of time, and make the same selection.

Options Selected

The CopyCatPlayer will be initialized with a publisher. This will cause the CopyCatPlayer to update the selected Element when the user makes a selection.

class CopyCatPlayer: Player {
  private var cancellableSet: Set<AnyCancellable> = []
  init(opponentElement: AnyPublisher<Element, Never>) {
  let _ = opponentElement
            .assign(to: \.element, on: self)
            .store(in: &cancellableSet)
  }
}

Assign is the subscriber. It uses the publisher and sets the published value to its own element variable as soon as the opponent makes a selection. The reference is stored in the cancellableSet so the subscriber will operate the full lifetime of the CopyCatPlayer.

Then the opponent is added to the App.swift class. The opponent is also published so ContentView can update.

class App: ObservableObject {
  @Published var user: Player
  @Published var opponent: CopyCatPlayer! = nil
  init() {
    self.user = Player()
    self.opponent = CopyCatPlayer(opponentElement: user.$element.eraseToAnyPublisher())
  }
}

When the CopyCatPlayer is instantiated, the publisher is using the eraseToAnyPublisher function to provide a cleaner type. This can be done on any publisher. It allows the subscriber to maintain abstraction from the underlying implementation.

There is now a CopyCatPlayer who will mimic the user’s selection, which is accomplished using the assign subscriber. In order to have the opponent have a delay before copying, I will need to introduce a new concept: operators.

Operator

Operators are arguably one of the most important elements of Combine. They are intermediaries between publishers and subscribers. Operators can serve many purposes, including, but not limited to, changing the timing of events, transforming data, filtering, or even combining multiple publishers into a single data source.

The first operator I’m going to look at will change the timing of the CopyCatPlayer. This will make the selection of the CopyCatPlayer delay by 200 milliseconds. In order to accomplish this, there are two operators needed: debounce and receive.

class CopyCatPlayer: Player {
  private var cancellableSet: Set<AnyCancellable> = []
  init(opponentElement: AnyPublisher<Element, Never>) {
    let _ = opponentElement
      .debounce(for: .milliseconds(200), scheduler: RunLoop.main)
      .receive(on: RunLoop.main)
      .assign(to: \.element, on: self)
      .store(in: &cancellableSet)
    }
  }

The debounce operator is used to delay the event. It takes two parameters: the amount of time to wait and the scheduler on which the operator will deliver the events. The receive operator is used to receive the event on the specified scheduler. It is easy to imagine how the two operators are often used together.

To show an even more powerful way to use operators, we are going to create a new object, Game, to subscribe to our player’s elements, in order to calculate the result of the Roshambo game.

Result Shown

The Winner enum has three options. The winner could be player one (our user), player two (the opponent), or neither (a tie).

enum Winner {
  case one, two, neither
}

The Game object will publish the Winner so our ContentView can tell the user who won the game. To calculate the Winner, it will be initialized with two publishers, one for each player.

init(playerOneElement: AnyPublisher<Element, Never>, 
     playerTwoElement: AnyPublisher<Element, Never>) {
  let _ = Publishers
    .CombineLatest(playerOneElement, playerTwoElement)
    .map { self.getWinner(elementOne: $0, elementTwo: $1)}
    .assign(to: \.winner, on: self)
    .store(in: &cancellableSet)
}

The two publishers are combined using an operator called CombineLatest, which will take the latest two elements from each publisher. So the input is two Element objects, but the operator transforms the data type to a tuple of elements — (Element, Element). Then a new operator familiar to most people is used, map. The map operator uses the closure to transform the data type from (Element, Element) to Winner?. This is the function used in the closure:

func getWinner(elementOne: Element, elementTwo: Element) -> Winner? {
  let result = battle(elementOne: elementTwo, 
                      elementTwo: elementTwo)
  switch result {
  case .win: return .one
  case .lose: return .two
  case .tie: return .neither
  default: return nil
  }
}
func battle(elementOne: Element, 
            elementTwo: Element) -> BattleResult? {
  if elementOne == .none || elementTwo == .none {
    return nil
  }
  if elementOne == elementTwo {
    return .tie
  }
  if elementOne == .water {
    return elementTwo == .fire ? .win : .lose
  }
  if elementOne == .fire {
    return elementTwo == .grass ? .win : .lose
  }
  if elementOne == .grass {
    return elementTwo == .water ? .win : .lose
  }
  return nil
}

The return type from the map operator is assigned to the winner variable. The winner variable is then published so the ContentView can update based on the winner. The App object has been updated to add the Game class:

class App: ObservableObject {
  @Published var user: Player
  @Published var opponent: CopyCatPlayer! = nil
  @Published var winner: Winner?
  private var game: Game?
  private var cancellableSet: Set<AnyCancellable> = []
  init() {
    self.user = Player(resetPublisher.eraseToAnyPublisher())
    self.opponent = CopyCatPlayer(
      opponentElement: user.$element.eraseToAnyPublisher(),
      resetPublisher.eraseToAnyPublisher())
    self.game = Game(
      playerOneElement:user.$element.eraseToAnyPublisher(),
      playerTwoElement: opponent!.$element.eraseToAnyPublisher())
    game!.$winner.assign(to: \.winner, on: self)
      .store(in: &cancellableSet)
  }
}

There is one more portion of our game I need to add. It won’t be fun if the user can only play once. I need to be able to start over to play again. To accomplish this, I need to teach about subjects, a new Combine topic.

Subject

Combine has a special type of publisher, Subject. It is a protocol that a publisher can adhere to which will add a method: send(). This publisher is used so items can be injected into the stream and broadcast values to subscribers.

There are two types of Subjects built into Combine: PassthroughSubject and CurrentValueSubject. The only difference between the two is that CurrentValueSubject requires an initial value. Both will send updated values.

I am going to add a Subject for resetting. When the reset button is pressed, I will send a Bool. Creating the PassthroughSubject is almost as simple as any other publisher. So the end result of App object is:

class App: ObservableObject {
  @Published var user: Player
  @Published var opponent: CopyCatPlayer! = nil
  @Published var winner: Winner?
  let resetPublisher = PassthroughSubject<Bool, Never>()
  private var game: Game?
  private var cancellableSet: Set<AnyCancellable> = []
  init() {
    self.user = Player(resetPublisher.eraseToAnyPublisher())
    self.opponent = CopyCatPlayer(
      opponentElement: user.$element.eraseToAnyPublisher(),
      resetPublisher.eraseToAnyPublisher())
    self.game = Game(
      playerOneElement:user.$element.eraseToAnyPublisher(),
      playerTwoElement: opponent!.$element.eraseToAnyPublisher())
    game!.$winner.assign(to: \.winner, on: self)
      .store(in: &cancellableSet)
  }
  func reset() {
    resetPublisher.send(true)
  }
}

The new publisher will be added to the Player class so both the user and opponent can handle resetting. This will introduce one new subscriber, sink. It will simply receive the value and execute the closure. In this case, setting the element for the Player back to none.

class Player: ObservableObject {
  @Published var element = Element.none
  private var cancellableSet: Set<AnyCancellable> = []
  init(_ resetter: AnyPublisher<Bool, Never>) {
    resetter.sink {
      self.element = $0 ? .none : self.element
    }.store(in: &cancellableSet)
  }
}

This makes resetting extremely extensible. If a new UI element is added, it just needs to subscribe to the reset subject. Without a publisher, the reset function would have to contain all the logic for which objects or UI to update. The function would then be really brittle. Now, the logic for how to reset is contained in the element or class which needs to update.

Conclusion

Combine is very powerful and can be used throughout Swift. It is a central feature of SwiftUI, but can also be used for many other use cases, including network requests, error handling, and async operations. This tutorial only scratches the surface by introducing the basic terminology and concepts. I will continue to explore what else I can do with Combine and how to start thinking in the functional reactive programming paradigm.

If this topic interests you, there is excellent free information, including patterns and recipes, written by Joseph Heck and Apple’s documentation.

To continue the conversation, feel free to reach out to me on Twitter or LinkedIn.


Joshua Schmidt

SDE II at Amazon

© 2021   

Swift Guild by JP McGlone, LLC