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
}
}
}
Some transitions are invalid in this logic, such as executing the
play
method in theplay
state. However, we must validate this case because it's part of the switch requirements.Each method has to review all the states, which makes the code more complex and hard to read.
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:
Some transitions are invalid in this logic. - โ Each state now only cares about its valid transition, otherwise we just keep the same state.
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.It breaks SOLID principles:
Single Responsibility - โ
SimpleAudioPlayer
,AudioPlayerStateMachine
and each state only manage what they really need.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
Head First Design Patterns: https://a.co/d/bqIYvtY
Refactoring Guru: https://refactoring.guru/design-patterns/state
Subscribe to my newsletter
Read articles from Pedro Rojas directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by