Skip to content

Instantly share code, notes, and snippets.

@vijaysharm
Last active April 22, 2021 03:11
Show Gist options
  • Save vijaysharm/ffc8c265e5edf0702266a42770a8491a to your computer and use it in GitHub Desktop.
Save vijaysharm/ffc8c265e5edf0702266a42770a8491a to your computer and use it in GitHub Desktop.
XState State Machine for Swift

XState for Swift

This is a rough implementation of the javascript XState library by davidkpiano.

I was looking around for a finite state machine for Swift, and decided to port over the javascript library.

It's not as flexible as the javascript library, but does have that Swift type safety-ness.

TODO

  • Have a way to avoid having to provide an init event in Service.start
  • Remove the need to pass a generic Context type in the case that there's no actual context data (see toggle.swift example above requiring DataType when it's never used. Maybe a NoContext could be provided?)
  • Eliminate need for redundant State class and StateType without resorting to String (i.e. there's an enum States and I have to create an instance of State where the id is a StateType. This was done to have other states refer to other states during transitions.
  • Have a specific mutating action that allows you to accept a Context and return an optionally mutated Context. in the given implementation, the Context object given would be mutated directly by a given side-effect.
  • Support Nested/Parallel/History type finite state machines.
//
// StateMachine.swift
// StateMachine
//
import Foundation
protocol EventType: Hashable {}
protocol StateType: Equatable {}
protocol ContextType {}
class Action<E: EventType, C: ContextType> {
public var exec: (E, C?) -> Void {
get {
if let callback = self.callback {
return callback
} else {
return self.main(event:context:)
}
}
}
public let queue: DispatchQueue?
private let callback: ((E, C?) -> Void)?
init(queue: DispatchQueue? = nil, callback: ((E, C?) -> Void)? = nil) {
self.callback = callback
self.queue = queue
}
func main(event: E, context: C?) {}
}
class Transition<S: StateType, E: EventType, C: ContextType> {
public let target: S?
public let actions: [Action<E, C>]
public let condition: (E, C?) -> Bool
init(
target: S?,
actions: [Action<E, C>] = [],
condition: @escaping (E, C?) -> Bool = { _, _ in true }
) {
self.target = target
self.actions = actions
self.condition = condition
}
}
class State<S: StateType, E: EventType, C: ContextType> {
public let id: S
public let on: [E: [Transition<S, E, C>]]?
public let enter: [Action<E, C>]
public let exit: [Action<E, C>]
init(
id: S,
on: [E: [Transition<S, E, C>]]? = [:],
enter: [Action<E, C>] = [],
exit: [Action<E, C>] = []
) {
self.id = id
self.on = on
self.enter = enter
self.exit = exit
}
}
struct TransitionResult<S: StateType, E: EventType, C: ContextType> {
public let state: State<S, E, C>
public let actions: [Action<E, C>]?
public let context: C?
public let changed: Bool
}
class Machine<S: StateType, E: EventType, C: ContextType> {
public let initial: State<S, E, C>
public let states: [State<S, E, C>]
public let context: C?
convenience init(
initialType: S,
states: [State<S, E, C>],
context: C? = nil
) {
let initial = states.first(where: { $0.id == initialType })
self.init(initial: initial!, states: states, context: context)
}
init(
initial: State<S, E, C>,
states: [State<S, E, C>],
context: C? = nil
) {
self.initial = initial
self.states = states
self.context = context
}
func transition(currentState: State<S, E, C>, event: E) -> TransitionResult<S, E, C> {
guard let current = states.first(where: {$0.id == currentState.id}) else {
print("State '\(currentState)' not found on machine")
return unchanged(state: currentState)
}
guard let on = current.on, let transitions = on[event] else {
return unchanged(state: current)
}
let result = transitions
.filter{ $0.condition(event, context) }
.map{ (transition: Transition<S, E, C>) -> TransitionResult<S, E, C> in
guard let target = transition.target == nil ? current.id : transition.target else {
return unchanged(state: current)
}
guard let next = states.first(where: {$0.id == target}) else {
return unchanged(state: current)
}
var actions: [Action<E, C>] = []
actions.append(contentsOf: current.exit)
actions.append(contentsOf: transition.actions)
actions.append(contentsOf: next.enter)
return TransitionResult<S, E, C>(
state: next,
actions: actions,
context: self.context,
changed: target != current.id
)
}
.first
guard let transition = result else {
return unchanged(state: current)
}
return transition
}
private func unchanged(state: State<S, E, C>) -> TransitionResult<S, E, C> {
return TransitionResult(state: state, actions: nil, context: self.context, changed: false)
}
}
class Service<S: StateType, E: EventType, C: ContextType> {
private enum Status {
case noStarted
case running
case stopped
}
private let machine: Machine<S, E, C>
private var current: TransitionResult<S, E, C>
private var listeners: [(TransitionResult<S, E, C>) -> Void] = []
private var status: Status = .noStarted
public var state: State<S, E, C> {
get {
return current.state
}
}
convenience init(
initialType: S,
states: [State<S, E, C>],
context: C? = nil
) {
let initial = states.first(where: { $0.id == initialType })
self.init(initial: initial!, states: states, context: context)
}
convenience init(
initial: State<S, E, C>,
states: [State<S, E, C>],
context: C? = nil
) {
self.init(machine: Machine<S, E, C>(initial: initial, states: states, context: context))
}
init(machine: Machine<S, E, C>) {
self.machine = machine
self.current = TransitionResult(
state: machine.initial,
actions: machine.initial.enter,
context: machine.context,
changed: false
)
}
@discardableResult
public func subscribe(_ listener: @escaping (TransitionResult<S, E, C>) -> Void) -> () -> Void {
listeners.append(listener)
let itemIndex = listeners.count - 1
listener(current)
return { [self] in
if (itemIndex < listeners.count) {
self.listeners.remove(at: itemIndex)
}
}
}
@discardableResult
public func start(initEvent event: E) -> Service {
status = .running
current.actions?.forEach { action in
guard let queue = action.queue else {
action.exec(event, current.context)
return
}
queue.async { [self] in
action.exec(event, current.context)
}
}
return self;
}
@discardableResult
public func stop() -> Service {
status = .stopped
listeners.removeAll()
return self;
}
public func send(event: E) {
guard status == .running else { fatalError("Sending event without calling .start()") }
current = machine.transition(currentState: current.state, event: event)
var notified: [(TransitionResult<S, E, C>) -> Void] = []
current.actions?.forEach{ action in
guard let queue = action.queue else {
action.exec(event, current.context)
return
}
queue.async { [self] in
action.exec(event, current.context)
}
}
notified.append(contentsOf: listeners)
notified.forEach{ $0(current) }
}
}
class AsyncService<S: StateType, E: EventType, C: ContextType> {
private enum Status {
case noStarted
case running
case stopped
}
class ListenerWrapper {
public let callback: (TransitionResult<S, E, C>) -> Void
public let queue: DispatchQueue
init(callback: @escaping (TransitionResult<S, E, C>) -> Void, queue: DispatchQueue) {
self.callback = callback
self.queue = queue
}
}
class ActionOperation: Operation {
private let semaphore = DispatchSemaphore(value: 0)
private let action: Action<E, C>
private let event: E
private let context: C?
init(action: Action<E, C>, event: E, context: C?) {
self.action = action
self.event = event
self.context = context
}
override func main() {
guard let queue = action.queue else {
action.exec(event, context)
semaphore.signal()
return
}
queue.async { [self] in
action.exec(event, context)
semaphore.signal()
}
semaphore.wait()
}
}
private let machine: Machine<S, E, C>
private var current: TransitionResult<S, E, C>
private var listeners: [ListenerWrapper] = []
private var status: Status = .noStarted
private let workQueue = DispatchQueue(label: "com.vavavoom.sdk.statemachine")
private let actionQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "com.vavavoom.sdk.statemachine.actions"
return queue
}()
convenience init(
initialType: S,
states: [State<S, E, C>],
context: C? = nil
) {
let initial = states.first(where: { $0.id == initialType })
self.init(initial: initial!, states: states, context: context)
}
convenience init(
initial: State<S, E, C>,
states: [State<S, E, C>],
context: C? = nil
) {
self.init(machine: Machine<S, E, C>(initial: initial, states: states, context: context))
}
init(machine: Machine<S, E, C>) {
self.machine = machine
self.current = TransitionResult(
state: machine.initial,
actions: machine.initial.enter,
context: machine.context,
changed: false
)
}
@discardableResult
public func start(initEvent event: E) -> Self {
guard status != .running else { fatalError("Service is already runing") }
status = .running
workQueue.async { [self] in
self.runActions(
actions: current.actions,
event: event,
context: current.context
)
}
return self;
}
@discardableResult
public func stop() -> Self {
status = .stopped
listeners.removeAll()
return self;
}
public func send(event: E) {
guard status == .running else { fatalError("Sending event without calling .start()") }
workQueue.async { [self] in
current = machine.transition(currentState: current.state, event: event)
self.runActions(
actions: current.actions,
event: event,
context: current.context
)
var notified: [ListenerWrapper] = []
notified.append(contentsOf: listeners)
notified.forEach{ listener in
listener.queue.async {
listener.callback(current)
}
}
}
}
@discardableResult
public func subscribe(
_ listener: @escaping (TransitionResult<S, E, C>) -> Void,
queue: DispatchQueue = .main
) -> () -> Void {
listeners.append(ListenerWrapper(callback: listener, queue: queue))
let itemIndex = listeners.count - 1
listener(current)
return { [self] in
if (itemIndex < listeners.count) {
self.listeners.remove(at: itemIndex)
}
}
}
private func runActions(actions: [Action<E, C>]?, event: E, context: C?) {
guard let actions = actions, actions.count > 0 else {
return
}
let operations = actions.map { action in
return ActionOperation(
action:action,
event: event,
context: context
)
}
self.actionQueue.addOperations(operations, waitUntilFinished: true)
}
}
//
// toggle.swift
// StateMachine
//
import Foundation
enum States: StateType {
case inactive
case active
}
enum Event: EventType {
case initEvent
case toggle
}
struct DataType: Context {}
let inactive = State<States, Event, DataType>(
id: .inactive,
on: [
.toggle: [Transition(target: .active)],
]
)
let active = State<States, Event, DataType>(
id: .active,
on: [
.toggle: [Transition(target: .inactive)],
]
)
let machine = Machine<States, Event, DataType>(
initial: inactive,
states: [inactive, active]
)
let service = Service<States, Event, DataType>(machine: machine)
let unsubscribe = service.subscribe {
if ($0.changed) {
print("New State \($0.state.id)")
}
}
service.start(initEvent: .initEvent)
service.send(event: .toggle)
service.send(event: .toggle)
unsubscribe()
//
// lightSwitch.swift
// StateMachine
//
import Foundation
enum States: StateType {
case green
case yellow
case red
}
enum Event: EventType {
case initEvent
case timer
}
struct DataType: Context {}
let green = State<States, Event, DataType>(
id: .green,
on: [
.timer: [Transition(target: .yellow)],
]
)
let yellow = State<States, Event, DataType>(
id: .yellow,
on: [
.timer: [Transition(target: .red)],
]
)
let red = State<States, Event, DataType>(
id: .red,
on: [
.timer: [Transition(target: .green)],
]
)
let machine = Machine<States, Event, DataType>(
initial: green,
states: [green, yellow, red]
)
let service = Service<States, Event, DataType>(machine: machine)
let unsubscribe = service.subscribe {
if ($0.changed) {
print("New State \($0.state.id)")
}
}
service.start(initEvent: .initEvent)
service.send(event: .timer)
service.send(event: .timer)
service.send(event: .timer)
unsubscribe()
//
// timer.swift
// StateMachine
//
/***
* Here's an example of using the state machine with some context data.
* Sadly the context is mutated by the side-effect instead of returning
* a new context object.
*
* The only benefit of this version is you don't have to define both a `StateType`
* and create instance `State` variables that match those `StateType`. Everything
* can be done with just `StateType`s
*/
import Foundation
class DataType: Context {
public var t = 0
public var timer: Timer? = nil
}
enum TimerStates: StateType {
case idle
case running
case paused
}
enum TimerEvents: EventType {
case initEvent
case start
case pause
case reset
}
let timerService = Service<TimerStates, TimerEvents, DataType>(
initialType: .idle,
states: [
State(
id: .idle,
on: [.start: [Transition(target: .running)]],
enter: [{ _, context in
guard let context = context else { return }
context.t = 0
context.timer?.invalidate()
context.timer = nil
}]
),
State(
id: .running,
on: [.pause: [Transition(target: .paused)]],
enter: [{ _, context in
guard let context = context else { return }
context.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
context.t += 1
print("T: \(context.t)")
}
}]
),
State(
id: .paused,
on: [
.reset: [Transition(target: .idle)],
.start: [Transition(target: .running)]
],
enter: [{ _, context in
guard let context = context else { return }
context.timer?.invalidate()
context.timer = nil
}]
)
],
context: DataType()
)
timerService.subscribe {
print("New State \($0.state.id)")
}
timerService.start(initEvent: .initEvent)
timerService.send(event: .start)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment