Skip to content

Instantly share code, notes, and snippets.

@gordonbrander
Last active February 17, 2022 01:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gordonbrander/b75cdc0f86cc19a3ee33ecc0a9a3909a to your computer and use it in GitHub Desktop.
Save gordonbrander/b75cdc0f86cc19a3ee33ecc0a9a3909a to your computer and use it in GitHub Desktop.
Store.swift - a simple Elm-like ObservableObject store for SwiftUI
//
// Store.swift
//
// Created by Gordon Brander on 9/15/21.
//
// MIT LICENSE
// Copyright 2021 Gordon Brander
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// allcopies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
import Foundation
import Combine
import SwiftUI
import os
/// Fx is a publisher that publishes actions and never fails.
public typealias Fx<Action> = AnyPublisher<Action, Never>
/// Update represents a `State` change, together with an `Fx` publisher.
public struct Update<State, Action>
where State: Equatable {
/// `State` for this update
var state: State
/// `Fx` for this update.
/// Default is an `Empty` publisher (no effects)
var fx: Fx<Action> = Empty(completeImmediately: true)
.eraseToAnyPublisher()
/// Pipe a state through another update function,
/// merging their `Fx`.
public func pipe(
_ through: (State) -> Update<State, Action>
) -> Update<State, Action> {
let up = through(self.state)
let fx = self.fx.merge(with: up.fx).eraseToAnyPublisher()
return Update(state: up.state, fx: fx)
}
}
/// Store is a source of truth for a state.
///
/// Store is an `ObservableObject`. You can use it in a view via
/// `@ObservedObject` or `@StateObject` to power view rendering.
///
/// Store has a `@Published` `state` (typically a struct).
/// All updates and effects to this state happen through actions
/// sent to `store.send`.
///
/// Store is meant to be used as part of a single app-wide, or
/// major-view-wide component. Store deliberately does not solve for nested
/// components or nested stores. Following Elm, deeply nested components
/// are avoided. Instead, an app should use a single store, or perhaps one
/// store per major view. Components should not have to communicate with
/// each other. If nested components do have to communicate, it is
/// probably a sign they should be the same component with a shared store.
///
/// Instead of decomposing an app into components, we decompose the app into
/// views that share the same store and actions. Sub-views should be either
/// stateless, consuming bare properties of `store.state`, or take bindings,
/// which can be created with `store.binding`.
///
/// See https://guide.elm-lang.org/architecture/
/// and https://guide.elm-lang.org/webapps/structure.html
/// for more about this approach.
public final class Store<State, Environment, Action>: ObservableObject
where State: Equatable {
/// Stores cancellables by ID
private var cancellables: [UUID: AnyCancellable] = [:]
/// Current state.
/// All writes to state happen through actions sent to `Store.send`.
@Published public private(set) var state: State
/// Update function for state
public var update: (
State,
Environment,
Action
) -> Update<State, Action>
/// Environment, which typically holds references to outside information,
/// such as API methods.
///
/// This is also a good place to put long-lived services, such as keyboard
/// listeners, since its lifetime will match the lifetime of the Store.
///
/// Tip: if you need to publish external events to the store, such as
/// keyboard events, consider publishing them via a Combine Publisher on
/// the environment. You can subscribe to the publisher in `update`, for
/// example, by firing an action `onAppear`, then mapping the environment
/// publisher to an `fx` and returning it as part of an `Update`.
/// Store will hold on to the resulting `fx` publisher until it completes,
/// which in the case of long-lived services, could be until the
/// app is stopped.
public var environment: Environment
/// Logger, used when in debug mode
public var logger: Logger
/// Toggle debug mode
public var debug: Bool
init(
update: @escaping (
State,
Environment,
Action
) -> Update<State, Action>,
state: State,
environment: Environment,
logger: Logger,
debug: Bool = false
) {
self.update = update
self.state = state
self.environment = environment
self.logger = logger
self.debug = debug
}
/// Create a binding that can update the store.
/// Sets send actions to the store, rather than setting values directly.
/// Optional `animation` parameter allows you to trigger an animation
/// for binding sets.
public func binding<Value>(
get: @escaping (State) -> Value,
tag: @escaping (Value) -> Action,
animation: Animation? = nil
) -> Binding<Value> {
Binding(
get: { get(self.state) },
set: { value in
withAnimation(animation) {
self.send(action: tag(value))
}
}
)
}
/// Subscribe to a publisher of actions, piping them through to
/// the store.
///
/// Holds on to the cancellable until publisher completes.
/// When publisher completes, removes cancellable.
public func subscribe(fx: Fx<Action>) {
// Create a UUID for the cancellable.
// Store cancellable in dictionary by UUID.
// Remove cancellable from dictionary upon effect completion.
// This retains the effect pipeline for as long as it takes to complete
// the effect, and then removes it, so we don't have a cancellables
// memory leak.
let id = UUID()
let cancellable = fx.sink(
receiveCompletion: { [weak self] _ in
self?.cancellables.removeValue(forKey: id)
},
receiveValue: self.send
)
self.cancellables[id] = cancellable
}
/// Send an action to the store to update state and generate effects.
/// Any effects generated are fed back into the store.
///
/// Note: SwiftUI requires that all UI changes happen on main thread.
/// We run effects as-given, without forcing them on to main thread.
/// This means that main-thread effects will be run immediately, enabling
/// you to drive things like withAnimation via actions.
/// However it also means that publishers which run off-main-thread MUST
/// make sure that they join the main thread (e.g. with
/// `.receive(on: DispatchQueue.main)`).
public func send(action: Action) {
if debug {
logger.debug("Action: \(String(reflecting: action))")
}
// Generate next state and effect
let change = update(self.state, self.environment, action)
if debug {
logger.debug("State: \(String(reflecting: change.state))")
}
// Set `state` if changed.
//
// Mutating state (a `@Published` property) will fire `objectWillChange`
// and cause any views that subscribe to store to re-evaluate
// their body property.
//
// If no change has occurred, we avoid setting the property
// so that body does not need to be reevaluated.
if self.state != change.state {
self.state = change.state
}
// Run effect
self.subscribe(fx: change.fx)
}
}
import SwiftUI
import os
import Combine
enum AppAction {
case increment
}
// Services like API methods go here
struct AppEnvironment {
}
struct AppModel {
var count = 0
static func update(
model: AppModel,
environment: AppEnvironment,
action: AppAction
) -> Update<AppModel, AppAction> {
switch action {
case .increment:
var model = self
model.count = model.count + 1
return Change(state: model)
}
}
}
struct AppView: View {
@ObservedObject var store: Store<AppModel, AppEnvironment, AppAction>
var body: some View {
VStack {
Text("The count is: \(store.state.count)")
Button(
action: {
store.send(action: .increment)
},
label: {
Text("Increment")
}
)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment