State Pattern in Swift: Going Beyond Conditionals

ยท

12 min read

State Pattern in Swift: Going Beyond Conditionals

Hi everyone, in this article, I want to start a new series about design patterns in Swift. This first article will cover the State Pattern, explaining the problem it solves and how to implement it in our code. Let's get started!

What is the problem?

Like many of you, I've been feeling a wave of nostalgia for old Apple products, like the iPhone 4s with iOS 6 or the iconic iPod Classic. This sentiment inspired me to embark on a journey to recreate the iPod player in an app:

Remember that U2 album Apple gave away in 2014?

Of course, a project like that will require a lot of effort and lines of code, so for this article, I will focus only on the core logic that represents the different state transitions on the app.

Let's start from the basics, creating a class SimpleAudioPlayer to store the player's current state. This player will initially have three states: play, pause, and stop, being stop the initial state:

class SimpleAudioPlayer {
    enum State {
        case play
        case pause
        case stop
    }

    var currentState = State.stop

    func play() {
        // Missing Implementation...
    }

    func pause() {
        // Missing Implementation...
    }

    func stop() {
        // Missing Implementation...
    }
}

We also need to implement three methods, play, pause, and stop, that will take action depending on the current state.

Let's start with the play method and update the state if not already in the play state:

func play() {
    switch currentState {
    case .play:
        print("Audio is already playing!")
    case .pause:
        print("Playing audio.")
        currentState = .play
    case .stop:
        print("Starting audio.")
        currentState = .play
    }
}

Now let's do the same for pause and stop methods:

func pause() {
    switch currentState {
    case .play:
        print("Pausing Music.")
        currentState = .pause
    case .pause:
        print("Audio is already paused")
    case .stop:
        print("There's no audio playing right now")
    }
}

func stop() {
    switch currentState {
    case .play:
        print("Stopping Audio")
        currentState = .stop
    case .pause:
        print("Stopping Audio")
        currentState = .stop
    case .stop:
        print("Audio is already stopped")
    }
}

Now let's paste this code into Xcode playground and test if it is working as expected:

let audioPlayer = SimpleAudioPlayer()
audioPlayer.play()
audioPlayer.pause()
audioPlayer.pause()
audioPlayer.play()
audioPlayer.stop()
audioPlayer.stop()

/* Output:
Starting audio.
Pausing Music.
Audio is already paused
Playing audio.
Stopping Audio
Audio is already stopped
*/

Very nice!... But wait a second! ๐Ÿง

Although the code works, I don't know if you notice a few issues. Let me bring back the whole implementation so far:

class SimpleAudioPlayer {
    enum State {
        case play
        case pause
        case stop
    }

    var currentState = State.stop

    func play() {
        switch currentState { // 2
        case .play:
            print("Audio is already playing!") // 1
        case .pause:
            print("Playing audio.")
            currentState = .play
        case .stop:
            print("Starting audio.")
            currentState = .play
        }
    }

    func pause() {
        switch currentState { // 2
        case .play:
            print("Pausing Music.")
            currentState = .pause
        case .pause:
            print("Audio is already paused") // 1
        case .stop:
            print("There's no audio playing right now")
        }
    }

    func stop() {
        switch currentState { // 2
        case .play:
            print("Stopping Audio")
            currentState = .stop
        case .pause:
            print("Stopping Audio")
            currentState = .stop
        case .stop:
            print("Audio is already stopped") // 1
        }
    }
}
  1. Some transitions are invalid in this logic, such as executing the play method in the play state. However, we must validate this case because it's part of the switch requirements.

  2. Each method has to review all the states, which makes the code more complex and hard to read.

  3. It breaks SOLID principles:

    • Single Responsibility: SimpleAudioPlayer has multiple responsibilities, such as reviewing all the app's different states instead of just focusing on playing, pausing, and stopping.

    • Open/Close Principle: SimpleAudioPlayer must be modified each time we add or remove a state.

Let's expand on the Open/Close principle. The plan for this app is to introduce backward and forward states/methods, which means refactoring all the methods:

class SimpleAudioPlayer {
    enum State {
        case play
        case pause
        case stop
        case backward // new state
        case forward // new state
    }

    var currentState = State.stop

    func play() {
        switch currentState {
        case .play:
            print("Audio is already playing!")
        case .pause:
            print("Playing audio.")
            currentState = .play
        case .stop:
            print("Starting audio.")
            currentState = .play
        case .backward:
            // More code...
        case .forward:
            // More code...
        }
    }

    func pause() {
        switch currentState {
        case .play:
            print("Pausing Music.")
            currentState = .pause
        case .pause:
            print("Audio is already paused")
        case .stop:
            print("There's no audio playing right now")
        case .backward:
            // More code...
        case .forward:
            // More code...
        }
    }

    func stop() {
        switch currentState {
        case .play:
            print("Stopping Audio")
            currentState = .stop
        case .pause:
            print("Stopping Audio")
            currentState = .stop
        case .stop:
            print("Audio is already stopped")
        case .backward:
            // More code...
        case .forward:
            // More code...
        }
    }

