Awesome
NOTE: This repository has been discontinued in favor of Actomaton.
🌾 Harvest
Apple's Combine.framework (from iOS 13) + State Machine, inspired by Elm.
This is a sister library of the following projects:
- ReactiveAutomaton (using ReactiveSwift)
- RxAutomaton (using RxSwift)
Requirement
Xcode 11 (Swift 5.1 / macOS 10.15, iOS 13, ...)
Example
To make a state transition diagram like above with additional effects, follow these steps:
1. Define State
s and Input
s
// 1. Define `State`s and `Input`s.
enum State {
case loggedOut, loggingIn, loggedIn, loggingOut
}
enum Input {
case login, loginOK, logout, logoutOK
case forceLogout
}
2. Define EffectQueue
enum EffectQueue: EffectQueueProtocol {
case `default`
case request
var flattenStrategy: FlattenStrategy {
switch self {
case .default: return .merge
case .request: return .latest
}
}
static var defaultEffectQueue: EffectQueue {
.default
}
}
EffectQueue
allows additional side-effects (Effect
, a wrapper of Publisher
) to be scheduled with a specific FlattenStrategy
, such as flatMap
(.merge
), flatMapLatest
(.latest
), etc.
In above case, we want to automatically cancel previous network requests if occurred multiple times, so we also prepare case request
queue with .latest
strategy.
3. Create EffectMapping
(Effect-wise reducer)
// NOTE: `EffectID` is useful for manual effect cancellation, but not used in this example.
typealias EffectID = Never
typealias Harvester = Harvest.Harvester<Input, State>
typealias EffectMapping = Harvester.EffectMapping<EffectQueue, EffectID>
typealias Effect = Harvester.Effect<Input, EffectQueue, EffectID>
// Additional effects while state-transitioning.
let loginOKPublisher = /* show UI, setup DB, request APIs, ..., and send `Input.loginOK` */
let logoutOKPublisher = /* show UI, clear cache, cancel APIs, ..., and send `Input.logoutOK` */
let forceLogoutOKPublisher = /* do something more special, ..., and send `Input.logoutOK` */
let canForceLogout: (State) -> Bool = [.loggingIn, .loggedIn].contains
let mappings: [EffectMapping] = [
/* Input | fromState => toState | Effect */
/* ----------------------------------------------------------*/
.login | .loggedOut => .loggingIn | Effect(loginOKPublisher, queue: .request),
.loginOK | .loggingIn => .loggedIn | .empty,
.logout | .loggedIn => .loggingOut | Effect(logoutOKPublisher, queue: .request),
.logoutOK | .loggingOut => .loggedOut | .empty,
.forceLogout | canForceLogout => .loggingOut | Effect(forceLogoutOKPublisher, queue: .request)
]
EffectMapping
is Redux's Reducer
or Elm's Update
pure function that also returns Effect
during the state-transition.
Note that queue: .request
is specified so that those effects will be handled in the same queue with .latest
strategy.
Instead of writing it as a plain function with pattern-matching, you can also write in a fancy markdown-table-like syntax as shown above.
4. Setup Harvester
(state machine)
// Prepare input pipe for sending `Input` to `Harvester`.
let inputs = PassthroughSubject<Input, Never>()
var cancellables: [AnyCancellable] = []
// Setup state machine.
let harvester = Harvester(
state: .loggedOut,
input: inputs,
mapping: .reduce(.first, mappings), // combine mappings using `reduce` helper
scheduler: DispatchQueue.main
)
// Observe state-transition replies (`.success` or `.failure`).
harvester.replies.sink { reply in
print("received reply = \(reply)")
}.store(in: &cancellables)
// Observe current state changes.
harvester.state.sink { state in
print("current state = \(state)")
}.store(in: &cancellables)
NOTE: func reduce
is declared to combine multiple mappings
into one.
5. And let's test!
let send = inputs.send
expect(harvester.state) == .loggedIn // already logged in
send(Input.logout)
expect(harvester.state) == .loggingOut // logging out...
// `logoutOKPublisher` will automatically send `Input.logoutOK` later
// and transit to `State.loggedOut`.
expect(harvester.state) == .loggedOut // already logged out
send(Input.login)
expect(harvester.state) == .loggingIn // logging in...
// `loginOKPublisher` will automatically send `Input.loginOK` later
// and transit to `State.loggedIn`.
// 👨🏽 < But wait, there's more!
// Let's send `Input.forceLogout` immediately after `State.loggingIn`.
send(Input.forceLogout) // 💥💣💥
expect(harvester.state) == .loggingOut // logging out...
// `forceLogoutOKublisher` will automatically send `Input.logoutOK` later
// and transit to `State.loggedOut`.
Please notice how state-transitions, effect calls and cancellation are nicely performed.
If your cancellation strategy is more complex than just using FlattenStrategy.latest
, you can also use Effect.cancel
to manually stop specific EffectID
.
Note that any sizes of State
and Input
will work using Harvester
, from single state (like above example) to covering whole app's states (like React.js + Redux architecture).
Using Feedback
effect model as alternative
Instead of using EffectMapping
with fine-grained EffectQueue
model, Harvest also supports Feedback
system as described in the following libraries:
See inamiy/ReactiveAutomaton#12 for more discussion.
Composable Architecture with SwiftUI
Pull Request #8 introduced HarvestStore
and HarvestOptics
frameworks for Composable Architecture, especially focused on SwiftUI.
- HarvestStore: 2-way bindable
Store
optimized for SwiftUI - HarvestOptics: Input & state lifting helpers using FunOptics
See Harvest-SwiftUI-Gallery for the examples.
See also Babylonpartners/ios-playbook#171 for further related discussion.
- TODO: Write documentation
References
- ReactiveAutomaton (using ReactiveSwift)
- RxAutomaton (using RxSwift)
- iOSDC 2016 (Tokyo, in Japanese) (2016/08/20)
- iOSConf SG (Singapore, in English) (2016/10/20-21)