Swiftpack.co - Package - StevenLambion/SwiftDux

SwiftDux

Predictable state management for SwiftUI applications.

Swift Version Platform Versions Github workflow codecov

SwiftDux is a redux inspired state management solution built on top of Combine and SwiftUI. It presents a way to implement an elm or flux like architecture as an alternative to the MVVM-like patterns Apple has presented. It allows you to build an application around predictable state using reactive, unidirectional data flows.

Should I use this?

SwiftDux relies on advance functionality of SwiftUI that may break or change between Xcode updates. Because of this, it is in constant development and should be considered a beta until SwiftUI has a more stable release. If you're starting a new application using SwiftUI that will be released with or after the next major OS, then it should be OK to use this library. If you need to target an older operating system or require a more established library, check out ReSwift instead.

Features

  • Familiar API to Redux.
  • Built for SwiftUI.
  • Middleware support
  • Combine powered Action Plans to perform asynchronous workflows.
  • OrderedState<_> to display sorted entities in List views.

Built-in Middleware

  • PersistStateMiddleware automatically persists and restores the application state.
  • PrintActionMiddleware prints out each dispatched action for debugging purposes.

Libraries

Installation

Prerequisites

  • Xcode 11+
  • Swift 5.1+
  • iOS 13+, macOS 10.15+, tvOS 13+, or watchOS 6+

Install via Xcode 11:

Search for SwiftDux in Xcode's Swift Package Manager integration.

Install via the Swift Package Manager:

import PackageDescription

let package = Package(
  dependencies: [
    .Package(url: "https://github.com/StevenLambion/SwiftDux.git", majorVersion: 1, minor: 2)
  ]
)

Demo Application

Take a look at the Todo Example App to see how SwiftDux works.

Getting Started

SwiftDux helps build SwiftUI-based applications around an elm-like architecture using a single, centralized state container. It has 4 basic principles:

  • State - An immutable, single source of truth within the application.
  • Action - Describes a single change of the state.
  • Reducer - Returns a new state by consuming the previous one with an action.
  • View - The visual representation of the current state.

State

The state is a single, immutable structure acting as the single source of truth within the application.

Below is an example of a todo app's state. It has a root AppState as well as an ordered list of TodoState objects.

import SwiftDux

struct AppState: StateTyoe {
  todos: OrderedState<TodoItem>
}

struct TodoItem: IdentifiableState {
  var id: String,
  var text: String
}

Actions

An action is a description of how the state will change. They're typically dispatched from events in the application. This could be a user interacting with the application or a service API receiving updates. Swift's enum type is ideal for actions, but structs and classes could be used as well.

import SwiftDux

enum TodoAction: Action {
  case addTodo(text: String)
  case removeTodos(at: IndexSet)
  case moveTodos(from: IndexSet, to: Int)
}

Reducers

A reducer consumes an action to produce a new state. The Reducer protocol has two primary methods to override:

  • reduce(state:action:) - For actions supported by the reducer.
  • reduceNext(state:action:) - Dispatches an action to any sub-reducers. This method is optional.
final class TodosReducer: Reducer {

  func reduce(state: OrderedState<TodoItem>, action: TodoAction) -> OrderedState<TodoItem> {
    var state = state
    switch action {
    case .addTodo(let text):
      let id = UUID().uuidString
      state.append(TodoItemState(id: id, text: text))
    case .removeTodos(let indexSet):
      state.remove(at: indexSet)
    case .moveTodos(let indexSet, let index):
      state.move(from: indexSet, to: index)
    }
    return state
  }

}

Here's an example of a root reducer dispatching to a subreducer.

final class AppReducer: Reducer {
  let todosReducer = TodosReducer()

  func reduceNext(state: AppState, action: TodoAction) -> AppState {
    State(
      todos: todosReducer.reduceAny(state.todos, action)
    )
  }

}

Reducers can also be combined together. This is useful when multiple root reducers are needed, such as two reducers from separate modules.

let combinedReducer = AppReducer + NavigationReducer

Store

The store acts as the container of the state. It needs to be initialized with a default state and a root reducer. Then inject it into the application using the provideStore(_:) view modifier.

import SwiftDux

let store = Store(AppState(todos: OrderedState()), AppReducer())

window.rootViewController = UIHostingController(
  rootView: RootView().provideStore(store)
)

Connectable View

The ConnectableView protocol provides a slice of the application state to your views using the functions map(state:) and body(props:). The @MappedDispatch property wrapper injects an ActionDispatcher to send actions to the store.

