• Stars
    star
    139
  • Rank 262,954 (Top 6 %)
  • Language
    Swift
  • License
    MIT License
  • Created about 3 years ago
  • Updated over 2 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

🎭 Swift async/await & Actor-powered effectful state-management framework.

🎭 Actomaton

Swift 5.6

🧑‍🎤 Actor + 🤖 Automaton = 🎭 Actomaton

Actomaton is Swift async/await & Actor-powered effectful state-management framework inspired by Elm and swift-composable-architecture.

This repository consists of 3 frameworks:

  1. Actomaton: Actor-based effect-handling state-machine at its core. Linux ready.
  2. ActomatonUI: SwiftUI & UIKit & Combine support
  3. ActomatonDebugging: Helper module to print Action and State (with diffing) per Reducer call.

(NOTE: ActomatonStore is deprecated in ver 0.7.0)

These frameworks depend on swift-case-paths as Functional Prism library, which is a powerful tool to construct an App-level Mega-Reducer from each screen's Reducers.

This framework is a successor of the following projects:

Installation

In Package.swift:

let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/Actomaton/Actomaton", .branch("main"))
    ]
)

Note: Specifying by "git version tag" is not currently supported due to usage of unsafe flags. See also: #56

Demo App

1. Actomaton (Core)

Example 1-1. Simple Counter

struct State: Sendable {
    var count: Int = 0
}

enum Action: Sendable {
    case increment
    case decrement
}

typealias Environment = Void

let reducer: Reducer<Action, State, Environment>
reducer = Reducer { action, state, environment in
    switch action {
    case .increment:
        state.count += 1
        return Effect.empty
    case .decrement:
        state.count -= 1
        return Effect.empty
    }
}

let actomaton = Actomaton<Action, State>(
    state: State(),
    reducer: reducer
)

@main
enum Main {
    static func main() async {
        assertEqual(await actomaton.state.count, 0)

        await actomaton.send(.increment)
        assertEqual(await actomaton.state.count, 1)

        await actomaton.send(.increment)
        assertEqual(await actomaton.state.count, 2)

        await actomaton.send(.decrement)
        assertEqual(await actomaton.state.count, 1)

        await actomaton.send(.decrement)
        assertEqual(await actomaton.state.count, 0)
    }
}

If you want to do some logging (side-effect), add Effect in Reducer as follows:

reducer = Reducer { action, state, environment in
    switch action {
    case .increment:
        state.count += 1
        return Effect.fireAndForget {
            print("increment")
        }
    case .decrement:
        state.count -= 1
        return Effect.fireAndForget {
            print("decrement and sleep...")
            try await Task.sleep(...) // NOTE: We can use `await`!
            print("I'm awake!")
        }
    }
}

NOTE: There are 5 ways of creating Effect in Actomaton:

  1. No side-effects, but next action only
    • Effect.nextAction
  2. Single async without next action
    • Effect.fireAndForget(id:run:)
  3. Single async with next action
    • Effect.init(id:run:)
  4. Multiple asyncs (i.e. AsyncSequence) with next actions
    • Effect.init(id:sequence:)
  5. Manual cancellation
    • Effect.cancel(id:) / .cancel(ids:)

Example 1-2. Login-Logout (and ForceLogout)

login-diagram

enum State: Sendable {
    case loggedOut, loggingIn, loggedIn, loggingOut
}

enum Action: Sendable {
    case login, loginOK, logout, logoutOK
    case forceLogout
}

// NOTE:
// Use same `EffectID` so that if previous effect is still running,
// next effect with same `EffectID` will automatically cancel the previous one.
//
// Note that `EffectID` is also useful for manual cancellation via `Effect.cancel`.
struct LoginFlowEffectID: EffectIDProtocol {}

struct Environment: Sendable {
    let loginEffect: (userId: String) -> Effect<Action>
    let logoutEffect: Effect<Action>
}

let environment = Environment(
    loginEffect: { userId in
        Effect(id: LoginFlowEffectID()) {
            let loginRequest = ...
            let data = try? await URLSession.shared.data(for: loginRequest)
            if Task.isCancelled { return nil }
            ...
            return Action.loginOK // next action
        }
    },
    logoutEffect: {
        Effect(id: LoginFlowEffectID()) {
            let logoutRequest = ...
            let data = try? await URLSession.shared.data(for: logoutRequest)
            if Task.isCancelled { return nil }
            ...
            return Action.logoutOK // next action
        }
    }
)