    func backward() {
        // A switch ceremony reviewing all states... ๐Ÿ’€
    }

    func forward() {
        // A switch ceremony reviewing all states... ๐Ÿ’€
    }
}

Hold on ๐Ÿ˜ฎโ€๐Ÿ’จ! Even this very simple code is already a pain to maintain. Can you imagine bringing more and more logic that we haven't discussed yet? ๐Ÿ”ฅ

We have to do something about it. The good news is that we have a pattern that can help us refactor this code, eliminating all those conditionals and better encapsulating all the transition states without worrying about invalid states.

Let's introduce the State Pattern!

What is the State Pattern?

The State pattern is a behavioral design pattern that allows an object to alter its behavior when its internal state changes. It involves creating separate state classes/structs representing different states an object can be. The object called the context (or state machine) maintains a reference to one of these state classes and delegates its behavior to the current state object.

Let's digest what does it mean. First, let's review the different transitions we have in our code. To make it easier, I've created this drawing using my iPad and Apple pencil:

The State pattern is the perfect excuse to justify a new iPad Pro with Apple Pencil Proโ€ฆ just $aying! :)

As we mentioned at the beginning, the initial state is stop, meaning no audio is played yet. From here, we can play a song. Then, we can go back and forth, pausing and playing or stopping the song if we need a break.

This diagram representing a system's states and transitions is called a State Machine. A state machine is a computational model that represents and controls an object's behavior based on its states.

In other words, the State Pattern helps to model our logic to act as a State Machine.

Let's now learn how to implement it by refactoring the code above.

Applying State Pattern

First, we must look back at the diagram with all the transitions. What we want is to name them to identify the proper transition (aka event) to apply:

From there, we have playing, pausing, and stopping. Let's create an enum with those events:

enum AudioPlayerEvent {
    case stopping
    case pausing
    case playing
}

Now, Let's create a State protocol to outline how each state should be defined:

protocol State {
    func apply(event: AudioPlayerEvent) -> any State
}

State only contains a method called apply, which, based on a given event, will transition to a new state.

Now, let's implement each state starting from StopState. StopState only has one event to handle, .playing, everything else is considered as an invalid transition that we don't care.

To implement that, let's create a switch inside apply method that will review AudioPlayer event and return the respective new State:

struct StopState: State {
    func apply(event: AudioPlayerEvent) -> any State {
        switch event {
        case .playing:
            print("Starting audio.")
            return PlayState()
        default:
            print("[Invalid Transition for \(self)] - \(event)")
            return self
        }
    }
}

That's it! StopState is done. Let's jump to PlayState.

Same as the StopState, we will review AudioPlayerEvent and transition to a new state only if the event is valid. For PlayState, we can transition to StopState or PauseState (that will be created in a bit):

struct PlayState: State {    
    func apply(event: AudioPlayerEvent) -> any State {
        switch event {
        case .stopping:
            print("Stopping Audio")
            return StopState()
        case .pausing:
            print("Pausing Audio")
            return PauseState()
        default:
            print("[Invalid Transition for \(self)] - \(event)")
            return self
        }
    }
}

Lastly, let's implement PauseState and their transitions:

struct PauseState: State {
    func apply(event: AudioPlayerEvent) -> any State {
        switch event {
        case .playing:
            print("Resuming Audio")
            return PlayState()
        case .stopping:
            print("Stopping Audio")
            return StopState()
        default:
            print("[Invalid Transition for \(self)] - \(event)")
            return self
        }
    }
}

Now that all the states were created, let's implement the state machine, which for this example is really simple. We just need a variable to store the currentState and a method to handle the AudioPlayerEvent.

We will apply the event to the currentState and based on the transitions created earlier, we will receive a new state:

class AudioPlayerStateMachine {
    private(set) var currentState: any State = StopState()

    func handle(event: AudioPlayerEvent) {
        let newState = currentState.apply(event: event)
        currentState = newState
    }
}

By the way, AudioPlayerStateMachine is a class because we want to persist a single current state.

State Pattern has been implemented! ๐Ÿฅณ It's time to go back to SimpleAudioPlayer and get rid of all the conditionals. Instead, we will create a property to store the state machine and it will call the respective event based on the methods:

class SimpleAudioPlayer {
    private var machine = AudioPlayerStateMachine()

    func play() {
        machine.handle(event: .playing)
    }

    func stop() {
        machine.handle(event: .stopping)
    }

    func pause() {
        machine.handle(event: .pausing)
    }
}

This very cool, because now our state machine is managing all the logic, and SimpleAudioPlayer don't even care about what's going on inside. It just execute a play, stop and pause events.

Advantages of the State Pattern

