├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── Tests
└── TCABoundariesTests
│ └── TCABoundariesTests.swift
├── Sources
└── TCABoundaries
│ ├── BoundedReducers
│ ├── ComposedBoundedReducer.swift
│ └── BoundingReducer.swift
│ └── BoundedActions
│ ├── TCAFeatureAction.swift
│ └── Extensions
│ ├── TCAFeatureAction+Store.swift
│ ├── TCAFeatureAction+ForEachReducer.swift
│ ├── TCAFeatureAction+IfLetReducer.swift
│ └── TCAFeatureAction+Scope.swift
├── Package.swift
├── .gitignore
├── Package.resolved
└── README.md
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Tests/TCABoundariesTests/TCABoundariesTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import TCABoundaries
3 |
4 | final class TCABoundariesTests: XCTestCase {
5 | func testExample() throws {
6 | XCTAssertTrue(true)
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedReducers/ComposedBoundedReducer.swift:
--------------------------------------------------------------------------------
1 | import CasePaths
2 | import ComposableArchitecture
3 |
4 | public protocol ComposedBoundingReducer: BoundingReducer {
5 | @ReducerBuilder var body: Body { get }
6 | }
7 |
8 | public extension ComposedBoundingReducer {
9 | func reduceCore(into state: inout State, action: Action) -> Effect {
10 | if let action = action[case: \.view] {
11 | return reduce(into: &state, viewAction: action)
12 | }
13 | if let action = action[case: \._internal] {
14 | return reduce(into: &state, internalAction: action)
15 | }
16 | if let action = action[case: \.delegate] {
17 | return reduce(into: &state, delegateAction: action)
18 | }
19 | return .none
20 | }
21 |
22 | var coreReducer: Reduce {
23 | Reduce(reduceCore)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "tca-boundaries",
8 | platforms: [
9 | .iOS(.v13),
10 | .macOS(.v10_15),
11 | .tvOS(.v13),
12 | .watchOS(.v6),
13 | ],
14 | products: [
15 | .library(
16 | name: "TCABoundaries",
17 | targets: [
18 | "TCABoundaries"
19 | ]
20 | ),
21 | ],
22 | dependencies: [
23 | .package(
24 | url: "https://github.com/pointfreeco/swift-composable-architecture.git",
25 | from: "1.22.3"
26 | ),
27 | ],
28 | targets: [
29 | .target(
30 | name: "TCABoundaries",
31 | dependencies: [
32 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
33 | ]
34 | ),
35 | .testTarget(
36 | name: "TCABoundariesTests",
37 | dependencies: [
38 | "TCABoundaries"
39 | ]
40 | ),
41 | ]
42 | )
43 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedReducers/BoundingReducer.swift:
--------------------------------------------------------------------------------
1 | import CasePaths
2 | import ComposableArchitecture
3 |
4 | public protocol BoundingReducer: Reducer where Action: TCAFeatureAction {
5 | func reduce(
6 | into state: inout State,
7 | viewAction action: Action.ViewAction
8 | ) -> Effect
9 |
10 | func reduce(
11 | into state: inout State,
12 | internalAction action: Action.InternalAction
13 | ) -> Effect
14 |
15 | func reduce(
16 | into state: inout State,
17 | delegateAction action: Action.DelegateAction
18 | ) -> Effect
19 | }
20 |
21 | public extension BoundingReducer {
22 | func reduce(
23 | into state: inout State,
24 | internalAction action: Action.InternalAction
25 | ) -> ComposableArchitecture.Effect { .none }
26 |
27 | func reduce(
28 | into state: inout State,
29 | delegateAction action: Action.DelegateAction
30 | ) -> Effect { .none }
31 | }
32 |
33 | public extension BoundingReducer where Body == Never {
34 | func reduce(into state: inout State, action: Action) -> Effect {
35 | if let viewAction = action[case: \.view] {
36 | return reduce(into: &state, viewAction: viewAction)
37 | }
38 |
39 | if let internalAction = action[case: \._internal] {
40 | return reduce(into: &state, internalAction: internalAction)
41 | }
42 |
43 | if let delegateAction = action[case: \.delegate] {
44 | return reduce(into: &state, delegateAction: delegateAction)
45 | }
46 |
47 | return .none
48 | }
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedActions/TCAFeatureAction.swift:
--------------------------------------------------------------------------------
1 | import CasePaths
2 | import ComposableArchitecture
3 | import Foundation
4 |
5 | /// The `TCAFeatureAction` defines a pattern for actions on TCA based on https://www.merowing.info/boundries-in-tca/
6 | /// Its idea is to set a well defined specification for actions on TCA views, where ideally View and Internal actions should not go out of the view scope.
7 | ///
8 | /// Example:
9 | /// ```swift
10 | /// enum ExampleAction: TCAFeatureAction {
11 | /// enum ViewAction: Equatable {
12 | /// case onTapLoginButton
13 | /// }
14 | ///
15 | /// enum DelegateAction: Equatable {
16 | /// case notifyLoginSuccess
17 | /// }
18 | ///
19 | /// enum InternalAction: Equatable {
20 | /// case loginResult(Result)
21 | /// }
22 | ///
23 | /// case view(ViewAction)
24 | /// case delegate(DelegateAction)
25 | /// case _internal(InternalAction)
26 | /// }
27 | /// ```
28 | ///
29 | /// Conforming types automatically satisfy ``ComposableArchitecture/ViewAction`` so they can adopt
30 | /// conveniences like the ``ComposableArchitecture/ViewAction(for:)`` macro. You should still
31 | /// leverage ``CasePathable`` synthesis (for example by annotating the reducer with ``Reducer()`` or
32 | /// the action enum with ``CasePathable``) so that case key paths can be composed ergonomically when
33 | /// interacting with the latest versions of the Composable Architecture.
34 | public protocol TCAFeatureAction: CasePathable, Equatable, ComposableArchitecture.ViewAction {
35 | /// `DelegateAction` relates to actions that are delegate to parent components (like the well known Delegate pattern)
36 | associatedtype DelegateAction: Equatable
37 | /// `InternalAction` relates to actions that happen inside the Reducer's scope, like: handling results, internal/reused actions and such
38 | associatedtype InternalAction: Equatable
39 |
40 | static func delegate(_: DelegateAction) -> Self
41 | static func _internal(_: InternalAction) -> Self
42 | }
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Custom
6 | .DS_Store
7 |
8 | ## User settings
9 | xcuserdata/
10 |
11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
12 | *.xcscmblueprint
13 | *.xccheckout
14 |
15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
16 | build/
17 | DerivedData/
18 | *.moved-aside
19 | *.pbxuser
20 | !default.pbxuser
21 | *.mode1v3
22 | !default.mode1v3
23 | *.mode2v3
24 | !default.mode2v3
25 | *.perspectivev3
26 | !default.perspectivev3
27 |
28 | ## Obj-C/Swift specific
29 | *.hmap
30 |
31 | ## App packaging
32 | *.ipa
33 | *.dSYM.zip
34 | *.dSYM
35 |
36 | ## Playgrounds
37 | timeline.xctimeline
38 | playground.xcworkspace
39 |
40 | # Swift Package Manager
41 | #
42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
43 | # Packages/
44 | # Package.pins
45 | # Package.resolved
46 | # *.xcodeproj
47 | #
48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
49 | # hence it is not needed unless you have added a package configuration file to your project
50 | # .swiftpm
51 |
52 | .build/
53 |
54 | # CocoaPods
55 | #
56 | # We recommend against adding the Pods directory to your .gitignore. However
57 | # you should judge for yourself, the pros and cons are mentioned at:
58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
59 | #
60 | # Pods/
61 | #
62 | # Add this line if you want to avoid checking in source code from the Xcode workspace
63 | # *.xcworkspace
64 |
65 | # Carthage
66 | #
67 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
68 | # Carthage/Checkouts
69 |
70 | Carthage/Build/
71 |
72 | # Accio dependency management
73 | Dependencies/
74 | .accio/
75 |
76 | # fastlane
77 | #
78 | # It is recommended to not store the screenshots in the git repo.
79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
80 | # For more information about the recommended setup visit:
81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
82 |
83 | fastlane/report.xml
84 | fastlane/Preview.html
85 | fastlane/screenshots/**/*.png
86 | fastlane/test_output
87 |
88 | # Code Injection
89 | #
90 | # After new code Injection tools there's a generated folder /iOSInjectionProject
91 | # https://github.com/johnno1962/injectionforxcode
92 |
93 | iOSInjectionProject/
94 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+Store.swift:
--------------------------------------------------------------------------------
1 | import CasePaths
2 | import ComposableArchitecture
3 |
4 | /// `ViewOnlyStoreOf` For use when scoping down to a store of only `ViewAction`
5 | public typealias ViewOnlyStoreOf = Store
6 |
7 | /// `ViewOnlyViewStoreOf` For use when scoping down to a viewstore of only `ViewAction`
8 | public typealias RestrictedViewStore = ViewStore
9 |
10 | extension Store where Action: TCAFeatureAction {
11 | /// Convenience var to quickly scope a store to just it's `ViewAction`
12 | public var viewScope: Store {
13 | scope(state: \.self, action: \.view)
14 | }
15 | /// When you have a `Child` flow inside a `Parent` store and need to scope it is often expressed as an `InternalAction` of the `Parent` store.
16 | /// This can lead to a bit a an awkward API when trying to express that relation...
17 | /// ```swift
18 | /// enum ParentAction: TCAFeatureAction {
19 | /// enum InternalAction: Equatable {
20 | /// // ...
21 | /// case child(ChildAction)
22 | /// }
23 | /// // ...
24 | /// case view(ViewAction)
25 | /// case _internal(InternalAction)
26 | /// case delegate(DelegateAction)
27 | /// }
28 | /// ```
29 | /// Without this extension we would have to write something like:
30 | /// ```swift
31 | /// struct ParentView: View {
32 | /// let store: Store
33 | /// var body: some View {
34 | /// // ...
35 | /// ChildView(
36 | /// store.scope(
37 | /// state: \.childState,
38 | /// action: \ParentAction.Cases._internal.child
39 | /// )
40 | /// )
41 | /// // ...
42 | /// }
43 | /// }
44 | /// ```
45 | /// With this exntesion we are able to get a cleaner API like below:
46 | /// ```swift
47 | /// struct ParentView: View {
48 | /// let store: Store
49 | /// var body: some View {
50 | /// // ...
51 | /// ChildView(
52 | /// store.scope(
53 | /// state: \.childState,
54 | /// action: \ParentAction.Cases._internal.child
55 | /// )
56 | /// )
57 | /// // ...
58 | /// }
59 | /// }
60 | /// ```
61 | /// - Parameters:
62 | /// - toChildState: A key path that transforms `State` into `ChildState`.
63 | /// - toChildAction: A case key path that transforms `Action.InternalAction` into `ChildAction`.
64 | /// - Returns: A new store with its domain (state and action) transformed.
65 | public func scope(
66 | state toChildState: KeyPath,
67 | action toChildAction: CaseKeyPath
68 | ) -> Store {
69 | scope(
70 | state: toChildState,
71 | action: (\Action.Cases._internal).appending(path: toChildAction)
72 | )
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+ForEachReducer.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | extension Reducer where Action: TCAFeatureAction {
4 | /// Embeds a child reducer in a parent domain that works on elements of a collection in parent
5 | /// state.
6 | ///
7 | /// For example, if a parent feature holds onto an array of child states, then it can perform
8 | /// its core logic _and_ the child's logic by using the `forEach` operator:
9 | ///
10 | /// ```swift
11 | /// struct Parent: Reducer {
12 | /// struct State {
13 | /// var rows: IdentifiedArrayOf
14 | /// // ...
15 | /// }
16 | /// enum Action {
17 | /// case row(id: Row.State.ID, action: Row.Action)
18 | /// // ...
19 | /// }
20 | ///
21 | /// var body: some Reducer {
22 | /// Reduce { state, action in
23 | /// // Core logic for parent feature
24 | /// }
25 | /// .forEach(\.rows, action: \.row) {
26 | /// Row()
27 | /// }
28 | /// }
29 | /// }
30 | /// ```
31 | ///
32 | /// > Tip: We are using `IdentifiedArray` from our
33 | /// [Identified Collections][swift-identified-collections] library because it provides a safe
34 | /// and ergonomic API for accessing elements from a stable ID rather than positional indices.
35 | ///
36 | /// The `forEach` forces a specific order of operations for the child and parent features. It
37 | /// runs the child first, and then the parent. If the order was reversed, then it would be
38 | /// possible for the parent feature to remove the child state from the array, in which case the
39 | /// child feature would not be able to react to that action. That can cause subtle bugs.
40 | ///
41 | /// It is still possible for a parent feature higher up in the application to remove the child
42 | /// state from the array before the child has a chance to react to the action. In such cases a
43 | /// runtime warning is shown in Xcode to let you know that there's a potential problem.
44 | ///
45 | /// [swift-identified-collections]: http://github.com/pointfreeco/swift-identified-collections
46 | ///
47 | /// - Parameters:
48 | /// - toElementsState: A writable key path from parent state to an `IdentifiedArray` of child
49 | /// state.
50 | /// - toElementAction: A case path from parent action to child identifier and child actions.
51 | /// - element: A reducer that will be invoked with child actions against elements of child
52 | /// state.
53 | /// - Returns: A reducer that combines the child reducer with the parent reducer.
54 | @inlinable
55 | @warn_unqualified_access
56 | public func forEach(
57 | _ toElementsState: WritableKeyPath>,
58 | action toElementAction: CaseKeyPath,
59 | @ReducerBuilder element: () -> Element,
60 | fileID: StaticString = #fileID,
61 | line: UInt = #line
62 | ) -> _ForEachReducer where ElementState == Element.State, ElementAction == Element.Action {
63 | self.forEach(
64 | toElementsState,
65 | action: (\Action.Cases._internal).appending(path: toElementAction),
66 | element: element,
67 | fileID: fileID,
68 | line: line
69 | )
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+IfLetReducer.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | extension Reducer where Action: TCAFeatureAction {
4 | /// Embeds a child reducer in a parent domain that works on an optional property of parent state.
5 | ///
6 | /// For example, if a parent feature holds onto a piece of optional child state, then it can
7 | /// perform its core logic _and_ the child's logic by using the `ifLet` operator:
8 | ///
9 | /// ```swift
10 | /// struct Parent: Reducer {
11 | /// struct State {
12 | /// var child: Child.State?
13 | /// // ...
14 | /// }
15 | /// enum Action {
16 | /// case child(Child.Action)
17 | /// // ...
18 | /// }
19 | ///
20 | /// var body: some Reducer {
21 | /// Reduce { state, action in
22 | /// // Core logic for parent feature
23 | /// }
24 | /// .ifLet(\.child, action: \.child) {
25 | /// Child()
26 | /// }
27 | /// }
28 | /// }
29 | /// ```
30 | ///
31 | /// The `ifLet` operator does a number of things to try to enforce correctness:
32 | ///
33 | /// * It forces a specific order of operations for the child and parent features. It runs the
34 | /// child first, and then the parent. If the order was reversed, then it would be possible for
35 | /// the parent feature to `nil` out the child state, in which case the child feature would not
36 | /// be able to react to that action. That can cause subtle bugs.
37 | ///
38 | /// * It automatically cancels all child effects when it detects the child's state is `nil`'d
39 | /// out.
40 | ///
41 | /// * Automatically `nil`s out child state when an action is sent for alerts and confirmation
42 | /// dialogs.
43 | ///
44 | /// See ``Reducer/ifLet(_:action:destination:fileID:line:)`` for a more advanced operator
45 | /// suited to navigation.
46 | ///
47 | /// - Parameters:
48 | /// - toWrappedState: A writable key path from parent state to a property containing optional
49 | /// child state.
50 | /// - toWrappedAction: A case path from parent action to a case containing child actions.
51 | /// - wrapped: A reducer that will be invoked with child actions against non-optional child
52 | /// state.
53 | /// - Returns: A reducer that combines the child reducer with the parent reducer.
54 | @inlinable
55 | @warn_unqualified_access
56 | public func ifLet(
57 | _ toWrappedState: WritableKeyPath,
58 | action toWrappedAction: CaseKeyPath,
59 | @ReducerBuilder then wrapped: () -> Wrapped,
60 | fileID: StaticString = #fileID,
61 | line: UInt = #line
62 | ) -> _IfLetReducer where WrappedState == Wrapped.State, WrappedAction == Wrapped.Action {
63 | self.ifLet(
64 | toWrappedState,
65 | action: (\Action.Cases._internal).appending(path: toWrappedAction),
66 | then: wrapped,
67 | fileID: fileID,
68 | line: line
69 | )
70 | }
71 |
72 | @inlinable
73 | @warn_unqualified_access
74 | public func ifLet(
75 | _ toWrappedState: WritableKeyPath,
76 | action toWrappedAction: CaseKeyPath,
77 | fileID: StaticString = #fileID,
78 | line: UInt = #line
79 | ) -> _IfLetReducer> {
80 | self.ifLet(
81 | toWrappedState,
82 | action: (\Action.Cases._internal).appending(path: toWrappedAction),
83 | fileID: fileID,
84 | line: line
85 | )
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "combine-schedulers",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/pointfreeco/combine-schedulers",
7 | "state" : {
8 | "revision" : "5928286acce13def418ec36d05a001a9641086f2",
9 | "version" : "1.0.3"
10 | }
11 | },
12 | {
13 | "identity" : "swift-case-paths",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/pointfreeco/swift-case-paths",
16 | "state" : {
17 | "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
18 | "version" : "1.7.2"
19 | }
20 | },
21 | {
22 | "identity" : "swift-clocks",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/pointfreeco/swift-clocks",
25 | "state" : {
26 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
27 | "version" : "1.0.6"
28 | }
29 | },
30 | {
31 | "identity" : "swift-collections",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/apple/swift-collections",
34 | "state" : {
35 | "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
36 | "version" : "1.2.1"
37 | }
38 | },
39 | {
40 | "identity" : "swift-composable-architecture",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/pointfreeco/swift-composable-architecture.git",
43 | "state" : {
44 | "revision" : "2d60d4082dfb4978974307acf0f00dfa20e5f621",
45 | "version" : "1.22.3"
46 | }
47 | },
48 | {
49 | "identity" : "swift-concurrency-extras",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
52 | "state" : {
53 | "revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
54 | "version" : "1.3.2"
55 | }
56 | },
57 | {
58 | "identity" : "swift-custom-dump",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/pointfreeco/swift-custom-dump",
61 | "state" : {
62 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
63 | "version" : "1.3.3"
64 | }
65 | },
66 | {
67 | "identity" : "swift-dependencies",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/pointfreeco/swift-dependencies",
70 | "state" : {
71 | "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
72 | "version" : "1.9.5"
73 | }
74 | },
75 | {
76 | "identity" : "swift-identified-collections",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/pointfreeco/swift-identified-collections",
79 | "state" : {
80 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
81 | "version" : "1.1.1"
82 | }
83 | },
84 | {
85 | "identity" : "swift-navigation",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/pointfreeco/swift-navigation",
88 | "state" : {
89 | "revision" : "6b7f44d218e776bb7a5246efb940440d57c8b2cf",
90 | "version" : "2.4.2"
91 | }
92 | },
93 | {
94 | "identity" : "swift-perception",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/pointfreeco/swift-perception",
97 | "state" : {
98 | "revision" : "30721accd0370d7c9cb5bd0f7cdf5a1a767b383d",
99 | "version" : "2.0.8"
100 | }
101 | },
102 | {
103 | "identity" : "swift-sharing",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/pointfreeco/swift-sharing",
106 | "state" : {
107 | "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
108 | "version" : "2.7.4"
109 | }
110 | },
111 | {
112 | "identity" : "swift-syntax",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/swiftlang/swift-syntax",
115 | "state" : {
116 | "revision" : "4799286537280063c85a32f09884cfbca301b1a1",
117 | "version" : "602.0.0"
118 | }
119 | },
120 | {
121 | "identity" : "xctest-dynamic-overlay",
122 | "kind" : "remoteSourceControl",
123 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
124 | "state" : {
125 | "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317",
126 | "version" : "1.6.1"
127 | }
128 | }
129 | ],
130 | "version" : 2
131 | }
132 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TCABoundaries
2 |
3 | The `TCAFeatureAction` defines a pattern for actions on TCA based on https://www.merowing.info/boundries-in-tca/
4 | Its idea is to set a well defined specification for actions on TCA views, where ideally View and Internal actions should not go out of the feature scope.
5 |
6 | Example:
7 | ```swift
8 | enum ExampleAction: TCAFeatureAction {
9 | enum ViewAction: Equatable {
10 | case onTapLoginButton
11 | }
12 |
13 | enum DelegateAction: Equatable {
14 | case notifyLoginSuccess
15 | }
16 |
17 | enum InternalAction: Equatable {
18 | case loginResult(Result)
19 | }
20 |
21 | case view(ViewAction)
22 | case delegate(DelegateAction)
23 | case _internal(InternalAction)
24 | }
25 | ```
26 |
27 | ## Example app
28 |
29 | Below is a small counter feature that demonstrates how the boundaries pattern works together with
30 | the latest Composable Architecture APIs.
31 |
32 | ```swift
33 | import ComposableArchitecture
34 | import TCABoundaries
35 | import SwiftUI
36 |
37 | @Reducer
38 | struct CounterFeature: BoundingReducer {
39 | struct State: Equatable {
40 | var count = 0
41 | }
42 |
43 | enum Action: TCAFeatureAction {
44 | enum ViewAction: Equatable {
45 | case decrementButtonTapped
46 | case incrementButtonTapped
47 | }
48 |
49 | enum DelegateAction: Equatable {
50 | case finished
51 | }
52 |
53 | enum InternalAction: Equatable {
54 | case timerUpdated
55 | }
56 |
57 | case view(ViewAction)
58 | case delegate(DelegateAction)
59 | case _internal(InternalAction)
60 | }
61 |
62 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect {
63 | switch action {
64 | case .decrementButtonTapped:
65 | state.count -= 1
66 | return .none
67 |
68 | case .incrementButtonTapped:
69 | state.count += 1
70 | return .none
71 | }
72 | }
73 | }
74 |
75 | @ViewAction(for: CounterFeature.self)
76 | struct CounterView: View {
77 | let store: StoreOf
78 |
79 | var body: some View {
80 | WithPerceptionTracking {
81 | VStack {
82 | Text("\(store.count)")
83 |
84 | HStack {
85 | Button("−") { send(.decrementButtonTapped) }
86 | Button("+") { send(.incrementButtonTapped) }
87 | }
88 | }
89 | }
90 | }
91 | }
92 | ```
93 |
94 | ## Examples
95 | ### Child flow on parent, with boundaries
96 | When you have a `Child` flow inside a `Parent` store and need to scope it is often expressed as an `InternalAction` of the `Parent` store.
97 | ```swift
98 | enum ParentAction: TCAFeatureAction {
99 | enum InternalAction: Equatable {
100 | // ...
101 | case child(ChildAction)
102 | }
103 | // ...
104 | case view(ViewAction)
105 | case _internal(InternalAction)
106 | case delegate(DelegateAction)
107 | }
108 | ```
109 | This will be expressed like below:
110 | ```swift
111 | // On the reducer
112 | Scope(state: \.child, action: /Action.InternalAction.child) {
113 | ChildFeature()
114 | }
115 | // On the view
116 | struct ParentView: View {
117 | let store: StoreOf
118 |
119 | var body: some View {
120 | // ...
121 | ChildView(
122 | store.scope(
123 | state: \.childState,
124 | action: /ParentAction.InternalAction.child
125 | )
126 | )
127 | // ...
128 | }
129 | }
130 | ```
131 |
132 |
133 | # Bounding Reducers
134 | Bounding reducers is a protocol to define a standard when implementing reducers with Boundaries.
135 | We can have them on composed (reducers with body) or non-composed reducers.
136 |
137 | The main idea relies on setting a standard for separating the actions based on its type on specific functions:
138 |
139 | ```swift
140 | // To handle actions coming from the view
141 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect
142 |
143 | // To handle actions that happen inside the reducer
144 | func reduce(into state: inout State, internalAction action: Action.InternalAction) -> Effect
145 |
146 | // To handle actions that where delegated to this reducer
147 | func reduce(into state: inout State, delegateAction action: Action.DelegateAction) -> Effect
148 | ```
149 |
150 | Example for non-composed reducers:
151 | ```swift
152 | struct SomeFeature: BoundingReducer {
153 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect {
154 | switch action {
155 | ...
156 | }
157 | }
158 |
159 | func reduce(into state: inout State, internalAction action: Action.InternalAction) -> Effect {
160 | switch action {
161 | ...
162 | }
163 | }
164 |
165 | ...
166 | }
167 | ```
168 |
169 | Example for composed reducers:
170 | ```swift
171 | struct SomeFeature: ComposedBoundingReducer {
172 | var body: some Reducer {
173 | coreReducer
174 | .ifLet(\.child, action: /Action.InternalAction.child) {
175 | ChildFeature()
176 | }
177 | }
178 |
179 | func reduce(into state: inout State, viewAction action: Action.ViewAction) -> Effect {
180 | switch action {
181 | ...
182 | }
183 | }
184 | ...
185 | }
186 | ```
187 |
188 | ## Installation
189 |
190 | You can add TCABoundaries to an Xcode project by adding it as a package dependency.
191 |
192 | 1. From the File menu, select Add Packages...
193 | 2. Enter "https://github.com/bocato/tca-boundaries" into the package repository URL text field
194 |
--------------------------------------------------------------------------------
/Sources/TCABoundaries/BoundedActions/Extensions/TCAFeatureAction+Scope.swift:
--------------------------------------------------------------------------------
1 | import ComposableArchitecture
2 |
3 | extension Scope where ParentAction: TCAFeatureAction {
4 | /// When you have a `Child` flow inside a `Parent` store and need to scope it is often expressed as an `InternalAction` of the `Parent` store.
5 | /// This can lead to a bit a an awkward API when trying to express that relation...
6 | /// ```swift
7 | /// enum ParentAction: TCAFeatureAction {
8 | /// enum InternalAction: Equatable {
9 | /// // ...
10 | /// case child(ChildAction)
11 | /// }
12 | /// // ...
13 | /// case view(ViewAction)
14 | /// case _internal(InternalAction)
15 | /// case delegate(DelegateAction)
16 | /// }
17 | /// ```
18 | /// Without this extension we would have to write something like:
19 | /// ```swift
20 | /// Scope(\Action.Cases._internal.child) {
21 | /// ChildFeature()
22 | /// }
23 | /// ```
24 | /// With this extension we are able to get a cleaner API like below:
25 | /// ```swift
26 | /// Scope(state: \.child, action: \ParentAction.Cases._internal.child) {
27 | /// ChildFeature()
28 | /// }
29 | /// ```
30 | /// - Parameters:
31 | /// - state: A key path that transforms `State` into `ChildState`.
32 | /// - action: A case key path that transforms `Action.InternalAction` into `ChildAction`.
33 | /// - child: The reducer builder for the Child.
34 | @inlinable
35 | public init(
36 | state toChildState: WritableKeyPath,
37 | action toChildAction: CaseKeyPath,
38 | _ child: () -> Child
39 | ) {
40 | self = .init(
41 | state: toChildState,
42 | action: (\ParentAction.Cases._internal).appending(path: toChildAction),
43 | child: child
44 | )
45 | }
46 |
47 | /// Initializes a reducer that runs the given child reducer against a slice of parent state and
48 | /// actions.
49 | ///
50 | /// Useful for combining child reducers into a parent.
51 | ///
52 | /// ```swift
53 | /// var body: some Reducer {
54 | /// Scope(state: \.profile, action: \.profile) {
55 | /// Profile()
56 | /// }
57 | /// Scope(state: \.settings, action: \.settings) {
58 | /// Settings()
59 | /// }
60 | /// // ...
61 | /// }
62 | /// ```
63 | ///
64 | /// - Parameters:
65 | /// - toChildState: A writable key path from parent state to a property containing child state.
66 | /// - toChildAction: A case path from parent action to a case containing child actions.
67 | /// - child: A reducer that will be invoked with child actions against child state.
68 | @inlinable
69 | public init(
70 | state toChildState: WritableKeyPath,
71 | action toChildAction: CaseKeyPath,
72 | @ReducerBuilder child: () -> Child
73 | ) where ChildState == Child.State, ChildAction == Child.Action {
74 | self.init(
75 | state: toChildState,
76 | action: (\ParentAction.Cases._internal).appending(path: toChildAction),
77 | child: child
78 | )
79 | }
80 |
81 | /// Initializes a reducer that runs the given child reducer against a slice of parent state and
82 | /// actions.
83 | ///
84 | /// Useful for combining reducers of mutually-exclusive enum state.
85 | ///
86 | /// ```swift
87 | /// var body: some Reducer {
88 | /// Scope(state: \.loggedIn, action: \.loggedIn) {
89 | /// LoggedIn()
90 | /// }
91 | /// Scope(state: \.loggedOut, action: \.loggedOut) {
92 | /// LoggedOut()
93 | /// }
94 | /// }
95 | /// ```
96 | ///
97 | /// > Warning: Be careful when assembling reducers that are scoped to cases of enum state. If a
98 | /// > scoped reducer receives a child action when its state is set to an unrelated case, it will
99 | /// > not be able to process the action, which is considered an application logic error and will
100 | /// > emit runtime warnings.
101 | /// >
102 | /// > This can happen if another reducer in the parent domain changes the child state to an
103 | /// > unrelated case when it handles the action _before_ the scoped reducer runs. For example, a
104 | /// > parent may receive a dismissal action from the child domain:
105 | /// >
106 | /// > ```swift
107 | /// > Reduce { state, action in
108 | /// > switch action {
109 | /// > case .loggedIn(.quitButtonTapped):
110 | /// > state = .loggedOut(LoggedOut.State())
111 | /// > // ...
112 | /// > }
113 | /// > }
114 | /// > Scope(state: \.loggedIn, action: \.loggedIn) {
115 | /// > LoggedIn() // ⚠️ Logged-in domain can't handle `quitButtonTapped`
116 | /// > }
117 | /// > ```
118 | /// >
119 | /// > If the parent domain contains additional logic for switching between cases of child state,
120 | /// > prefer ``Reducer/ifCaseLet(_:action:then:fileID:line:)``, which better ensures that
121 | /// > child logic runs _before_ any parent logic can replace child state:
122 | /// >
123 | /// > ```swift
124 | /// > Reduce { state, action in
125 | /// > switch action {
126 | /// > case .loggedIn(.quitButtonTapped):
127 | /// > state = .loggedOut(LoggedOut.State())
128 | /// > // ...
129 | /// > }
130 | /// > }
131 | /// > .ifCaseLet(\.loggedIn, action: \.loggedIn) {
132 | /// > LoggedIn() // ✅ Receives actions before its case can change
133 | /// > }
134 | /// > ```
135 | ///
136 | /// - Parameters:
137 | /// - toChildState: A case path from parent state to a case containing child state.
138 | /// - toChildAction: A case path from parent action to a case containing child actions.
139 | /// - child: A reducer that will be invoked with child actions against child state.
140 | @inlinable
141 | public init(
142 | state toChildState: CaseKeyPath,
143 | action toChildAction: CaseKeyPath,
144 | @ReducerBuilder child: () -> Child,
145 | fileID: StaticString = #fileID,
146 | line: UInt = #line
147 | ) where ChildState == Child.State, ChildAction == Child.Action {
148 | self.init(
149 | state: toChildState,
150 | action: (\ParentAction.Cases._internal).appending(path: toChildAction),
151 | child: child,
152 | fileID: fileID,
153 | line: line
154 | )
155 | }
156 | }
157 |
--------------------------------------------------------------------------------