Skip to content

Commit

Permalink
Merge pull request #9 from jemmons/feature/publsiher-exploration
Browse files Browse the repository at this point in the history
Property Wrappers and Breaking Changes
  • Loading branch information
jemmons committed Nov 4, 2019
2 parents 5c52c66 + 711e436 commit cc0f6ec
Show file tree
Hide file tree
Showing 52 changed files with 1,999 additions and 2,865 deletions.
31 changes: 1 addition & 30 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,32 +1,3 @@
# Xcode
#
.DS_Store
build/
.build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate

# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control
#
# Pods/

# Carthage

Carthage/
.swiftpm/
11 changes: 1 addition & 10 deletions .jazzy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,4 @@ author: Joshua Emmons
author_url: https://figure.ink
github_url: https://github.com/jemmons/Gauntlet
root_url: https://jemmons.github.io/Gauntlet/
module: Gauntlet

custom_categories:
- name: Public Interface
children:
- Transitionable
- StateMachine
- name: Constants
children:
- GauntletNotification
module: Gauntlet
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// swift-tools-version:4.0
// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "Gauntlet",
platforms: [
.macOS(.v10_15),
.iOS(.v13)
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
Expand Down
96 changes: 76 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ enum TrafficLight: Transitionable {

func shouldTransition(to: TrafficLight) -> Bool {
switch (self, to) {
case (.red, .green), (.green, .yellow), (.yellow, .red):
case (.red, .green),
(.green, .yellow),
(.yellow, .red):
return true
default:
return false
Expand All @@ -25,45 +27,76 @@ enum TrafficLight: Transitionable {
```

## Light Weight Objects
Gauntlet doesn't require you subclass your objects from some abstract root machine or manage class hierarchies of behavior. Instead, a simple light weight `StateMachine` class is available for you to compose into any of your existing types.
Gauntlet doesnt require you to subclass objects from some abstract root machine or manage class hierarchies of behavior. Instead, a simple light weight `StateMachine` class is available to compose into your types and behaviors.

A `StateMachine` gets created with a `Transitionable` type and an initial state. Queueing up state changes is a simple method call and a delegate handler can be assigned to respond to transitions. Once again, swift's `switch` is a good fit here:
A `StateMachine` gets created with a `Transitionable` type and an initial state. Transitioning states is a simple method call. A `Combine` publisher gives subscribers the ability to react to transitions:

```swift
class MyClass {
let machine: StateMachine<TrafficLight>

let stateMachine = StateMachine(initialState: TrafficLight.red)
let subscription: AnyCancellable

init() {
machine = StateMachine(initialState: .red)
machine.delegates.didTransition = { [weak self] _, to in
subscription = stateMachine.publisher.sink { [weak self] _, to in
switch to {
case .red:
self?.stop()
case .yellow:
self?.caution()
self?.slow()
case .green:
self?.go()
self?.go()
}
}
}


func timerTriggerd(light: TrafficLight) {
stateMachine.transition(to: light)
}
}
```

func doThing() {
machine.queue(.green)
## Property Wrapper
Becasue `StateMachine` is so often used as a property, it can sometimes be more succinct to write it using property wrapper syntax. When used as a property wrapper:
* The type of the wrapped property is the state type conforming to `Transitionable`.
* The default value of the wrapped property is used as the initial state of the state machine.
* Values assigned to the wrapped property pass through `transition(to:)` and are ignored if they would result in an invalid transition.
* State changes are published to the “projected value” of the wrapped property — meaning we can access it by prefixing the wrapped property with a `$`.

The example above could be rewritten as:

```swift
class MyClass {
@StateMachine var stateMachine: TrafficLight = .red
let subscription: AnyCancellable

init() {
subscription = $stateMachine.sink { [weak self] _, to in
//...
}
}


func timerTriggerd(light: TrafficLight) {
stateMachine = light
}
}
```


## Associating Values

Conforming to `Transitionable` with an `enum` allows us to associate values with a state:
An oft overlooked advantage of conforming to `Transitionable` with an `enum` is it allows us to easily associate values with a state:

```swift
enum Connection: Transitionable {
case fetch(URLSessionTask), success([AnyHashable: Any]), failure(Error), cancel

func shouldTransition(to: Connection) -> Bool {
switch (self, to) {
case (.fetching, .success), (.fetching, .failure), (_, .cancel):
case (.fetch, .success),
(.fetch, .failure),
(_, .cancel):
return true
default:
return false
Expand All @@ -72,26 +105,26 @@ enum Connection: Transitionable {
}
```

Values get associated when a state change is queued:
Values get associated when a transition is requested:

```swift
func connect() {
let task = makeTask(for: myURL) { json, error in
guard error == nil else {
machine.queue(.faulure(error))
stateMachine.transition(to: .faulure(error))
return
}
machine.queue(.success(json))
stateMachine.transition(to: .success(json))
}

machine.queue(.fetching(task))
stateMachine.transition(to: .fetch(task))
}
```

…And can be pulled out again when handling transitions:
…And can be pulled out again when handling state changes:

```swift
machine.delegates.didTransition = { [weak self] from, to in
subscription = stateMachine.publisher.sink { [weak self] from, to in
switch (from, to) {
case (_, .success(let json)):
self?.processJSON(json)
Expand All @@ -105,5 +138,28 @@ machine.delegates.didTransition = { [weak self] from, to in
}
```

## Migrating from Gauntlet v4.x
Version 5 of Gauntlet presents a number of breaking changes.

* `StateType` was deprecated last version. Now it’s dead. Use `Transitionable` instead.

* Delegates were always a bit wonky in Gauntlet and have been replaced with a `Combine` pub/sub model. The `delegates` property and its `didTransition` member have been removed. Subscribe to the `publisher` property (or the `$` projected value of a wrapped property) to get notified of state changes.

* `queue(_:)` has been renamed to `transition(to:)`. This is simple enough, but is indicative of a larger change…

* Timing is version 5 is subtlely different in significant ways. In version 4, state changes were queued onto the next cycle of the run loop. Then the state change and the (now obsolete) `didTransition` would run “together”. As a result, we could rely on the `state` property of the state machine and the `to` argument to `didTransition` to be in agreement.

As of version 5, this has changd. Now state changes are applied to the state machine synchronously. But notification of these changes (via publication to the `publisher` property) still happens asynchronously (to allow for recursive transitions without overflowing the stack).

As a result, the `state` of the machine is much more stable and less prone to timing-related edge cases. Yay! But we can no longer assume the `to` arguments of our subscriptions to `publisher` reflect the current `state` of the machine. Boo?

Subscribers will always get all state changes and will always receive them in the order they were made, so in practice I'm hoping this isn’t a big deal. But if you were relying on notifications of state change happening along side the actual change itself, it’s time to revisit those assumptions.

* In version 4, becasue `state` was set asyncronously, it was surpassingly hard to test unless the transition happened to trigger some behavior observable to the test case. So Gauntlet provided `willTransition` and `didTransition` notifications that would fire if `GAUNTLET_POST_TEST_NOTIFICATIONS` was set in the environment.

Now that transitions happen syncronously in version 5, these are no longer necessary and have been removed.



## API
Full API documentation [can be found here](https://jemmons.github.io/Gauntlet/Protocols/Transitionable.html).
Full API documentation [can be found here](https://jemmons.github.io/Gauntlet/Classes/StateMachine.html).
63 changes: 63 additions & 0 deletions Sources/Gauntlet/Baggage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation



/**
- Warning: Obsolete. Use `Transitionable` instead.
*/
@available(*, unavailable, renamed: "Transitionable")
public typealias StateType = Transitionable


public extension StateMachine {
/**
- Warning: Obsolete. Subscribe to `publisher` instead.
*/
@available(*, unavailable, message: "Subscribe to `publisher` instead.")
struct StateTransitionDelegates {
/**
- Warning: Obsolete. Subscribe to `publisher` instead.
*/
@available(*, unavailable, message: "Subscribe to `publisher` instead.")
public var didTransition: ((_ from: State, _ to: State) -> Void)?
}


/**
- Warning: Obsolete. Subscribe to `publisher` instead.
*/
@available(*, unavailable, message: "Subscribe to `publisher` instead.")
var delegates: StateTransitionDelegates {
get { StateTransitionDelegates() }
set { }
}


/**
- Warning: Obsolete. Use `trasition(to:)` instead.
*/
@available(*, unavailable, renamed: "transition(to:)")
public func queue(_ state: State) {}
}



/**
- Warning: Obsolete. States are now assigned syncronously in the current run loop. Debug notifications are no longer needed.
*/
@available(*, unavailable, message: "States are now assigned syncronously in the current run loop. Debug notifications are no longer needed.")
public enum GauntletNotification {
/**
- Warning: Obsolete. States are now assigned syncronously in the current run loop. Debug notifications are no longer needed.
*/
@available(*, unavailable, message: "States are now assigned syncronously in the current run loop. Debug notifications are no longer needed.")
public static let willTransition = Notification.Name(rawValue: "GauntletWillTransitionNotification")


/**
- Warning: Obsolete. States are now assigned syncronously in the current run loop. Debug notifications are no longer needed.
*/
@available(*, unavailable, message: "States are now assigned syncronously in the current run loop. Debug notifications are no longer needed.")
public static let didTransition = Notification.Name(rawValue: "GauntletDidTransitionNotification")
}

25 changes: 0 additions & 25 deletions Sources/Gauntlet/GauntletNotifitcation.swift

This file was deleted.

0 comments on commit cc0f6ec

Please sign in to comment.