Let's review the three original issues we found:

  1. Some transitions are invalid in this logic. - โœ… Each state now only cares about its valid transition, otherwise we just keep the same state.

  2. Each method (from SimpleAudioPlayer) has to review all the states. - โœ… Not anymore! The state machine handle the transitions to a new state, and each state internally manages its valid transitions.

  3. It breaks SOLID principles:

    1. Single Responsibility - โœ… SimpleAudioPlayer, AudioPlayerStateMachine and each state only manage what they really need.

    2. Open/Close Principle - โœ… If we introduce new states, we don't have to update SimpleAudioPlayer anymore!

In fact, let's now add the rewind and fast forward states in this new implementation:

Basically, the only modification to do is creating the two new states and update Play to add the respective transitions. StopState and PauseState (for the purpose of this demo) won't transition to the new states.

Let's add tne new events:

enum AudioPlayerEvent {
    case stopping
    case pausing
    case playing
    case rewinding // new
    case forwarding // new
}

Create the states:

struct RewindState: State {
    func apply(event: AudioPlayerEvent) -> any State {
        switch event {
        case .playing:
            print("Resume Audio")
            return PlayState()
        default:
            print("[Invalid Transition for \(self)] - \(event)")
            return self
        }
    }
}

struct FastForwardState: State {    
    func apply(event: AudioPlayerEvent) -> any State {
        switch event {
        case .playing:
            print("Resume Audio")
            return PlayState()
        default:
            print("[Invalid Transition for \(self)] - \(event)")
            return self
        }
    }
}

And update PlayState:

struct PlayState: State {
    func apply(event: AudioPlayerEvent) -> any State {
        switch event {
        case .stopping:
            print("Stopping Audio")
            return StopState()
        case .pausing:
            print("Pausing Audio")
            return PauseState()
        case .rewinding: // New
            print("Rewinding...")
            return RewindState()
        case .forwarding: // New
            print("Fast Forwarding...")
            return FastForwardState()
        default:
            print("[Invalid Transition for \(self)] - \(event)")
            return self
        }
    }
}

That's it! No more code is needed, and as I said earlier, SimpleAudioPlayer (and AudioPlayerStateMachine) doesn't have to be modified (opened) for modifications, meaning that Open/Close principle is followed! โœ…

Great for Unit Testing and TDD

But wait, there is more!

One cool benefit of State pattern is that allow us to implement unit tests really easy, and even follow Test Driven Development:

func testFromStopToPlay() {
    let expected = PlayState()

    stateMachine.handle(event: .playing)
    let actual = stateMachine.currentState

    XCTAssertNotNil(actual as? PlayState, "Invalid Transition found. This is a bug in your logic. The expected type is \(type(of: expected)).")
}

func testFromPlayToStop() {
    let expected = StopState()

    stateMachine.handle(event: .playing)
    stateMachine.handle(event: .stopping)
    let actual = stateMachine.currentState

    XCTAssertNotNil(actual as? StopState, "Invalid Transition found. This is a bug in your logic. The expected type is \(type(of: expected)).")
}

func testFromPlayToPause() {
    let expected = PauseState()

    stateMachine.handle(event: .playing)
    stateMachine.handle(event: .pausing)
    let actual = stateMachine.currentState

    XCTAssertNotNil(actual as? PauseState, "Invalid Transition found. This is a bug in your logic. The expected type is \(type(of: expected)).")
}

If you want to review all the unit tests for this demo, check out this link

The only additional thing to do would be making State conforming to Equatable and Identifiable protocols:

protocol State: Equatable, Identifiable {
    var id: ID { get }

    func apply(event: AudioPlayerEvent) -> any State
}

extension State {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }
}

// States:
struct StopState: State {
    let id = "Stop"
    // ...
}
struct PlayState: State {
    let id = "Play"
    // ...
}
// Etc...

Disadvantages of the State Pattern

Design patterns should be used only when absolutely necessary. In this demo, we prioritized design over simplicity, resulting in significantly more code to maintain.

Whether this is beneficial or detrimental depends on your project. The key point is that applying the State Pattern when it's not needed can lead to over-engineering, especially if you don't really need a state machine.

Moreover, if your app's state transitions are infrequent, the refactor might not be worth it. Sometimes, maintaining simplicity is better than overcomplicating the design. Ultimately, it's a decision you need to make based on your project's requirements.

Using the State Pattern in a real project

I'm preparing a video to show how to use this pattern in a SwiftUI project, demonstrating its capabilities in a more realistic app. Subscribe to Swift and Tips on YouTube to stay updated!

Wrap up

As you can see, the State Pattern is very useful for improving state management and making unit testing easier. Now tell me, what do you think about the State Pattern? Are you planning to use it in your development? Let me know in the comments below or through my social media.

Also, I would like to know which other design patterns you would like to see next.

If you want to review the full demo using the State Pattern, you can check out this link.

I hope you found this information useful ๐Ÿ˜Š. Remember, my name is Pitt, and this is swiftandtips.com. Thanks for reading and have a great day! ๐Ÿ‘‹๐Ÿป

Social Media

References

ย