struct TodosView: ConnectableView {
  @MappedDispatch() private var dispatch

  func map(state: AppState) -> OrderedState<Todo>? {
    state.todos
  }

  func body(props: OrderedState<Todo>): some View {
    List {
      ForEach(todos) { todo in
        TodoItemRow(item: todo)
      }
      .onDelete { self.dispatch(TodoAction.removeTodos(at: $0)) }
      .onMove { self.dispatch(TodoAction.moveTodos(from: $0, to: $1)) }
    }
  }
}

The view can later be placed like any other.

struct RootView: View {

  var body: some View {
    TodosView()
  }
}

Passing Data to a Connectable View

In some cases, a connected view needs external information to map the state to its props, such as an identifier. Simply add any needed variables to your view, and access them in the mapping function.

struct TodoDetailsView: ConnectableView {
  var id: String

  func map(state: TodoList) -> Todo? {
    state[id]
  }
}

// Somewhere else in the view hierarchy:

TodoDetailsView(id: "123")

ActionBinding<_>

SwiftUI has a focus on two-way bindings that connect to a single value source. To support updates through actions, SwiftDux provides a convenient API in the ConnectableView protocol using an ActionBinder object. Use the map(state:binder:) method on the protocol as shown below. It provides a value to the text field, and dispatches an action when the text value changes. It also binds a function to a dispatchable action.

struct LoginForm: View {

  struct Props: Equatable {
    @ActionBinding var email: String
    @ActionBinding var onSubmit: ()->()
  }

  func map(state: AppState, binder: ActionBinder) -> Props? {
    Props(
      email: binder.bind(state.loginForm.email) { 
        LoginFormAction.setEmail($0)
      },
      onSubmit: binder.bind(LoginFormAction.submit)
    )
  }

  func body(props: Props) -> some View {
    VStack {
      TextField("Email", text: $props.email)
      /* ... */
      Button(action: props.onSubmit) {
        Text("Submit")
      }
    }
  }
}

Previewing Connected Views

To preview a connected view by itself, you can provide a store that contains the parent state and reducer it maps from. This preview is based on a view in the Todo List Example project. Make sure to add provideStore(_:) after the connect method.

#if DEBUG
public enum TodoRowContainer_Previews: PreviewProvider {
  static var store: Store<TodoList> {
    Store(
      state: TodoList(
        id: "1",
        name: "TodoList",
        todos: .init([
          Todo(id: "1", text: "Get milk")
        ])
      ),
      reducer: TodosReducer()
    )
  }
  
  public static var previews: some View {
    TodoRowContainer(id: "1")
      .provideStore(store)
  }
  
}
#endif

Action Plans

An ActionPlan is a special kind of action that can be used to group other actions together or perform any kind of async logic.

/// Dispatch multiple actions together synchronously:

let plan = ActionPlan<AppState> { store in
  store.send(actionA)
  store.send(actionB)
  store.send(actionB)
}

/// Perform async operations:

let plan = ActionPlan<AppState> { store in
  userLocationService.getLocation { location
    store.send(LocationAction.updateLocation(location))
  }
}

/// Subscribe to services and publish new actions to the store.

let plan = ActionPlan<AppState> { store, completed in
  userLocationService
    .subscribeToUpdates()
    .map { LocationAction.updateLocation($0) }
    .send(to: store, receivedCompletion: completed)
}

/// In a View, dispatch the plan like any other action:

dispatch(plan)

Query External Services

Action plans can be used in conjunction with the onAppear(dispatch:) view modifier to connect to external data sources when a view appears. If the action plan returns a publisher, it will automatically cancel when the view disappears. Optionally, use onAppear(dispatch:cancelOnDisappear:) if the publisher should continue.

Action plans can also subscribe to the store. This is useful when the query needs to be refreshed if the application state changes. Rather than imperatively handling this by re-sending the action plan, it can be done more declaratively within it.

Here's an example of an action plan that queries for todos. It updates whenever the filter changes. It also debounces to reduce the amount of queries sent to the external services.

enum TodoListAction {
  ...
}

extension TodoListAction {

  static func getState(from store: Store<AppState>) -> some Publisher {
    Just(store.state).merge(with: store.didChange.map { _ in store.state })
  }

  static func queryTodos() -> ActionPlan<AppState> {
    ActionPlan<AppState> { store, completed in
      getState(from: store)
        .map { $0.filterBy }
        .removeDuplicates()
        .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
        .flatMap { filter in self.services.queryTodos(filter: filter) }
        .catch { _ in Just<[TodoItem]>([]) }
        .map { todos -> Action in TodoListAction.setTodos(todos) }
        .send(to: store, receivedCompletion: completed)
    }
  }
}