let reducer = Reducer { action, state, environment in
    switch (action, state) {
    case (.login, .loggedOut):
        state = .loggingIn
        return environment.login(state.userId)

    case (.loginOK, .loggingIn):
        state = .loggedIn
        return .empty

    case (.logout, .loggedIn),
        (.forceLogout, .loggingIn),
        (.forceLogout, .loggedIn):
        state = .loggingOut
        return environment.logout()

    case (.logoutOK, .loggingOut):
        state = .loggedOut
        return .empty

    default:
        return Effect.fireAndForget {
            print("State transition failed...")
        }
    }
}

let actomaton = Actomaton<Action, State>(
    state: .loggedOut,
    reducer: reducer,
    environment: environment
)

@main
enum Main {
    static func test_login_logout() async {
        var t: Task<(), Error>?

        assertEqual(await actomaton.state, .loggedOut)

        t = await actomaton.send(.login)
        assertEqual(await actomaton.state, .loggingIn)

        await t?.value // wait for previous effect
        assertEqual(await actomaton.state, .loggedIn)

        t = await actomaton.send(.logout)
        assertEqual(await actomaton.state, .loggingOut)

        await t?.value // wait for previous effect
        assertEqual(await actomaton.state, .loggedOut)

        XCTAssertFalse(isLoginCancelled)
    }

    static func test_login_forceLogout() async throws {
        var t: Task<(), Error>?

        assertEqual(await actomaton.state, .loggedOut)

        await actomaton.send(.login)
        assertEqual(await actomaton.state, .loggingIn)

        // Wait for a while and interrupt by `forceLogout`.
        // Login's effect will be automatically cancelled because of same `EffectID.
        try await Task.sleep(/* 1 ms */)
        t = await actomaton.send(.forceLogout)

        assertEqual(await actomaton.state, .loggingOut)

        await t?.value // wait for previous effect
        assertEqual(await actomaton.state, .loggedOut)

    }
}

Here we see the notions of EffectID, Environment, and let task: Task<(), Error> = actomaton.send(...)

  • EffectID is for both manual & automatic cancellation of previous running effects. In this example, forceLogout will cancel login's networking effect.
  • Environment is useful for injecting effects to be called inside Reducer so that they become replaceable. Environment is known as Dependency Injection (using Reader monad).
  • (Optional) Task<(), Error> returned from actomaton.send(action) is another fancy way of dealing with "all the effects triggered by action". We can call await task.value to wait for all of them to be completed, or task.cancel() to cancel all. Note that Actomaton already manages such tasks for us internally, so we normally don't need to handle them by ourselves (use this as a last resort!).

Example 1-3. Timer (using AsyncSequence) and EffectID cancellation

typealias State = Int

enum Action: Sendable {
    case start, tick, stop
}

struct TimerID: EffectIDProtocol {}

struct Environment {
    let timerEffect: Effect<Action>
}

let environment = Environment(
    timerEffect: { userId in
        Effect(id: TimerID(), sequence: {
            AsyncStream<()> { continuation in
                let task = Task {
                    while true {
                        try await Task.sleep(/* 1 sec */)
                        continuation.yield(())
                    }
                }

                continuation.onTermination = { @Sendable _ in
                    task.cancel()
                }
            }
        })
    }
)

let reducer = Reducer { action, state, environment in
    switch action {
    case .start:
        return environment.timerEffect
    case .tick:
        state += 1
        return .empty
    case .stop:
        return Effect.cancel(id: TimerID())
    }
}

let actomaton = Actomaton<Action, State>(
    state: 0,
    reducer: reducer,
    environment: environment
)

@main
enum Main {
    static func test_timer() async {
        assertEqual(await actomaton.state, 0)

        await actomaton.send(.start)

        assertEqual(await actomaton.state, 0)

        try await Task.sleep(/* 1 sec */)
        assertEqual(await actomaton.state, 1)

        try await Task.sleep(/* 1 sec */)
        assertEqual(await actomaton.state, 2)

        try await Task.sleep(/* 1 sec */)
        assertEqual(await actomaton.state, 3)

        await actomaton.send(.stop)

        try await Task.sleep(/* long enough */)
        assertEqual(await actomaton.state, 3,
                    "Should not increment because timer is stopped.")
    }
}

In this example, Effect(id:sequence:) is used for timer effect, which yields Action.tick multiple times.

Example 1-4. EffectQueue

enum Action: Sendable {
    case fetch(id: String)
    case _didFetch(Data)
}

struct State: Sendable {} // no state

struct Environment: Sendable {
    let fetch: @Sendable (_ id: String) async throws -> Data
}

struct DelayedEffectQueue: EffectQueueProtocol {
    // First 3 effects will run concurrently, and other sent effects will be suspended.
    var effectQueuePolicy: EffectQueuePolicy {
        .runOldest(maxCount: 3, .suspendNew)
    }

    // Adds delay between effect start. (This is useful for throttling / deboucing)
    var effectQueueDelay: EffectQueueDelay {
        .random(0.1 ... 0.3)
    }
}

let reducer = Reducer<Action, State, Environment> { action, state, environment in
    switch action {
    case let .fetch(id):
        return Effect(queue: DelayedEffectQueue()) {
            let data = try await environment.fetch(id)
            return ._didFetch(data)
        }
    case let ._didFetch(data):
        // Do something with `data`.
        return .empty
    }
}

let actomaton = Actomaton<Action, State>(
    state: State(),
    reducer: reducer,
        environment: Environment(fetch: { /* ... */ })
)

await actomaton.send(.fetch(id: "item1"))
await actomaton.send(.fetch(id: "item2")) // min delay of 0.1
await actomaton.send(.fetch(id: "item3")) // min delay of 0.1 (after item2 actually starts)
await actomaton.send(.fetch(id: "item4")) // starts when item1 or 2 or 3 finishes

Above code uses a custom DelayedEffectQueue that conforms to EffectQueueProtocol with suspendable EffectQueuePolicy and delays between each effect by EffectQueueDelay.

See EffectQueuePolicy for how each policy takes different queueing strategy for effects.

/// `EffectQueueProtocol`'s buffering policy.
public enum EffectQueuePolicy: Hashable, Sendable
{
    /// Runs `maxCount` newest effects, cancelling old running effects.
    case runNewest(maxCount: Int)

    /// Runs `maxCount` old effects with either suspending or discarding new effects.
    case runOldest(maxCount: Int, OverflowPolicy)

    public enum OverflowPolicy: Sendable
    {
        /// Suspends new effects when `.runOldest` `maxCount` of old effects is reached until one of them is completed.
        case suspendNew

        /// Discards new effects when `.runOldest` `maxCount` of old effects is reached until one of them is completed.
        case discardNew
    }
}

For convenient EffectQueueProtocol protocol conformance, there are built-in sub-protocols:

/// A helper protocol where `effectQueuePolicy` is set to `.runNewest(maxCount: 1)`.
public protocol Newest1EffectQueueProtocol: EffectQueueProtocol {}

/// A helper protocol where `effectQueuePolicy` is set to `.runOldest(maxCount: 1, .discardNew)`.
public protocol Oldest1DiscardNewEffectQueueProtocol: EffectQueueProtocol {}

/// A helper protocol where `effectQueuePolicy` is set to `.runOldest(maxCount: 1, .suspendNew)`.
public protocol Oldest1SuspendNewEffectQueueProtocol: EffectQueueProtocol {}

so that we can write in one-liner: struct MyEffectQueue: Newest1EffectQueueProtocol {}

Example 1-5. Reducer composition

Actomaton-Gallery provides a good example of how Reducers can be combined together into one big Reducer using Reducer.combine.

In this example, swift-case-paths is used as a counterpart of WritableKeyPath, so if we use both, we can easily construct Mega-Reducer without a hassle.

(NOTE: CasePath is useful when dealing with enums, e.g. enum Action and enum Current in this example)

enum Root {} // just a namespace

extension Root {
    enum Action: Sendable {
        case changeCurrent(State.Current?)

        case counter(Counter.Action)
        case stopwatch(Stopwatch.Action)
        case stateDiagram(StateDiagram.Action)
        case todo(Todo.Action)
        case github(GitHub.Action)
    }

    struct State: Equatable, Sendable {
        var current: Current?

        // Current screen (NOTE: enum, so only 1 screen will appear)
        enum Current: Equatable {
            case counter(Counter.State)
            case stopwatch(Stopwatch.State)
            case stateDiagram(StateDiagram.State)
            case todo(Todo.State)
            case github(GitHub.State)
        }
    }

    // NOTE: `contramap` is also called `pullback` in swift-composable-architecture.
    static var reducer: Reducer<Action, State, Environment> {
        Reducer.combine(
            Counter.reducer
                .contramap(action: /Action.counter)
                .contramap(state: /State.Current.counter)
                .contramap(state: \State.current)
                .contramap(environment: { _ in () }),

            Todo.reducer
                .contramap(action: /Action.todo)
                .contramap(state: /State.Current.todo)
                .contramap(state: \State.current)
                .contramap(environment: { _ in () }),

            StateDiagram.reducer
                .contramap(action: /Action.stateDiagram)
                .contramap(state: /State.Current.stateDiagram)
                .contramap(state: \State.current)
                .contramap(environment: { _ in () }),

            Stopwatch.reducer
                .contramap(action: /Action.stopwatch)
                .contramap(state: /State.Current.stopwatch)
                .contramap(state: \State.current)
                .contramap(environment: { $0.stopwatch }),

            GitHub.reducer
                .contramap(action: /Action.github)
                .contramap(state: /State.Current.github)
                .contramap(state: \State.current)
                .contramap(environment: { $0.github })
        )
    }
}

To learn more about CasePath, visit the official site and tutorials:

2. ActomatonUI (SwiftUI & UIKit)

Store (from ActomatonUI.framework) provides a thin wrapper of Actomaton to work seamlessly in SwiftUI and UIKit world.

To find out more, check the following resources:

References

License

MIT

More Repositories

1

SwiftRewriter

📝 Swift code formatter using SwiftSyntax.
Swift
825
star
2

RxAutomaton

🤖 RxSwift + State Machine, inspired by Redux and Elm.
Swift
715
star
3

Cassowary

An incremental linear constraint-solving algorithm (Auto Layout) in Swift.
Swift
501
star
4

Harvest

🌾 Harvest: Apple's Combine.framework + State Machine, inspired by Elm.
Swift
385
star
5

YIPopupTextView

facebook's post-like input text view for iOS (Beerware license)
Objective-C
245
star
6

YIFullScreenScroll

Pinterest-like scroll-to-fullscreen UI for iOS5+.
Objective-C
214
star
7

ReactiveAutomaton

🤖 ReactiveCocoa + State Machine, inspired by Redux and Elm.
Swift
207
star
8

Harvest-SwiftUI-Gallery

🖼 Gallery App for Harvest (Elm Architecture + Optics) + SwiftUI + Combine.
Swift
161
star
9

YIInnerShadowView

Inner-shadow UIView/CALayer for iOS.
Objective-C
155
star
10

YISplashScreen

Easy splash screen + animation maker for iOS5+.
Objective-C
141
star
11

SherlockForms

🕵️‍♂️ An elegant SwiftUI Form builder to create a searchable Settings and DebugMenu screens for iOS.
Swift
124
star
12

SwiftElm

Reactive + Automaton + VTree in Swift, inspired by Elm.
Swift
103
star
13

VTree

VirtualDOM for Swift (iOS, macOS)
Swift
91
star
14

RxProperty

A get-only `BehaviorRelay ` that is (almost) equivalent to ReactiveSwift's `Property`
Swift
85
star
15

FunRouter

Functional & type-safe URL routing example for http://2016.funswiftconf.com
Swift
82
star
16

Swizzle

Method-Swizzling for Swift.
Swift
81
star
17

Flexbox

Swift wrapper of facebook/yoga (CSS Flexbox layout engine).
Swift
76
star
18

Zelkova

Elm/React.js-like architecture in Swift, powered by ReactiveSwift and LayoutKit.
Swift
69
star
19

ReactiveCocoaCatalog

UI Catalog for ReactiveCocoa.
Swift
60
star
20

YISwipeShiftCaret

Swipe-to-shift text input caret for iOS (no private APIs)
Objective-C
47
star
21

DebugLog

DebugLog macro alternative for Swift.
Swift
44
star
22

HigherKindSwift

An experimental Higher Kinded Types in Swift.
Swift
44
star
23

Await

Swift port of C# Await using Cocoa's Run Loop mechanism.
Swift
43
star
24

MultibyteDescription

