Last active
February 17, 2022 01:55
-
-
Save gordonbrander/b75cdc0f86cc19a3ee33ecc0a9a3909a to your computer and use it in GitHub Desktop.
Store.swift - a simple Elm-like ObservableObject store for SwiftUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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