struct TodoListView: ConnectableView {

  func map(state: AppState) -> [TodoItem]? {
    state.todoList.items
  }

  func body(props: [TodoItem]) -> some View {
    renderTodos(todos: props)
      .onAppear(dispatch: TodoListAction.queryTodos())
  }

  // ...
}

Github

link
Stars: 85

Dependencies

Used By

Total: 0

Releases

v1.2.1 - 2020-05-13 01:06:23

Added

  • View.provideStore(_:as:) To inject a store with a specific type of state. #42

v1.2.0 - 2020-05-05 00:27:18

Changed

  • ActionBinder can now bind actions to callable functions. #40, #38
  • Replaced the old internal state connection API with a faster and simpler implementation. #39

Removed

  • Removed Connectable API in favor of ConnectableView due to incompatibilities with the new internal, reference-free implementation. #39

v1.1.0 - 2020-04-11 20:04:39

Changed

  • Loosened the adherence to StateType at the Store level. #36
  • Simplified ActionDispatcher API by moving the proxy functionality to OnActionViewModifier. #32
  • Provided a more consistent Store API between reducers and middleware. #33
  • ActionDispatcher can now be called as a function. #32
  • MappedDispatch injects an ActionDispatcher instead of a function. #32
  • Modified the implementation of action plans to make it easier to include 3rd party ones. #36

Added

  • Added ActionBinding<> to replace the Equatable extension on Binding<>. #30

Removed

  • onAppearAsync and onDisappearAsync. #36

v1.0.1 - 2020-01-23 15:21:29

Added

  • ConnectableView as a convenient wrapper over Connectable #27

v1.0.0 - 2020-01-22 13:29:53

This marks the 1.0.0 release of the library. The API will be stabilized moving forward.

Fixed

  • PersistStateMiddleware would save the state when the StoreAction<_>.reset was dispatched. #24
    • It no longer saves the state when the store is reset.
  • Connectable.updateWhen would stop a view from updating from its own dispatched actions. #24
    • If the method is implemented, both it and any dispatched actions will cause an update.
  • Connectable.updateWhen would fail to work in some cases when the first call returned true. #24
    • Returning true using any possible implementation will now work.

Changed

  • State is now required to be equatable. #26
  • Middleware is now a protocol type. #25
  • StoreProxy.state is now a non-optional type. #24
  • Store, ActionDispatcher, and StatePersistor no longer adhere to Subscriber. #24
  • Store.didChange is now an AnyPublisher<Action, Never>. #24
  • PersistStateMiddleware now debounces for 1 second by default. #24
  • PrintActionMiddleware now accepts a new filter block. #24
  • onAppearAsync and onDisappearAsync are deprecated. #24
    • These were need to workaround a bug in a pervious beta of iOS 13. #24

Added

  • ActionPlan has a new init block that accepts an AnyCancellable to support ActionSubsriber, #24
  • ActionSubscriber to dispatch actions from a publisher. #24
  • PersistSubscriber to persist state from a publisher. #24
  • StoreProxy.done for use in action plans to tell the proxy that it has completed. #24
  • TypedMiddleware and HandleActionMiddleware. #25

v0.12.1 - 2020-01-11 19:40:16

Fixed

  • Inconsistency with onMove(_:_:) method of OrderedState<_> #21

Added

  • StateBinder API to create 2-way bindings between a state value and an action. #22

v0.12.0 - 2020-01-11 00:43:50

Changes

  • Action plans themselves now trigger the change event from the store. #19

Added

  • API to chain action plans together. #20
  • API to call code blocks when an action plan completes. #20
  • onAppear(dispatch:cancelOnDisappear:) method on View #19

v0.11.3 - 2019-11-27 04:07:36

Fixed

  • Manually implemented coding protocol due to bugs with the synthesized version. #15
  • The SwiftExtra module now works on macOS without requiring Catalyst. #16

Technical

  • Added macOS as a testing target. #16

v0.11.2 - 2019-10-03 20:46:55

Changes

  • Cleanup to documentation. #13

Added

  • API to cancel publisher based ActionPlans. #14

Technical

  • Code formatting via swift-format. #13
  • Moved documentation to gh-pages branch. #13
  • Set up CI linting and testing via Github workflows. #13
  • Added code coverage reports via codecov. #13

v0.11.1 - 2019-09-19 15:45:52

Fixed

  • Fixed type erasing in StateConnectionViewGuard by returning the contents as an optional view rather than using AnyView.

v0.10.0 - 2019-08-30 15:57:01

Changes

  • PublishableActionPlan has been merged into ActionPlan.
  • All actions dispatched from a view will update that view. This includes actions dispatched inside an action plan.

Added

  • Middleware API
  • PrintActionMiddleware

v0.11.0 - 2019-08-30 15:51:41

Changes

  • Moved PrintActionMiddleware to SwiftDuxExtras
  • StoreProvider view modifier updates its view when a StoreAction<_> Is dispatched.

Added

  • SwiftDuxExtras module
  • Persistence API to SwiftDuxExtras
  • PersistStateMiddleware
  • StoreReducer for store specific actions.
  • "StoreAction<_>.prepare" action fired from the store at initialization.
  • "StoreAction<_>.reset(state:)" action to replace the current state of the store.

v0.9.0 - 2019-08-26 19:03:35

Changes

  • The view of a mapped state will wait to render until the state is exists (non-nil). This completes all the previous functionality of the Connector API.
  • The updateWhen argument of View.connect(updateWhen:mapState:) is now optional with a default value.

Fixed

  • Views continue to properly update when dispatching an acton after their parent view has re-rendered.

Removed

  • Connector API has been removed.

v0.8.0 - 2019-08-02 03:00:23

Changes

  • Reimplemented property wrapper API now that environment objects are working correctly.
  • Renamed the modifyAction(_:) method to onAction(perform:) to better match the SwiftUI API.
  • An ActionDispatcher no longer returns a publisher when sending an action to simply the API.

Added

  • Connectable and ParameterizedConnectable protocols to replace the Connector API.
  • @MappedState property wrapper to bind a mapped state to a view.
  • @MappedDispatch property wrapper to bind an action dispatcher to a view.

Deprecated

  • Connector API in favor of Connectable

v0.7.1 - 2019-07-20 17:33:19

Changes

  • Updated to beta 4 of SwiftUI and Combine.

v0.7.0 - 2019-07-06 19:06:02

This version reverts from using property wrappers back to a simplified API due to SwiftUI's inability to handle the creation of bindable objects. It appears that bindable objects of any kind have to be external and/or singleton in nature within SwiftUI. The new Connector API is built around this.

Changes

  • Updated to support beta 3.
  • Removed property wrappers with a new Connector API to externalize and reduce the creation of bindable objects within SwiftUI.
  • Removed withState view modifier in favor of Connector API.
  • Added a new Connector API to generate the mapping of state to a view outside of SwiftUI.
  • Removed modifyAction in favor of using DispatcherProxy directly, so that it can be created outside of SwiftUI.

Known issues

  • NavigationLink has a bug with environment objects. See the "Known Issues" section of the README.

v0.6.0 - 2019-06-22 02:48:57

Additions

  • Added onAppearAsync() and onDisappearAsync() to View to help trigger view updates when dispatching actions.

Changes

  • OrderedState<_> is now a RandomAccessCollection, allowing it to be used directly with SwiftUI's list elements.
  • OrderedState<_> now allows subscripting by index or id.

Fixes

  • The state mapping API now assumes SwiftUI Views may still exist shortly after the state itself was removed.

v0.5.0 - 2019-06-20 22:06:25

Changes

  • Made major refinements to the property wrapper API.
  • New state mapping API to map the application to a substate, views can focus on just their slice.
  • Store publishes changes on main thread.

v0.4.0 - 2019-06-20 04:41:11

Changes

  • Changed action plans from a closure type to structs.
  • ActionDispatcher is simplified to have one method that can received all types of actions (including action plans)
  • Converted API to use the new property wrapper API of Swift when mapping state to a view.

v0.3.0 - 2019-06-16 20:27:19

Changes

  • Added Store<>.connect(:, wrapper:) to update views using a mapping of the application's state as an alternative to updating off of an action type.
  • Hooked up dispatcher proxying for connect functions.

v0.2.0 - 2019-06-15 19:38:42

Changes

  • Added a new StoreActionDispatcher to separate the store from action dispatching. This allows the ability to proxy, modify, and monitor actions sent upstream.
  • Added View.provideStore(_:) modifier to inject an initial store and dispatcher object into the view environment.
  • Cleaned up and refined APIs
  • Added and cleaned up documentation
  • Reducer.reduceAny(state:action:) will always dispatch the action to Reducer.reduceNext(state:action:)