Skip to content

Commit

Permalink
Implemented ComposedState and ComposedAction
Browse files Browse the repository at this point in the history
  • Loading branch information
Obbut committed Jul 3, 2023
1 parent f0b9f84 commit dc401d3
Show file tree
Hide file tree
Showing 12 changed files with 365 additions and 21 deletions.
Binary file added Docs/Header.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
95 changes: 95 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c",
"version" : "0.10.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984",
"version" : "0.14.1"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d",
"version" : "0.3.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
"version" : "1.0.4"
}
},
{
"identity" : "swift-composable-architecture",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-composable-architecture.git",
"state" : {
"revision" : "89f80fe2400d21a853abc9556a060a2fa50eb2cb",
"version" : "0.55.0"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "3a35f7892e7cf6ba28a78cd46a703c0be4e0c6dc",
"version" : "0.11.0"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "de1a984a71e51f6e488e98ce3652035563eb8acb",
"version" : "0.5.1"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29",
"version" : "0.8.0"
}
},
{
"identity" : "swiftui-navigation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swiftui-navigation",
"state" : {
"revision" : "2aa885e719087ee19df251c08a5980ad3e787f12",
"version" : "0.8.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "4af50b38daf0037cfbab15514a241224c3f62f98",
"version" : "0.8.5"
}
}
],
"version" : 2
}
12 changes: 10 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import PackageDescription

let package = Package(
name: "ComposableComposition",
platforms: [
.iOS(.v13),
.macOS(.v10_15),
.tvOS(.v13),
.watchOS(.v6)
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
Expand All @@ -13,14 +19,16 @@ let package = Package(
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "0.54.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "ComposableComposition",
dependencies: []),
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
]),
.testTarget(
name: "ComposableCompositionTests",
dependencies: ["ComposableComposition"]),
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# ComposableComposition
![ComposableComposition Package Header](Docs/Header.png)

A description of this package.
ComposableComposition is a Swift package 📦 that extends the fantastic [Swift Composable Architecture (TCA)](https://github.com/pointfreeco/swift-composable-architecture) to provide additional capabilities for composing and handling state and actions. It introduces the concepts of child and parent states and actions, allowing for more granular control over state mutations and action handling, without having to duplicate (and sync) parent state to child features.
24 changes: 24 additions & 0 deletions Sources/ComposableComposition/Action/ComposedAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import ComposableArchitecture

/// An enum representing an action that is either local to a specific reducer or is passed to a parent reducer for handling.
public enum ComposedAction<Local, Parent> {
/// An action for the local reducer.
case local(Local)

/// An action for the parent reducer.
case parent(Parent)
}

extension ComposedAction: Equatable where Local: Equatable, Parent: Equatable {}
extension ComposedAction: Hashable where Local: Hashable, Parent: Hashable {}
extension ComposedAction: CaseIterable where Local: CaseIterable, Parent: CaseIterable {
public static var allCases: [Self] {
Local.allCases.map(Self.local) + Parent.allCases.map(Self.parent)
}
}
extension ComposedAction: BindableAction where Local: BindableAction {
public typealias State = Local.State
public static func binding(_ action: BindingAction<State>) -> ComposedAction<Local, Parent> {
.local(.binding(action))
}
}
40 changes: 40 additions & 0 deletions Sources/ComposableComposition/Action/ComposedActionReducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import ComposableArchitecture

/// A protocol for a reducer that can handle actions that are local to a specific component or pass others to a parent component for handling.
/// This protocol is useful in conjunction with `ComposedState`.
public protocol ComposedActionReducer: ReducerProtocol where Action == ComposedAction<LocalAction, ParentAction> {
associatedtype LocalAction
associatedtype ParentAction

/// A method to handle actions and produce an effect task.
///
/// - Parameters:
/// - state: A reference to the current state that can be modified.
/// - action: The action to handle.
/// - Returns: An effect task that encapsulates work to be done in response to the action.
func reduce(into state: inout State, action: LocalAction) -> EffectTask<Action>
}

public extension ComposedActionReducer {
/// A function to compose multiple reducers together. Used in the body of a reducer, for example, `Reducer(core)`.
///
/// - Parameters:
/// - state: A reference to the current state that can be modified.
/// - action: The action to handle.
/// - Returns: An effect task that encapsulates work to be done in response to the action.
func core(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .local(let local):
return self.reduce(into: &state, action: local)
case .parent:
return .none
}
}
}

/// A default implementation of `reduce(into:action:)` for reducers that don't have a `body`.
public extension ComposedActionReducer where Body == Never {
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
core(into: &state, action: action)
}
}
25 changes: 25 additions & 0 deletions Sources/ComposableComposition/Action/EffectTask+LocalAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import ComposableArchitecture