A better way to NSLog multibyte string for OSX/iOS. (see also: http://qiita.com/items/85437eba2623f6ffbdbd)
Objective-C
41
star
25

YIDragScrollBar

Attaches draggable scroll bar on top of original UIScrollView for iOS5+, works like a drug.
Objective-C
37
star
26

Actomaton-Gallery

🖼 Gallery App for Actomaton (async/await + Elm Architecture) + SwiftUI.
Swift
30
star
27

YIDetectWindow

A subclass of UIWindow for detecting shake, status-bar-tap, long-press, touchBegan/Moved/Ended/Cancelled, via NSNotification.
Objective-C
29
star
28

FunAsync

⏳ Collection of Swift 5.5 async/await utility functions.
Swift
25
star
29

Harvest-SwiftUI-GameOfLife

🧬 Conway's Game of Life written in SwiftUI + Harvest
Swift
24
star
30

YIEmoji

NSString addition for iOS Emoji.
Objective-C
23
star
31

Swift-Intersection

Extensible records / intersection type in Swift.
Swift
21
star
32

SwiftUI-PhotoPicker

iOS 14 PHPickerViewController wrapper for SwiftUI with data loader support.
Swift
20
star
33

YIStrictEdgePanGesture

Never get angry with UINavigationController's interactivePopGestureRecognizer.
Objective-C
18
star
34

ImagePlaceholder

Yet another UIImage / NSImage placeholder written in Swift.
Swift
18
star
35

SwiftAndLogic

Sample code for iOSDC Japan 2019 and NSSpain 2019
Swift
16
star
36

OrientationKit

iOS device/interface/image/video orientation translation & detection using CoreMotion + SwiftUI + Combine.
Swift
14
star
37

ShapeLayerView

CAShapeLayer-backed UIView subclass that synchronizes with UIKit-internal animations, e.g. orientation change.
Swift
12
star
38

AsyncHotStream

♨️ A missing hot stream in Swift Concurrency.
Swift
12
star
39

YIEdgePanGestureRecognizer

A subclass of UIPanGestureRecognizer which only activates at the edge of the view.
Objective-C
12
star
40

Swift-Union

Poor man's untagged union type in Swift.
Swift
11
star
41

YIHideableTabBar

UITabBarController category to show/hide UITabBar for iOS.
Objective-C
11
star
42

Harvest-SwiftUI-VideoDetector

📹 Video image/text recognizers written in SwiftUI + Harvest + iOS Vision + SwiftyTesseract
Swift
11
star
43

YIVariableViewSize

Layout subviews first, then its container. Not using AutoLayout, works on iOS5+.
Objective-C
10
star
44

Swift-Lens-Example

Swift Lens example
Swift
9
star
45

FunOptics

🔍Simple functional Optics in Swift
Swift
9
star
46

AVFoundation-Combine

AVFoundation + Combine extensions
Swift
8
star
47

DDFileReader

Swift port of DDFileReader by Dave DeLong (http://stackoverflow.com/a/3711079/666371)
Swift
7
star
48

YILogHook

NSLog-Hook using _NSSetLogCStringFunction (private API)
Objective-C
7
star
49

AnyScheduler

iOS 13 Combine's type-erased AnyScheduler.
Swift
6
star
50

YITimeTracker

A simple time-tracking tool which can easily integrate with other libraries e.g. SVProgressHUD, MTStatusBarOverlay.
Objective-C
5
star
51

iOS6-ForwardAutorotate

UIKit-additions to forward iOS6 rotation methods.
Objective-C
5
star
52

MNIST-iOS-Demo

MNIST-iOS demo with PyTorch -> ONNX -> CoreML conversion
Swift
3
star
53

appstore-node-coffee

AppStore review scraper using node+CoffeeScirpt
CoffeeScript
3
star
54

YIRightTouchableToolbar

Bug fix for right UIBarButtonItem not responding at bottom toolbar in iOS 7.0.3.
Objective-C
3
star
55

iOS15-SwiftUI-Navigation-Bug

Demonstrates SwiftUI Navigation behavior change from iOS 14 to iOS 15 which disallows single-source-of-truth state management.
3
star
56

YICustomModal

Custom modal, mainly for iOS5 youtube-fullscreen-dismiss bug (see also: https://github.com/inamiy/ModalYoutubeIOS5Bug)
Objective-C
2
star
57

YIHorizontalTableView

Transformed-UITableView to achieve horizontal scrolling for iOS.
Objective-C
1
star
58

inamiy

Welcome to a special repository!
1
star
59

YIPickerActionSheet

UIActionSheet+UIPickerView for iOS
Objective-C
1
star
60

inamiy.github.com

1
star
61

ToAnyObject

Cocoa-friendly AnyObject (and JSON) minimal encoder using Mirror API.
Swift
1
star
62

YINilHandling

NSArray/NSDictionary categories to nullify/ignore nil value for iOS.
Objective-C
1
star
63

Log-YIHelper

log macros for iOS
C++
1
star
64

ModalYoutubeIOS5Bug

Modal+WebView+Youtube Bug, found in iOS5.1 (It's now OK in iOS6 beta 2)
Objective-C
1
star
65

iOS7-ToolbarTouchBug

Demo for right UIBarButtonItem not responding at bottom toolbar in iOS 7.0.3.
Objective-C
1
star
66

Test

Test
1
star
67

github-experiment

Shell
1
star