State Design Pattern in Swift


Greetings, traveler!

It is the turn of a new Behavioral Design Pattern — the State. The State is a pattern that allows you to dynamically change the behavior of an object when its state changes. This pattern is handy when an object needs to change its behavior because of state changes while keeping the logic for each state encapsulated. State-dependent behaviors are moved to separate classes. The initial class maintains a reference to one of these state objects and delegates tasks to it.

The State pattern significantly enhances code clarity by linking the logic for state changes to a single trigger. This approach prevents the creation of unnecessary relationships that could lead to conflicts. Moreover, you can adhere to the single responsibility principle by encapsulating the logic in a separate object. This relieves objects dependent on this logic from unnecessary actions, making it easier to change the module as a whole.

Example of usage

The theory is enough. Let’s move on to an example. You probably know about YouTube, where content creators create channels to which viewers subscribe. When a certain number of subscribers is reached, YouTube awards the creator with a special button — a physical button that many YouTubers are proud of. Here is how YouTube rewards content creators:

  • Silver: When you reach 100,000 subscribers.
  • Gold: When you reach 1,000,000 subscribers.
  • Diamond: When you reach 10,000,000 subscribers.
  • Red Diamond: When you reach 100,000,000 subscribers.

Now, let’s describe a similar scheme in code using the State design pattern.

First, let’s define a protocol. This protocol will be named YouTuberState. It will have a name property and two methods. 

The first method, called handle, takes a specific object as a parameter — a YouTubeContext. What is the Context in the State pattern? The Context defines the interface for clients. It also contains a reference to an instance of a State class, which displays the current state of the Context.

The second function, called isEqual, will allow us to compare two objects implementing this protocol.

protocol YouTuberState: AnyObject {
    
    var name: String { get }
    
    func handle(context: YouTuberContext)
    func isEqual(to state: YouTuberState) -> Bool
    
}

For our example, all implementations of this protocol will be the same, so we will create an extension with the default implementation.

extension YouTuberState {
    
    var name: String {
        String(describing: self)
    }
    
    func handle(context: YouTuberContext) {
        print("I have a \(context.subscribersCount) subscribers and a \(name) button!")
    }
    
    func isEqual(to state: YouTuberState) -> Bool {
        self.name == state.name
    }
    
}

Now, let’s create several classes that implement this protocol. This will be the state of our YouTuber’s Button.

final class Regular: YouTuberState {}
final class Silver: YouTuberState {}
final class Gold: YouTuberState {}
final class Diamond: YouTuberState {}
final class RedDiamond: YouTuberState {}

For convenience, we will create an enumeration. This will store the button options and help us understand how many subscribers are needed to receive each one. In addition, we will write a property that will generate classes that implement the YouTuberState protocol. We will use it later.

enum YouTubeButtonKind: CaseIterable {
    case regular
    case silver
    case gold
    case diamond
    case redDiamond
    
    var subscribersCount: Int {
        switch self {
        case .regular:
            return 0
        case .silver:
            return 100_000
        case .gold:
            return 1_000_000
        case .diamond:
            return 10_000_000
        case .redDiamond:
            return 100_000_000
        }
    }
    
    var state: YouTuberState {
        switch self {
        case .regular:
            return Regular()
        case .silver:
            return Silver()
        case .gold:
            return Gold()
        case .diamond:
            return Diamond()
        case .redDiamond:
            return RedDiamond()
        }
    }
}

Let’s move on to the Context. Let’s create a class that holds a reference to our YouTuberState. We will immediately say that the first state will be the Regular state during initialization. And we will call the handle function after that. Using the didSet block, we will write a similar logic for each new State value. We will also make a property with the current number of subscribers.

final class YouTuberContext {
    
    var subscribersCount: Int = .zero
    
    private var state: YouTuberState {
        didSet {
            guard !state.isEqual(to: oldValue) else { return }
            state.handle(context: self)
        }
    }
  
    init() {
        state = Regular()
        state.handle(context: self)
    }
    
}

Next, we will create two functions. The first one, which we will call gainSubscribers, will increase the number of subscribers. The second function will be private. Inside this function, we will check if we have reached a certain number of subscribers and assign a new state to our Context if we have.

final class YouTuberContext {
    
    var subscribersCount: Int = .zero
    
    private var state: YouTuberState {
        didSet {
            guard !state.isEqual(to: oldValue) else { return }
            state.handle(context: self)
        }
    }
  
    init() {
        state = Regular()
        state.handle(context: self)
    }
    
    
    func gainSubscribers(count: Int) {
        subscribersCount += count
        configureState()
    }
    
    private func configureState() {
        if let button = YouTubeButtonKind.allCases.reversed().first(where: { button in subscribersCount >= button.subscribersCount }) {
            state = button.state
        }
    }
    
}

Now, we can use our code like this.

let context = YouTuberContext()
context.gainSubscribers(count: 10_000_000) // I have 10000000 subscribers and a Diamond button!

Conclusion

In our example, using the State design pattern, we eliminated the need to write conditional operators for the State Machine, focusing all the logic in one place. Now, such a module is easier to maintain and manage.

Alright then. I propose finishing the analysis of the State design pattern and starting the following article, which will examine the Strategy pattern.