// This is really an extension to EffectTask, which is a typealias to EffectPublisher
public extension EffectPublisher where Failure == Never {
/// Transforms the actions of the task using the provided transform function.
/// The transformed actions are wrapped in a `ComposedAction` as local actions.
///
/// - Parameter transform: A function that transforms the actions of the publisher into local actions.
/// - Returns: An `EffectPublisher` that publishes composed actions with the transformed local actions.
func map<LocalAction, ParentAction>(_ transform: @escaping (Action) -> LocalAction) -> EffectPublisher<ComposedAction<LocalAction, ParentAction>, Failure> {
return self.map { (action: Action) -> ComposedAction<LocalAction, ParentAction> in
let local = transform(action)
return ComposedAction<LocalAction, ParentAction>.local(local)
}
}

/// Creates an `EffectPublisher` that sends a local action wrapped in a `ComposedAction`.
///
/// - Parameter localAction: The local action to send.
/// - Returns: An `EffectPublisher` that sends the composed action.
static func send<Local, Parent>(_ localAction: Local) -> Self where Action == ComposedAction<Local, Parent> {
Self.send(ComposedAction.local(localAction))
}
}

70 changes: 70 additions & 0 deletions Sources/ComposableComposition/Action/ViewStore+LocalAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import ComposableArchitecture
import SwiftUI

/// Helpers to send LocalAction
public extension ViewStore {
/// Sends a local action wrapped in a `ComposedAction`.
///
/// - Parameter localAction: The local action to send.
/// - Returns: The task of the view store.
@discardableResult
func send<LocalAction, ParentAction>(
_ localAction: LocalAction
) -> ViewStoreTask where ViewAction == ComposedAction<LocalAction, ParentAction> {
send(.local(localAction))
}

/// Sends a local action wrapped in a `ComposedAction` with an animation.
///
/// - Parameters:
/// - localAction: The local action to send.
/// - animation: The animation to apply.
/// - Returns: The task of the view store.
@discardableResult
func send<LocalAction, ParentAction>(
_ localAction: LocalAction,
animation: Animation?
) -> ViewStoreTask where ViewAction == ComposedAction<LocalAction, ParentAction> {
send(.local(localAction), animation: animation)
}

/// Sends a local action wrapped in a `ComposedAction` with a transaction.
///
/// - Parameters:
/// - localAction: The local action to send.
/// - transaction: The transaction to apply.
/// - Returns: The task of the view store.
@discardableResult
func send<LocalAction, ParentAction>(
_ localAction: LocalAction,
transaction: Transaction
) -> ViewStoreTask where ViewAction == ComposedAction<LocalAction, ParentAction> {
send(.local(localAction), transaction: transaction)
}

/// Creates a binding from a state value to a local action.
///
/// - Parameters:
/// - get: A closure that gets a value from the state.
/// - valueToLocalAction: A closure that transforms the value to a local action.
/// - Returns: A binding from the state value to the local action.
func binding<Value, LocalAction, ParentAction>(
get: @escaping (ViewState) -> Value,
send valueToLocalAction: @escaping (Value) -> LocalAction
) -> Binding<Value> where ViewAction == ComposedAction<LocalAction, ParentAction> {
self.binding(get: get, send: { .local(valueToLocalAction($0)) })
}

/// Creates a binding from a state value to a local action.
///
/// - Parameters:
/// - get: A closure that gets a value from the state.
/// - localAction: The local action to bind to the state value.
/// - Returns: A binding from the state value to the local action.
func binding<Value, LocalAction, ParentAction>(
get: @escaping (ViewState) -> Value,
send localAction: LocalAction
) -> Binding<Value> where ViewAction == ComposedAction<LocalAction, ParentAction> {
self.binding(get: get, send: .local(localAction))
}
}
6 changes: 0 additions & 6 deletions Sources/ComposableComposition/ComposableComposition.swift

This file was deleted.

39 changes: 39 additions & 0 deletions Sources/ComposableComposition/State/ComposedState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/// A `ComposedState` is a type that composes the state of two other types, allowing access to the properties of both.
/// It allows read-only access to the parent state and read-write access to the child state.
@dynamicMemberLookup
public struct ComposedState<Child, Parent> {
/// The state that can be read from and written to.
public var child: Child

/// The state that can only be read from.
public let parent: Parent

/// Accesses the value of the parent state using the given key path.
public subscript<T>(dynamicMember keyPath: KeyPath<Parent, T>) -> T {
parent[keyPath: keyPath]
}

/// Accesses the value of the child state using the given key path.
public subscript<T>(dynamicMember keyPath: KeyPath<Child, T>) -> T {
child[keyPath: keyPath]
}

/// Accesses and modifies the value of the child state using the given key path.
public subscript<T>(dynamicMember keyPath: WritableKeyPath<Child, T>) -> T {
get { child[keyPath: keyPath] }
set { child[keyPath: keyPath] = newValue }
}

/// Initializes a new instance of `ComposedState` with the given child and parent states.
///
/// - Parameters:
/// - child: The child state that can be read from and written to.
/// - parent: The parent state that can only be read from.
public init(child: Child, parent: Parent) {
self.child = child
self.parent = parent
}
}

extension ComposedState: Equatable where Child: Equatable, Parent: Equatable {}
extension ComposedState: Hashable where Child: Hashable, Parent: Hashable {}
11 changes: 0 additions & 11 deletions Tests/ComposableCompositionTests/ComposableCompositionTests.swift

This file was deleted.

Loading

0 comments on commit dc401d3

Please sign in to comment.